fix(skills): polish desktop browser layout and rendering

Improve the desktop Skills browser so SKILL.md metadata renders cleanly and the settings view uses space like a real document browser. Add coverage for the new detail, markdown, and i18n behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-09 14:59:33 +08:00
parent 78c7b23c11
commit 5911388626
12 changed files with 963 additions and 188 deletions

View File

@ -4,6 +4,8 @@ import '@testing-library/jest-dom'
import { Settings } from '../pages/Settings'
import { useAgentStore } from '../stores/agentStore'
import { useSkillStore } from '../stores/skillStore'
import { useSettingsStore } from '../stores/settingsStore'
// Mock the API module so no real HTTP calls are made
vi.mock('../api/agents', () => ({
@ -12,17 +14,8 @@ vi.mock('../api/agents', () => ({
},
}))
/** Override fetchAgents to a no-op so useEffect doesn't clobber manually set state */
const noopFetch = vi.fn()
// Mock MarkdownRenderer to avoid pulling in `marked` and CodeViewer deps
vi.mock('../components/markdown/MarkdownRenderer', () => ({
MarkdownRenderer: ({ content }: { content: string }) => (
<div data-testid="markdown-renderer">{content}</div>
),
}))
// Mock the provider store so ProviderSettings doesn't crash
vi.mock('../stores/providerStore', () => ({
useProviderStore: () => ({
providers: [],
@ -39,11 +32,14 @@ vi.mock('../stores/providerStore', () => ({
}),
}))
// Mock the adapter settings to avoid its side effects
vi.mock('../pages/AdapterSettings', () => ({
AdapterSettings: () => <div>Adapter Settings Mock</div>,
}))
vi.mock('../components/chat/CodeViewer', () => ({
CodeViewer: ({ code }: { code: string }) => <pre data-testid="code-viewer">{code}</pre>,
}))
const MOCK_AGENTS = [
{
name: 'code-reviewer',
@ -69,20 +65,69 @@ const MOCK_AGENTS = [
},
]
const MOCK_SKILL_DETAIL = {
meta: {
name: 'skill-docs',
displayName: 'Skill Docs',
description: 'A rich skill readme',
source: 'user' as const,
userInvocable: true,
contentLength: 200,
hasDirectory: true,
},
tree: [
{ name: 'SKILL.md', path: 'SKILL.md', type: 'file' as const },
{ name: 'helper.ts', path: 'helper.ts', type: 'file' as const },
],
files: [
{
path: 'SKILL.md',
language: 'markdown',
content: '# Heading\n\nParagraph with `inline code`.\n\n## Section\n\n- First item\n- Second item\n\n> Helpful quote',
body: '# Heading\n\nParagraph with `inline code`.\n\n## Section\n\n- First item\n- Second item\n\n> Helpful quote',
isEntry: true,
frontmatter: {
description: 'A rich skill readme',
model: 'sonnet',
},
},
{
path: 'helper.ts',
language: 'typescript',
content: 'export const helper = true',
isEntry: false,
},
],
skillRoot: '/tmp/skill-docs',
}
function switchToAgentsTab() {
// The Agents tab button has text "Agents"
const agentsTab = screen.getByText('Agents')
fireEvent.click(agentsTab)
fireEvent.click(screen.getByText('Agents'))
}
function switchToSkillsTab() {
fireEvent.click(screen.getByText('Skills'))
}
describe('Settings > Agents tab', () => {
beforeEach(() => {
// Reset store to default state before each test
useSettingsStore.setState({ locale: 'en' })
useAgentStore.setState({
agents: [],
isLoading: false,
error: null,
selectedAgent: null,
fetchAgents: noopFetch,
})
useSkillStore.setState({
skills: [],
selectedSkill: null,
isLoading: false,
isDetailLoading: false,
error: null,
fetchSkills: noopFetch,
fetchSkillDetail: noopFetch,
clearSelection: () => useSkillStore.setState({ selectedSkill: null }),
})
})
@ -128,7 +173,6 @@ describe('Settings > Agents tab', () => {
expect(screen.getByText('Reviews code for quality and security')).toBeInTheDocument()
expect(screen.getByText('doc-writer')).toBeInTheDocument()
expect(screen.getByText('Writes technical documentation')).toBeInTheDocument()
// Agent count badge
expect(screen.getByText('3 agents')).toBeInTheDocument()
})
@ -156,12 +200,9 @@ describe('Settings > Agents tab', () => {
fireEvent.click(screen.getByText('code-reviewer'))
// Detail view should show
expect(screen.getByText('Back to list')).toBeInTheDocument()
expect(screen.getByText('Reviews code for quality and security')).toBeInTheDocument()
// Model meta
expect(screen.getByText(/claude-sonnet-4-6/)).toBeInTheDocument()
// Tools meta
expect(screen.getByText(/Read, Grep, Glob/)).toBeInTheDocument()
})
@ -172,9 +213,8 @@ describe('Settings > Agents tab', () => {
fireEvent.click(screen.getByText('code-reviewer'))
const mdRenderer = screen.getByTestId('markdown-renderer')
expect(mdRenderer).toBeInTheDocument()
expect(mdRenderer.textContent).toContain('Code Reviewer')
expect(screen.getByRole('heading', { name: 'Code Reviewer' })).toBeInTheDocument()
expect(screen.getByText('You are an expert code reviewer.')).toBeInTheDocument()
})
it('shows "no system prompt" message when agent has no prompt', () => {
@ -192,16 +232,63 @@ describe('Settings > Agents tab', () => {
render(<Settings />)
switchToAgentsTab()
// Go to detail
fireEvent.click(screen.getByText('code-reviewer'))
expect(screen.getByText('Back to list')).toBeInTheDocument()
// Go back
fireEvent.click(screen.getByText('Back to list'))
// Should see the list again
expect(screen.getByText('code-reviewer')).toBeInTheDocument()
expect(screen.getByText('doc-writer')).toBeInTheDocument()
expect(screen.getByText('plain-agent')).toBeInTheDocument()
})
})
describe('Settings > Skills tab', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
useSkillStore.setState({
skills: [],
selectedSkill: null,
isLoading: false,
isDetailLoading: false,
error: null,
fetchSkills: noopFetch,
fetchSkillDetail: noopFetch,
clearSelection: () => useSkillStore.setState({ selectedSkill: null }),
})
})
it('renders markdown skills with document styling in detail view', () => {
useSkillStore.setState({
selectedSkill: MOCK_SKILL_DETAIL,
clearSelection: () => useSkillStore.setState({ selectedSkill: null }),
})
render(<Settings />)
switchToSkillsTab()
expect(screen.getByText('Skill metadata')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Heading' })).toBeInTheDocument()
const rendererRoot = screen.getByRole('heading', { name: 'Heading' }).closest('div[class*="prose"]')
expect(rendererRoot?.className).toContain('max-w-[72ch]')
expect(rendererRoot?.className).toContain('prose-h2:border-b')
expect(rendererRoot?.className).toContain('prose-p:text-[15px]')
expect(screen.getByText('Helpful quote')).toBeInTheDocument()
})
it('keeps code files rendered in CodeViewer instead of markdown prose', () => {
useSkillStore.setState({
selectedSkill: MOCK_SKILL_DETAIL,
clearSelection: () => useSkillStore.setState({ selectedSkill: null }),
})
render(<Settings />)
switchToSkillsTab()
fireEvent.click(screen.getAllByText('helper.ts')[0]!)
expect(screen.getByTestId('code-viewer')).toHaveTextContent('export const helper = true')
expect(screen.queryByRole('heading', { name: 'Heading' })).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Settings } from '../pages/Settings'
import { useSkillStore } from '../stores/skillStore'
import { useSettingsStore } from '../stores/settingsStore'
vi.mock('../api/agents', () => ({
agentsApi: {
list: vi.fn().mockResolvedValue({ agents: [] }),
},
}))
vi.mock('../stores/providerStore', () => ({
useProviderStore: () => ({
providers: [],
activeId: null,
isLoading: false,
fetchProviders: vi.fn(),
deleteProvider: vi.fn(),
activateProvider: vi.fn(),
activateOfficial: vi.fn(),
testProvider: vi.fn(),
createProvider: vi.fn(),
updateProvider: vi.fn(),
testConfig: vi.fn(),
}),
}))
vi.mock('../pages/AdapterSettings', () => ({
AdapterSettings: () => <div>Adapter Settings Mock</div>,
}))
vi.mock('../stores/agentStore', () => ({
useAgentStore: () => ({
agents: [],
isLoading: false,
error: null,
selectedAgent: null,
fetchAgents: vi.fn(),
selectAgent: vi.fn(),
}),
}))
vi.mock('../components/chat/CodeViewer', () => ({
CodeViewer: ({ code }: { code: string }) => <pre data-testid="code-viewer">{code}</pre>,
}))
const MOCK_FETCH_SKILLS = vi.fn()
const MOCK_FETCH_SKILL_DETAIL = vi.fn()
const MOCK_CLEAR_SELECTION = vi.fn()
function switchToSkillsTab() {
fireEvent.click(screen.getByText('Skills'))
}
describe('Settings > Skills tab', () => {
beforeEach(() => {
vi.clearAllMocks()
useSettingsStore.setState({ locale: 'en' })
useSkillStore.setState({
skills: [],
selectedSkill: null,
isLoading: false,
isDetailLoading: false,
error: null,
fetchSkills: MOCK_FETCH_SKILLS,
fetchSkillDetail: MOCK_FETCH_SKILL_DETAIL,
clearSelection: MOCK_CLEAR_SELECTION,
})
})
it('renders browser summary and grouped skill cards', () => {
useSkillStore.setState({
skills: [
{
name: 'alpha',
displayName: 'Alpha Skill',
description: 'First skill description',
source: 'user',
userInvocable: true,
version: '1.0.0',
contentLength: 400,
hasDirectory: true,
},
{
name: 'beta',
description: 'Second skill description',
source: 'project',
userInvocable: false,
contentLength: 200,
hasDirectory: true,
},
],
})
render(<Settings />)
switchToSkillsTab()
expect(screen.getByText('Browse installed skills')).toBeInTheDocument()
expect(screen.getByText('Skill Browser')).toBeInTheDocument()
expect(screen.getByText('Total skills')).toBeInTheDocument()
expect(screen.getByText('Alpha Skill')).toBeInTheDocument()
expect(screen.getByText('Second skill description')).toBeInTheDocument()
})
it('opens skill detail with metadata cards and parsed markdown body', () => {
useSkillStore.setState({
selectedSkill: {
meta: {
name: 'alpha',
displayName: 'Alpha Skill',
description: 'First skill description',
source: 'user',
userInvocable: true,
version: '1.0.0',
contentLength: 400,
hasDirectory: true,
},
tree: [
{ name: 'SKILL.md', path: 'SKILL.md', type: 'file' },
{ name: 'run.ts', path: 'run.ts', type: 'file' },
],
files: [
{
path: 'SKILL.md',
content: '# Hello\n\nBody content',
body: '# Hello\n\nBody content',
language: 'markdown',
isEntry: true,
frontmatter: {
description: 'Frontmatter description',
'allowed-tools': ['Read', 'Edit'],
model: 'sonnet',
},
},
{
path: 'run.ts',
content: 'console.log("hello")',
language: 'typescript',
isEntry: false,
},
],
skillRoot: '/tmp/alpha',
},
})
render(<Settings />)
switchToSkillsTab()
expect(screen.getByText('Skill metadata')).toBeInTheDocument()
expect(screen.getByText('/slash')).toBeInTheDocument()
expect(screen.getByText('Frontmatter description')).toBeInTheDocument()
expect(screen.getByText('Read, Edit')).toBeInTheDocument()
expect(screen.getByText('Hello')).toBeInTheDocument()
expect(screen.queryByText(/^---$/)).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { MarkdownRenderer } from './MarkdownRenderer'
describe('MarkdownRenderer', () => {
it('applies document prose classes and custom width classes', () => {
const { container } = render(
<MarkdownRenderer
content={'# Skill Title\n\nReadable paragraph text.'}
variant="document"
className="mx-auto max-w-[72ch]"
/>,
)
const root = container.firstChild as HTMLDivElement
expect(root).toBeInTheDocument()
expect(root.className).toContain('prose-p:text-[15px]')
expect(root.className).toContain('prose-h2:border-b')
expect(root.className).toContain('mx-auto')
expect(root.className).toContain('max-w-[72ch]')
expect(screen.getByText('Skill Title')).toBeInTheDocument()
expect(screen.getByText('Readable paragraph text.')).toBeInTheDocument()
})
it('keeps default variant free of document-only typography classes', () => {
const { container } = render(
<MarkdownRenderer content={'## Default Heading\n\nBody copy.'} />,
)
const root = container.firstChild as HTMLDivElement
expect(root).toBeInTheDocument()
expect(root.className).not.toContain('prose-p:text-[15px]')
expect(root.className).not.toContain('prose-h2:border-b')
expect(screen.getByText('Default Heading')).toBeInTheDocument()
expect(screen.getByText('Body copy.')).toBeInTheDocument()
})
})

View File

@ -4,6 +4,8 @@ import { CodeViewer } from '../chat/CodeViewer'
type Props = {
content: string
variant?: 'default' | 'document'
className?: string
}
type CodeBlock = {
@ -36,8 +38,50 @@ function parseMarkdown(content: string): { html: string; codeBlocks: CodeBlock[]
return { html, codeBlocks }
}
export function MarkdownRenderer({ content }: Props) {
const BASE_PROSE_CLASSES = `prose prose-sm max-w-none text-[var(--color-text-primary)]
prose-headings:text-[var(--color-text-primary)] prose-headings:font-semibold
prose-p:my-2 prose-p:leading-relaxed
prose-p:break-words
prose-code:text-[13px] prose-code:font-[var(--font-mono)] prose-code:bg-[var(--color-surface-info)] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded
prose-pre:!bg-transparent prose-pre:!p-0 prose-pre:!shadow-none
prose-a:text-[var(--color-text-accent)] prose-a:no-underline hover:prose-a:underline
prose-strong:text-[var(--color-text-primary)]
prose-ul:my-2 prose-ol:my-2
prose-li:my-0.5
prose-table:w-full prose-table:table-auto prose-table:text-sm
prose-th:bg-[var(--color-surface-info)] prose-th:px-3 prose-th:py-2 prose-th:whitespace-normal prose-th:break-words prose-th:align-top
prose-td:px-3 prose-td:py-2 prose-td:border-[var(--color-border)] prose-td:whitespace-normal prose-td:break-words prose-td:align-top`
const DOCUMENT_PROSE_CLASSES = `
prose-p:text-[15px] prose-p:leading-7
prose-headings:scroll-mt-6 prose-headings:tracking-[-0.01em]
prose-h1:mb-4 prose-h1:text-2xl prose-h1:font-semibold prose-h1:leading-tight
prose-h2:mt-8 prose-h2:mb-3 prose-h2:border-b prose-h2:border-[var(--color-border)] prose-h2:pb-2 prose-h2:text-xl prose-h2:font-semibold
prose-h3:mt-6 prose-h3:mb-2 prose-h3:text-base prose-h3:font-semibold
prose-h4:mt-5 prose-h4:mb-2 prose-h4:text-sm prose-h4:font-semibold
prose-blockquote:my-4 prose-blockquote:rounded-r-lg prose-blockquote:border-l-4 prose-blockquote:border-[var(--color-outline-variant)] prose-blockquote:bg-[var(--color-surface-container-low)] prose-blockquote:px-4 prose-blockquote:py-2 prose-blockquote:italic
prose-hr:my-6 prose-hr:border-[var(--color-border)]
prose-img:rounded-lg prose-img:border prose-img:border-[var(--color-border)]
prose-kbd:rounded prose-kbd:border prose-kbd:border-[var(--color-border)] prose-kbd:bg-[var(--color-surface-container-lowest)] prose-kbd:px-1.5 prose-kbd:py-0.5 prose-kbd:font-[var(--font-mono)] prose-kbd:text-[12px] prose-kbd:font-normal prose-kbd:text-[var(--color-text-secondary)] prose-kbd:shadow-none
prose-ul:pl-5 prose-ul:[&>li]:marker:text-[var(--color-text-tertiary)]
prose-ol:pl-5 prose-ol:[&>li]:marker:text-[var(--color-text-tertiary)]
prose-li:my-1.5
prose-table:my-5
prose-th:text-left
prose-td:bg-[var(--color-surface)]`
function getProseClasses(variant: 'default' | 'document', className?: string) {
return [BASE_PROSE_CLASSES, variant === 'document' ? DOCUMENT_PROSE_CLASSES : '', className ?? '']
.filter(Boolean)
.join(' ')
}
export function MarkdownRenderer({ content, variant = 'default', className }: Props) {
const { html, codeBlocks } = useMemo(() => parseMarkdown(content), [content])
const proseClasses = useMemo(
() => getProseClasses(variant, className),
[variant, className],
)
const parts = useMemo(() => {
if (codeBlocks.length === 0) {
@ -87,20 +131,6 @@ export function MarkdownRenderer({ content }: Props) {
}
}, [])
const proseClasses = `prose prose-sm max-w-none text-[var(--color-text-primary)]
prose-headings:text-[var(--color-text-primary)] prose-headings:font-semibold
prose-p:my-2 prose-p:leading-relaxed
prose-p:break-words
prose-code:text-[13px] prose-code:font-[var(--font-mono)] prose-code:bg-[var(--color-surface-info)] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded
prose-pre:!bg-transparent prose-pre:!p-0 prose-pre:!shadow-none
prose-a:text-[var(--color-text-accent)] prose-a:no-underline hover:prose-a:underline
prose-strong:text-[var(--color-text-primary)]
prose-ul:my-2 prose-ol:my-2
prose-li:my-0.5
prose-table:w-full prose-table:table-auto prose-table:text-sm
prose-th:bg-[var(--color-surface-info)] prose-th:px-3 prose-th:py-2 prose-th:whitespace-normal prose-th:break-words prose-th:align-top
prose-td:px-3 prose-td:py-2 prose-td:border-[var(--color-border)] prose-td:whitespace-normal prose-td:break-words prose-td:align-top`
if (codeBlocks.length === 0) {
return (
<div

View File

@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
vi.mock('../markdown/MarkdownRenderer', () => ({
MarkdownRenderer: ({
content,
variant,
className,
}: {
content: string
variant?: string
className?: string
}) => (
<div
data-testid="markdown-renderer"
data-content={content}
data-variant={variant}
data-classname={className}
/>
),
}))
vi.mock('../chat/CodeViewer', () => ({
CodeViewer: ({ code }: { code: string }) => <div data-testid="code-viewer">{code}</div>,
}))
import { SkillDetail } from './SkillDetail'
import { useSkillStore } from '../../stores/skillStore'
import { useSettingsStore } from '../../stores/settingsStore'
const fetchSkills = vi.fn()
const fetchSkillDetail = vi.fn()
const clearSelection = vi.fn(() => {
useSkillStore.setState({ selectedSkill: null })
})
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
useSkillStore.setState({
skills: [],
selectedSkill: null,
isLoading: false,
isDetailLoading: false,
error: null,
fetchSkills,
fetchSkillDetail,
clearSelection,
})
fetchSkills.mockReset()
fetchSkillDetail.mockReset()
clearSelection.mockClear()
})
describe('SkillDetail markdown presentation', () => {
it('renders markdown files with the document variant and readable width', () => {
useSkillStore.setState({
selectedSkill: {
meta: {
name: 'skill-test',
displayName: 'Skill Test',
description: 'Skill description',
source: 'user',
userInvocable: true,
contentLength: 120,
hasDirectory: true,
},
tree: [{ name: 'SKILL.md', path: 'SKILL.md', type: 'file' }],
files: [
{
path: 'SKILL.md',
content: '# Skill Body',
language: 'markdown',
isEntry: true,
},
],
skillRoot: '/tmp/skill-test',
},
})
render(<SkillDetail />)
const markdown = screen.getByTestId('markdown-renderer')
expect(markdown).toBeInTheDocument()
expect(markdown).toHaveAttribute('data-variant', 'document')
expect(markdown).toHaveAttribute('data-classname', 'mx-auto max-w-[72ch]')
expect(markdown).toHaveAttribute('data-content', '# Skill Body')
})
})

View File

@ -1,17 +1,36 @@
import { useState } from 'react'
import { useMemo, useState, type ReactNode } from 'react'
import { useSkillStore } from '../../stores/skillStore'
import { useTranslation } from '../../i18n'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { CodeViewer } from '../chat/CodeViewer'
import type { FileTreeNode } from '../../types/skill'
import type { FileTreeNode, SkillFrontmatter } from '../../types/skill'
// ─── Main Component ──────────────────────────────────────────────────────────
const META_PRIORITY = [
'description',
'when_to_use',
'argument-hint',
'model',
'effort',
'allowed-tools',
'paths',
'agent',
'context',
'version',
'user-invocable',
] as const
export function SkillDetail() {
const { selectedSkill, isDetailLoading, clearSelection } = useSkillStore()
const t = useTranslation()
const [selectedFile, setSelectedFile] = useState<string>('SKILL.md')
const normalizedSelection = useMemo(() => {
if (!selectedSkill) return 'SKILL.md'
return selectedSkill.files.some((file) => file.path === selectedFile)
? selectedFile
: selectedSkill.files[0]?.path || 'SKILL.md'
}, [selectedFile, selectedSkill])
if (isDetailLoading) {
return (
<div className="flex justify-center py-12">
@ -23,75 +42,176 @@ export function SkillDetail() {
if (!selectedSkill) return null
const { meta, tree, files } = selectedSkill
const currentFile = files.find((f) => f.path === selectedFile) || files[0]
const currentFile = files.find((f) => f.path === normalizedSelection) || files[0]
const frontmatter = currentFile?.frontmatter
const metaEntries = getMetaEntries(frontmatter)
return (
<div className="flex flex-col h-full">
{/* Back button */}
<div className="mb-3">
<div className="flex h-full min-h-0 flex-col gap-4 min-w-0">
<div>
<button
onClick={clearSelection}
className="flex items-center gap-1 text-sm text-[var(--color-text-accent)] hover:underline"
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-sm text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]"
>
<span className="material-symbols-outlined text-[16px]">
arrow_back
</span>
<span className="material-symbols-outlined text-[16px]">arrow_back</span>
{t('settings.skills.back')}
</button>
</div>
{/* Skill header */}
<div className="mb-4">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-base font-semibold text-[var(--color-text-primary)]">
{meta.displayName || meta.name}
</h3>
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)] leading-none uppercase">
{meta.source}
</span>
</div>
<p className="text-sm text-[var(--color-text-tertiary)]">
{meta.description}
</p>
<div className="flex gap-3 mt-1 text-xs text-[var(--color-text-tertiary)]">
<span>~{Math.ceil(meta.contentLength / 4)} tokens</span>
<span>
{files.length} {t('settings.skills.files')}
</span>
{meta.version && <span>v{meta.version}</span>}
</div>
</div>
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
<div className="grid gap-4 px-5 py-5 lg:grid-cols-[minmax(0,1.5fr)_minmax(280px,0.9fr)] lg:items-start">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
{t('settings.skills.entryEyebrow')}
</div>
<div className="flex flex-wrap items-center gap-2 mb-2">
<h3 className="text-[22px] font-semibold leading-tight text-[var(--color-text-primary)] break-all">
{meta.displayName || meta.name}
</h3>
<MetaPill>{t(`settings.skills.source.${meta.source}`)}</MetaPill>
{meta.version && <MetaPill>v{meta.version}</MetaPill>}
{meta.userInvocable && <MetaPill>{t('settings.skills.slashCommand')}</MetaPill>}
</div>
<p className="max-w-4xl text-sm leading-6 text-[var(--color-text-secondary)]">
{meta.description}
</p>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2 text-xs text-[var(--color-text-tertiary)]">
<span>{t('settings.skills.tokenEstimate', { count: String(Math.ceil(meta.contentLength / 4)) })}</span>
<span>
{files.length} {t('settings.skills.files')}
</span>
<span>{currentFile?.isEntry ? t('settings.skills.entryFile') : currentFile?.path}</span>
</div>
</div>
{/* Two-panel: file tree + content viewer */}
<div className="flex flex-1 min-h-0 border border-[var(--color-border)] rounded-xl overflow-hidden">
{/* Left: File tree */}
<div className="w-[200px] border-r border-[var(--color-border)] overflow-y-auto bg-[var(--color-surface-container-low)] py-1">
<TreeView
nodes={tree}
selectedPath={selectedFile}
onSelect={setSelectedFile}
depth={0}
/>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-2">
<DetailStat
label={t('settings.skills.summary.totalFiles')}
value={String(files.length)}
icon="folder_open"
/>
<DetailStat
label={t('settings.skills.summary.tokens')}
value={t('settings.skills.tokenEstimateShort', { count: String(Math.ceil(meta.contentLength / 4)) })}
icon="notes"
/>
<DetailStat
label={t('settings.skills.summary.source')}
value={t(`settings.skills.source.${meta.source}`)}
icon="layers"
/>
<DetailStat
label={t('settings.skills.summary.entry')}
value={files.some((file) => file.isEntry) ? 'SKILL.md' : '—'}
icon="article"
/>
</div>
</div>
</section>
{/* Right: File content */}
<div className="flex-1 overflow-y-auto">
{currentFile && (
<div className="flex flex-col">
{/* File path header */}
<div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--color-border)] bg-[var(--color-surface-container)]">
<span className="text-xs text-[var(--color-text-tertiary)] font-mono">
{currentFile.path}
</span>
<span className="text-[10px] text-[var(--color-text-tertiary)] uppercase tracking-wider">
{currentFile.language}
</span>
{metaEntries.length > 0 && (
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-4">
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
tune
</span>
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('settings.skills.metaTitle')}
</h4>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{metaEntries.map(([key, value]) => (
<div
key={key}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-3 min-w-0"
>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
{formatMetaKey(key)}
</div>
<div className="mt-2 text-sm leading-6 text-[var(--color-text-primary)] break-words">
{formatMetaValue(value)}
</div>
</div>
))}
</div>
</section>
)}
{/* Content */}
<div className="p-4">
<section className="flex flex-1 min-h-0 min-w-0 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
<aside className="hidden w-[250px] flex-shrink-0 border-r border-[var(--color-border)] bg-[var(--color-surface-container-low)] lg:flex lg:flex-col">
<div className="border-b border-[var(--color-border)] px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--color-text-tertiary)]">
{t('settings.skills.filesPanel')}
</div>
<p className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">
{t('settings.skills.filesPanelHint')}
</p>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
<TreeView
nodes={tree}
selectedPath={normalizedSelection}
onSelect={setSelectedFile}
depth={0}
/>
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-mono text-[var(--color-text-secondary)] break-all">
{currentFile?.path}
</span>
{currentFile?.isEntry && <MetaPill>{t('settings.skills.entryFile')}</MetaPill>}
</div>
<div className="mt-1 text-[11px] text-[var(--color-text-tertiary)]">
{t('settings.skills.readingMode', {
mode:
currentFile?.language === 'markdown'
? t('settings.skills.docMode')
: t('settings.skills.codeMode'),
})}
</div>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-[var(--color-surface)] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] border border-[var(--color-border)]">
{currentFile?.language}
</span>
</div>
</div>
<div className="lg:hidden border-b border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 overflow-x-auto">
<div className="flex gap-2 min-w-max">
{files.map((file) => {
const active = file.path === normalizedSelection
return (
<button
key={file.path}
onClick={() => setSelectedFile(file.path)}
className={`rounded-full border px-3 py-1.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] ${
active
? 'border-[var(--color-brand)] bg-[var(--color-primary-fixed)] text-[var(--color-text-primary)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
{file.path}
</button>
)
})}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto bg-[var(--color-surface-container-lowest)]">
{currentFile && (
<div className={currentFile.language === 'markdown' ? 'px-6 py-5 lg:px-8' : 'p-4'}>
{currentFile.language === 'markdown' ? (
<MarkdownRenderer content={currentFile.content} />
<MarkdownRenderer
content={currentFile.body ?? currentFile.content}
variant="document"
className="mx-auto max-w-[72ch]"
/>
) : (
<CodeViewer
code={currentFile.content}
@ -101,16 +221,14 @@ export function SkillDetail() {
/>
)}
</div>
</div>
)}
)}
</div>
</div>
</div>
</section>
</div>
)
}
// ─── File Tree Components ────────────────────────────────────────────────────
function TreeView({
nodes,
selectedPath,
@ -152,22 +270,18 @@ function TreeItem({
const isSelected = node.path === selectedPath
const isDir = node.type === 'directory'
const icon = isDir
? expanded
? 'folder_open'
: 'folder'
: fileIcon(node.name)
const icon = isDir ? (expanded ? 'folder_open' : 'folder') : fileIcon(node.name)
return (
<div>
<button
onClick={() => (isDir ? setExpanded(!expanded) : onSelect(node.path))}
className={`w-full flex items-center gap-1.5 px-2 py-1 text-left text-xs transition-colors ${
className={`flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] ${
isSelected
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] font-medium'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
style={{ paddingLeft: `${8 + depth * 16}px` }}
style={{ marginLeft: `${depth * 12}px`, width: `calc(100% - ${depth * 12}px)` }}
>
{isDir ? (
<span className="material-symbols-outlined text-[12px] text-[var(--color-text-tertiary)]">
@ -194,6 +308,74 @@ function TreeItem({
)
}
function DetailStat({
label,
value,
icon,
}: {
label: string
value: string
icon: string
}) {
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-[14px]">{icon}</span>
<span>{label}</span>
</div>
<div className="mt-2 text-base font-semibold text-[var(--color-text-primary)] break-all">
{value}
</div>
</div>
)
}
function MetaPill({ children }: { children: ReactNode }) {
return (
<span className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
{children}
</span>
)
}
function getMetaEntries(frontmatter?: SkillFrontmatter): Array<[string, unknown]> {
if (!frontmatter) return []
const entries = Object.entries(frontmatter).filter(([, value]) => {
if (value == null) return false
if (typeof value === 'string') return value.trim().length > 0
if (Array.isArray(value)) return value.length > 0
return true
})
entries.sort((a, b) => {
const aIndex = META_PRIORITY.indexOf(a[0] as (typeof META_PRIORITY)[number])
const bIndex = META_PRIORITY.indexOf(b[0] as (typeof META_PRIORITY)[number])
const normalizedA = aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex
const normalizedB = bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex
return normalizedA - normalizedB || a[0].localeCompare(b[0])
})
return entries
}
function formatMetaKey(key: string) {
return key.replace(/[-_]/g, ' ')
}
function formatMetaValue(value: unknown) {
if (Array.isArray(value)) {
return value.map((item) => String(item)).join(', ')
}
if (typeof value === 'boolean') {
return value ? 'true' : 'false'
}
if (typeof value === 'object' && value !== null) {
return JSON.stringify(value)
}
return String(value)
}
function fileIcon(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase()
switch (ext) {

View File

@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEffect, useMemo } from 'react'
import { useSkillStore } from '../../stores/skillStore'
import { useTranslation } from '../../i18n'
import type { SkillMeta, SkillSource } from '../../types/skill'
@ -13,6 +13,18 @@ const SOURCE_ICONS: Record<SkillSource, string> = {
bundled: 'inventory_2',
}
const SOURCE_ACCENT_CLASSES: Record<SkillSource, string> = {
user: 'bg-[var(--color-primary-fixed)] text-[var(--color-brand)]',
project: 'bg-[var(--color-success-container)] text-[var(--color-success)]',
plugin: 'bg-[var(--color-warning-container)] text-[var(--color-warning)]',
mcp: 'bg-[var(--color-info-container)] text-[var(--color-info)]',
bundled: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]',
}
function estimateTokens(contentLength: number) {
return Math.ceil(contentLength / 4)
}
export function SkillList() {
const { skills, isLoading, error, fetchSkills, fetchSkillDetail } =
useSkillStore()
@ -22,6 +34,20 @@ export function SkillList() {
fetchSkills()
}, [fetchSkills])
const grouped = useMemo(() => {
const result: Partial<Record<SkillSource, SkillMeta[]>> = {}
for (const skill of skills) {
const src = skill.source as SkillSource
;(result[src] ??= []).push(skill)
}
return result
}, [skills])
const totalTokens = useMemo(
() => skills.reduce((sum, skill) => sum + estimateTokens(skill.contentLength), 0),
[skills],
)
if (isLoading) {
return (
<div className="flex justify-center py-12">
@ -36,7 +62,7 @@ export function SkillList() {
if (skills.length === 0) {
return (
<div className="text-center py-12">
<div className="text-center py-12 rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-6">
<span className="material-symbols-outlined text-[40px] text-[var(--color-text-tertiary)] mb-2 block">
auto_awesome
</span>
@ -50,73 +76,169 @@ export function SkillList() {
)
}
// Group by source
const grouped: Partial<Record<SkillSource, SkillMeta[]>> = {}
for (const skill of skills) {
const src = skill.source as SkillSource
;(grouped[src] ??= []).push(skill)
}
return (
<div className="flex flex-col gap-4">
{SOURCE_ORDER.map((source) => {
const group = grouped[source]
if (!group?.length) return null
return (
<div key={source}>
{/* Group header */}
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]">
{SOURCE_ICONS[source]}
</span>
<span className="text-xs font-semibold text-[var(--color-text-tertiary)] uppercase tracking-wider">
{t(`settings.skills.source.${source}`)}
</span>
<span className="text-xs text-[var(--color-text-tertiary)]">
({group.length})
</span>
<div className="flex flex-col gap-6 min-w-0">
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
<div className="grid gap-4 px-5 py-5 md:grid-cols-[minmax(0,1.6fr)_minmax(280px,1fr)] md:items-end">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
{t('settings.skills.browserEyebrow')}
</div>
<div className="flex items-center gap-3 mb-2">
<span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">
auto_awesome
</span>
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
{t('settings.skills.browserTitle')}
</h3>
</div>
<p className="text-sm leading-6 text-[var(--color-text-secondary)] max-w-3xl">
{t('settings.skills.browserDescription')}
</p>
</div>
{/* Skill items */}
<div className="flex flex-col gap-1">
{group.map((skill) => (
<button
key={`${skill.source}-${skill.name}`}
onClick={() =>
skill.hasDirectory &&
fetchSkillDetail(skill.source, skill.name)
}
disabled={!skill.hasDirectory}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-[var(--color-border)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] transition-all text-left group disabled:opacity-60 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:border-[var(--color-border)]"
>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
auto_awesome
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)] truncate">
{skill.displayName || skill.name}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">
{skill.description}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[10px] text-[var(--color-text-tertiary)]">
~{Math.ceil(skill.contentLength / 4)} tokens
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<SummaryCard
label={t('settings.skills.summary.totalSkills')}
value={String(skills.length)}
icon="auto_awesome"
/>
<SummaryCard
label={t('settings.skills.summary.sources')}
value={String(
SOURCE_ORDER.filter((source) => (grouped[source] ?? []).length > 0)
.length,
)}
icon="layers"
/>
<SummaryCard
label={t('settings.skills.summary.tokens')}
value={t('settings.skills.tokenEstimateShort', { count: String(totalTokens) })}
icon="notes"
className="col-span-2 md:col-span-1"
/>
</div>
</div>
</section>
<div className="grid gap-4 xl:grid-cols-2">
{SOURCE_ORDER.map((source) => {
const group = grouped[source]
if (!group?.length) return null
const sourceLabel = t(`settings.skills.source.${source}`)
const sourceTokenCount = group.reduce(
(sum, skill) => sum + estimateTokens(skill.contentLength),
0,
)
return (
<section
key={source}
className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden min-w-0"
>
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`inline-flex h-7 w-7 items-center justify-center rounded-full ${SOURCE_ACCENT_CLASSES[source]}`}>
<span className="material-symbols-outlined text-[16px]">
{SOURCE_ICONS[source]}
</span>
</span>
{skill.hasDirectory && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
{sourceLabel}
</h4>
<span className="text-xs text-[var(--color-text-tertiary)]">
{group.length}
</span>
</div>
<p className="text-xs leading-5 text-[var(--color-text-tertiary)]">
{t('settings.skills.groupHint', {
source: sourceLabel,
count: String(group.length),
})}
</p>
</div>
<div className="text-[11px] text-[var(--color-text-tertiary)] whitespace-nowrap">
{t('settings.skills.tokenEstimateShort', { count: String(sourceTokenCount) })}
</div>
</div>
<div className="flex flex-col p-2">
{group.map((skill) => (
<button
key={`${skill.source}-${skill.name}`}
onClick={() =>
skill.hasDirectory &&
fetchSkillDetail(skill.source, skill.name)
}
disabled={!skill.hasDirectory}
className="group rounded-xl border border-transparent px-3 py-3 text-left transition-all hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)] disabled:opacity-60 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:border-transparent"
>
<div className="flex items-start gap-3">
<span className="mt-0.5 material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
auto_awesome
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-[var(--color-text-primary)] break-all">
{skill.displayName || skill.name}
</span>
{skill.version && (
<span className="rounded-full bg-[var(--color-surface-container-high)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
v{skill.version}
</span>
)}
{skill.userInvocable && (
<span className="rounded-full border border-[var(--color-border)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
{t('settings.skills.slashCommand')}
</span>
)}
</div>
<p className="mt-1 text-xs leading-5 text-[var(--color-text-secondary)] break-words">
{skill.description}
</p>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--color-text-tertiary)]">
<span>{sourceLabel}</span>
<span>{t('settings.skills.tokenEstimateShort', { count: String(estimateTokens(skill.contentLength)) })}</span>
<span>{skill.hasDirectory ? t('settings.skills.ready') : t('settings.skills.unavailable')}</span>
</div>
</div>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] opacity-60 transition-transform group-hover:translate-x-0.5 group-hover:opacity-100">
chevron_right
</span>
)}
</div>
</button>
))}
</div>
</div>
)
})}
</div>
</button>
))}
</div>
</section>
)
})}
</div>
</div>
)
}
function SummaryCard({
label,
value,
icon,
className = '',
}: {
label: string
value: string
icon: string
className?: string
}) {
return (
<div className={`rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 ${className}`}>
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-[14px]">{icon}</span>
<span>{label}</span>
</div>
<div className="mt-2 text-lg font-semibold text-[var(--color-text-primary)]">
{value}
</div>
</div>
)
}

View File

@ -157,10 +157,33 @@ export const en = {
// Settings > Skills
'settings.skills.title': 'Installed Skills',
'settings.skills.description': 'Skills extend Claude with specialized capabilities. Manage skills in ~/.claude/skills/',
'settings.skills.browserTitle': 'Browse installed skills',
'settings.skills.browserEyebrow': 'Skill Browser',
'settings.skills.browserDescription': 'Inspect bundled, project, and user skills, compare their scope, and open each skill folder to read its docs and source files.',
'settings.skills.entryEyebrow': 'Skill Entry',
'settings.skills.slashCommand': '/slash',
'settings.skills.tokenEstimate': '~{count} tokens',
'settings.skills.tokenEstimateShort': '~{count}',
'settings.skills.summary.totalSkills': 'Total skills',
'settings.skills.summary.totalFiles': 'Files',
'settings.skills.summary.sources': 'Sources',
'settings.skills.summary.tokens': 'Estimated tokens',
'settings.skills.summary.source': 'Source',
'settings.skills.summary.entry': 'Entry',
'settings.skills.groupHint': '{count} skills available from {source}',
'settings.skills.ready': 'Ready',
'settings.skills.unavailable': 'Unavailable',
'settings.skills.empty': 'No skills installed',
'settings.skills.emptyHint': 'Add skills to ~/.claude/skills/ to get started',
'settings.skills.back': 'Back to list',
'settings.skills.files': 'files',
'settings.skills.entryFile': 'entry file',
'settings.skills.metaTitle': 'Skill metadata',
'settings.skills.filesPanel': 'Files',
'settings.skills.filesPanelHint': 'Browse the entry file and supporting implementation files.',
'settings.skills.readingMode': 'Viewing in {mode}',
'settings.skills.docMode': 'document mode',
'settings.skills.codeMode': 'code mode',
'settings.skills.source.user': 'User',
'settings.skills.source.project': 'Project',
'settings.skills.source.plugin': 'Plugin',

View File

@ -159,10 +159,33 @@ export const zh: Record<TranslationKey, string> = {
// Settings > Skills
'settings.skills.title': '已安装技能',
'settings.skills.description': '技能扩展 Claude 的能力。在 ~/.claude/skills/ 中管理技能。',
'settings.skills.browserTitle': '浏览已安装技能',
'settings.skills.browserEyebrow': '技能浏览器',
'settings.skills.browserDescription': '查看内置、项目和用户技能,比较它们的来源与规模,并打开技能目录阅读文档和源码文件。',
'settings.skills.entryEyebrow': '技能入口',
'settings.skills.slashCommand': '/斜杠命令',
'settings.skills.tokenEstimate': '约 {count} tokens',
'settings.skills.tokenEstimateShort': '约 {count}',
'settings.skills.summary.totalSkills': '技能总数',
'settings.skills.summary.totalFiles': '文件数',
'settings.skills.summary.sources': '来源类型',
'settings.skills.summary.tokens': '预估 tokens',
'settings.skills.summary.source': '来源',
'settings.skills.summary.entry': '入口文件',
'settings.skills.groupHint': '{source}中有 {count} 个技能可用',
'settings.skills.ready': '可查看',
'settings.skills.unavailable': '不可用',
'settings.skills.empty': '暂无已安装技能',
'settings.skills.emptyHint': '在 ~/.claude/skills/ 中添加技能即可开始',
'settings.skills.back': '返回列表',
'settings.skills.files': '个文件',
'settings.skills.entryFile': '入口文件',
'settings.skills.metaTitle': '技能元数据',
'settings.skills.filesPanel': '文件',
'settings.skills.filesPanelHint': '浏览入口文件及其配套实现文件。',
'settings.skills.readingMode': '当前为{mode}',
'settings.skills.docMode': '文档模式',
'settings.skills.codeMode': '代码模式',
'settings.skills.source.user': '用户',
'settings.skills.source.project': '项目',
'settings.skills.source.plugin': '插件',

View File

@ -769,14 +769,14 @@ function SkillSettings() {
if (selectedSkill) {
return (
<div className="max-w-3xl">
<div className="w-full min-w-0 max-w-[1400px]">
<SkillDetail />
</div>
)
}
return (
<div className="max-w-2xl">
<div className="w-full min-w-0 max-w-[1280px]">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">
{t('settings.skills.title')}
</h2>

View File

@ -19,10 +19,15 @@ export type FileTreeNode = {
children?: FileTreeNode[]
}
export type SkillFrontmatter = Record<string, unknown>
export type SkillFile = {
path: string
content: string
language: string
frontmatter?: SkillFrontmatter
body?: string
isEntry?: boolean
}
export type SkillDetail = {

View File

@ -9,7 +9,7 @@
import * as os from 'os'
import * as path from 'path'
import * as fs from 'fs/promises'
import { parse as parseYaml } from 'yaml'
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
// ─── Types ───────────────────────────────────────────────────────────────────
@ -36,6 +36,9 @@ type SkillFile = {
path: string
content: string
language: string
frontmatter?: Record<string, unknown>
body?: string
isEntry?: boolean
}
// ─── Constants ───────────────────────────────────────────────────────────────
@ -59,17 +62,14 @@ function detectLanguage(filename: string): string {
return LANG_MAP[ext] || 'text'
}
function parseFrontmatter(content: string): {
function normalizeFrontmatter(content: string, sourcePath?: string): {
frontmatter: Record<string, unknown>
body: string
} {
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/)
if (!match) return { frontmatter: {}, body: content }
try {
const frontmatter = (parseYaml(match[1]) as Record<string, unknown>) || {}
return { frontmatter, body: content.slice(match[0].length) }
} catch {
return { frontmatter: {}, body: content }
const parsed = parseFrontmatter(content, sourcePath)
return {
frontmatter: parsed.frontmatter as Record<string, unknown>,
body: parsed.content,
}
}
@ -85,7 +85,7 @@ async function loadSkillMeta(
const skillFile = path.join(skillDir, 'SKILL.md')
try {
const raw = await fs.readFile(skillFile, 'utf-8')
const { frontmatter, body } = parseFrontmatter(raw)
const { frontmatter, body } = normalizeFrontmatter(raw, skillFile)
const description =
(frontmatter.description as string) ||
@ -157,11 +157,27 @@ async function buildFileTree(
const stat = await fs.stat(fullPath)
if (stat.size <= MAX_FILE_SIZE) {
const content = await fs.readFile(fullPath, 'utf-8')
files.push({
path: relPath,
content,
language: detectLanguage(entry.name),
})
const language = detectLanguage(entry.name)
const isEntry = relPath === 'SKILL.md'
if (isEntry && language === 'markdown') {
const { frontmatter, body } = normalizeFrontmatter(content, fullPath)
files.push({
path: relPath,
content: body,
body,
frontmatter,
language,
isEntry: true,
})
} else {
files.push({
path: relPath,
content,
language,
isEntry: false,
})
}
fileCount++
}
} catch {