fix: use opencode skills as source of truth

This commit is contained in:
Bohdan Triapitsyn
2026-05-17 14:51:25 +03:00
parent 8900b8f0f2
commit bbc297202e
20 changed files with 384 additions and 101 deletions
@@ -54,6 +54,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { useGitBranches, useGitStore, useIsGitRepo } from '@/stores/useGitStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
@@ -71,6 +72,7 @@ const MAX_VISIBLE_TEXTAREA_LINES = 8;
const EMPTY_QUEUE: QueuedMessage[] = [];
const EMPTY_MESSAGES: Message[] = [];
const FILE_MENTION_TOKEN = /^@[^\s]+$/;
const INLINE_SKILL_TOKEN_PATTERN = /(^|\s)\/([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)/g;
const CHAT_DRAFT_PERSIST_DEBOUNCE_MS = 500;
const VS_CODE_DROP_DATA_TYPES = [
'CodeFiles',
@@ -81,6 +83,28 @@ const VS_CODE_DROP_DATA_TYPES = [
'text/plain',
];
const collectInlineSkillMentions = (text: string, skillNames: Set<string>): string[] => {
const mentions: string[] = [];
INLINE_SKILL_TOKEN_PATTERN.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = INLINE_SKILL_TOKEN_PATTERN.exec(text)) !== null) {
const prefix = match[1] || '';
const name = match[2] || '';
const slashIndex = match.index + prefix.length;
if (slashIndex === 0 || !skillNames.has(name) || mentions.includes(name)) {
continue;
}
mentions.push(name);
}
return mentions;
};
const buildSkillMentionInstruction = (skillNames: string[]): string | null => {
if (skillNames.length === 0) return null;
const formatted = skillNames.map((name) => `/${name}`).join(', ');
return `The user explicitly mentioned these skills in their message: ${formatted}. Use the corresponding skill tool when it is relevant to accomplishing the user's request.`;
};
const hasUserMessages = (sessionId: string, directory?: string) => {
return getSyncMessages(sessionId, directory).some((message) => message.role === 'user');
};
@@ -1508,6 +1532,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
let primaryAttachments: AttachedFile[] = [];
let agentMentionName: string | undefined;
const additionalParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }> = [];
const availableSkillNames = new Set(useSkillsStore.getState().skills.map((skill) => skill.name));
const mentionedSkillNames: string[] = [];
const addMentionedSkills = (text: string) => {
for (const name of collectInlineSkillMentions(text, availableSkillNames)) {
if (!mentionedSkillNames.includes(name)) mentionedSkillNames.push(name);
}
};
// Consume any pending synthetic parts (from conflict resolution, etc.)
const syntheticParts = consumePendingSyntheticParts();
@@ -1517,6 +1548,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const queuedMsg = queuedMessages[i];
const { sanitizedText, mention } = parseAgentMentions(queuedMsg.content, agents);
const { sanitizedText: queuedText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText);
addMentionedSkills(queuedText);
// Use agent mention from first message that has one
if (!agentMentionName && mention?.name) {
@@ -1546,6 +1578,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents);
const { sanitizedText: messageText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText);
const attachmentsToSend = sanitizeAttachmentsForSend(sendableAttachedFiles);
addMentionedSkills(messageText);
if (!agentMentionName && mention?.name) {
agentMentionName = mention.name;
@@ -1611,6 +1644,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
});
}
const skillMentionInstruction = buildSkillMentionInstruction(mentionedSkillNames);
if (skillMentionInstruction) {
additionalParts.push({
text: skillMentionInstruction,
synthetic: true,
});
}
if (!primaryText && additionalParts.length === 0) return;
// Clear queue and input
@@ -6,6 +6,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
interface SkillInfo {
name: string;
scope: string;
source?: string;
description?: string;
}
@@ -111,6 +112,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
const renderSkill = (skill: SkillInfo, index: number) => {
const isProject = skill.scope === 'project';
const source = skill.source || 'opencode';
return (
<div
key={`${skill.name}-${skill.scope}`}
@@ -135,6 +137,9 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
)}>
{skill.scope}
</span>
<span className="text-[10px] leading-none uppercase font-bold tracking-tight px-1.5 py-1 rounded border flex-shrink-0 bg-[var(--surface-muted)] text-muted-foreground border-[var(--interactive-border)]/60">
{source}
</span>
</div>
{skill.description && (
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
@@ -18,6 +18,7 @@ import { isExpandableTool, isStandaloneTool, isStaticTool } from './toolRenderUt
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useUIStore } from '@/stores/useUIStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import ReasoningPart from './ReasoningPart';
import JustificationBlock from './JustificationBlock';
import { areRenderRelevantPartsEqual } from '../renderCompare';
@@ -599,7 +600,9 @@ const StaticToolRowInner: React.FC<{
const isReadGroup = toolName.toLowerCase() === 'read';
const runtime = React.useContext(RuntimeAPIContext);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const skills = useSkillsStore((state) => state.skills);
const hasRunningActivity = React.useMemo(() => activities.some((activity) => isActivityRunning(activity)), [activities]);
const skillByName = React.useMemo(() => new Map(skills.map((skill) => [skill.name, skill])), [skills]);
const descriptions = React.useMemo(() => {
const descs: string[] = [];
@@ -648,6 +651,15 @@ const StaticToolRowInner: React.FC<{
uiStore.openContextFile(contextDirectory, absolutePath);
}, [currentDirectory, runtime]);
const handleSkillClick = React.useCallback((skillName: string) => {
const skill = skillByName.get(skillName);
if (!skill?.path) {
return;
}
const uiStore = useUIStore.getState();
uiStore.openContextFile(currentDirectory || getContextDirectoryForPath('', skill.path), skill.path);
}, [currentDirectory, skillByName]);
const normalizedToolName = toolName.toLowerCase();
const isSearchGroup = normalizedToolName === 'grep'
|| normalizedToolName === 'search'
@@ -655,6 +667,7 @@ const StaticToolRowInner: React.FC<{
|| normalizedToolName === 'ripgrep'
|| normalizedToolName === 'glob';
const isFetchGroup = normalizedToolName === 'webfetch' || normalizedToolName === 'fetch' || normalizedToolName === 'curl' || normalizedToolName === 'wget';
const isSkillGroup = normalizedToolName === 'skill';
return (
<div
@@ -725,7 +738,25 @@ const StaticToolRowInner: React.FC<{
</a>
))
: null}
{!isReadGroup && !isSearchGroup && !isFetchGroup && descriptions.length > 0 ? (
{isSkillGroup && descriptions.length > 0
? descriptions.map((skillName, index) => (
<button
key={`${skillName}-${index}`}
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleSkillClick(skillName);
}}
className="min-w-0 flex-1 truncate whitespace-nowrap typography-meta leading-5 text-left hover:opacity-90"
style={{ color: 'var(--tools-description)' }}
title={skillName}
>
{skillName}
</button>
))
: null}
{!isReadGroup && !isSearchGroup && !isFetchGroup && !isSkillGroup && descriptions.length > 0 ? (
<Text
variant={animateTailText ? 'generate-effect' : 'static'}
className="min-w-0 flex-1 truncate whitespace-nowrap typography-meta leading-5"
@@ -4,7 +4,9 @@ import type { Part } from '@opencode-ai/sdk/v2';
import type { AgentMentionInfo } from '../types';
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
import { useUIStore } from '@/stores/useUIStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { Icon } from "@/components/icon/Icon";
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
type PartWithText = Part & { text?: string; content?: string; value?: string };
@@ -20,6 +22,8 @@ const buildMentionUrl = (name: string): string => {
return `https://opencode.ai/docs/agents/#${encoded}`;
};
const SKILL_TOKEN_PATTERN = /(^|\s)\/([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)/g;
const escapeHtml = (text: string): string => {
return text
.replace(/&/g, '&amp;')
@@ -41,8 +45,18 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
const [isExpanded, setIsExpanded] = React.useState(false);
const [isTruncated, setIsTruncated] = React.useState(false);
const userMessageRenderingMode = useUIStore((state) => state.userMessageRenderingMode);
const skills = useSkillsStore((state) => state.skills);
const openContextFile = useUIStore((state) => state.openContextFile);
const effectiveDirectory = useEffectiveDirectory();
const normalizedRenderingMode = normalizeUserMessageRenderingMode(userMessageRenderingMode);
const textRef = React.useRef<HTMLDivElement>(null);
const skillByName = React.useMemo(() => new Map(skills.map((skill) => [skill.name, skill])), [skills]);
const openSkill = React.useCallback((name: string) => {
const skill = skillByName.get(name);
if (!skill?.path) return;
openContextFile(effectiveDirectory || skill.path.replace(/\/[^/]*$/, '') || '/', skill.path);
}, [effectiveDirectory, openContextFile, skillByName]);
const hasActiveSelectionInElement = React.useCallback((element: HTMLElement): boolean => {
if (typeof window === 'undefined') {
@@ -76,7 +90,17 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
return () => resizeObserver.disconnect();
}, [textContent, isExpanded]);
const handleClick = React.useCallback(() => {
const handleClick = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement | null;
const skillLink = target?.closest<HTMLElement>('[data-skill-name]');
const skillName = skillLink?.dataset.skillName;
if (skillName) {
event.preventDefault();
event.stopPropagation();
openSkill(skillName);
return;
}
const element = textRef.current;
if (!element) {
return;
@@ -89,7 +113,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
if (!isExpanded && isTruncated) {
setIsExpanded(true);
}
}, [hasActiveSelectionInElement, isExpanded, isTruncated]);
}, [hasActiveSelectionInElement, isExpanded, isTruncated, openSkill]);
const handleCollapse = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
@@ -108,21 +132,62 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
content = content.replace(agentMention.token, mentionHtml);
}
content = content.replace(SKILL_TOKEN_PATTERN, (match, prefix: string, skillName: string, offset: number) => {
const slashIndex = offset + prefix.length;
if (slashIndex === 0 || !skillByName.has(skillName)) return match;
return `${prefix}<a href="#" data-skill-name="${skillName}" class="text-primary hover:underline">/${skillName}</a>`;
});
return content;
}, [agentMention, textContent]);
}, [agentMention, skillByName, textContent]);
const plainTextContent = React.useMemo(() => {
if (!agentMention?.token || !textContent.includes(agentMention.token)) {
return textContent;
const nodes: React.ReactNode[] = [];
let cursor = 0;
let agentMentionUsed = false;
let match: RegExpExecArray | null;
SKILL_TOKEN_PATTERN.lastIndex = 0;
while ((match = SKILL_TOKEN_PATTERN.exec(textContent)) !== null) {
const prefix = match[1] || '';
const skillName = match[2];
const slashIndex = match.index + prefix.length;
if (slashIndex === 0 || !skillByName.has(skillName)) continue;
if (match.index > cursor) nodes.push(textContent.slice(cursor, match.index));
if (prefix) nodes.push(prefix);
nodes.push(
<button
key={`skill-${slashIndex}-${skillName}`}
type="button"
className="text-primary hover:underline"
onClick={(event) => {
event.stopPropagation();
openSkill(skillName);
}}
>
/{skillName}
</button>
);
cursor = slashIndex + skillName.length + 1;
}
const idx = textContent.indexOf(agentMention.token);
const before = textContent.slice(0, idx);
const after = textContent.slice(idx + agentMention.token.length);
return (
<>
{before}
if (cursor < textContent.length) nodes.push(textContent.slice(cursor));
const withSkills = nodes.length > 0 ? nodes : [textContent];
if (!agentMention?.token || !textContent.includes(agentMention.token)) {
return withSkills;
}
return withSkills.flatMap((node, index) => {
if (agentMentionUsed || typeof node !== 'string') return node;
const idx = node.indexOf(agentMention.token);
if (idx === -1) return node;
agentMentionUsed = true;
return [
node.slice(0, idx),
<a
key={`agent-${index}`}
href={buildMentionUrl(agentMention.name)}
className="text-primary hover:underline"
target="_blank"
@@ -130,11 +195,11 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
onClick={(event) => event.stopPropagation()}
>
{agentMention.token}
</a>
{after}
</>
);
}, [agentMention, textContent]);
</a>,
node.slice(idx + agentMention.token.length),
];
});
}, [agentMention, openSkill, skillByName, textContent]);
if (!textContent || textContent.trim().length === 0) {
return null;
@@ -108,6 +108,10 @@ const SkillsInstalledPage: React.FC = () => {
switch (value) {
case 'project-opencode':
return t('settings.skills.location.option.projectOpencode.label');
case 'user-claude':
return t('settings.skills.location.option.userClaude.label');
case 'project-claude':
return t('settings.skills.location.option.projectClaude.label');
case 'user-agents':
return t('settings.skills.location.option.userAgents.label');
case 'project-agents':
@@ -121,6 +125,10 @@ const SkillsInstalledPage: React.FC = () => {
switch (value) {
case 'project-opencode':
return t('settings.skills.location.option.projectOpencode.description');
case 'user-claude':
return t('settings.skills.location.option.userClaude.description');
case 'project-claude':
return t('settings.skills.location.option.projectClaude.description');
case 'user-agents':
return t('settings.skills.location.option.userAgents.description');
case 'project-agents':
@@ -197,6 +205,7 @@ const SkillsInstalledPage: React.FC = () => {
instructions: instructions.trim() || undefined,
scope: isNewSkill ? draftScope : undefined,
source: isNewSkill ? draftSource : undefined,
targetPath: !isNewSkill ? selectedSkill?.path : undefined,
supportingFiles: isNewSkill && pendingFiles.length > 0 ? pendingFiles : undefined,
};
@@ -386,11 +395,6 @@ const SkillsInstalledPage: React.FC = () => {
<div className="min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground truncate flex items-center gap-2">
{isNewSkill ? t('settings.skills.page.title.newSkill') : selectedSkillName}
{selectedSkill?.source === 'claude' && (
<span className="typography-micro font-normal bg-[var(--surface-muted)] text-muted-foreground px-1.5 py-0.5 rounded">
{t('settings.skills.page.badge.claudeCompatible')}
</span>
)}
</h2>
<p className="typography-meta text-muted-foreground truncate">
{selectedSkill
@@ -437,6 +437,12 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
}) => {
const { t } = useI18n();
const isMobile = isMobileDeviceViaCSS();
const sourceLabel = skill.source === 'claude'
? t('settings.skills.sidebar.badge.claude')
: skill.source === 'agents'
? t('settings.skills.sidebar.badge.agents')
: t('settings.skills.sidebar.badge.opencode');
const badgeClassName = 'typography-micro text-muted-foreground bg-[var(--surface-muted)] px-1 rounded flex-shrink-0 leading-none pb-px border border-[var(--interactive-border)]/50';
return (
<div
className={cn(
@@ -458,19 +464,10 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
<span className="typography-ui-label font-normal truncate text-foreground">
{skill.name}
</span>
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
<span className={badgeClassName}>
{skill.scope}
</span>
{skill.source === 'claude' && (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{t('settings.skills.sidebar.badge.claude')}
</span>
)}
{skill.source === 'agents' && (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{t('settings.skills.sidebar.badge.agents')}
</span>
)}
<span className={badgeClassName}>{sourceLabel}</span>
</div>
</button>
@@ -1,6 +1,6 @@
import type { SkillScope, SkillSource } from '@/stores/useSkillsStore';
export type SkillLocationValue = 'user-opencode' | 'project-opencode' | 'user-agents' | 'project-agents';
export type SkillLocationValue = 'user-opencode' | 'project-opencode' | 'user-claude' | 'project-claude' | 'user-agents' | 'project-agents';
export const SKILL_LOCATION_OPTIONS: Array<{
value: SkillLocationValue;
@@ -40,13 +40,17 @@ export const SKILL_LOCATION_OPTIONS: Array<{
];
export function locationValueFrom(scope: SkillScope, source: SkillSource): SkillLocationValue {
if (scope === 'project' && source === 'claude') return 'project-claude';
if (scope === 'project' && source === 'agents') return 'project-agents';
if (source === 'claude') return 'user-claude';
if (scope === 'project') return 'project-opencode';
if (source === 'agents') return 'user-agents';
return 'user-opencode';
}
export function locationPartsFrom(value: SkillLocationValue): { scope: SkillScope; source: SkillSource } {
if (value === 'user-claude') return { scope: 'user', source: 'claude' };
if (value === 'project-claude') return { scope: 'project', source: 'claude' };
const match = SKILL_LOCATION_OPTIONS.find((option) => option.value === value);
if (!match) {
return { scope: 'user', source: 'opencode' };
@@ -55,6 +59,8 @@ export function locationPartsFrom(value: SkillLocationValue): { scope: SkillScop
}
export function locationLabel(scope: SkillScope, source: SkillSource): string {
if (scope === 'user' && source === 'claude') return 'User / Claude';
if (scope === 'project' && source === 'claude') return 'Project / Claude';
const match = SKILL_LOCATION_OPTIONS.find((option) => option.scope === scope && option.source === source);
return match?.label || `${scope} / ${source}`;
}
@@ -337,7 +337,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
// Load stores when project changes or when a page becomes active.
React.useEffect(() => {
if (!isSettingsDialogOpen && !runtimeCtx.isVSCode) {
if (!isSettingsDialogOpen && !runtimeCtx.isVSCode && !isWindowed) {
return;
}
@@ -357,7 +357,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
void useSkillsStore.getState().loadSkills();
void useSkillsCatalogStore.getState().loadCatalog();
}
}, [activeProjectId, isSettingsDialogOpen, runtimeCtx.isVSCode, settingsSlug]);
}, [activeProjectId, isSettingsDialogOpen, isWindowed, runtimeCtx.isVSCode, settingsSlug]);
const openPage = React.useCallback((slug: SettingsPageSlug) => {
setSettingsPage(slug);