diff --git a/packages/ui/src/components/sections/skills/SkillsPage.tsx b/packages/ui/src/components/sections/skills/SkillsPage.tsx index bf346d51..9f91f047 100644 --- a/packages/ui/src/components/sections/skills/SkillsPage.tsx +++ b/packages/ui/src/components/sections/skills/SkillsPage.tsx @@ -1,7 +1,9 @@ import React from 'react'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; +import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor'; import { toast } from '@/components/ui'; import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore'; import { useShallow } from 'zustand/react/shallow'; @@ -21,6 +23,8 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Icon } from "@/components/icon/Icon"; +import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; +import { PreviewToggleButton } from '@/components/views/PreviewToggleButton'; import { SkillsCatalogPage } from './catalog/SkillsCatalogPage'; import { SKILL_LOCATION_OPTIONS, @@ -29,6 +33,12 @@ import { type SkillLocationValue, } from './skillLocations'; import { useI18n } from '@/lib/i18n'; +import { languageByExtension } from '@/lib/codemirror/languageByExtension'; +import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme'; +import { useThemeSystem } from '@/contexts/useThemeSystem'; +import { cn } from '@/lib/utils'; +import { EditorView } from '@codemirror/view'; +import type { Extension } from '@codemirror/state'; export interface SkillsPageProps { view?: 'installed' | 'catalog'; @@ -38,8 +48,57 @@ const SkillsCatalogStandalone: React.FC = () => ( {}} showModeTabs={false} /> ); +type SkillDocumentParseResult = { + description: string | null; + instructions: string; +}; + +const SKILL_DOCUMENT_PATH = 'SKILL.md'; +const SKILL_EDITOR_HEIGHT_CLASS = 'h-[clamp(320px,58dvh,680px)] min-h-[260px] max-h-[calc(100dvh-220px)]'; + +const buildSkillMarkdown = (description: string, instructions: string): string => { + const frontmatter = stringifyYaml({ description }).trimEnd(); + const body = instructions.trimStart(); + return `---\n${frontmatter}\n---${body ? `\n\n${body}` : '\n'}`; +}; + +const isRecord = (value: unknown): value is Record => ( + typeof value === 'object' && value !== null && !Array.isArray(value) +); + +const parseSkillMarkdown = (value: string): SkillDocumentParseResult => { + const match = value.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/); + if (!match) { + return { description: null, instructions: value }; + } + + let description: string | null = null; + try { + const frontmatter: unknown = parseYaml(match[1]); + if (isRecord(frontmatter)) { + const candidate = frontmatter.description; + if (typeof candidate === 'string') { + description = candidate; + } + } + } catch { + description = null; + } + + return { + description, + instructions: match[2].replace(/^\r?\n/, ''), + }; +}; + +const replaceSkillMarkdownDescription = (value: string, description: string): string => { + const parsed = parseSkillMarkdown(value); + return buildSkillMarkdown(description, parsed.instructions); +}; + const SkillsInstalledPage: React.FC = () => { const { t } = useI18n(); + const { currentTheme } = useThemeSystem(); const { selectedSkillName, getSkillByName, @@ -65,6 +124,7 @@ const SkillsInstalledPage: React.FC = () => { const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName) : null; const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill); const hasStaleSelection = Boolean(selectedSkillName && !selectedSkill && !skillDraft); + const isReadOnlySkill = selectedSkill?.path === ''; React.useEffect(() => { if (!hasStaleSelection) { @@ -79,6 +139,8 @@ const SkillsInstalledPage: React.FC = () => { const [draftSource, setDraftSource] = React.useState<'opencode' | 'agents'>('opencode'); const [description, setDescription] = React.useState(''); const [instructions, setInstructions] = React.useState(''); + const [skillMarkdown, setSkillMarkdown] = React.useState(() => buildSkillMarkdown('', '')); + const [skillEditorMode, setSkillEditorMode] = React.useState<'edit' | 'preview'>('edit'); const [supportingFiles, setSupportingFiles] = React.useState([]); const [pendingFiles, setPendingFiles] = React.useState([]); const [isSaving, setIsSaving] = React.useState(false); @@ -141,11 +203,14 @@ const SkillsInstalledPage: React.FC = () => { React.useEffect(() => { const loadSkillDetails = async () => { if (isNewSkill && skillDraft) { + const nextDescription = skillDraft.description || ''; + const nextInstructions = skillDraft.instructions || ''; setDraftName(skillDraft.name || ''); setDraftScope(skillDraft.scope || 'user'); setDraftSource(skillDraft.source === 'agents' ? 'agents' : 'opencode'); - setDescription(skillDraft.description || ''); - setInstructions(skillDraft.instructions || ''); + setDescription(nextDescription); + setInstructions(nextInstructions); + setSkillMarkdown(buildSkillMarkdown(nextDescription, nextInstructions)); setOriginalDescription(''); setOriginalInstructions(''); setSupportingFiles([]); @@ -156,10 +221,13 @@ const SkillsInstalledPage: React.FC = () => { const detail = await getSkillDetail(selectedSkillName); if (detail) { const md = detail.sources.md; - setDescription(md.description || ''); - setInstructions(md.instructions || ''); - setOriginalDescription(md.description || ''); - setOriginalInstructions(md.instructions || ''); + const nextDescription = md.description || ''; + const nextInstructions = md.instructions || ''; + setDescription(nextDescription); + setInstructions(nextInstructions); + setSkillMarkdown(buildSkillMarkdown(nextDescription, nextInstructions)); + setOriginalDescription(nextDescription); + setOriginalInstructions(nextInstructions); setSupportingFiles(md.supportingFiles || []); } } catch (error) { @@ -173,6 +241,39 @@ const SkillsInstalledPage: React.FC = () => { loadSkillDetails(); }, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail]); + const skillEditorExtensions = React.useMemo(() => { + const extensions: Extension[] = [createFlexokiCodeMirrorTheme(currentTheme)]; + const markdownExtension = languageByExtension(SKILL_DOCUMENT_PATH); + if (markdownExtension) { + extensions.push(markdownExtension); + } + extensions.push(EditorView.lineWrapping); + return extensions; + }, [currentTheme]); + + const supportingFileEditorExtensions = React.useMemo(() => { + const filePath = newFileName.trim() || 'supporting-file.md'; + const extensions: Extension[] = [createFlexokiCodeMirrorTheme(currentTheme)]; + const languageExtension = languageByExtension(filePath); + if (languageExtension) { + extensions.push(languageExtension); + } + extensions.push(EditorView.lineWrapping); + return extensions; + }, [currentTheme, newFileName]); + + const handleDescriptionChange = React.useCallback((nextDescription: string) => { + setDescription(nextDescription); + setSkillMarkdown((current) => replaceSkillMarkdownDescription(current, nextDescription)); + }, []); + + const handleSkillMarkdownChange = React.useCallback((nextMarkdown: string) => { + setSkillMarkdown(nextMarkdown); + const parsed = parseSkillMarkdown(nextMarkdown); + setDescription(parsed.description ?? ''); + setInstructions(parsed.instructions); + }, []); + const handleSave = async () => { const skillName = isNewSkill ? draftName.trim().replace(/\s+/g, '-').toLowerCase() : selectedSkillName?.trim(); @@ -469,10 +570,11 @@ const SkillsInstalledPage: React.FC = () => {