feat: replace prompt templates with snippets

Replace the prompt-template workflow with snippet support that is compatible with opencode snippet conventions. Snippets are now stored and loaded from global and project snippet directories, including legacy pluralized paths, with frontmatter metadata for aliases and descriptions. Snippet expansion supports recursive references plus prepend and append sections, while inject sections are treated as unsupported no-ops so OpenChamber remains compatible without requiring an external plugin.

Add the snippets settings experience and remove the old prompt-template settings surface. The new settings page and sidebar support creating, editing, deleting, selecting, and describing snippets, with localized copy across every supported locale. The settings navigation now exposes Snippets with a dedicated icon and metadata.

Wire snippets into all prompt-entry surfaces that need them. Chat, multi-run groups, and scheduled task prompts now offer hash-trigger snippet autocomplete and expand snippets before sending work to OpenCode. Chat also uses an adaptive compact placeholder on mobile or narrow composer widths so helper trigger guidance stays readable in constrained layouts.

Keep multi-run aligned with grouped prompts. Multi-run sessions now use a shared title builder that handles both legacy titles and the newer g1, g2 prompt-group title format. Fusion parsing now recognizes grouped multi-run titles, scopes fusion sources to the same prompt group, and creates fusion sessions under the matching group so outputs from different prompts are not mixed accidentally.

Harden the icon sprite pipeline. The sprite generator now discovers icon names used through typed icon maps, JSX icon props, IconName returns, and generated-value flows without scanning unrelated string literals or the generated sprite itself. The generated sprite is strictly typed so invalid icon names are caught by type checking, and existing invalid or unsafe icon references were cleaned up across settings, provider, Git identity, scheduled task, voice, header, and sidebar surfaces.

Update backend configuration routes and documentation for snippets. The OpenCode config route layer now exposes snippet CRUD and expansion endpoints, accepts JSON bodies for snippet writes, and removes the old prompt-template provider. Scheduled task runtime expansion now uses snippets before dispatching messages.

Add regression coverage for snippet storage and expansion, config-route JSON handling, and multi-run title parsing. Validated with full type checking, full linting, targeted multi-run title tests, and targeted OpenCode snippet/config route tests.
This commit is contained in:
Bohdan Triapitsyn
2026-05-21 20:00:35 +03:00
parent 7d98f388c0
commit 6fd3afd25a
53 changed files with 2037 additions and 1054 deletions
+115 -5
View File
@@ -12,6 +12,7 @@ import type { AttachedFile } from '@/stores/types/sessionTypes';
import * as sessionActions from '@/sync/session-actions';
import { useDirectorySync, useUserMessageHistory } from '@/sync/sync-context';
import { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
import { useSnippetsStore } from '@/stores/useSnippetsStore';
import { appendInlineComments } from '@/lib/messages/inlineComments';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
@@ -19,6 +20,7 @@ import { QueuedMessageChips } from './QueuedMessageChips';
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from './CommandAutocomplete';
import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete';
import { SnippetAutocomplete, type SnippetAutocompleteHandle } from './SnippetAutocomplete';
import { cn, formatDirectoryName, isMacOS } from '@/lib/utils';
import { ModelControls } from './ModelControls';
import { parseAgentMentions } from '@/lib/messages/agentMentions';
@@ -74,6 +76,7 @@ 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 COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560;
const VS_CODE_DROP_DATA_TYPES = [
'CodeFiles',
'codefiles',
@@ -906,6 +909,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const [autocompleteTab, setAutocompleteTab] = React.useState<'commands' | 'agents' | 'files'>('commands');
const [showSkillAutocomplete, setShowSkillAutocomplete] = React.useState(false);
const [skillQuery, setSkillQuery] = React.useState('');
const [showSnippetAutocomplete, setShowSnippetAutocomplete] = React.useState(false);
const [snippetQuery, setSnippetQuery] = React.useState('');
const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null);
const [mobileControlsPanel, setMobileControlsPanel] = React.useState<MobileControlsPanel>(null);
// Message history navigation state (up/down arrow to recall previous messages)
@@ -924,6 +929,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const mentionRef = React.useRef<FileMentionHandle>(null);
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
const skillRef = React.useRef<SkillAutocompleteHandle>(null);
const snippetRef = React.useRef<SnippetAutocompleteHandle>(null);
// Ref to track current message value without triggering re-renders in effects
const messageRef = React.useRef(message);
const draftPersistTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -993,9 +999,35 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept);
const composerHighlightRef = React.useRef<HTMLDivElement | null>(null);
const [isNarrowComposer, setIsNarrowComposer] = React.useState(false);
const isDesktopExpanded = isExpandedInput && !isMobile;
const chatInputRadius = 'var(--radius-xl)';
const useCompactChatPlaceholder = isMobile || isNarrowComposer;
React.useEffect(() => {
const element = dropZoneRef.current;
if (!element) return;
const updateWidth = (width: number) => {
const next = width > 0 && width < COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH;
setIsNarrowComposer((prev) => (prev === next ? prev : next));
};
updateWidth(element.clientWidth);
if (typeof ResizeObserver === 'undefined') {
const handleResize = () => updateWidth(element.clientWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}
const observer = new ResizeObserver((entries) => {
updateWidth(entries[0]?.contentRect.width ?? element.clientWidth);
});
observer.observe(element);
return () => observer.disconnect();
}, []);
const sendableAttachedFiles = attachedFiles;
@@ -1782,6 +1814,16 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
}
try {
const expandText = useSnippetsStore.getState().expandText;
primaryText = await expandText(primaryText);
for (const part of additionalParts) {
if (!part.synthetic) part.text = await expandText(part.text);
}
} catch (error) {
console.warn('[ChatInput] Failed to expand snippets, sending original text:', error);
}
// Collect all attachments for error recovery
const allAttachments = [
...primaryAttachments,
@@ -1957,6 +1999,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
}
if (showSnippetAutocomplete && snippetRef.current) {
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
e.preventDefault();
snippetRef.current.handleKeyDown(e.key);
return;
}
}
if (showFileMention && mentionRef.current) {
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
e.preventDefault();
@@ -1980,7 +2030,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
? 1
: 0;
if (cycleAgentDirection !== 0 && !showCommandAutocomplete && !showSkillAutocomplete && !showFileMention) {
if (cycleAgentDirection !== 0 && !showCommandAutocomplete && !showSkillAutocomplete && !showSnippetAutocomplete && !showFileMention) {
e.preventDefault();
e.stopPropagation();
handleCycleAgent(cycleAgentDirection);
@@ -1990,7 +2040,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
// Handle ArrowUp/ArrowDown for message history navigation
// ArrowUp: only when cursor at start (position 0) or input is empty
// ArrowDown: also works when cursor at end (to cycle forward through history)
const isAnyAutocompleteOpen = showCommandAutocomplete || showSkillAutocomplete || showFileMention;
const isAnyAutocompleteOpen = showCommandAutocomplete || showSkillAutocomplete || showSnippetAutocomplete || showFileMention;
const cursorAtStart = textareaRef.current?.selectionStart === 0 && textareaRef.current?.selectionEnd === 0;
const cursorAtEnd = textareaRef.current?.selectionStart === message.length && textareaRef.current?.selectionEnd === message.length;
const canNavigateHistoryUp = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtStart);
@@ -2118,7 +2168,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
return;
}
if (!showCommandAutocomplete && !showSkillAutocomplete && !showFileMention) {
if (!showCommandAutocomplete && !showSkillAutocomplete && !showSnippetAutocomplete && !showFileMention) {
setAutocompleteOverlayPosition(null);
return;
}
@@ -2143,7 +2193,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const spaceBelow = containerRect.height - caretY - popupMargin;
const place: 'above' | 'below' = spaceBelow >= estimatedPopupHeight || spaceBelow >= spaceAbove ? 'below' : 'above';
const desiredWidth = showFileMention ? 520 : showCommandAutocomplete ? 450 : 360;
const desiredWidth = showFileMention ? 520 : showCommandAutocomplete || showSnippetAutocomplete ? 450 : 360;
const clampedLeft = Math.max(
popupMargin,
Math.min(caretX - 24, containerRect.width - desiredWidth - popupMargin)
@@ -2163,6 +2213,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
message.length,
showCommandAutocomplete,
showFileMention,
showSnippetAutocomplete,
showSkillAutocomplete,
]);
@@ -2173,6 +2224,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
message,
showCommandAutocomplete,
showSkillAutocomplete,
showSnippetAutocomplete,
showFileMention,
isDesktopExpanded,
]);
@@ -2280,6 +2332,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
setShowCommandAutocomplete(false);
setShowFileMention(false);
setShowSkillAutocomplete(false);
setShowSnippetAutocomplete(false);
return;
}
@@ -2298,6 +2351,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
setShowCommandAutocomplete(true);
setShowFileMention(false);
setShowSkillAutocomplete(false);
setShowSnippetAutocomplete(false);
return;
}
}
@@ -2324,6 +2378,21 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
setShowSkillAutocomplete(false);
setSkillQuery('');
const lastHashSymbol = textBeforeCursor.lastIndexOf('#');
if (lastHashSymbol !== -1) {
const charBefore = lastHashSymbol > 0 ? textBeforeCursor[lastHashSymbol - 1] : null;
const textAfterHash = textBeforeCursor.substring(lastHashSymbol + 1);
const isWordBoundary = !charBefore || /\s/.test(charBefore);
if (isWordBoundary && !textAfterHash.includes(' ') && !textAfterHash.includes('\n')) {
setSnippetQuery(textAfterHash);
setShowSnippetAutocomplete(true);
setShowFileMention(false);
return;
}
}
setShowSnippetAutocomplete(false);
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
if (lastAtSymbol !== -1) {
const charBefore = lastAtSymbol > 0 ? textBeforeCursor[lastAtSymbol - 1] : null;
@@ -2703,6 +2772,28 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
textareaRef.current?.focus();
};
const handleSnippetSelect = (_snippet: unknown, trigger: string) => {
const textarea = textareaRef.current;
const cursorPosition = textarea?.selectionStart ?? message.length;
const textBeforeCursor = message.substring(0, cursorPosition);
const lastHashSymbol = textBeforeCursor.lastIndexOf('#');
const startIndex = lastHashSymbol !== -1 ? lastHashSymbol : cursorPosition;
const newMessage = `${message.substring(0, startIndex)}#${trigger} ${message.substring(cursorPosition)}`;
setMessage(newMessage);
const nextCursor = startIndex + trigger.length + 2;
requestAnimationFrame(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = nextCursor;
textareaRef.current.selectionEnd = nextCursor;
}
adjustTextareaHeight();
updateAutocompleteState(newMessage, nextCursor);
});
setShowSnippetAutocomplete(false);
setSnippetQuery('');
textareaRef.current?.focus();
};
const handleCommandSelect = (command: CommandInfo) => {
setMessage(`/${command.name} `);
@@ -3896,6 +3987,25 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
/>
)}
{showSnippetAutocomplete && (
<SnippetAutocomplete
ref={snippetRef}
searchQuery={snippetQuery}
onSnippetSelect={handleSnippetSelect}
onClose={() => setShowSnippetAutocomplete(false)}
style={isDesktopExpanded && autocompleteOverlayPosition
? {
left: `${autocompleteOverlayPosition.left}px`,
top: `${autocompleteOverlayPosition.top}px`,
bottom: 'auto',
width: `min(450px, calc(100% - ${autocompleteOverlayPosition.left + 8}px))`,
maxHeight: `${autocompleteOverlayPosition.maxHeight}px`,
transform: autocompleteOverlayPosition.place === 'above' ? 'translateY(-100%)' : undefined,
}
: undefined}
/>
)}
{showFileMention && (
<FileMentionAutocomplete
@@ -3986,7 +4096,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
placeholder={currentSessionId || newSessionDraftOpen
? inputMode === 'shell'
? t('chat.chatInput.placeholder.shell')
: t('chat.chatInput.placeholder.chat')
: t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat')
: t('chat.chatInput.placeholder.selectSession')}
disabled={!currentSessionId && !newSessionDraftOpen}
autoCorrect={isMobile ? "on" : "off"}
@@ -0,0 +1,159 @@
import React from 'react';
import { cn, fuzzyMatch } from '@/lib/utils';
import { useSnippetsStore } from '@/stores/useSnippetsStore';
import { useUIStore } from '@/stores/useUIStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import type { Snippet } from '@/types/snippet';
export interface SnippetAutocompleteHandle {
handleKeyDown: (key: string) => void;
}
interface SnippetAutocompleteProps {
searchQuery: string;
onSnippetSelect: (snippet: Snippet, trigger: string) => void;
onClose: () => void;
style?: React.CSSProperties;
}
function snippetPreview(snippet: Snippet): string {
return (snippet.description || snippet.content).replace(/\s+/g, ' ').trim().slice(0, 120);
}
export const SnippetAutocomplete = React.forwardRef<SnippetAutocompleteHandle, SnippetAutocompleteProps>(({
searchQuery,
onSnippetSelect,
onClose,
style,
}, ref) => {
const { t } = useI18n();
const containerRef = React.useRef<HTMLDivElement | null>(null);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const selectedIndexRef = React.useRef(0);
const [filteredSnippets, setFilteredSnippets] = React.useState<Snippet[]>([]);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const snippets = useSnippetsStore((s) => s.snippets);
const loadSnippets = useSnippetsStore((s) => s.loadSnippets);
const setSnippetDraft = useSnippetsStore((s) => s.setSnippetDraft);
const setSelectedSnippet = useSnippetsStore((s) => s.setSelectedSnippet);
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
const setSettingsPage = useUIStore((s) => s.setSettingsPage);
React.useEffect(() => {
void loadSnippets();
}, [loadSnippets]);
React.useEffect(() => {
const query = searchQuery.trim();
const matches = query.length
? snippets.filter((snippet) => fuzzyMatch(snippet.name, query) || snippet.aliases.some((alias) => fuzzyMatch(alias, query)))
: snippets;
const sortedMatches = [...matches].sort((a, b) => {
if (a.source === 'project' && b.source !== 'project') return -1;
if (a.source !== 'project' && b.source === 'project') return 1;
return a.name.localeCompare(b.name);
});
setFilteredSnippets(sortedMatches);
setSelectedIndex(sortedMatches.length ? 1 : 0);
}, [searchQuery, snippets]);
React.useEffect(() => {
selectedIndexRef.current = selectedIndex;
itemRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
React.useEffect(() => {
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node | null;
if (target && containerRef.current && !containerRef.current.contains(target)) onClose();
};
document.addEventListener('pointerdown', handlePointerDown, true);
return () => document.removeEventListener('pointerdown', handlePointerDown, true);
}, [onClose]);
const chooseSnippet = React.useCallback((snippet: Snippet) => {
const query = searchQuery.trim();
const trigger = snippet.aliases.includes(query) ? query : snippet.name;
onSnippetSelect(snippet, trigger);
}, [onSnippetSelect, searchQuery]);
const openNewSnippetSettings = React.useCallback(() => {
const existing = new Set(snippets.map((snippet) => snippet.name));
let name = 'new-snippet';
let counter = 1;
while (existing.has(name)) {
name = `new-snippet-${counter++}`;
}
setSnippetDraft({ name, scope: 'global' });
setSelectedSnippet(name);
setSettingsPage('snippets');
setSettingsDialogOpen(true);
onClose();
}, [onClose, setSelectedSnippet, setSettingsDialogOpen, setSettingsPage, setSnippetDraft, snippets]);
React.useImperativeHandle(ref, () => ({
handleKeyDown: (key: string) => {
if (key === 'Escape') {
onClose();
return;
}
const itemCount = filteredSnippets.length + 1;
if (key === 'ArrowDown') {
setSelectedIndex((prev) => (prev + 1) % itemCount);
return;
}
if (key === 'ArrowUp') {
setSelectedIndex((prev) => (prev - 1 + itemCount) % itemCount);
return;
}
if (key === 'Enter' || key === 'Tab') {
if (selectedIndexRef.current === 0) {
openNewSnippetSettings();
return;
}
const snippet = filteredSnippets[selectedIndexRef.current - 1];
if (snippet) chooseSnippet(snippet);
}
},
}), [chooseSnippet, filteredSnippets, onClose, openNewSnippetSettings]);
return (
<div ref={containerRef} className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col" style={style}>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2">
<div
ref={(el) => { itemRefs.current[0] = el; }}
className={cn('flex items-center gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label', selectedIndex === 0 && 'bg-interactive-selection')}
onClick={openNewSnippetSettings}
onMouseMove={() => setSelectedIndex(0)}
>
<Icon name="add" className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium text-foreground">{t('chat.snippetAutocomplete.action.addNew')}</span>
</div>
{filteredSnippets.length ? filteredSnippets.map((snippet, index) => (
<div
key={`${snippet.source}:${snippet.filePath}`}
ref={(el) => { itemRefs.current[index + 1] = el; }}
className={cn('flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label', index + 1 === selectedIndex && 'bg-interactive-selection')}
onClick={() => chooseSnippet(snippet)}
onMouseMove={() => setSelectedIndex(index + 1)}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold truncate">#{snippet.name}</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">{t(`snippets.source.${snippet.source}`)}</span>
</div>
<div className="typography-meta text-muted-foreground mt-0.5 truncate">{snippetPreview(snippet)}</div>
</div>
</div>
)) : (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">{t('chat.snippetAutocomplete.empty')}</div>
)}
</ScrollableOverlay>
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">{t('chat.snippetAutocomplete.footer')}</div>
</div>
);
});
SnippetAutocomplete.displayName = 'SnippetAutocomplete';
+3 -2
View File
@@ -1,7 +1,7 @@
// This file is auto-generated by scripts/generate-icon-sprite.mjs
// Do not edit manually. Run the script to update.
export const iconSpriteData: Record<string, string> = {
export const iconSpriteData = {
"add-circle": `<path d="M11 11V7H13V11H17V13H13V17H11V13H7V11H11ZM12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20Z" fill="currentColor"/>`,
"add": `<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z" fill="currentColor"/>`,
"ai-agent-fill": `<path d="M12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12 15C9.71266 15 7.65042 15.961 6.19238 17.5C7.65042 19.039 9.71266 20 12 20C14.2871 20 16.3486 19.0387 17.8066 17.5C16.3486 15.9613 14.2871 15 12 15ZM12.4707 5.31934C12.2943 4.89337 11.7058 4.89339 11.5293 5.31934L11.2764 5.93066C10.8445 6.97341 10.0384 7.80621 9.02539 8.25684L8.30762 8.57617C7.89751 8.75905 7.89744 9.35625 8.30762 9.53906L9.06738 9.87695C10.0551 10.3163 10.8476 11.1193 11.2871 12.1279L11.5332 12.6934C11.7138 13.1073 12.2863 13.1073 12.4668 12.6934L12.7139 12.1279C13.1534 11.1194 13.9449 10.3163 14.9326 9.87695L15.6924 9.53906C16.1026 9.35624 16.1025 8.75907 15.6924 8.57617L14.9746 8.25684C13.9616 7.8062 13.1556 6.9734 12.7236 5.93066L12.4707 5.31934Z" fill="currentColor"/>`,
@@ -44,6 +44,7 @@ export const iconSpriteData: Record<string, string> = {
"chat-ai-3": `<path d="M12 1.99996C12.8632 1.99996 13.701 2.10973 14.5 2.31539L14 4.25192C13.3608 4.0874 12.6906 3.99997 12 3.99997C7.58174 3.99997 4.00002 7.58172 4 12C4 13.3344 4.3255 14.6174 4.93945 15.7656L5.28906 16.4189L4.63379 19.3662L7.58105 18.7109L8.23438 19.0605C9.38255 19.6745 10.6656 20 12 20C16.4183 20 20 16.4183 20 12C20 11.6771 19.9805 11.3587 19.9434 11.0459L21.9297 10.8095C21.976 11.1999 22 11.5972 22 12C22 17.5228 17.5228 22 12 22C10.2975 22 8.69425 21.5746 7.29102 20.8242L2 22L3.17578 16.709C2.42541 15.3057 2 13.7025 2 12C2.00002 6.47714 6.47717 1.99996 12 1.99996ZM19.5293 1.3193C19.7058 0.893513 20.2942 0.8935 20.4707 1.3193L20.7236 1.93063C21.1555 2.97343 21.9615 3.80614 22.9746 4.2568L23.6914 4.57614C24.1022 4.75882 24.1022 5.35635 23.6914 5.53903L22.9326 5.87692C21.945 6.3162 21.1534 7.11943 20.7139 8.1279L20.4668 8.69333C20.2863 9.10747 19.7136 9.10747 19.5332 8.69333L19.2861 8.1279C18.8466 7.11942 18.0551 6.3162 17.0674 5.87692L16.3076 5.53903C15.8974 5.35618 15.8974 4.75895 16.3076 4.57614L17.0254 4.2568C18.0384 3.80614 18.8445 2.97343 19.2764 1.93063L19.5293 1.3193Z" fill="currentColor"/>`,
"chat-history": `<path d="M12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C10.298 22 8.69525 21.5748 7.29229 20.8248L2 22L3.17629 16.7097C2.42562 15.3063 2 13.7028 2 12C2 6.47715 6.47715 2 12 2ZM12 4C7.58172 4 4 7.58172 4 12C4 13.3347 4.32563 14.6181 4.93987 15.7664L5.28952 16.4201L4.63445 19.3663L7.58189 18.7118L8.23518 19.061C9.38315 19.6747 10.6659 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4ZM13 7V12H17V14H11V7H13Z" fill="currentColor"/>`,
"chat-new": `<path d="M14 3V5H4V18.3851L5.76282 17H20V10H22V18C22 18.5523 21.5523 19 21 19H6.45455L2 22.5V4C2 3.44772 2.44772 3 3 3H14ZM19 3V0H21V3H24V5H21V8H19V5H16V3H19Z" fill="currentColor"/>`,
"chat-thread": `<path d="M2 22L7.29117 20.8242C8.6944 21.5746 10.2975 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 13.7025 2.42544 15.3056 3.17581 16.7088L2 22ZM8.23428 19.0605L7.58075 18.711L4.63416 19.3658L5.28896 16.4192L4.93949 15.7657C4.32549 14.6175 4 13.3345 4 12C4 7.58172 7.58172 4 12 4C16.4183 4 20 7.58172 20 12C20 16.4183 16.4183 20 12 20C10.6655 20 9.38248 19.6745 8.23428 19.0605ZM15.4487 7H13.4411L13.2661 9.00024H11.2584L11.4334 7H9.42577L9.25077 9.00024H7V11.0002H9.0758L8.90082 13.0002H7V15.0002H8.72584L8.55089 17H10.5585L10.7335 15.0002H12.7411L12.5662 17H14.5738L14.7488 15.0002H17V13.0002H14.9237L15.0987 11.0002H17V9.00024H15.2737L15.4487 7ZM11.0834 11.0002H13.0911L12.9161 13.0002H10.9085L11.0834 11.0002Z" fill="currentColor"/>`,
"check": `<path d="M9.9997 15.1709L19.1921 5.97852L20.6063 7.39273L9.9997 17.9993L3.63574 11.6354L5.04996 10.2212L9.9997 15.1709Z" fill="currentColor"/>`,
"checkbox-blank-circle-fill": `<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" fill="currentColor"/>`,
"checkbox-blank": `<path d="M4 3H20C20.5523 3 21 3.44772 21 4V20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V4C3 3.44772 3.44772 3 4 3ZM5 5V19H19V5H5Z" fill="currentColor"/>`,
@@ -225,4 +226,4 @@ export const iconSpriteData: Record<string, string> = {
"voice-recognition": `<path d="M4.99805 15V19H8.99805V21H2.99805V15H4.99805ZM20.998 15V21H14.998V19H18.998V15H20.998ZM12.998 6V18H10.998V6H12.998ZM8.99805 9V15H6.99805V9H8.99805ZM16.998 9V15H14.998V9H16.998ZM8.99805 3V5H4.99805V9H2.99805V3H8.99805ZM20.998 3V9H18.998V5H14.998V3H20.998Z" fill="currentColor"/>`,
"volume-up": `<path d="M6.60282 10.0001L10 7.22056V16.7796L6.60282 14.0001H3V10.0001H6.60282ZM2 16.0001H5.88889L11.1834 20.3319C11.2727 20.405 11.3846 20.4449 11.5 20.4449C11.7761 20.4449 12 20.2211 12 19.9449V4.05519C12 3.93977 11.9601 3.8279 11.887 3.73857C11.7121 3.52485 11.3971 3.49335 11.1834 3.66821L5.88889 8.00007H2C1.44772 8.00007 1 8.44778 1 9.00007V15.0001C1 15.5524 1.44772 16.0001 2 16.0001ZM23 12C23 15.292 21.5539 18.2463 19.2622 20.2622L17.8445 18.8444C19.7758 17.1937 21 14.7398 21 12C21 9.26016 19.7758 6.80629 17.8445 5.15557L19.2622 3.73779C21.5539 5.75368 23 8.70795 23 12ZM18 12C18 10.0883 17.106 8.38548 15.7133 7.28673L14.2842 8.71584C15.3213 9.43855 16 10.64 16 12C16 13.36 15.3213 14.5614 14.2842 15.2841L15.7133 16.7132C17.106 15.6145 18 13.9116 18 12Z" fill="currentColor"/>`,
"window": `<path d="M21 3C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21ZM20 11H4V19H20V11ZM20 5H4V9H20V5ZM11 6V8H9V6H11ZM7 6V8H5V6H7Z" fill="currentColor"/>`,
};
} as const satisfies Record<string, string>;
+1 -2
View File
@@ -35,7 +35,6 @@ import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { useDeviceInfo, useTabletStandalonePwaRuntime } from '@/lib/device';
import { cn, hasModifier } from '@/lib/utils';
import { McpDropdownContent } from '@/components/mcp/McpDropdown';
import { McpIcon } from '@/components/icons/McpIcon';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
@@ -1533,7 +1532,7 @@ export const Header: React.FC<HeaderProps> = ({
}
base.push(
{ value: 'usage', label: t('layout.services.usage'), icon: "timer" },
{ value: 'mcp', label: 'MCP', icon: McpIcon as unknown as IconName }
{ value: 'mcp', label: 'MCP', icon: "plug-2" }
);
return base;
}, [isDesktopApp, t]);
@@ -113,6 +113,7 @@ export function MultiRunFusionDialog({
.map((candidate): FusionSource | null => {
const candidateParsed = parseMultiRunSessionTitle(candidate.title);
if (!candidateParsed || candidateParsed.groupSlug !== parsed.groupSlug || candidateParsed.fusion) return null;
if ((candidateParsed.runGroup ?? null) !== (parsed.runGroup ?? null)) return null;
const directory = useSessionUIStore.getState().getDirectoryForSession(candidate.id)
?? resolveGlobalSessionDirectory(candidate);
const projectDirectory = getSessionProjectDirectory(candidate.id, directory);
@@ -154,7 +155,7 @@ export function MultiRunFusionDialog({
}
const directory = sources[0]?.projectDirectory ?? sources[0]?.directory ?? null;
const fusionTitle = getFusionSessionTitle(parsed.groupSlug, providerID, modelID);
const fusionTitle = getFusionSessionTitle(parsed.groupSlug, providerID, modelID, parsed.runGroup);
const [visiblePrompt, instructionsPrompt] = await Promise.all([
renderMagicPrompt('session.fusion.visible'),
renderMagicPrompt('session.fusion.instructions'),
@@ -13,13 +13,15 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useMultiRunStore } from '@/stores/useMultiRunStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { usePromptTemplatesStore } from '@/stores/usePromptTemplatesStore';
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
import type { ProjectRef } from '@/lib/openchamberConfig';
import type { CreateMultiRunParams, MultiRunGroup } from '@/types/multirun';
import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from './ModelMultiSelect';
import { BranchSelector, useBranchOptions } from './BranchSelector';
import { AgentSelector } from './AgentSelector';
import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from '@/components/chat/CommandAutocomplete';
import { FileMentionAutocomplete, type FileMentionHandle } from '@/components/chat/FileMentionAutocomplete';
import { SnippetAutocomplete, type SnippetAutocompleteHandle } from '@/components/chat/SnippetAutocomplete';
import { Icon } from "@/components/icon/Icon";
import { isDesktopShell } from '@/lib/desktop';
import { useTabletStandalonePwaRuntime } from '@/lib/device';
@@ -42,7 +44,6 @@ interface MultiRunAttachedFile {
interface RunGroupState {
id: string;
templateId: string;
prompt: string;
models: ModelSelectionWithId[];
}
@@ -91,7 +92,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
const { t } = useI18n();
const [name, setName] = React.useState('');
const [runGroups, setRunGroups] = React.useState<RunGroupState[]>(() => [
{ id: generateInstanceId(), templateId: '', prompt: '', models: [] },
{ id: generateInstanceId(), prompt: '', models: [] },
]);
const [selectedAgent, setSelectedAgent] = React.useState<string>('');
const [attachedFiles, setAttachedFiles] = React.useState<MultiRunAttachedFile[]>([]);
@@ -102,12 +103,6 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
const [isolateRuns, setIsolateRuns] = React.useState(true);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const templates = usePromptTemplatesStore((s) => s.templates);
React.useEffect(() => {
usePromptTemplatesStore.getState().loadTemplates();
}, []);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null);
const homeDirectory = useDirectoryStore((state) => state.homeDirectory ?? null);
@@ -304,17 +299,9 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
}, []);
const addGroup = React.useCallback(() => {
setRunGroups((prev) => [...prev, { id: generateInstanceId(), templateId: '', prompt: '', models: [] }]);
setRunGroups((prev) => [...prev, { id: generateInstanceId(), prompt: '', models: [] }]);
}, []);
const handleTemplateChange = React.useCallback((groupId: string, templateId: string) => {
const template = templateId ? templates.find((t) => t.id === templateId) : null;
updateGroup(groupId, {
templateId,
prompt: template ? template.body : '',
});
}, [templates, updateGroup]);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
@@ -390,7 +377,6 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
const groups: MultiRunGroup[] = validGroups.map((g) => ({
prompt: g.prompt.trim(),
models: g.models.map((m) => ({ providerID: m.providerID, modelID: m.modelID, displayName: m.displayName, variant: m.variant })),
templateId: g.templateId || undefined,
}));
const params: CreateMultiRunParams = {
@@ -646,11 +632,9 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
key={group.id}
group={group}
groupIndex={groupIndex}
templates={templates}
canRemove={runGroups.length > 1}
onUpdate={updateGroup}
onRemove={removeGroup}
onTemplateChange={handleTemplateChange}
/>
))}
@@ -712,23 +696,29 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
interface RunGroupCardProps {
group: RunGroupState;
groupIndex: number;
templates: { id: string; name: string; body: string; isDefault: boolean }[];
canRemove: boolean;
onUpdate: (groupId: string, updates: Partial<RunGroupState>) => void;
onRemove: (groupId: string) => void;
onTemplateChange: (groupId: string, templateId: string) => void;
}
const RunGroupCard: React.FC<RunGroupCardProps> = ({
group,
groupIndex,
templates,
canRemove,
onUpdate,
onRemove,
onTemplateChange,
}) => {
const { t } = useI18n();
const [showFileMention, setShowFileMention] = React.useState(false);
const [mentionQuery, setMentionQuery] = React.useState('');
const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false);
const [commandQuery, setCommandQuery] = React.useState('');
const [showSnippetAutocomplete, setShowSnippetAutocomplete] = React.useState(false);
const [snippetQuery, setSnippetQuery] = React.useState('');
const promptTextareaRef = React.useRef<HTMLTextAreaElement>(null);
const mentionRef = React.useRef<FileMentionHandle>(null);
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
const snippetRef = React.useRef<SnippetAutocompleteHandle>(null);
const handleAddModel = React.useCallback((model: ModelSelectionWithId) => {
if (group.models.length >= MAX_MODELS_PER_GROUP) return;
@@ -743,6 +733,182 @@ const RunGroupCard: React.FC<RunGroupCardProps> = ({
onUpdate(group.id, { models: group.models.map((item, i) => (i === index ? model : item)) });
}, [group.id, group.models, onUpdate]);
const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => {
if (value.startsWith('/')) {
const firstSpace = value.indexOf(' ');
const firstNewline = value.indexOf('\n');
const commandEnd = Math.min(
firstSpace === -1 ? value.length : firstSpace,
firstNewline === -1 ? value.length : firstNewline,
);
if (cursorPosition <= commandEnd && firstSpace === -1) {
setCommandQuery(value.substring(1, commandEnd));
setShowCommandAutocomplete(true);
setShowFileMention(false);
setShowSnippetAutocomplete(false);
return;
}
}
setShowCommandAutocomplete(false);
const textBeforeCursor = value.substring(0, cursorPosition);
const lastHashSymbol = textBeforeCursor.lastIndexOf('#');
if (lastHashSymbol !== -1) {
const charBefore = lastHashSymbol > 0 ? textBeforeCursor[lastHashSymbol - 1] : null;
const textAfterHash = textBeforeCursor.substring(lastHashSymbol + 1);
const isWordBoundary = !charBefore || /\s/.test(charBefore);
if (isWordBoundary && !textAfterHash.includes(' ') && !textAfterHash.includes('\n')) {
setSnippetQuery(textAfterHash);
setShowSnippetAutocomplete(true);
setShowFileMention(false);
return;
}
}
setShowSnippetAutocomplete(false);
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
if (lastAtSymbol !== -1) {
const charBefore = lastAtSymbol > 0 ? textBeforeCursor[lastAtSymbol - 1] : null;
const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1);
const isWordBoundary = !charBefore || /\s/.test(charBefore);
if (isWordBoundary && !textAfterAt.includes(' ') && !textAfterAt.includes('\n')) {
setMentionQuery(textAfterAt);
setShowFileMention(true);
} else {
setShowFileMention(false);
}
return;
}
setShowFileMention(false);
}, []);
const setPrompt = React.useCallback((value: string) => {
onUpdate(group.id, { prompt: value });
}, [group.id, onUpdate]);
const handleFileSelect = React.useCallback((file: { name: string; path: string; relativePath?: string }) => {
const prompt = group.prompt;
const textarea = promptTextareaRef.current;
const cursorPosition = textarea?.selectionStart ?? prompt.length;
const textBeforeCursor = prompt.substring(0, cursorPosition);
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
const mentionPath = (file.relativePath && file.relativePath.trim().length > 0)
? file.relativePath.trim()
: (file.path || file.name);
const startIndex = lastAtSymbol !== -1 ? lastAtSymbol : cursorPosition;
const nextPrompt = `${prompt.substring(0, startIndex)}@${mentionPath} ${prompt.substring(cursorPosition)}`;
const nextCursor = startIndex + mentionPath.length + 2;
setPrompt(nextPrompt);
setShowFileMention(false);
setMentionQuery('');
requestAnimationFrame(() => {
const currentTextarea = promptTextareaRef.current;
if (currentTextarea) {
currentTextarea.selectionStart = nextCursor;
currentTextarea.selectionEnd = nextCursor;
currentTextarea.focus();
}
updateAutocompleteState(nextPrompt, nextCursor);
});
}, [group.prompt, setPrompt, updateAutocompleteState]);
const handleAgentSelect = React.useCallback((agentName: string) => {
const prompt = group.prompt;
const textarea = promptTextareaRef.current;
const cursorPosition = textarea?.selectionStart ?? prompt.length;
const textBeforeCursor = prompt.substring(0, cursorPosition);
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
const startIndex = lastAtSymbol !== -1 ? lastAtSymbol : cursorPosition;
const nextPrompt = `${prompt.substring(0, startIndex)}@${agentName} ${prompt.substring(cursorPosition)}`;
const nextCursor = startIndex + agentName.length + 2;
setPrompt(nextPrompt);
setShowFileMention(false);
setMentionQuery('');
requestAnimationFrame(() => {
const currentTextarea = promptTextareaRef.current;
if (currentTextarea) {
currentTextarea.selectionStart = nextCursor;
currentTextarea.selectionEnd = nextCursor;
currentTextarea.focus();
}
updateAutocompleteState(nextPrompt, nextCursor);
});
}, [group.prompt, setPrompt, updateAutocompleteState]);
const handleCommandSelect = React.useCallback((command: CommandInfo) => {
const nextPrompt = `/${command.name} `;
setPrompt(nextPrompt);
setShowCommandAutocomplete(false);
setCommandQuery('');
setShowSnippetAutocomplete(false);
requestAnimationFrame(() => {
const currentTextarea = promptTextareaRef.current;
if (currentTextarea) {
currentTextarea.focus();
currentTextarea.selectionStart = currentTextarea.value.length;
currentTextarea.selectionEnd = currentTextarea.value.length;
}
updateAutocompleteState(nextPrompt, nextPrompt.length);
});
}, [setPrompt, updateAutocompleteState]);
const handleSnippetSelect = React.useCallback((_snippet: unknown, trigger: string) => {
const prompt = group.prompt;
const textarea = promptTextareaRef.current;
const cursorPosition = textarea?.selectionStart ?? prompt.length;
const textBeforeCursor = prompt.substring(0, cursorPosition);
const lastHashSymbol = textBeforeCursor.lastIndexOf('#');
const startIndex = lastHashSymbol !== -1 ? lastHashSymbol : cursorPosition;
const nextPrompt = `${prompt.substring(0, startIndex)}#${trigger} ${prompt.substring(cursorPosition)}`;
const nextCursor = startIndex + trigger.length + 2;
setPrompt(nextPrompt);
setShowSnippetAutocomplete(false);
setSnippetQuery('');
requestAnimationFrame(() => {
const currentTextarea = promptTextareaRef.current;
if (currentTextarea) {
currentTextarea.selectionStart = nextCursor;
currentTextarea.selectionEnd = nextCursor;
currentTextarea.focus();
}
updateAutocompleteState(nextPrompt, nextCursor);
});
}, [group.prompt, setPrompt, updateAutocompleteState]);
const handlePromptKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showCommandAutocomplete && commandRef.current) {
if (event.key === 'Enter' || event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Escape' || event.key === 'Tab') {
event.preventDefault();
commandRef.current.handleKeyDown(event.key);
return;
}
}
if (showFileMention && mentionRef.current) {
if (event.key === 'Enter' || event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Escape' || event.key === 'Tab') {
event.preventDefault();
mentionRef.current.handleKeyDown(event.key);
}
}
if (showSnippetAutocomplete && snippetRef.current) {
if (event.key === 'Enter' || event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Escape' || event.key === 'Tab') {
event.preventDefault();
snippetRef.current.handleKeyDown(event.key);
}
}
}, [showCommandAutocomplete, showFileMention, showSnippetAutocomplete]);
return (
<div className="rounded-lg border border-border p-3 space-y-3">
<div className="flex items-center justify-between gap-2">
@@ -761,33 +927,55 @@ const RunGroupCard: React.FC<RunGroupCardProps> = ({
)}
</div>
<div className="flex flex-col gap-1.5">
<FieldLabel>{t('multirun.launcher.groups.template.label')}</FieldLabel>
<Select
value={group.templateId || '__custom__'}
onValueChange={(v) => onTemplateChange(group.id, v === '__custom__' ? '' : v)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t('multirun.launcher.groups.template.placeholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__custom__">{t('multirun.launcher.groups.template.custom')}</SelectItem>
{templates.map((tpl) => (
<SelectItem key={tpl.id} value={tpl.id}>{tpl.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<FieldLabel required>{t('multirun.launcher.groups.prompt.label')}</FieldLabel>
<Textarea
value={group.prompt}
onChange={(e) => onUpdate(group.id, { prompt: e.target.value })}
placeholder={t('multirun.launcher.groups.prompt.placeholder')}
className="typography-meta min-h-[80px] max-h-[200px] resize-none overflow-y-auto field-sizing-content"
required
/>
<div className="relative">
<Textarea
ref={promptTextareaRef}
value={group.prompt}
onChange={(event) => {
const nextPrompt = event.target.value;
setPrompt(nextPrompt);
const cursorPosition = event.target.selectionStart ?? nextPrompt.length;
updateAutocompleteState(nextPrompt, cursorPosition);
}}
onKeyDown={handlePromptKeyDown}
placeholder={t('multirun.launcher.groups.prompt.placeholder')}
className="typography-meta min-h-[80px] max-h-[200px] resize-none overflow-y-auto field-sizing-content"
required
/>
{showCommandAutocomplete ? (
<CommandAutocomplete
ref={commandRef}
searchQuery={commandQuery}
onCommandSelect={handleCommandSelect}
onClose={() => setShowCommandAutocomplete(false)}
style={{ left: 0, top: 'auto', bottom: 'calc(100% + 6px)', marginBottom: 0, maxWidth: '100%' }}
/>
) : null}
{showFileMention ? (
<FileMentionAutocomplete
ref={mentionRef}
searchQuery={mentionQuery}
onFileSelect={handleFileSelect}
onAgentSelect={handleAgentSelect}
onClose={() => setShowFileMention(false)}
style={{ left: 0, top: 'auto', bottom: 'calc(100% + 6px)', marginBottom: 0, maxWidth: '100%' }}
/>
) : null}
{showSnippetAutocomplete ? (
<SnippetAutocomplete
ref={snippetRef}
searchQuery={snippetQuery}
onSnippetSelect={handleSnippetSelect}
onClose={() => setShowSnippetAutocomplete(false)}
style={{ left: 0, top: 'auto', bottom: 'calc(100% + 6px)', marginBottom: 0, maxWidth: '100%' }}
/>
) : null}
</div>
</div>
<div className="flex flex-col gap-1.5">
@@ -12,6 +12,7 @@ import {
} from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
import { useGitIdentitiesStore, type GitIdentityProfile, type GitIdentityAuthType } from '@/stores/useGitIdentitiesStore';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
@@ -24,7 +25,7 @@ const PROFILE_COLORS = [
{ key: 'type', label: 'Yellow', cssVar: 'var(--syntax-type)' },
];
const PROFILE_ICONS = [
const PROFILE_ICONS: Array<{ key: string; Icon: IconName; label: string }> = [
{ key: 'branch', Icon: 'git-branch', label: 'Branch' },
{ key: 'briefcase', Icon: 'briefcase', label: 'Work' },
{ key: 'house', Icon: 'home', label: 'Personal' },
@@ -22,10 +22,11 @@ import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings
import { GitIdentityEditorDialog } from './GitIdentityEditorDialog';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
const ICON_MAP: Record<string, string> = {
const ICON_MAP: Record<string, IconName> = {
branch: 'git-branch',
briefcase: 'briefcase',
house: 'home',
@@ -1,181 +0,0 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { usePromptTemplatesStore } from '@/stores/usePromptTemplatesStore';
import { useShallow } from 'zustand/react/shallow';
import { RiFileTextLine } from '@remixicon/react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useI18n } from '@/lib/i18n';
export const PromptTemplatesPage: React.FC = () => {
const { t } = useI18n();
const {
selectedTemplateId,
templates,
updateTemplate,
createTemplate,
getTemplateById,
} = usePromptTemplatesStore(useShallow((s) => ({
selectedTemplateId: s.selectedTemplateId,
templates: s.templates,
updateTemplate: s.updateTemplate,
createTemplate: s.createTemplate,
getTemplateById: s.getTemplateById,
})));
const selectedTemplate = selectedTemplateId ? getTemplateById(selectedTemplateId) : null;
const isNew = Boolean(selectedTemplateId && !selectedTemplate && templates.length > 0);
const [name, setName] = React.useState('');
const [body, setBody] = React.useState('');
const [isSaving, setIsSaving] = React.useState(false);
const initialStateRef = React.useRef<{ name: string; body: string } | null>(null);
React.useEffect(() => {
if (selectedTemplate) {
setName(selectedTemplate.name);
setBody(selectedTemplate.body);
initialStateRef.current = { name: selectedTemplate.name, body: selectedTemplate.body };
} else if (isNew && selectedTemplateId) {
setName(selectedTemplateId);
setBody('');
initialStateRef.current = { name: selectedTemplateId, body: '' };
}
}, [selectedTemplate, isNew, selectedTemplateId, templates]);
const isDirty = React.useMemo(() => {
const initial = initialStateRef.current;
if (!initial) return false;
return name !== initial.name || body !== initial.body;
}, [name, body]);
const handleSave = async () => {
if (!selectedTemplateId) return;
const trimmedName = name.trim();
const trimmedBody = body.trim();
if (!trimmedName) {
toast.error(t('settings.promptTemplates.page.toast.nameRequired'));
return;
}
setIsSaving(true);
try {
if (selectedTemplate) {
const updates: { name?: string; body?: string } = {};
if (trimmedName !== selectedTemplate.name) updates.name = trimmedName;
if (trimmedBody !== selectedTemplate.body) updates.body = trimmedBody;
if (Object.keys(updates).length > 0) {
const success = await updateTemplate(selectedTemplateId, updates);
if (success) {
toast.success(t('settings.promptTemplates.page.toast.updated'));
initialStateRef.current = { name: trimmedName, body: trimmedBody };
} else {
toast.error(t('settings.promptTemplates.page.toast.updateFailed'));
}
}
} else {
const success = await createTemplate(selectedTemplateId, trimmedName, trimmedBody);
if (success) {
toast.success(t('settings.promptTemplates.page.toast.created'));
initialStateRef.current = { name: trimmedName, body: trimmedBody };
} else {
toast.error(t('settings.promptTemplates.page.toast.createFailed'));
}
}
} catch (error) {
console.error('Error saving prompt template:', error);
toast.error(t('settings.promptTemplates.page.toast.saveUnexpectedError'));
} finally {
setIsSaving(false);
}
};
if (!selectedTemplateId) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<RiFileTextLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
<p className="typography-body">{t('settings.promptTemplates.page.empty.title')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.promptTemplates.page.empty.description')}</p>
</div>
</div>
);
}
return (
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
<div className="mb-4 flex items-center justify-between gap-4">
<div className="min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground truncate">
{selectedTemplate ? selectedTemplate.name : t('settings.promptTemplates.page.title.new')}
</h2>
<p className="typography-meta text-muted-foreground truncate">
{selectedTemplate ? t('settings.promptTemplates.page.subtitle.edit') : t('settings.promptTemplates.page.subtitle.new')}
</p>
</div>
</div>
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.promptTemplates.page.section.identity')}
</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-0">
<div className="py-1.5">
<span className="typography-ui-label text-foreground">{t('settings.promptTemplates.page.field.name')}</span>
<div className="mt-1.5">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('settings.promptTemplates.page.field.namePlaceholder')}
className="h-7 w-full max-w-sm px-2"
disabled={selectedTemplate?.isDefault === true}
/>
</div>
</div>
</section>
</div>
<div className="mb-2">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.promptTemplates.page.section.template')}
</h3>
</div>
<section className="px-2 pb-2 pt-0">
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={t('settings.promptTemplates.page.field.templatePlaceholder')}
rows={12}
className="w-full font-mono typography-meta min-h-[160px] max-h-[60vh] bg-transparent resize-y"
/>
</section>
<div className="mt-2 px-2">
<p className="typography-meta text-muted-foreground">
{t('settings.promptTemplates.page.templateHint')}
</p>
</div>
</div>
<div className="px-2 py-1">
<Button
onClick={handleSave}
disabled={isSaving || !isDirty}
size="xs"
className="!font-normal"
>
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
</Button>
</div>
</div>
</ScrollableOverlay>
);
};
@@ -1,316 +0,0 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
import { isMobileDeviceViaCSS } from '@/lib/device';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { RiAddLine, RiMore2Line, RiDeleteBinLine, RiFileCopyLine, RiEditLine, RiFileTextLine } from '@remixicon/react';
import { usePromptTemplatesStore } from '@/stores/usePromptTemplatesStore';
import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import type { PromptTemplate } from '@/types/prompt-template';
interface PromptTemplatesSidebarProps {
onItemSelect?: () => void;
}
export const PromptTemplatesSidebar: React.FC<PromptTemplatesSidebarProps> = ({ onItemSelect }) => {
const { t } = useI18n();
const [confirmDeleteTemplate, setConfirmDeleteTemplate] = React.useState<PromptTemplate | null>(null);
const [isDeletePending, setIsDeletePending] = React.useState(false);
const [openMenuId, setOpenMenuId] = React.useState<string | null>(null);
const [renameDialogTemplate, setRenameDialogTemplate] = React.useState<PromptTemplate | null>(null);
const [renameNewName, setRenameNewName] = React.useState('');
const {
selectedTemplateId,
templates,
setSelectedTemplate,
deleteTemplate,
updateTemplate,
loadTemplates,
} = usePromptTemplatesStore(useShallow((s) => ({
selectedTemplateId: s.selectedTemplateId,
templates: s.templates,
setSelectedTemplate: s.setSelectedTemplate,
deleteTemplate: s.deleteTemplate,
updateTemplate: s.updateTemplate,
loadTemplates: s.loadTemplates,
})));
React.useEffect(() => {
loadTemplates();
}, [loadTemplates]);
const handleCreateNew = async () => {
const baseName = 'new-template';
let newName = baseName;
let counter = 1;
const existingIds = new Set(templates.map((t) => t.id));
while (existingIds.has(newName.replace(/\s+/g, '-').toLowerCase())) {
newName = `${baseName}-${counter}`;
counter++;
}
const slug = newName.replace(/\s+/g, '-').toLowerCase();
const success = await usePromptTemplatesStore.getState().createTemplate(slug, newName, '');
if (!success) {
toast.error(t('settings.promptTemplates.page.toast.createFailed'));
return;
}
usePromptTemplatesStore.getState().setSelectedTemplate(slug);
onItemSelect?.();
};
const handleDelete = async () => {
if (!confirmDeleteTemplate) return;
setIsDeletePending(true);
const success = await deleteTemplate(confirmDeleteTemplate.id);
if (success) {
toast.success(t('settings.promptTemplates.sidebar.toast.deleted', { name: confirmDeleteTemplate.name }));
setConfirmDeleteTemplate(null);
} else {
toast.error(t('settings.promptTemplates.sidebar.toast.deleteFailed'));
}
setIsDeletePending(false);
};
const handleDuplicate = async (template: PromptTemplate) => {
let copyName = `${template.name} Copy`;
let copyId = `${template.id}-copy`;
let counter = 1;
const existingIds = new Set(templates.map((t) => t.id));
while (existingIds.has(copyId)) {
copyName = `${template.name} Copy ${counter}`;
copyId = `${template.id}-copy-${counter}`;
counter++;
}
const success = await usePromptTemplatesStore.getState().createTemplate(copyId, copyName, template.body);
if (!success) {
toast.error(t('settings.promptTemplates.page.toast.createFailed'));
return;
}
setSelectedTemplate(copyId);
onItemSelect?.();
};
const handleOpenRename = (template: PromptTemplate) => {
setRenameNewName(template.name);
setRenameDialogTemplate(template);
};
const handleRename = async () => {
if (!renameDialogTemplate) return;
const trimmed = renameNewName.trim();
if (!trimmed) {
toast.error(t('settings.promptTemplates.sidebar.toast.nameRequired'));
return;
}
if (trimmed === renameDialogTemplate.name) {
setRenameDialogTemplate(null);
return;
}
const success = await updateTemplate(renameDialogTemplate.id, { name: trimmed });
if (success) {
toast.success(t('settings.promptTemplates.sidebar.toast.renamed'));
} else {
toast.error(t('settings.promptTemplates.sidebar.toast.renameFailed'));
}
setRenameDialogTemplate(null);
};
const sortedTemplates = React.useMemo(
() => [...templates].sort((a, b) => a.name.localeCompare(b.name)),
[templates],
);
return (
<div className={cn('flex h-full flex-col', 'bg-background')}>
<div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.promptTemplates.sidebar.title')}</h2>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.promptTemplates.sidebar.total', { count: templates.length })}</span>
<Button size="sm" variant="ghost" className="h-7 w-7 px-0 -my-1 text-muted-foreground" onClick={handleCreateNew}>
<RiAddLine className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2">
{sortedTemplates.length === 0 ? (
<div className="py-12 px-4 text-center text-muted-foreground">
<RiFileTextLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
<p className="typography-ui-label font-medium">{t('settings.promptTemplates.sidebar.empty.title')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.promptTemplates.sidebar.empty.description')}</p>
</div>
) : (
sortedTemplates.map((template) => (
<TemplateListItem
key={template.id}
template={template}
isSelected={selectedTemplateId === template.id}
onSelect={() => {
setSelectedTemplate(template.id);
onItemSelect?.();
}}
onDelete={() => setConfirmDeleteTemplate(template)}
onRename={() => handleOpenRename(template)}
onDuplicate={() => handleDuplicate(template)}
isMenuOpen={openMenuId === template.id}
onMenuOpenChange={(open) => setOpenMenuId(open ? template.id : null)}
/>
))
)}
</ScrollableOverlay>
<Dialog
open={confirmDeleteTemplate !== null}
onOpenChange={(open) => { if (!open && !isDeletePending) setConfirmDeleteTemplate(null); }}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('settings.promptTemplates.sidebar.dialog.deleteTitle')}</DialogTitle>
<DialogDescription>
{t('settings.promptTemplates.sidebar.dialog.deleteDescription', { name: confirmDeleteTemplate?.name ?? '' })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button size="sm" variant="ghost" onClick={() => setConfirmDeleteTemplate(null)} disabled={isDeletePending}>
{t('settings.common.actions.cancel')}
</Button>
<Button size="sm" onClick={handleDelete} disabled={isDeletePending}>
{t('settings.common.actions.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={renameDialogTemplate !== null} onOpenChange={(open) => !open && setRenameDialogTemplate(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('settings.promptTemplates.sidebar.renameDialog.title')}</DialogTitle>
<DialogDescription>
{t('settings.promptTemplates.sidebar.renameDialog.description', { name: renameDialogTemplate?.name ?? '' })}
</DialogDescription>
</DialogHeader>
<Input
value={renameNewName}
onChange={(e) => setRenameNewName(e.target.value)}
placeholder={t('settings.promptTemplates.sidebar.renameDialog.placeholder')}
className="text-foreground placeholder:text-muted-foreground"
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); }}
/>
<DialogFooter>
<Button size="sm" variant="ghost" onClick={() => setRenameDialogTemplate(null)}>
{t('settings.common.actions.cancel')}
</Button>
<Button size="sm" onClick={handleRename}>
{t('settings.common.actions.rename')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
interface TemplateListItemProps {
template: PromptTemplate;
isSelected: boolean;
onSelect: () => void;
onDelete?: () => void;
onRename?: () => void;
onDuplicate: () => void;
isMenuOpen: boolean;
onMenuOpenChange: (open: boolean) => void;
}
const TemplateListItem: React.FC<TemplateListItemProps> = ({
template,
isSelected,
onSelect,
onDelete,
onRename,
onDuplicate,
isMenuOpen,
onMenuOpenChange,
}) => {
const { t } = useI18n();
const isMobile = isMobileDeviceViaCSS();
return (
<div
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover',
)}
onContextMenu={!isMobile ? (e) => { e.preventDefault(); onMenuOpenChange(true); } : undefined}
>
<div className="flex min-w-0 flex-1 items-center">
<button
onClick={onSelect}
className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
tabIndex={0}
>
<div className="flex items-center gap-2">
<span className="typography-ui-label font-normal truncate text-foreground">
{template.name}
</span>
{template.isDefault && (
<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.promptTemplates.sidebar.badge.default')}
</span>
)}
</div>
{template.body && (
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
{template.body.substring(0, 80)}
</div>
)}
</button>
{!template.isDefault && (
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="ghost" className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
<RiMore2Line className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
{onRename && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onRename(); }}>
<RiEditLine className="h-4 w-4 mr-px" />
{t('settings.common.actions.rename')}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onDuplicate(); }}>
<RiFileCopyLine className="h-4 w-4 mr-px" />
{t('settings.common.actions.duplicate')}
</DropdownMenuItem>
{onDelete && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onDelete(); }} className="text-destructive focus:text-destructive">
<RiDeleteBinLine className="h-4 w-4 mr-px" />
{t('settings.common.actions.delete')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
);
};
@@ -14,6 +14,7 @@ import {
import { toast } from '@/components/ui';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard';
@@ -1055,7 +1056,7 @@ export const ProvidersPage: React.FC = () => {
const contextTokens = formatTokens(metadata?.limit?.context);
const outputTokens = formatTokens(metadata?.limit?.output);
const capabilityIcons: Array<{ key: string; icon: string; label: string }> = [];
const capabilityIcons: Array<{ key: string; icon: IconName; label: string }> = [];
if (metadata?.tool_call) capabilityIcons.push({ key: 'tools', icon: "tools", label: t('settings.providers.page.models.capability.toolCalling') });
if (metadata?.reasoning) capabilityIcons.push({ key: 'reasoning', icon: "brain-ai-3", label: t('settings.providers.page.models.capability.reasoning') });
if (metadata?.attachment) capabilityIcons.push({ key: 'image', icon: "file-image", label: t('settings.providers.page.models.capability.imageInput') });
@@ -7,13 +7,14 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
import { cn } from '@/lib/utils';
export interface SettingsSidebarItemAction {
/** Label shown in dropdown menu */
label: string;
/** Icon component to show before label */
icon?: string;
icon?: IconName;
/** Callback when action is clicked */
onClick: () => void;
/** If true, uses destructive styling (red text) */
@@ -0,0 +1,169 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { useSnippetsStore, type SnippetScope } from '@/stores/useSnippetsStore';
import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from '@/components/icon/Icon';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useI18n } from '@/lib/i18n';
export const SnippetsPage: React.FC = () => {
const { t } = useI18n();
const { selectedSnippetName, snippets, snippetDraft, setSnippetDraft, updateSnippet, createSnippet } = useSnippetsStore(useShallow((s) => ({
selectedSnippetName: s.selectedSnippetName,
snippets: s.snippets,
snippetDraft: s.snippetDraft,
setSnippetDraft: s.setSnippetDraft,
updateSnippet: s.updateSnippet,
createSnippet: s.createSnippet,
})));
const selectedSnippet = React.useMemo(
() => selectedSnippetName
? snippets.find((snippet) => snippet.name === selectedSnippetName || snippet.aliases.includes(selectedSnippetName)) ?? null
: null,
[selectedSnippetName, snippets],
);
const isNew = Boolean(snippetDraft && snippetDraft.name === selectedSnippetName && !selectedSnippet);
const [draftName, setDraftName] = React.useState('');
const [draftScope, setDraftScope] = React.useState<SnippetScope>('global');
const [description, setDescription] = React.useState('');
const [aliases, setAliases] = React.useState('');
const [content, setContent] = React.useState('');
const [isSaving, setIsSaving] = React.useState(false);
const initialStateRef = React.useRef<{ draftName: string; draftScope: SnippetScope; description: string; aliases: string; content: string } | null>(null);
React.useEffect(() => {
if (isNew && snippetDraft) {
const next = {
draftName: snippetDraft.name || '',
draftScope: snippetDraft.scope || 'global',
description: snippetDraft.description || '',
aliases: (snippetDraft.aliases || []).join(', '),
content: snippetDraft.content || '',
};
setDraftName(next.draftName);
setDraftScope(next.draftScope);
setDescription(next.description);
setAliases(next.aliases);
setContent(next.content);
initialStateRef.current = next;
} else if (selectedSnippet) {
const next = {
draftName: '',
draftScope: 'global' as SnippetScope,
description: selectedSnippet.description ?? '',
aliases: selectedSnippet.aliases.join(', '),
content: selectedSnippet.content,
};
setDescription(next.description);
setAliases(next.aliases);
setContent(next.content);
initialStateRef.current = next;
}
}, [selectedSnippet, isNew, selectedSnippetName, snippetDraft]);
const isDirty = React.useMemo(() => {
const initial = initialStateRef.current;
if (!initial) return false;
if (isNew && draftName !== initial.draftName) return true;
if (isNew && draftScope !== initial.draftScope) return true;
return description !== initial.description || aliases !== initial.aliases || content !== initial.content;
}, [aliases, content, description, draftName, draftScope, isNew]);
const handleSave = async () => {
const snippetName = isNew ? draftName.trim().replace(/\s+/g, '-') : selectedSnippetName?.trim();
if (!snippetName) {
toast.error(t('settings.snippets.page.toast.nameRequired'));
return;
}
if (!content.trim()) {
toast.error(t('settings.snippets.page.toast.contentRequired'));
return;
}
const parsedAliases = aliases.split(',').map((alias) => alias.trim()).filter(Boolean);
setIsSaving(true);
try {
const success = isNew
? await createSnippet(snippetName, content, { aliases: parsedAliases, description, scope: draftScope })
: await updateSnippet(snippetName, { content, aliases: parsedAliases, description });
if (!success) {
toast.error(t('settings.snippets.page.toast.saveFailed'));
return;
}
toast.success(t('settings.snippets.page.toast.saved'));
if (isNew) setSnippetDraft(null);
} finally {
setIsSaving(false);
}
};
if (!selectedSnippetName) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<Icon name="file-text" className="mx-auto mb-3 h-12 w-12 opacity-50" />
<p className="typography-body">{t('settings.snippets.page.empty.title')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.snippets.page.empty.description')}</p>
</div>
</div>
);
}
return (
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
<div className="mb-4 min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground truncate">
{isNew ? t('settings.snippets.page.title.new') : `#${selectedSnippetName}`}
</h2>
{selectedSnippet ? <p className="typography-meta text-muted-foreground truncate">{selectedSnippet.filePath}</p> : null}
</div>
<div className="mb-8 space-y-3 px-2">
<div>
{isNew ? (
<div className="mb-3 flex items-center gap-2">
<span className="typography-ui-label text-foreground">#</span>
<Input value={draftName} onChange={(e) => setDraftName(e.target.value)} placeholder={t('settings.snippets.page.field.namePlaceholder')} className="h-7 w-44 px-2" />
<Select value={draftScope} onValueChange={(value) => setDraftScope(value as SnippetScope)}>
<SelectTrigger className="w-fit min-w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="global">{t('settings.common.scope.global')}</SelectItem>
<SelectItem value="project">{t('settings.common.scope.project')}</SelectItem>
</SelectContent>
</Select>
</div>
) : null}
<span className="typography-ui-label text-foreground">{t('settings.common.field.description')}</span>
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder={t('settings.snippets.page.field.descriptionPlaceholder')} className="mt-1.5 h-7 w-full max-w-sm px-2" />
</div>
<div>
<span className="typography-ui-label text-foreground">{t('settings.snippets.page.field.aliases')}</span>
<Input value={aliases} onChange={(e) => setAliases(e.target.value)} placeholder={t('settings.snippets.page.field.aliasesPlaceholder')} className="mt-1.5 h-7 w-full max-w-sm px-2" />
</div>
</div>
<div className="mb-2 px-2">
<span className="typography-ui-label text-foreground">{t('settings.snippets.page.field.content')}</span>
<Textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder={t('settings.snippets.page.field.contentPlaceholder')} rows={12} className="mt-1.5 w-full font-mono typography-meta min-h-[160px] max-h-[60vh] bg-transparent" />
<p className="mt-2 typography-meta text-muted-foreground">{t('settings.snippets.page.hint')}</p>
</div>
<div className="px-2 py-1">
<Button onClick={handleSave} disabled={isSaving || !isDirty} size="xs" className="!font-normal">
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
</Button>
</div>
</div>
</ScrollableOverlay>
);
};
@@ -0,0 +1,115 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { useSnippetsStore } from '@/stores/useSnippetsStore';
import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { cn } from '@/lib/utils';
import type { Snippet } from '@/types/snippet';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
interface SnippetsSidebarProps {
onItemSelect?: () => void;
}
export const SnippetsSidebar: React.FC<SnippetsSidebarProps> = ({ onItemSelect }) => {
const { t } = useI18n();
const [confirmDeleteSnippet, setConfirmDeleteSnippet] = React.useState<Snippet | null>(null);
const [openMenuName, setOpenMenuName] = React.useState<string | null>(null);
const { selectedSnippetName, snippets, setSelectedSnippet, setSnippetDraft, deleteSnippet, loadSnippets } = useSnippetsStore(useShallow((s) => ({
selectedSnippetName: s.selectedSnippetName,
snippets: s.snippets,
setSelectedSnippet: s.setSelectedSnippet,
setSnippetDraft: s.setSnippetDraft,
deleteSnippet: s.deleteSnippet,
loadSnippets: s.loadSnippets,
})));
React.useEffect(() => {
loadSnippets();
}, [loadSnippets]);
const handleCreateNew = async () => {
const existing = new Set(snippets.map((snippet) => snippet.name));
let name = 'new-snippet';
let counter = 1;
while (existing.has(name)) {
name = `new-snippet-${counter++}`;
}
setSnippetDraft({ name, scope: 'global' });
setSelectedSnippet(name);
onItemSelect?.();
};
const handleDelete = async () => {
if (!confirmDeleteSnippet) return;
const success = await deleteSnippet(confirmDeleteSnippet.name);
if (success) {
toast.success(t('settings.snippets.sidebar.toast.deleted'));
setConfirmDeleteSnippet(null);
} else {
toast.error(t('settings.snippets.sidebar.toast.deleteFailed'));
}
};
const sortedSnippets = React.useMemo(() => [...snippets].sort((a, b) => a.name.localeCompare(b.name)), [snippets]);
return (
<div className={cn('flex h-full flex-col', 'bg-background')}>
<div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.snippets.sidebar.title')}</h2>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.snippets.sidebar.total', { count: snippets.length })}</span>
<Button size="sm" variant="ghost" className="h-7 w-7 px-0 -my-1 text-muted-foreground" onClick={handleCreateNew} aria-label={t('settings.snippets.sidebar.actions.create')}>
<Icon name="add" className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2">
{sortedSnippets.map((snippet) => (
<div key={`${snippet.source}:${snippet.filePath}`} className={cn('group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none', selectedSnippetName === snippet.name ? 'bg-interactive-selection' : 'hover:bg-interactive-hover')}>
<button onClick={() => { setSelectedSnippet(snippet.name); onItemSelect?.(); }} className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50">
<div className="flex items-center gap-2">
<span className="typography-ui-label font-normal truncate text-foreground">#{snippet.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">{t(`snippets.source.${snippet.source}`)}</span>
</div>
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
{snippet.description || snippet.content.replace(/\s+/g, ' ').substring(0, 80)}
</div>
</button>
<DropdownMenu open={openMenuName === snippet.name} onOpenChange={(open) => setOpenMenuName(open ? snippet.name : null)}>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="ghost" className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100" aria-label={t('settings.snippets.sidebar.actions.more', { name: snippet.name })}>
<Icon name="more-2" className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); setConfirmDeleteSnippet(snippet); }} className="text-destructive focus:text-destructive">
<Icon name="delete-bin" className="h-4 w-4 mr-px" />
{t('settings.common.actions.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</ScrollableOverlay>
<Dialog open={confirmDeleteSnippet !== null} onOpenChange={(open) => { if (!open) setConfirmDeleteSnippet(null); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('settings.snippets.sidebar.dialog.deleteTitle')}</DialogTitle>
<DialogDescription>{t('settings.snippets.sidebar.dialog.deleteDescription', { name: confirmDeleteSnippet?.name ?? '' })}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button size="sm" variant="ghost" onClick={() => setConfirmDeleteSnippet(null)}>{t('settings.common.actions.cancel')}</Button>
<Button size="sm" onClick={handleDelete}>{t('settings.common.actions.delete')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
@@ -14,6 +14,7 @@ import { AgentSelector } from '@/components/sections/commands/AgentSelector';
import { isPrimaryMode } from '@/components/chat/mobileControlsUtils';
import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from '@/components/chat/CommandAutocomplete';
import { FileMentionAutocomplete, type FileMentionHandle } from '@/components/chat/FileMentionAutocomplete';
import { SnippetAutocomplete, type SnippetAutocompleteHandle } from '@/components/chat/SnippetAutocomplete';
import { Icon } from "@/components/icon/Icon";
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
@@ -618,6 +619,8 @@ export function ScheduledTaskEditorDialog(props: {
const [mentionQuery, setMentionQuery] = React.useState('');
const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false);
const [commandQuery, setCommandQuery] = React.useState('');
const [showSnippetAutocomplete, setShowSnippetAutocomplete] = React.useState(false);
const [snippetQuery, setSnippetQuery] = React.useState('');
const [calendarMonth, setCalendarMonth] = React.useState<Date>(() => {
const initialDate = parseISODateToLocal(task?.schedule?.date || '') || new Date();
return new Date(initialDate.getFullYear(), initialDate.getMonth(), 1);
@@ -626,6 +629,7 @@ export function ScheduledTaskEditorDialog(props: {
const promptTextareaRef = React.useRef<HTMLTextAreaElement>(null);
const mentionRef = React.useRef<FileMentionHandle>(null);
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
const snippetRef = React.useRef<SnippetAutocompleteHandle>(null);
const localeUse24Hour = React.useMemo(() => getUses24Hour(locale), [locale]);
const localeWeekStartsOn = React.useMemo(() => getWeekStartsOn(locale), [locale]);
const use24Hour = React.useMemo(() => {
@@ -828,6 +832,7 @@ export function ScheduledTaskEditorDialog(props: {
setCommandQuery(value.substring(1, commandEnd));
setShowCommandAutocomplete(true);
setShowFileMention(false);
setShowSnippetAutocomplete(false);
return;
}
}
@@ -835,6 +840,21 @@ export function ScheduledTaskEditorDialog(props: {
setShowCommandAutocomplete(false);
const textBeforeCursor = value.substring(0, cursorPosition);
const lastHashSymbol = textBeforeCursor.lastIndexOf('#');
if (lastHashSymbol !== -1) {
const charBefore = lastHashSymbol > 0 ? textBeforeCursor[lastHashSymbol - 1] : null;
const textAfterHash = textBeforeCursor.substring(lastHashSymbol + 1);
const isWordBoundary = !charBefore || /\s/.test(charBefore);
if (isWordBoundary && !textAfterHash.includes(' ') && !textAfterHash.includes('\n')) {
setSnippetQuery(textAfterHash);
setShowSnippetAutocomplete(true);
setShowFileMention(false);
return;
}
}
setShowSnippetAutocomplete(false);
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
if (lastAtSymbol !== -1) {
const charBefore = lastAtSymbol > 0 ? textBeforeCursor[lastAtSymbol - 1] : null;
@@ -921,6 +941,7 @@ export function ScheduledTaskEditorDialog(props: {
setPromptValue(nextPrompt);
setShowCommandAutocomplete(false);
setCommandQuery('');
setShowSnippetAutocomplete(false);
requestAnimationFrame(() => {
const currentTextarea = promptTextareaRef.current;
@@ -933,6 +954,31 @@ export function ScheduledTaskEditorDialog(props: {
});
}, [setPromptValue, updateAutocompleteState]);
const handleSnippetSelect = React.useCallback((_snippet: unknown, trigger: string) => {
const promptValue = draft.execution.prompt;
const textarea = promptTextareaRef.current;
const cursorPosition = textarea?.selectionStart ?? promptValue.length;
const textBeforeCursor = promptValue.substring(0, cursorPosition);
const lastHashSymbol = textBeforeCursor.lastIndexOf('#');
const startIndex = lastHashSymbol !== -1 ? lastHashSymbol : cursorPosition;
const nextPrompt = `${promptValue.substring(0, startIndex)}#${trigger} ${promptValue.substring(cursorPosition)}`;
const nextCursor = startIndex + trigger.length + 2;
setPromptValue(nextPrompt);
setShowSnippetAutocomplete(false);
setSnippetQuery('');
requestAnimationFrame(() => {
const currentTextarea = promptTextareaRef.current;
if (currentTextarea) {
currentTextarea.selectionStart = nextCursor;
currentTextarea.selectionEnd = nextCursor;
currentTextarea.focus();
}
updateAutocompleteState(nextPrompt, nextCursor);
});
}, [draft.execution.prompt, setPromptValue, updateAutocompleteState]);
const handlePromptKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showCommandAutocomplete && commandRef.current) {
if (event.key === 'Enter' || event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Escape' || event.key === 'Tab') {
@@ -948,7 +994,14 @@ export function ScheduledTaskEditorDialog(props: {
mentionRef.current.handleKeyDown(event.key);
}
}
}, [showCommandAutocomplete, showFileMention]);
if (showSnippetAutocomplete && snippetRef.current) {
if (event.key === 'Enter' || event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Escape' || event.key === 'Tab') {
event.preventDefault();
snippetRef.current.handleKeyDown(event.key);
}
}
}, [showCommandAutocomplete, showFileMention, showSnippetAutocomplete]);
const handleSubmit = React.useCallback(async () => {
const validationError = validateDraft(draft, t);
@@ -1407,6 +1460,22 @@ export function ScheduledTaskEditorDialog(props: {
}}
/>
) : null}
{showSnippetAutocomplete ? (
<SnippetAutocomplete
ref={snippetRef}
searchQuery={snippetQuery}
onSnippetSelect={handleSnippetSelect}
onClose={() => setShowSnippetAutocomplete(false)}
style={{
left: 0,
top: 'auto',
bottom: 'calc(100% + 6px)',
marginBottom: 0,
maxWidth: '100%',
}}
/>
) : null}
</div>
</div>
</div>
@@ -12,6 +12,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
@@ -147,7 +148,7 @@ const STATUS_META: Record<
ScheduledTaskStatus,
{
tone: StatusTone;
Icon: string;
Icon: IconName;
spin?: boolean;
}
> = {
@@ -5,7 +5,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useAgentsStore } from '@/stores/useAgentsStore';
import { useCommandsStore } from '@/stores/useCommandsStore';
import { useMcpConfigStore } from '@/stores/useMcpConfigStore';
import { usePromptTemplatesStore } from '@/stores/usePromptTemplatesStore';
import { useSnippetsStore } from '@/stores/useSnippetsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -29,8 +29,8 @@ import { UsageSidebar } from '@/components/sections/usage/UsageSidebar';
import { UsagePage } from '@/components/sections/usage/UsagePage';
import { MagicPromptsSidebar } from '@/components/sections/magic-prompts/MagicPromptsSidebar';
import { MagicPromptsPage } from '@/components/sections/magic-prompts/MagicPromptsPage';
import { PromptTemplatesSidebar } from '@/components/sections/prompt-templates/PromptTemplatesSidebar';
import { PromptTemplatesPage } from '@/components/sections/prompt-templates/PromptTemplatesPage';
import { SnippetsSidebar } from '@/components/sections/snippets/SnippetsSidebar';
import { SnippetsPage } from '@/components/sections/snippets/SnippetsPage';
import { GitPage } from '@/components/sections/git-identities/GitPage';
import type { OpenChamberSection } from '@/components/sections/openchamber/types';
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
@@ -76,7 +76,7 @@ const pageOrder: SettingsPageSlug[] = [
'shortcuts',
'git',
'magic-prompts',
'prompt-templates',
'snippets',
'projects',
'remote-instances',
'agents',
@@ -91,6 +91,8 @@ const pageOrder: SettingsPageSlug[] = [
'tunnel',
];
const SNIPPETS_SETTINGS_ICON = { icon: 'chat-thread' } as const;
function buildRuntimeContext(isDesktop: boolean): SettingsRuntimeContext {
const isVSCode = isVSCodeRuntime();
const isWeb = !isDesktop && isWebRuntime();
@@ -117,8 +119,8 @@ export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
return 'chat-ai-3';
case 'magic-prompts':
return 'ai-generate-2';
case 'prompt-templates':
return 'file-text';
case 'snippets':
return SNIPPETS_SETTINGS_ICON.icon;
case 'notifications':
return 'notification-3';
case 'shortcuts':
@@ -363,8 +365,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
void useSkillsStore.getState().loadSkills();
void useSkillsCatalogStore.getState().loadCatalog();
}
if (settingsSlug === 'prompt-templates') {
void usePromptTemplatesStore.getState().loadTemplates();
if (settingsSlug === 'snippets') {
void useSnippetsStore.getState().loadSnippets();
}
}, [activeProjectId, isSettingsDialogOpen, isWindowed, runtimeCtx.isVSCode, settingsSlug]);
@@ -432,8 +434,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return t('settings.page.sessions.title');
case 'magic-prompts':
return t('settings.page.magicPrompts.title');
case 'prompt-templates':
return t('settings.page.promptTemplates.title');
case 'snippets':
return t('settings.page.snippets.title');
case 'notifications':
return t('settings.page.notifications.title');
case 'voice':
@@ -477,8 +479,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return <UsageSidebar onItemSelect={opts.onItemSelect} />;
case 'magic-prompts':
return <MagicPromptsSidebar onItemSelect={opts.onItemSelect} />;
case 'prompt-templates':
return <PromptTemplatesSidebar onItemSelect={opts.onItemSelect} />;
case 'snippets':
return <SnippetsSidebar onItemSelect={opts.onItemSelect} />;
default:
return null;
}
@@ -515,8 +517,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return <UsagePage />;
case 'magic-prompts':
return <MagicPromptsPage />;
case 'prompt-templates':
return <PromptTemplatesPage />;
case 'snippets':
return <SnippetsPage />;
case 'git':
return <GitPage />;
case 'appearance':
@@ -21,6 +21,7 @@ import React from 'react';
import type { BrowserVoiceStatus } from '@/hooks/useBrowserVoice';
import { useI18n } from '@/lib/i18n';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
export interface VoiceStatusIndicatorProps {
/** Current voice status */
@@ -53,7 +54,7 @@ const sizeClasses = {
const statusConfig: Record<
BrowserVoiceStatus,
{
icon: string;
icon: IconName;
color: string;
labelKey:
| 'voice.status.idle'
+1 -1
View File
@@ -40,7 +40,7 @@ export const SIDEBAR_SECTIONS: SidebarSectionConfig[] = [
id: 'mcp',
label: 'MCP',
description: 'Manage Model Context Protocol servers and their configurations.',
icon: "plug-line",
icon: "plug-2",
},
{
id: 'providers',
@@ -44,6 +44,29 @@ export const settingsDict = {
'settings.page.notifications.title': 'Notifications',
'settings.page.voice.title': 'Voice',
'settings.page.tunnel.title': 'Remote Tunnel',
'settings.page.snippets.title': 'Snippets',
'settings.snippets.sidebar.title': 'Snippets',
'settings.snippets.sidebar.total': 'Total: {count}',
'settings.snippets.sidebar.actions.create': 'Create snippet',
'settings.snippets.sidebar.actions.more': 'More actions for {name}',
'settings.snippets.sidebar.toast.deleted': 'Snippet deleted',
'settings.snippets.sidebar.toast.deleteFailed': 'Failed to delete snippet',
'settings.snippets.sidebar.dialog.deleteTitle': 'Delete snippet?',
'settings.snippets.sidebar.dialog.deleteDescription': 'This will permanently delete #{name}.',
'settings.snippets.page.empty.title': 'Select a snippet',
'settings.snippets.page.empty.description': 'Choose a snippet from the sidebar to edit it.',
'settings.snippets.page.title.new': 'New snippet',
'settings.snippets.page.field.namePlaceholder': 'snippet-name',
'settings.snippets.page.field.descriptionPlaceholder': 'What this snippet does',
'settings.snippets.page.field.aliases': 'Aliases',
'settings.snippets.page.field.aliasesPlaceholder': 'safe, careful',
'settings.snippets.page.field.content': 'Content',
'settings.snippets.page.field.contentPlaceholder': 'Snippet markdown...',
'settings.snippets.page.hint': 'Use #name in prompts. Supports aliases and prepend/append blocks compatible with opencode-snippets.',
'settings.snippets.page.toast.nameRequired': 'Snippet name is required',
'settings.snippets.page.toast.contentRequired': 'Snippet content is required',
'settings.snippets.page.toast.saveFailed': 'Failed to save snippet',
'settings.snippets.page.toast.saved': 'Snippet saved',
'settings.page.promptTemplates.title': 'Prompt Templates',
'settings.promptTemplates.sidebar.title': 'Prompt Templates',
'settings.promptTemplates.sidebar.total': 'Total: {count}',
+7 -1
View File
@@ -1554,8 +1554,14 @@ export const dict = {
'chat.chatInput.linked.pr.openInBrowserAria': 'Open pull request in browser',
'chat.chatInput.linked.pr.removeAria': 'Remove linked pull request',
'chat.chatInput.placeholder.shell': 'Enter shell command...',
'chat.chatInput.placeholder.chat': '@ for files/agents; / for commands; ! for shell',
'chat.chatInput.placeholder.chat': '@ for files/agents; / for commands and skills; ! for shell; # for snippets',
'chat.chatInput.placeholder.chatCompact': 'Use @ / ! # for helpers',
'chat.chatInput.placeholder.selectSession': 'Select or create a session to start chatting',
'chat.snippetAutocomplete.action.addNew': '+ Add new snippet',
'chat.snippetAutocomplete.empty': 'No snippets found',
'chat.snippetAutocomplete.footer': '↑↓ navigate • Enter select • Esc close',
'snippets.source.global': 'global',
'snippets.source.project': 'project',
'chat.chatInput.toast.compactFailed': 'Failed to compact session',
'chat.chatInput.toast.summaryFailed': 'Failed to generate summary',
'chat.chatInput.toast.reviewFailed': 'Failed to review changes',
@@ -44,6 +44,29 @@ export const settingsDict = {
"settings.page.notifications.title": "Notificaciones",
"settings.page.voice.title": "Voz",
"settings.page.tunnel.title": "Túnel remoto",
"settings.page.snippets.title": "Snippets",
"settings.snippets.sidebar.title": "Snippets",
"settings.snippets.sidebar.total": "Total: {count}",
"settings.snippets.sidebar.actions.create": "Crear snippet",
"settings.snippets.sidebar.actions.more": "Más acciones para {name}",
"settings.snippets.sidebar.toast.deleted": "Snippet eliminado",
"settings.snippets.sidebar.toast.deleteFailed": "No se pudo eliminar el snippet",
"settings.snippets.sidebar.dialog.deleteTitle": "¿Eliminar snippet?",
"settings.snippets.sidebar.dialog.deleteDescription": "Esto eliminará permanentemente #{name}.",
"settings.snippets.page.empty.title": "Selecciona un snippet",
"settings.snippets.page.empty.description": "Elige un snippet de la barra lateral para editarlo.",
"settings.snippets.page.title.new": "Nuevo snippet",
"settings.snippets.page.field.namePlaceholder": "snippet-name",
"settings.snippets.page.field.descriptionPlaceholder": "Qué hace este snippet",
"settings.snippets.page.field.aliases": "Alias",
"settings.snippets.page.field.aliasesPlaceholder": "safe, careful",
"settings.snippets.page.field.content": "Contenido",
"settings.snippets.page.field.contentPlaceholder": "Markdown del snippet...",
"settings.snippets.page.hint": "Usa #name en los prompts. Admite alias y bloques prepend/append compatibles con opencode-snippets.",
"settings.snippets.page.toast.nameRequired": "El nombre del snippet es obligatorio",
"settings.snippets.page.toast.contentRequired": "El contenido del snippet es obligatorio",
"settings.snippets.page.toast.saveFailed": "No se pudo guardar el snippet",
"settings.snippets.page.toast.saved": "Snippet guardado",
"settings.openchamber.tunnel.title": "Túnel remoto",
"settings.openchamber.tunnel.description": "Configura el acceso remoto seguro con enlaces rápidos o tu propio túnel gestionado de Cloudflare.",
"settings.openchamber.tunnel.note.serverSideEnforced": "El acceso seguro al túnel se aplica en el servidor.",
+7 -1
View File
@@ -1520,8 +1520,14 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.linked.pr.openInBrowserAria": "Abrir PR en el navegador",
"chat.chatInput.linked.pr.removeAria": "Eliminar PR vinculada",
"chat.chatInput.placeholder.shell": "Escribe un comando de shell...",
"chat.chatInput.placeholder.chat": "@ para archivos/agentes; / para comandos; ! para shell",
"chat.chatInput.placeholder.chat": "@ para archivos/agentes; / para comandos y habilidades; ! para shell; # para snippets",
"chat.chatInput.placeholder.chatCompact": "Usa @ / ! # para ayudas",
"chat.chatInput.placeholder.selectSession": "Selecciona o crea una sesión para comenzar a chatear",
"chat.snippetAutocomplete.action.addNew": "+ Agregar nuevo snippet",
"chat.snippetAutocomplete.empty": "No se encontraron snippets",
"chat.snippetAutocomplete.footer": "↑↓ navegar • Enter seleccionar • Esc cerrar",
"snippets.source.global": "global",
"snippets.source.project": "proyecto",
"chat.chatInput.toast.compactFailed": "No se pudo comprimir la sesión",
"chat.chatInput.toast.summaryFailed": "No se pudo generar el resumen",
"chat.chatInput.toast.reviewFailed": "No se pudieron revisar los cambios",
@@ -44,6 +44,29 @@ export const settingsDict = {
'settings.page.notifications.title': '알림',
'settings.page.voice.title': '음성',
'settings.page.tunnel.title': '원격 터널',
'settings.page.snippets.title': '스니펫',
'settings.snippets.sidebar.title': '스니펫',
'settings.snippets.sidebar.total': '총 {count}개',
'settings.snippets.sidebar.actions.create': '스니펫 만들기',
'settings.snippets.sidebar.actions.more': '{name} 추가 작업',
'settings.snippets.sidebar.toast.deleted': '스니펫이 삭제되었습니다',
'settings.snippets.sidebar.toast.deleteFailed': '스니펫 삭제 실패',
'settings.snippets.sidebar.dialog.deleteTitle': '스니펫을 삭제할까요?',
'settings.snippets.sidebar.dialog.deleteDescription': '이 작업은 #{name}을(를) 영구적으로 삭제합니다.',
'settings.snippets.page.empty.title': '스니펫 선택',
'settings.snippets.page.empty.description': '편집할 스니펫을 사이드바에서 선택하세요.',
'settings.snippets.page.title.new': '새 스니펫',
'settings.snippets.page.field.namePlaceholder': 'snippet-name',
'settings.snippets.page.field.descriptionPlaceholder': '이 스니펫의 역할',
'settings.snippets.page.field.aliases': '별칭',
'settings.snippets.page.field.aliasesPlaceholder': 'safe, careful',
'settings.snippets.page.field.content': '내용',
'settings.snippets.page.field.contentPlaceholder': '스니펫 Markdown...',
'settings.snippets.page.hint': '프롬프트에서 #name을 사용하세요. opencode-snippets와 호환되는 별칭 및 prepend/append 블록을 지원합니다.',
'settings.snippets.page.toast.nameRequired': '스니펫 이름은 필수입니다',
'settings.snippets.page.toast.contentRequired': '스니펫 내용은 필수입니다',
'settings.snippets.page.toast.saveFailed': '스니펫 저장 실패',
'settings.snippets.page.toast.saved': '스니펫이 저장되었습니다',
'settings.openchamber.tunnel.title': '원격 터널',
'settings.openchamber.tunnel.description': '퀵 링크나 직접 관리하는 Cloudflare 터널로 안전한 원격 접속을 설정하세요.',
'settings.openchamber.tunnel.note.serverSideEnforced': '보안 터널 접근은 서버 측에서 강제됩니다.',
+7 -1
View File
@@ -1554,8 +1554,14 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.linked.pr.openInBrowserAria': '브라우저에서 PR 열기',
'chat.chatInput.linked.pr.removeAria': '연결된 PR 제거',
'chat.chatInput.placeholder.shell': '셸 명령 입력…',
'chat.chatInput.placeholder.chat': '@파일/에이전트 선택, /명령 실행, !셸 명령 실행',
'chat.chatInput.placeholder.chat': '@ 파일/에이전트; / 명령 및 스킬; ! shell; # 스니펫',
'chat.chatInput.placeholder.chatCompact': '@ / ! # 도우미 사용',
'chat.chatInput.placeholder.selectSession': '채팅을 시작할 세션을 선택하거나 새로 만드세요',
'chat.snippetAutocomplete.action.addNew': '+ 새 스니펫 추가',
'chat.snippetAutocomplete.empty': '스니펫을 찾을 수 없음',
'chat.snippetAutocomplete.footer': '↑↓ 이동 • Enter 선택 • Esc 닫기',
'snippets.source.global': '전역',
'snippets.source.project': '프로젝트',
'chat.chatInput.toast.compactFailed': '세션 압축 실패',
'chat.chatInput.toast.summaryFailed': '요약 생성 실패',
'chat.chatInput.toast.reviewFailed': '변경사항 검토 실패',
@@ -963,6 +963,29 @@ export const settingsDict = {
'settings.page.skills.title': 'Umiejętności',
'settings.page.skillsCatalog.title': 'Katalog umiejętności',
'settings.page.tunnel.title': 'Zdalny Tunel',
'settings.page.snippets.title': 'Fragmenty',
'settings.snippets.sidebar.title': 'Fragmenty',
'settings.snippets.sidebar.total': 'Suma: {count}',
'settings.snippets.sidebar.actions.create': 'Utwórz fragment',
'settings.snippets.sidebar.actions.more': 'Więcej akcji dla {name}',
'settings.snippets.sidebar.toast.deleted': 'Fragment usunięty',
'settings.snippets.sidebar.toast.deleteFailed': 'Nie udało się usunąć fragmentu',
'settings.snippets.sidebar.dialog.deleteTitle': 'Usunąć fragment?',
'settings.snippets.sidebar.dialog.deleteDescription': 'To trwale usunie #{name}.',
'settings.snippets.page.empty.title': 'Wybierz fragment',
'settings.snippets.page.empty.description': 'Wybierz fragment z paska bocznego, aby go edytować.',
'settings.snippets.page.title.new': 'Nowy fragment',
'settings.snippets.page.field.namePlaceholder': 'snippet-name',
'settings.snippets.page.field.descriptionPlaceholder': 'Co robi ten fragment',
'settings.snippets.page.field.aliases': 'Aliasy',
'settings.snippets.page.field.aliasesPlaceholder': 'safe, careful',
'settings.snippets.page.field.content': 'Treść',
'settings.snippets.page.field.contentPlaceholder': 'Markdown fragmentu...',
'settings.snippets.page.hint': 'Użyj #name w promptach. Obsługuje aliasy oraz bloki prepend/append zgodne z opencode-snippets.',
'settings.snippets.page.toast.nameRequired': 'Nazwa fragmentu jest wymagana',
'settings.snippets.page.toast.contentRequired': 'Treść fragmentu jest wymagana',
'settings.snippets.page.toast.saveFailed': 'Nie udało się zapisać fragmentu',
'settings.snippets.page.toast.saved': 'Fragment zapisany',
'settings.page.promptTemplates.title': 'Szablony promptów',
'settings.promptTemplates.sidebar.title': 'Szablony promptów',
'settings.promptTemplates.sidebar.total': 'Suma: {count}',
+7 -1
View File
@@ -874,9 +874,15 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.permissionAutoAccept.enable': 'Enable permission auto-accept',
'chat.chatInput.permissionAutoAccept.off': 'Permission auto-accept: off',
'chat.chatInput.permissionAutoAccept.on': 'Permission auto-accept: on',
'chat.chatInput.placeholder.chat': '@ for files/agents; / for commands; ! for shell',
'chat.chatInput.placeholder.chat': '@ dla plików/agentów; / dla poleceń i umiejętności; ! dla shell; # dla fragmentów',
'chat.chatInput.placeholder.chatCompact': 'Użyj @ / ! # dla pomocników',
'chat.chatInput.placeholder.selectSession': 'Wybierz lub utwórz sesję, aby zacząć czatować',
'chat.chatInput.placeholder.shell': 'Wpisz polecenie powłoki...',
'chat.snippetAutocomplete.action.addNew': '+ Dodaj nowy fragment',
'chat.snippetAutocomplete.empty': 'Nie znaleziono fragmentów',
'chat.snippetAutocomplete.footer': '↑↓ nawigacja • Enter wybierz • Esc zamknij',
'snippets.source.global': 'globalny',
'snippets.source.project': 'projekt',
'chat.chatInput.previewAnnotations': 'Adnotacje podglądu:',
'chat.chatInput.previewContext': 'Kontekst podglądu:',
'chat.chatInput.previewContextRemove': 'Usuń kontekst podglądu',
@@ -44,6 +44,29 @@ export const settingsDict = {
"settings.page.notifications.title": "Notificações",
"settings.page.voice.title": "Voz",
"settings.page.tunnel.title": "Túnel remoto",
"settings.page.snippets.title": "Snippets",
"settings.snippets.sidebar.title": "Snippets",
"settings.snippets.sidebar.total": "Total: {count}",
"settings.snippets.sidebar.actions.create": "Criar snippet",
"settings.snippets.sidebar.actions.more": "Mais ações para {name}",
"settings.snippets.sidebar.toast.deleted": "Snippet excluído",
"settings.snippets.sidebar.toast.deleteFailed": "Falha ao excluir snippet",
"settings.snippets.sidebar.dialog.deleteTitle": "Excluir snippet?",
"settings.snippets.sidebar.dialog.deleteDescription": "Isso excluirá permanentemente #{name}.",
"settings.snippets.page.empty.title": "Selecione um snippet",
"settings.snippets.page.empty.description": "Escolha um snippet na barra lateral para editá-lo.",
"settings.snippets.page.title.new": "Novo snippet",
"settings.snippets.page.field.namePlaceholder": "snippet-name",
"settings.snippets.page.field.descriptionPlaceholder": "O que este snippet faz",
"settings.snippets.page.field.aliases": "Aliases",
"settings.snippets.page.field.aliasesPlaceholder": "safe, careful",
"settings.snippets.page.field.content": "Conteúdo",
"settings.snippets.page.field.contentPlaceholder": "Markdown do snippet...",
"settings.snippets.page.hint": "Use #name em prompts. Aceita aliases e blocos prepend/append compatíveis com opencode-snippets.",
"settings.snippets.page.toast.nameRequired": "O nome do snippet é obrigatório",
"settings.snippets.page.toast.contentRequired": "O conteúdo do snippet é obrigatório",
"settings.snippets.page.toast.saveFailed": "Falha ao salvar snippet",
"settings.snippets.page.toast.saved": "Snippet salvo",
"settings.openchamber.tunnel.title": "Túnel remoto",
"settings.openchamber.tunnel.description": "Configure acesso remoto seguro com links rápidos ou com seu próprio túnel gerenciado da Cloudflare.",
"settings.openchamber.tunnel.note.serverSideEnforced": "O acesso seguro ao túnel é aplicado no servidor.",
+7 -1
View File
@@ -1520,8 +1520,14 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.linked.pr.openInBrowserAria": "Abrir PR no navegador",
"chat.chatInput.linked.pr.removeAria": "Excluir PR vinculada",
"chat.chatInput.placeholder.shell": "Digite um comando de shell...",
"chat.chatInput.placeholder.chat": "@ para arquivos/agentes; / para comandos; ! para shell",
"chat.chatInput.placeholder.chat": "@ para arquivos/agentes; / para comandos e habilidades; ! para shell; # para snippets",
"chat.chatInput.placeholder.chatCompact": "Use @ / ! # para ajudantes",
"chat.chatInput.placeholder.selectSession": "Selecione ou crie uma sessão para começar a conversar",
"chat.snippetAutocomplete.action.addNew": "+ Adicionar novo snippet",
"chat.snippetAutocomplete.empty": "Nenhum snippet encontrado",
"chat.snippetAutocomplete.footer": "↑↓ navegar • Enter selecionar • Esc fechar",
"snippets.source.global": "global",
"snippets.source.project": "projeto",
"chat.chatInput.toast.compactFailed": "Não foi possível comprimir a sessão",
"chat.chatInput.toast.summaryFailed": "Não foi possível gerar o resumo",
"chat.chatInput.toast.reviewFailed": "Não foi possível revisar as alterações",
@@ -44,6 +44,29 @@ export const settingsDict = {
"settings.page.notifications.title": "Сповіщення",
"settings.page.voice.title": "Голос",
"settings.page.tunnel.title": "Віддалений тунель",
"settings.page.snippets.title": "Сніпети",
"settings.snippets.sidebar.title": "Сніпети",
"settings.snippets.sidebar.total": "Всього: {count}",
"settings.snippets.sidebar.actions.create": "Створити сніпет",
"settings.snippets.sidebar.actions.more": "Додаткові дії для {name}",
"settings.snippets.sidebar.toast.deleted": "Сніпет видалено",
"settings.snippets.sidebar.toast.deleteFailed": "Не вдалося видалити сніпет",
"settings.snippets.sidebar.dialog.deleteTitle": "Видалити сніпет?",
"settings.snippets.sidebar.dialog.deleteDescription": "Це назавжди видалить #{name}.",
"settings.snippets.page.empty.title": "Оберіть сніпет",
"settings.snippets.page.empty.description": "Оберіть сніпет на бічній панелі для редагування.",
"settings.snippets.page.title.new": "Новий сніпет",
"settings.snippets.page.field.namePlaceholder": "snippet-name",
"settings.snippets.page.field.descriptionPlaceholder": "Що робить цей сніпет",
"settings.snippets.page.field.aliases": "Псевдоніми",
"settings.snippets.page.field.aliasesPlaceholder": "safe, careful",
"settings.snippets.page.field.content": "Вміст",
"settings.snippets.page.field.contentPlaceholder": "Markdown сніпета...",
"settings.snippets.page.hint": "Використовуйте #name у промптах. Підтримує псевдоніми та блоки prepend/append, сумісні з opencode-snippets.",
"settings.snippets.page.toast.nameRequired": "Назва сніпета обов'язкова",
"settings.snippets.page.toast.contentRequired": "Вміст сніпета обов'язковий",
"settings.snippets.page.toast.saveFailed": "Не вдалося зберегти сніпет",
"settings.snippets.page.toast.saved": "Сніпет збережено",
"settings.openchamber.tunnel.title": "Віддалений тунель",
"settings.openchamber.tunnel.description": "Налаштуйте безпечний віддалений доступ за допомогою швидких посилань або власного керованого тунелю Cloudflare.",
"settings.openchamber.tunnel.note.serverSideEnforced": "Безпечний доступ до тунелю забезпечується на стороні сервера.",
+7 -1
View File
@@ -1520,8 +1520,14 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.linked.pr.openInBrowserAria": "Відкрити PR в браузері",
"chat.chatInput.linked.pr.removeAria": "Видалити пов’язаний PR",
"chat.chatInput.placeholder.shell": "Введіть shell-команду...",
"chat.chatInput.placeholder.chat": "@ для файлів/агентів; / для команд; ! для shell-команд",
"chat.chatInput.placeholder.chat": "@ для файлів/агентів; / для команд і навичок; ! для shell; # для сніпетів",
"chat.chatInput.placeholder.chatCompact": "Використовуйте @ / ! # для помічників",
"chat.chatInput.placeholder.selectSession": "Виберіть або створіть сесію, щоб розпочати спілкування",
"chat.snippetAutocomplete.action.addNew": "+ Додати новий сніпет",
"chat.snippetAutocomplete.empty": "Сніпети не знайдено",
"chat.snippetAutocomplete.footer": "↑↓ навігація • Enter вибрати • Esc закрити",
"snippets.source.global": "глобальний",
"snippets.source.project": "проєкт",
"chat.chatInput.toast.compactFailed": "Не вдалося стиснути сесію",
"chat.chatInput.toast.summaryFailed": "Не вдалося створити підсумок",
"chat.chatInput.toast.reviewFailed": "Не вдалося переглянути зміни",
@@ -44,6 +44,29 @@ export const settingsDict = {
'settings.page.notifications.title': '通知',
'settings.page.voice.title': '语音',
'settings.page.tunnel.title': '远程隧道',
'settings.page.snippets.title': '代码片段',
'settings.snippets.sidebar.title': '代码片段',
'settings.snippets.sidebar.total': '共 {count} 个',
'settings.snippets.sidebar.actions.create': '创建代码片段',
'settings.snippets.sidebar.actions.more': '{name} 的更多操作',
'settings.snippets.sidebar.toast.deleted': '代码片段已删除',
'settings.snippets.sidebar.toast.deleteFailed': '删除代码片段失败',
'settings.snippets.sidebar.dialog.deleteTitle': '删除代码片段?',
'settings.snippets.sidebar.dialog.deleteDescription': '这将永久删除 #{name}。',
'settings.snippets.page.empty.title': '选择代码片段',
'settings.snippets.page.empty.description': '从侧边栏选择代码片段进行编辑。',
'settings.snippets.page.title.new': '新建代码片段',
'settings.snippets.page.field.namePlaceholder': 'snippet-name',
'settings.snippets.page.field.descriptionPlaceholder': '这个代码片段的作用',
'settings.snippets.page.field.aliases': '别名',
'settings.snippets.page.field.aliasesPlaceholder': 'safe, careful',
'settings.snippets.page.field.content': '内容',
'settings.snippets.page.field.contentPlaceholder': '代码片段 Markdown...',
'settings.snippets.page.hint': '在提示词中使用 #name。支持别名和与 opencode-snippets 兼容的 prepend/append 块。',
'settings.snippets.page.toast.nameRequired': '代码片段名称为必填项',
'settings.snippets.page.toast.contentRequired': '代码片段内容为必填项',
'settings.snippets.page.toast.saveFailed': '保存代码片段失败',
'settings.snippets.page.toast.saved': '代码片段已保存',
'settings.openchamber.tunnel.title': '远程隧道',
'settings.openchamber.tunnel.description': '通过快速链接或你自己的 Cloudflare Managed Remote 隧道配置安全远程访问。',
'settings.openchamber.tunnel.note.serverSideEnforced': '安全隧道访问在服务端强制执行。',
+7 -1
View File
@@ -1520,8 +1520,14 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.linked.pr.openInBrowserAria': '在浏览器中打开 Pull Request',
'chat.chatInput.linked.pr.removeAria': '移除已关联的 Pull Request',
'chat.chatInput.placeholder.shell': '输入 shell 命令...',
'chat.chatInput.placeholder.chat': '@ 用于文件/智能体;/ 用于命令;! 用于 shell',
'chat.chatInput.placeholder.chat': '@ 用于文件/智能体;/ 用于命令和技能! 用于 shell# 用于代码片段',
'chat.chatInput.placeholder.chatCompact': '使用 @ / ! # 辅助',
'chat.chatInput.placeholder.selectSession': '选择或创建会话以开始聊天',
'chat.snippetAutocomplete.action.addNew': '+ 新建代码片段',
'chat.snippetAutocomplete.empty': '未找到代码片段',
'chat.snippetAutocomplete.footer': '↑↓ 导航 • Enter 选择 • Esc 关闭',
'snippets.source.global': '全局',
'snippets.source.project': '项目',
'chat.chatInput.toast.compactFailed': '压缩会话失败',
'chat.chatInput.toast.summaryFailed': '生成总结失败',
'chat.chatInput.toast.reviewFailed': '审查变更失败',
@@ -0,0 +1,65 @@
import { describe, expect, test } from 'bun:test';
import { getFusionSessionTitle, getMultiRunSessionTitle, parseMultiRunSessionTitle } from './title';
describe('multi-run titles', () => {
test('parses legacy session titles', () => {
expect(parseMultiRunSessionTitle('bench/anthropic/claude')).toEqual({
groupSlug: 'bench',
providerID: 'anthropic',
modelID: 'claude',
fusion: false,
});
expect(parseMultiRunSessionTitle('bench/anthropic/claude/2')).toEqual({
groupSlug: 'bench',
providerID: 'anthropic',
modelID: 'claude',
index: 2,
fusion: false,
});
});
test('parses grouped session titles', () => {
expect(parseMultiRunSessionTitle('bench/g2/anthropic/claude')).toEqual({
groupSlug: 'bench',
runGroup: 'g2',
providerID: 'anthropic',
modelID: 'claude',
fusion: false,
});
expect(parseMultiRunSessionTitle('bench/g2/anthropic/claude/3')).toEqual({
groupSlug: 'bench',
runGroup: 'g2',
providerID: 'anthropic',
modelID: 'claude',
index: 3,
fusion: false,
});
});
test('keeps fusion titles scoped to the run group', () => {
expect(getFusionSessionTitle('bench', 'anthropic', 'claude')).toBe('bench/anthropic/claude/fusion');
expect(getFusionSessionTitle('bench', 'anthropic', 'claude', 'g2')).toBe('bench/g2/anthropic/claude/fusion');
expect(parseMultiRunSessionTitle('bench/g2/anthropic/claude/fusion')).toEqual({
groupSlug: 'bench',
runGroup: 'g2',
providerID: 'anthropic',
modelID: 'claude',
fusion: true,
});
});
test('builds duplicate titles without empty group segments', () => {
expect(getMultiRunSessionTitle({ groupSlug: 'bench', providerID: 'anthropic', modelID: 'claude', index: 1 })).toBe('bench/anthropic/claude/1');
expect(getMultiRunSessionTitle({ groupSlug: 'bench', runGroup: 'g1', providerID: 'anthropic', modelID: 'claude', index: 1 })).toBe('bench/g1/anthropic/claude/1');
expect(parseMultiRunSessionTitle('bench//anthropic/claude/1')).toEqual({
groupSlug: 'bench',
providerID: 'anthropic',
modelID: 'claude',
index: 1,
fusion: false,
});
});
});
+59 -12
View File
@@ -1,5 +1,6 @@
export type ParsedMultiRunTitle = {
groupSlug: string;
runGroup?: string;
providerID: string;
modelID: string;
index?: number;
@@ -7,36 +8,82 @@ export type ParsedMultiRunTitle = {
};
const GROUP_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,48}[a-z0-9])?$/;
const RUN_GROUP_PATTERN = /^g[1-9]\d*$/;
export const parseMultiRunSessionTitle = (title?: string | null): ParsedMultiRunTitle | null => {
if (!title) return null;
const segments = title.split('/');
if (segments.length !== 3 && segments.length !== 4) return null;
const [groupSlug, providerID, modelID, suffix] = segments;
const parseSuffix = (
groupSlug: string,
runGroup: string | undefined,
providerID: string,
modelID: string,
suffix: string | undefined,
): ParsedMultiRunTitle | null => {
if (!GROUP_SLUG_PATTERN.test(groupSlug)) return null;
if (runGroup !== undefined && !RUN_GROUP_PATTERN.test(runGroup)) return null;
if (!providerID?.trim() || !modelID?.trim()) return null;
if (providerID !== providerID.trim() || modelID !== modelID.trim()) return null;
if (segments.length === 3) {
return { groupSlug, providerID, modelID, fusion: false };
if (suffix === undefined) {
return { groupSlug, runGroup, providerID, modelID, fusion: false };
}
if (suffix === 'fusion') {
return { groupSlug, providerID, modelID, fusion: true };
return { groupSlug, runGroup, providerID, modelID, fusion: true };
}
if (!/^\d+$/.test(suffix)) return null;
const index = Number.parseInt(suffix, 10);
if (!Number.isSafeInteger(index) || index <= 0) return null;
return { groupSlug, providerID, modelID, index, fusion: false };
return { groupSlug, runGroup, providerID, modelID, index, fusion: false };
};
export const parseMultiRunSessionTitle = (title?: string | null): ParsedMultiRunTitle | null => {
if (!title) return null;
const segments = title.split('/');
if (segments.length < 3 || segments.length > 5) return null;
const [groupSlug] = segments;
if (segments.length === 3) {
return parseSuffix(groupSlug, undefined, segments[1], segments[2], undefined);
}
if (segments.length === 4) {
const [, second, third, fourth] = segments;
if (RUN_GROUP_PATTERN.test(second)) {
return parseSuffix(groupSlug, second, third, fourth, undefined);
}
return parseSuffix(groupSlug, undefined, second, third, fourth);
}
const [, runGroup, providerID, modelID, suffix] = segments;
if (runGroup === '') {
return parseSuffix(groupSlug, undefined, providerID, modelID, suffix);
}
return parseSuffix(groupSlug, runGroup, providerID, modelID, suffix);
};
export const getMultiRunSessionTitle = (parts: {
groupSlug: string;
runGroup?: string;
providerID: string;
modelID: string;
index?: number;
}): string => {
const segments = [parts.groupSlug];
if (parts.runGroup) segments.push(parts.runGroup);
segments.push(parts.providerID, parts.modelID);
if (parts.index !== undefined) segments.push(String(parts.index));
return segments.join('/');
};
export const isMultiRunSessionTitle = (title?: string | null): boolean => {
return parseMultiRunSessionTitle(title) !== null;
};
export const getFusionSessionTitle = (groupSlug: string, providerID: string, modelID: string): string => {
return `${groupSlug}/${providerID}/${modelID}/fusion`;
export const getFusionSessionTitle = (groupSlug: string, providerID: string, modelID: string, runGroup?: string): string => {
const segments = [groupSlug];
if (runGroup) segments.push(runGroup);
segments.push(providerID, modelID, 'fusion');
return segments.join('/');
};
+3 -3
View File
@@ -18,7 +18,7 @@ export type SettingsPageSlug =
| 'shortcuts'
| 'sessions'
| 'magic-prompts'
| 'prompt-templates'
| 'snippets'
| 'notifications'
| 'voice'
| 'tunnel';
@@ -186,8 +186,8 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
isAvailable: (ctx) => !ctx.isVSCode,
},
{
slug: 'prompt-templates',
title: 'Prompt Templates',
slug: 'snippets',
title: 'Snippets',
group: 'general',
kind: 'split',
keywords: ['prompt', 'templates', 'multi-run', 'strategy', 'approach'],
+15 -9
View File
@@ -10,6 +10,8 @@ import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { checkIsGitRepository } from '@/lib/gitApi';
import { useDirectoryStore } from './useDirectoryStore';
import { useProjectsStore } from './useProjectsStore';
import { useSnippetsStore } from './useSnippetsStore';
import { getMultiRunSessionTitle } from '@/lib/multirun/title';
const toGitSafeSlug = (value: string): string => {
return value
@@ -141,19 +143,21 @@ export const useMultiRunStore = create<MultiRunStore>()(
modelIndexes.set(key, index);
const modelSlug = toModelSlug(model.providerID, model.modelID);
const groupPart = groups.length > 1 ? `g${gi + 1}` : '';
const runGroup = groups.length > 1 ? `g${gi + 1}` : undefined;
const modelPart = count > 1
? generateWorktreeNameSeed(groupSlug, `${modelSlug}/${index}`)
: generateWorktreeNameSeed(groupSlug, modelSlug);
const preferredName = groupPart
? `${groupPart}/${modelPart}`
const preferredName = runGroup
? `${runGroup}/${modelPart}`
: modelPart;
const sessionTitle = count > 1
? `${groupSlug}/${groupPart}/${model.providerID}/${model.modelID}/${index}`
: groupPart
? `${groupSlug}/${groupPart}/${model.providerID}/${model.modelID}`
: `${groupSlug}/${model.providerID}/${model.modelID}`;
const sessionTitle = getMultiRunSessionTitle({
groupSlug,
runGroup,
providerID: model.providerID,
modelID: model.modelID,
index: count > 1 ? index : undefined,
});
try {
if (!shouldIsolateRuns) {
@@ -235,16 +239,18 @@ export const useMultiRunStore = create<MultiRunStore>()(
void (async () => {
try {
const expandText = useSnippetsStore.getState().expandText;
await Promise.allSettled(
createdRuns.map(async (run) => {
try {
const text = await expandText(run.prompt).catch(() => run.prompt);
await opencodeClient.withDirectory(run.worktreePath, () =>
opencodeClient.sendMessage({
id: run.sessionId,
providerID: run.providerID,
modelID: run.modelID,
variant: run.variant,
text: run.prompt,
text,
agent,
files: filesForMessage,
}),
@@ -1,138 +0,0 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import type { PromptTemplate } from '@/types/prompt-template';
interface PromptTemplatesStore {
templates: PromptTemplate[];
isLoading: boolean;
selectedTemplateId: string | null;
setSelectedTemplate: (id: string | null) => void;
loadTemplates: () => Promise<boolean>;
createTemplate: (id: string, name: string, body: string) => Promise<boolean>;
updateTemplate: (id: string, updates: { name?: string; body?: string }) => Promise<boolean>;
deleteTemplate: (id: string) => Promise<boolean>;
getTemplateById: (id: string) => PromptTemplate | undefined;
}
const TEMPLATES_LOAD_CACHE_TTL_MS = 5000;
let lastLoadedAt = 0;
let loadInFlight: Promise<boolean> | null = null;
export const usePromptTemplatesStore = create<PromptTemplatesStore>()(
devtools(
(set, get) => ({
templates: [],
isLoading: false,
selectedTemplateId: null,
setSelectedTemplate: (id: string | null) => {
set({ selectedTemplateId: id });
},
loadTemplates: async () => {
const now = Date.now();
if (get().templates.length > 0 && now - lastLoadedAt < TEMPLATES_LOAD_CACHE_TTL_MS) {
return true;
}
if (loadInFlight) {
return loadInFlight;
}
const request = (async () => {
set({ isLoading: true });
try {
const response = await fetch('/api/config/prompt-templates', {
headers: { 'Cache-Control': 'no-cache' },
});
if (!response.ok) {
throw new Error('Failed to load prompt templates');
}
const templates: PromptTemplate[] = await response.json();
set({ templates, isLoading: false });
lastLoadedAt = Date.now();
return true;
} catch (error) {
console.error('[PromptTemplatesStore] Failed to load:', error);
set({ isLoading: false });
return false;
}
})();
loadInFlight = request;
try {
return await request;
} finally {
loadInFlight = null;
}
},
createTemplate: async (id: string, name: string, body: string) => {
try {
const response = await fetch(`/api/config/prompt-templates/${encodeURIComponent(id)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, body }),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error || 'Failed to create prompt template');
}
lastLoadedAt = 0;
await get().loadTemplates();
return true;
} catch (error) {
console.error('[PromptTemplatesStore] Failed to create:', error);
return false;
}
},
updateTemplate: async (id: string, updates: { name?: string; body?: string }) => {
try {
const response = await fetch(`/api/config/prompt-templates/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error || 'Failed to update prompt template');
}
lastLoadedAt = 0;
await get().loadTemplates();
return true;
} catch (error) {
console.error('[PromptTemplatesStore] Failed to update:', error);
return false;
}
},
deleteTemplate: async (id: string) => {
try {
const response = await fetch(`/api/config/prompt-templates/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error || 'Failed to delete prompt template');
}
if (get().selectedTemplateId === id) {
set({ selectedTemplateId: null });
}
lastLoadedAt = 0;
await get().loadTemplates();
return true;
} catch (error) {
console.error('[PromptTemplatesStore] Failed to delete:', error);
return false;
}
},
getTemplateById: (id: string) => {
return get().templates.find((t) => t.id === id);
},
}),
{ name: 'prompt-templates-store' },
),
);
+173
View File
@@ -0,0 +1,173 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import type { Snippet } from '@/types/snippet';
import { opencodeClient } from '@/lib/opencode/client';
import { useProjectsStore } from '@/stores/useProjectsStore';
export type SnippetScope = 'global' | 'project';
export interface SnippetDraft {
name: string;
scope: SnippetScope;
content?: string;
aliases?: string[];
description?: string;
}
interface SnippetsStore {
snippets: Snippet[];
isLoading: boolean;
selectedSnippetName: string | null;
snippetDraft: SnippetDraft | null;
setSelectedSnippet: (name: string | null) => void;
setSnippetDraft: (draft: SnippetDraft | null) => void;
loadSnippets: () => Promise<boolean>;
createSnippet: (name: string, content: string, options?: { aliases?: string[]; description?: string; scope?: SnippetScope }) => Promise<boolean>;
updateSnippet: (name: string, updates: { content?: string; aliases?: string[]; description?: string }) => Promise<boolean>;
deleteSnippet: (name: string) => Promise<boolean>;
expandText: (text: string) => Promise<string>;
getSnippetByName: (name: string) => Snippet | undefined;
}
const SNIPPETS_LOAD_CACHE_TTL_MS = 5000;
let lastLoadedAt = 0;
let loadInFlight: Promise<boolean> | null = null;
const getRequestDirectory = (): string | null => {
try {
const activeProject = useProjectsStore.getState().getActiveProject?.();
if (activeProject?.path?.trim()) return activeProject.path.trim();
const clientDir = opencodeClient.getDirectory();
if (clientDir?.trim()) return clientDir.trim();
} catch (error) {
console.warn('[SnippetsStore] Error resolving config directory:', error);
}
return null;
};
export const useSnippetsStore = create<SnippetsStore>()(
devtools(
(set, get) => ({
snippets: [],
isLoading: false,
selectedSnippetName: null,
snippetDraft: null,
setSelectedSnippet: (name) => set({ selectedSnippetName: name }),
setSnippetDraft: (draft) => set({ snippetDraft: draft }),
loadSnippets: async () => {
const now = Date.now();
if (get().snippets.length > 0 && now - lastLoadedAt < SNIPPETS_LOAD_CACHE_TTL_MS) return true;
if (loadInFlight) return loadInFlight;
const request = (async () => {
set({ isLoading: true });
try {
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await fetch(`/api/config/snippets${queryParams}`, {
headers: { 'Cache-Control': 'no-cache', ...(directory ? { 'x-opencode-directory': directory } : {}) },
});
if (!response.ok) throw new Error('Failed to load snippets');
const snippets: Snippet[] = await response.json();
set({ snippets, isLoading: false });
lastLoadedAt = Date.now();
return true;
} catch (error) {
console.error('[SnippetsStore] Failed to load:', error);
set({ isLoading: false });
return false;
}
})();
loadInFlight = request;
try {
return await request;
} finally {
loadInFlight = null;
}
},
createSnippet: async (name, content, options = {}) => {
try {
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await fetch(`/api/config/snippets/${encodeURIComponent(name)}${queryParams}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(directory ? { 'x-opencode-directory': directory } : {}) },
body: JSON.stringify({ content, aliases: options.aliases, description: options.description, scope: options.scope }),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
if (response.status === 409) {
return await get().updateSnippet(name, { content, aliases: options.aliases, description: options.description });
}
throw new Error(payload?.error || 'Failed to create snippet');
}
lastLoadedAt = 0;
await get().loadSnippets();
return true;
} catch (error) {
console.error('[SnippetsStore] Failed to create:', error);
return false;
}
},
updateSnippet: async (name, updates) => {
try {
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await fetch(`/api/config/snippets/${encodeURIComponent(name)}${queryParams}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...(directory ? { 'x-opencode-directory': directory } : {}) },
body: JSON.stringify(updates),
});
if (!response.ok) throw new Error((await response.json().catch(() => null))?.error || 'Failed to update snippet');
lastLoadedAt = 0;
await get().loadSnippets();
return true;
} catch (error) {
console.error('[SnippetsStore] Failed to update:', error);
return false;
}
},
deleteSnippet: async (name) => {
try {
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await fetch(`/api/config/snippets/${encodeURIComponent(name)}${queryParams}`, {
method: 'DELETE',
headers: directory ? { 'x-opencode-directory': directory } : undefined,
});
if (!response.ok) throw new Error((await response.json().catch(() => null))?.error || 'Failed to delete snippet');
if (get().selectedSnippetName === name) set({ selectedSnippetName: null });
lastLoadedAt = 0;
await get().loadSnippets();
return true;
} catch (error) {
console.error('[SnippetsStore] Failed to delete:', error);
return false;
}
},
expandText: async (text) => {
if (!/#[a-z0-9_-]+/i.test(text)) return text;
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await fetch(`/api/config/snippets/expand${queryParams}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(directory ? { 'x-opencode-directory': directory } : {}) },
body: JSON.stringify({ text }),
});
if (!response.ok) throw new Error((await response.json().catch(() => null))?.error || 'Failed to expand snippets');
return (await response.json()).text ?? text;
},
getSnippetByName: (name) => get().snippets.find((snippet) => snippet.name === name || snippet.aliases.includes(name)),
}),
{ name: 'snippets-store' },
),
);
-1
View File
@@ -14,7 +14,6 @@ export interface MultiRunFileAttachment {
export interface MultiRunGroup {
prompt: string;
models: MultiRunModelSelection[];
templateId?: string;
}
export interface CreateMultiRunParams {
-6
View File
@@ -1,6 +0,0 @@
export interface PromptTemplate {
id: string;
name: string;
body: string;
isDefault: boolean;
}
+8
View File
@@ -0,0 +1,8 @@
export interface Snippet {
name: string;
content: string;
aliases: string[];
description?: string;
filePath: string;
source: 'global' | 'project';
}
@@ -18,6 +18,7 @@ This module provides OpenCode server integration utilities for the web server ru
- `packages/web/server/lib/opencode/network-runtime.js`: OpenCode URL construction, health-probe readiness checks, and API prefix runtime.
- `packages/web/server/lib/opencode/project-directory-runtime.js`: request-scoped and settings-backed project directory resolution/validation runtime.
- `packages/web/server/lib/opencode/config-entity-routes.js`: route registration for agent/command/MCP config orchestration and reload semantics.
- `packages/web/server/lib/opencode/snippets.js`: opencode-snippets-compatible snippet file CRUD, discovery, and hashtag expansion.
- `packages/web/server/lib/opencode/cli-options.js`: CLI/environment option parsing for server startup arguments.
- `packages/web/server/lib/opencode/core-routes.js`: server status/system routes, auth/access guard routes, and settings utility route registration.
- `packages/web/server/lib/opencode/shutdown-runtime.js`: graceful shutdown orchestration runtime for watcher/session/terminal/process/server teardown.
@@ -211,6 +212,7 @@ This module provides OpenCode server integration utilities for the web server ru
- Agents: `/api/config/agents/:name` and `/api/config/agents/:name/config`
- Commands: `/api/config/commands/:name`
- MCP servers: `/api/config/mcp` and `/api/config/mcp/:name`
- Snippets: `/api/config/snippets`, `/api/config/snippets/:name`, and `/api/config/snippets/expand`
## Public exports (auth-state-runtime.js)
- `createOpenCodeAuthStateRuntime(dependencies)`: creates runtime for managed OpenCode auth password state and request headers.
@@ -18,11 +18,12 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
createMcpConfig,
updateMcpConfig,
deleteMcpConfig,
listPromptTemplates,
getPromptTemplate,
createPromptTemplate,
updatePromptTemplate,
deletePromptTemplate,
listSnippets,
getSnippet,
createSnippet,
updateSnippet,
deleteSnippet,
expandSnippets,
} = dependencies;
const completeMcpMutation = async (res, action, name, applyChange) => {
@@ -373,94 +374,112 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
}
});
app.get('/api/config/prompt-templates', async (req, res) => {
app.get('/api/config/snippets', async (req, res) => {
try {
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ error });
}
const templates = listPromptTemplates(directory);
res.json(templates);
res.json(listSnippets(directory));
} catch (error) {
console.error('[API:GET /api/config/prompt-templates] Failed:', error);
res.status(500).json({ error: error.message || 'Failed to list prompt templates' });
console.error('[API:GET /api/config/snippets] Failed:', error);
res.status(500).json({ error: error.message || 'Failed to list snippets' });
}
});
app.get('/api/config/prompt-templates/:id', async (req, res) => {
app.post('/api/config/snippets/expand', async (req, res) => {
try {
const id = req.params.id;
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ error });
}
const template = getPromptTemplate(id, directory);
if (!template) {
return res.status(404).json({ error: `Prompt template "${id}" not found` });
}
res.json(template);
res.json({ text: expandSnippets(req.body?.text ?? '', directory) });
} catch (error) {
console.error('[API:GET /api/config/prompt-templates/:id] Failed:', error);
res.status(500).json({ error: error.message || 'Failed to get prompt template' });
console.error('[API:POST /api/config/snippets/expand] Failed:', error);
res.status(500).json({ error: error.message || 'Failed to expand snippets' });
}
});
app.post('/api/config/prompt-templates/:id', async (req, res) => {
app.get('/api/config/snippets/:name', async (req, res) => {
try {
const id = req.params.id;
const config = req.body || {};
const name = req.params.name;
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ error });
}
console.log(`[API:POST /api/config/prompt-templates] Creating prompt template: ${id}`);
const template = createPromptTemplate(id, config, directory);
res.json({ success: true, template });
const snippet = getSnippet(name, directory);
if (!snippet) {
return res.status(404).json({ error: `Snippet "${name}" not found` });
}
res.json(snippet);
} catch (error) {
console.error('[API:POST /api/config/prompt-templates/:id] Failed:', error);
console.error('[API:GET /api/config/snippets/:name] Failed:', error);
if (error.message?.includes('Snippet name')) {
return res.status(400).json({ error: error.message });
}
res.status(500).json({ error: error.message || 'Failed to get snippet' });
}
});
app.post('/api/config/snippets/:name', async (req, res) => {
try {
const name = req.params.name;
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ error });
}
const snippet = createSnippet(name, req.body || {}, directory, req.body?.scope || 'global');
res.json({ success: true, snippet });
} catch (error) {
console.error('[API:POST /api/config/snippets/:name] Failed:', error);
if (error.message?.includes('already exists')) {
return res.status(409).json({ error: error.message });
}
res.status(500).json({ error: error.message || 'Failed to create prompt template' });
if (error.message?.includes('Snippet name') || error.message?.includes('Project directory')) {
return res.status(400).json({ error: error.message });
}
res.status(500).json({ error: error.message || 'Failed to create snippet' });
}
});
app.patch('/api/config/prompt-templates/:id', async (req, res) => {
app.patch('/api/config/snippets/:name', async (req, res) => {
try {
const id = req.params.id;
const updates = req.body;
const name = req.params.name;
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ error });
}
console.log(`[API:PATCH /api/config/prompt-templates] Updating prompt template: ${id}`);
const template = updatePromptTemplate(id, updates, directory);
res.json({ success: true, template });
res.json({ success: true, snippet: updateSnippet(name, req.body || {}, directory) });
} catch (error) {
console.error('[API:PATCH /api/config/prompt-templates/:id] Failed:', error);
console.error('[API:PATCH /api/config/snippets/:name] Failed:', error);
if (error.message?.includes('not found')) {
return res.status(404).json({ error: error.message });
}
res.status(500).json({ error: error.message || 'Failed to update prompt template' });
if (error.message?.includes('Snippet name')) {
return res.status(400).json({ error: error.message });
}
res.status(500).json({ error: error.message || 'Failed to update snippet' });
}
});
app.delete('/api/config/prompt-templates/:id', async (req, res) => {
app.delete('/api/config/snippets/:name', async (req, res) => {
try {
const id = req.params.id;
const name = req.params.name;
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ error });
}
console.log(`[API:DELETE /api/config/prompt-templates] Deleting prompt template: ${id}`);
deletePromptTemplate(id, directory);
deleteSnippet(name, directory);
res.json({ success: true });
} catch (error) {
console.error('[API:DELETE /api/config/prompt-templates/:id] Failed:', error);
console.error('[API:DELETE /api/config/snippets/:name] Failed:', error);
if (error.message?.includes('not found')) {
return res.status(404).json({ error: error.message });
}
res.status(500).json({ error: error.message || 'Failed to delete prompt template' });
if (error.message?.includes('Snippet name')) {
return res.status(400).json({ error: error.message });
}
res.status(500).json({ error: error.message || 'Failed to delete snippet' });
}
});
};
@@ -464,6 +464,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
req.path.startsWith('/api/config/agents') ||
req.path.startsWith('/api/config/commands') ||
req.path.startsWith('/api/config/mcp') ||
req.path.startsWith('/api/config/snippets') ||
req.path.startsWith('/api/config/settings') ||
req.path.startsWith('/api/config/skills') ||
req.path.startsWith('/api/projects') ||
@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { registerServerStatusRoutes } from './core-routes.js';
import { registerCommonRequestMiddleware, registerServerStatusRoutes } from './core-routes.js';
describe('core-routes', () => {
it('should call gracefulShutdown with exitProcess: true on /api/system/shutdown', async () => {
@@ -14,6 +14,7 @@ describe('core-routes', () => {
getHealthSnapshot: () => ({ status: 'ok' }),
openchamberVersion: '1.0.0',
runtimeName: 'test',
express,
};
registerServerStatusRoutes(app, dependencies);
@@ -23,4 +24,19 @@ describe('core-routes', () => {
expect(dependencies.gracefulShutdown).toHaveBeenCalled();
expect(shutdownOpts).toEqual({ exitProcess: true });
});
it('should parse JSON bodies for snippet config routes', async () => {
const app = express();
registerCommonRequestMiddleware(app, { express });
app.post('/api/config/snippets/example', (req, res) => {
res.json({ body: req.body });
});
const response = await request(app)
.post('/api/config/snippets/example')
.send({ content: 'Snippet body' })
.expect(200);
expect(response.body).toEqual({ body: { content: 'Snippet body' } });
});
});
@@ -123,11 +123,12 @@ export const createFeatureRoutesRuntime = (dependencies) => {
createMcpConfig,
updateMcpConfig,
deleteMcpConfig,
listPromptTemplates,
getPromptTemplate,
createPromptTemplate,
updatePromptTemplate,
deletePromptTemplate,
listSnippets,
getSnippet,
createSnippet,
updateSnippet,
deleteSnippet,
expandSnippets,
} = await import('./index.js');
registerConfigEntityRoutes(app, {
@@ -149,11 +150,12 @@ export const createFeatureRoutesRuntime = (dependencies) => {
createMcpConfig,
updateMcpConfig,
deleteMcpConfig,
listPromptTemplates,
getPromptTemplate,
createPromptTemplate,
updatePromptTemplate,
deletePromptTemplate,
listSnippets,
getSnippet,
createSnippet,
updateSnippet,
deleteSnippet,
expandSnippets,
});
const {
+7 -7
View File
@@ -66,10 +66,10 @@ export {
} from './mcp.js';
export {
listPromptTemplates,
getPromptTemplate,
createPromptTemplate,
updatePromptTemplate,
deletePromptTemplate,
slugify as slugifyPromptTemplate,
} from './prompt-templates.js';
listSnippets,
getSnippet,
createSnippet,
updateSnippet,
deleteSnippet,
expandSnippets,
} from './snippets.js';
@@ -1,159 +0,0 @@
import {
readConfigLayers,
writeConfig,
getJsonWriteTarget,
CONFIG_FILE,
} from './shared.js';
const SECTION_KEY = 'promptTemplates';
const DEFAULT_TEMPLATES = {
simple: {
name: 'Simple',
body: 'Implement this task using the simplest possible approach. Prefer readability and straightforward solutions over clever abstractions. Keep the code easy to understand and maintain.',
isDefault: true,
},
fast: {
name: 'Fast',
body: 'Implement this task as quickly as possible. Optimize for speed of development. Use the most direct path to a working solution. Favor existing libraries and proven patterns.',
isDefault: true,
},
'memory-efficient': {
name: 'Memory Efficient',
body: 'Implement this task with memory efficiency in mind. Minimize memory allocations, use streaming where possible, avoid holding large data structures in memory, and prefer lazy evaluation.',
isDefault: true,
},
'cpu-efficient': {
name: 'CPU Efficient',
body: 'Implement this task with CPU efficiency in mind. Optimize algorithms, minimize unnecessary computations, use efficient data structures, and avoid redundant work.',
isDefault: true,
},
'tests-first': {
name: 'Tests First',
body: 'Implement this task using a test-driven approach. Write tests first, then implement the minimum code to pass them. Ensure comprehensive test coverage including edge cases.',
isDefault: true,
},
'spec-first': {
name: 'Spec First',
body: 'Implement this task by first creating a detailed specification, then implementing according to the spec. Start by documenting the requirements, interfaces, and expected behavior before writing any implementation code.',
isDefault: true,
},
};
function slugify(name) {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 60);
}
function ensureDefaults(templates) {
if (!templates || typeof templates !== 'object') {
return { ...DEFAULT_TEMPLATES };
}
let changed = false;
const result = { ...templates };
for (const [id, template] of Object.entries(DEFAULT_TEMPLATES)) {
if (!(id in result)) {
result[id] = { ...template };
changed = true;
}
}
return changed ? result : templates;
}
function readTemplatesFromConfig(workingDirectory) {
const layers = readConfigLayers(workingDirectory);
const merged = layers.mergedConfig || {};
const raw = merged[SECTION_KEY];
if (!raw || typeof raw !== 'object') {
return ensureDefaults(null);
}
return ensureDefaults(raw);
}
function writeTemplatesToConfig(templates, workingDirectory) {
const layers = readConfigLayers(workingDirectory);
const target = getJsonWriteTarget(layers, 'user');
const config = { ...target.config };
config[SECTION_KEY] = templates;
writeConfig(config, target.path || CONFIG_FILE);
}
export function listPromptTemplates(workingDirectory) {
const templates = readTemplatesFromConfig(workingDirectory);
return Object.entries(templates).map(([id, value]) => ({
id,
name: value.name || id,
body: value.body || '',
isDefault: value.isDefault === true,
}));
}
export function getPromptTemplate(id, workingDirectory) {
const templates = readTemplatesFromConfig(workingDirectory);
const entry = templates[id];
if (!entry) {
return null;
}
return {
id,
name: entry.name || id,
body: entry.body || '',
isDefault: entry.isDefault === true,
};
}
export function createPromptTemplate(id, config, workingDirectory) {
const templates = readTemplatesFromConfig(workingDirectory);
if (templates[id]) {
throw new Error(`Prompt template "${id}" already exists`);
}
templates[id] = {
name: config.name || id,
body: config.body || '',
isDefault: false,
};
writeTemplatesToConfig(templates, workingDirectory);
return getPromptTemplate(id, workingDirectory);
}
export function updatePromptTemplate(id, updates, workingDirectory) {
const templates = readTemplatesFromConfig(workingDirectory);
const existing = templates[id];
if (!existing) {
throw new Error(`Prompt template "${id}" not found`);
}
templates[id] = {
...existing,
...(updates.name !== undefined ? { name: updates.name } : {}),
...(updates.body !== undefined ? { body: updates.body } : {}),
};
writeTemplatesToConfig(templates, workingDirectory);
return getPromptTemplate(id, workingDirectory);
}
export function deletePromptTemplate(id, workingDirectory) {
const templates = readTemplatesFromConfig(workingDirectory);
if (!templates[id]) {
throw new Error(`Prompt template "${id}" not found`);
}
delete templates[id];
writeTemplatesToConfig(templates, workingDirectory);
}
export { slugify };
@@ -0,0 +1,233 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import yaml from 'yaml';
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
const GLOBAL_SNIPPET_DIR = path.join(OPENCODE_CONFIG_DIR, 'snippet');
const GLOBAL_SNIPPET_DIR_ALT = path.join(OPENCODE_CONFIG_DIR, 'snippets');
const SNIPPET_EXTENSION = '.md';
const SNIPPET_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,79}$/i;
const HASHTAG_PATTERN = /#([a-z0-9_-]+)/gi;
const MAX_EXPANSION_COUNT = 15;
function getProjectSnippetDirs(workingDirectory) {
if (!workingDirectory) return [];
return [
path.join(workingDirectory, '.opencode', 'snippets'),
path.join(workingDirectory, '.opencode', 'snippet'),
];
}
function getGlobalSnippetDirs() {
return [GLOBAL_SNIPPET_DIR_ALT, GLOBAL_SNIPPET_DIR];
}
function getLoadDirs(workingDirectory) {
return [
...getGlobalSnippetDirs().map((dir) => ({ dir, source: 'global' })),
...getProjectSnippetDirs(workingDirectory).map((dir) => ({ dir, source: 'project' })),
];
}
function assertValidSnippetName(name) {
if (typeof name !== 'string' || !SNIPPET_NAME_PATTERN.test(name)) {
throw new Error('Snippet name must use letters, numbers, dashes, or underscores');
}
}
function parseMarkdownFile(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match) {
return { frontmatter: {}, body: content.trim() };
}
return {
frontmatter: yaml.parse(match[1]) || {},
body: match[2].trim(),
};
}
function normalizeAliases(frontmatter) {
const raw = frontmatter.aliases ?? frontmatter.alias;
if (!raw) return [];
const aliases = Array.isArray(raw) ? raw : [raw];
return aliases.map((alias) => String(alias).trim()).filter(Boolean);
}
function writeMarkdownFile(filePath, { content, aliases = [], description }) {
const frontmatter = {};
const normalizedAliases = aliases.map((alias) => String(alias).trim()).filter(Boolean);
if (normalizedAliases.length > 0) frontmatter.aliases = normalizedAliases;
if (description?.trim()) frontmatter.description = description.trim();
const body = content ?? '';
const output = Object.keys(frontmatter).length > 0
? `---\n${yaml.stringify(frontmatter)}---\n${body ? `\n${body}` : ''}`
: body;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, output, 'utf8');
}
function loadSnippetFile(dir, filename, source) {
const name = path.basename(filename, SNIPPET_EXTENSION);
if (!SNIPPET_NAME_PATTERN.test(name)) return null;
const filePath = path.join(dir, filename);
const { frontmatter, body } = parseMarkdownFile(filePath);
return {
name,
content: body,
aliases: normalizeAliases(frontmatter),
description: typeof frontmatter.description === 'string' ? frontmatter.description : undefined,
filePath,
source,
};
}
function registerSnippet(registry, snippet) {
const key = snippet.name.toLowerCase();
const existing = registry.get(key);
if (existing) {
for (const alias of existing.aliases) registry.delete(alias.toLowerCase());
}
registry.set(key, snippet);
for (const alias of snippet.aliases) {
if (SNIPPET_NAME_PATTERN.test(alias)) registry.set(alias.toLowerCase(), snippet);
}
}
function loadSnippetRegistry(workingDirectory) {
const registry = new Map();
for (const { dir, source } of getLoadDirs(workingDirectory)) {
if (!fs.existsSync(dir)) continue;
for (const filename of fs.readdirSync(dir)) {
if (!filename.endsWith(SNIPPET_EXTENSION)) continue;
try {
const snippet = loadSnippetFile(dir, filename, source);
if (snippet) registerSnippet(registry, snippet);
} catch (error) {
console.warn(`[Snippets] Failed to load ${path.join(dir, filename)}:`, error);
}
}
}
return registry;
}
function listUniqueSnippets(registry) {
const seen = new Set();
const snippets = [];
for (const snippet of registry.values()) {
const key = `${snippet.source}:${snippet.filePath}`;
if (seen.has(key)) continue;
seen.add(key);
snippets.push(snippet);
}
return snippets.sort((a, b) => a.name.localeCompare(b.name));
}
function getWritableSnippetDir(scope, workingDirectory) {
if (scope === 'project') {
if (!workingDirectory) throw new Error('Project directory is required for project snippets');
const preferred = path.join(workingDirectory, '.opencode', 'snippet');
const alternate = path.join(workingDirectory, '.opencode', 'snippets');
return fs.existsSync(alternate) && !fs.existsSync(preferred) ? alternate : preferred;
}
return fs.existsSync(GLOBAL_SNIPPET_DIR_ALT) && !fs.existsSync(GLOBAL_SNIPPET_DIR)
? GLOBAL_SNIPPET_DIR_ALT
: GLOBAL_SNIPPET_DIR;
}
function findSnippetByName(name, workingDirectory) {
assertValidSnippetName(name);
const registry = loadSnippetRegistry(workingDirectory);
return registry.get(name.toLowerCase()) ?? null;
}
function parseSnippetBlocks(content) {
const blocks = { prepend: [], append: [] };
let inline = content;
for (const type of ['prepend', 'append']) {
const regex = new RegExp(`<${type}>([\\s\\S]*?)(?:<\\/${type}>|$)`, 'gi');
inline = inline.replace(regex, (_match, value) => {
const normalized = String(value).trim();
if (normalized) blocks[type].push(normalized);
return '';
});
}
inline = inline.replace(/<inject>[\s\S]*?(?:<\/inject>|$)/gi, '').trim();
return { inline, prepend: blocks.prepend, append: blocks.append };
}
function expandText(text, registry, expansionCounts, collector) {
let expanded = text;
let changed = true;
while (changed) {
const previous = expanded;
let loopDetected = false;
HASHTAG_PATTERN.lastIndex = 0;
expanded = expanded.replace(HASHTAG_PATTERN, (match, name, offset, input) => {
if (name.toLowerCase() === 'skill' && input[offset + match.length] === '(') return match;
const snippet = registry.get(name.toLowerCase());
if (!snippet) return match;
const key = snippet.name.toLowerCase();
const count = (expansionCounts.get(key) || 0) + 1;
if (count > MAX_EXPANSION_COUNT) {
loopDetected = true;
return match;
}
expansionCounts.set(key, count);
const parsed = parseSnippetBlocks(snippet.content);
for (const block of parsed.prepend) collector.prepend.push(expandText(block, registry, expansionCounts, collector));
for (const block of parsed.append) collector.append.push(expandText(block, registry, expansionCounts, collector));
return expandText(parsed.inline, registry, expansionCounts, collector);
});
changed = expanded !== previous && !loopDetected;
}
return expanded;
}
export function listSnippets(workingDirectory) {
return listUniqueSnippets(loadSnippetRegistry(workingDirectory));
}
export function getSnippet(name, workingDirectory) {
return findSnippetByName(name, workingDirectory);
}
export function createSnippet(name, config, workingDirectory, scope = 'global') {
assertValidSnippetName(name);
const dir = getWritableSnippetDir(scope, workingDirectory);
const filePath = path.join(dir, `${name}${SNIPPET_EXTENSION}`);
if (fs.existsSync(filePath)) throw new Error(`Snippet "${name}" already exists`);
writeMarkdownFile(filePath, config || {});
return getSnippet(name, workingDirectory);
}
export function updateSnippet(name, updates, workingDirectory) {
const existing = findSnippetByName(name, workingDirectory);
if (!existing) throw new Error(`Snippet "${name}" not found`);
writeMarkdownFile(existing.filePath, { ...existing, ...(updates || {}) });
return getSnippet(name, workingDirectory);
}
export function deleteSnippet(name, workingDirectory) {
const existing = findSnippetByName(name, workingDirectory);
if (!existing) throw new Error(`Snippet "${name}" not found`);
fs.unlinkSync(existing.filePath);
}
export function expandSnippets(text, workingDirectory) {
const registry = loadSnippetRegistry(workingDirectory);
const collector = { prepend: [], append: [] };
const expanded = expandText(text || '', registry, new Map(), collector).trim();
return [...collector.prepend, expanded, ...collector.append].filter(Boolean).join('\n\n');
}
export { assertValidSnippetName };
@@ -0,0 +1,68 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
createSnippet,
deleteSnippet,
expandSnippets,
getSnippet,
listSnippets,
updateSnippet,
} from './snippets.js';
let projectDir;
function writeSnippet(relativePath, content) {
const filePath = path.join(projectDir, relativePath);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, 'utf8');
}
describe('snippets', () => {
beforeEach(() => {
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-snippets-'));
});
afterEach(() => {
fs.rmSync(projectDir, { recursive: true, force: true });
});
test('loads project snippets with aliases and description', () => {
writeSnippet('.opencode/snippet/review.md', '---\naliases: [rev]\ndescription: Review helper\n---\nReview carefully.');
expect(listSnippets(projectDir)).toContainEqual(
expect.objectContaining({ name: 'review', aliases: ['rev'], description: 'Review helper', source: 'project' }),
);
expect(getSnippet('rev', projectDir)).toEqual(expect.objectContaining({ name: 'review' }));
});
test('snippet directory wins over snippets directory', () => {
writeSnippet('.opencode/snippets/same.md', 'Old');
writeSnippet('.opencode/snippet/same.md', 'New');
expect(getSnippet('same', projectDir)?.content).toBe('New');
});
test('creates updates and deletes snippets', () => {
expect(createSnippet('custom-one', { content: 'Body', aliases: ['co'] }, projectDir, 'project')).toEqual(
expect.objectContaining({ name: 'custom-one', content: 'Body', aliases: ['co'] }),
);
expect(updateSnippet('custom-one', { content: 'Updated' }, projectDir)).toEqual(
expect.objectContaining({ name: 'custom-one', content: 'Updated', aliases: ['co'] }),
);
deleteSnippet('custom-one', projectDir);
expect(getSnippet('custom-one', projectDir)).toBeNull();
});
test('expands snippets recursively with prepend and append blocks', () => {
writeSnippet('.opencode/snippet/base.md', 'Base text');
writeSnippet('.opencode/snippet/review.md', '<prepend>Before</prepend>Review #base<append>After</append>');
expect(expandSnippets('Please #review', projectDir)).toBe('Before\n\nPlease Review Base text\n\nAfter');
});
test('rejects invalid snippet names', () => {
expect(() => createSnippet('../bad', { content: '' }, projectDir, 'project')).toThrow('Snippet name');
});
});
@@ -1,6 +1,7 @@
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
import { DateTime } from 'luxon';
import parser from 'cron-parser';
import { expandSnippets } from '../opencode/snippets.js';
const DEFAULT_GLOBAL_CONCURRENCY = 4;
const DEFAULT_PROJECT_CONCURRENCY = 2;
@@ -405,7 +406,7 @@ export const createScheduledTasksRuntime = (deps) => {
return projectRunning < maxProjectConcurrency;
};
const buildPromptAsyncPayload = (task) => ({
const buildPromptAsyncPayload = (task, projectPath) => ({
model: {
providerID: task.execution.providerID,
modelID: task.execution.modelID,
@@ -415,7 +416,7 @@ export const createScheduledTasksRuntime = (deps) => {
parts: [
{
type: 'text',
text: task.execution.prompt,
text: expandSnippets(task.execution.prompt, projectPath),
},
],
});
@@ -430,7 +431,7 @@ export const createScheduledTasksRuntime = (deps) => {
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify(buildPromptAsyncPayload(task)),
body: JSON.stringify(buildPromptAsyncPayload(task, projectPath)),
});
if (!response.ok) {
+199 -78
View File
@@ -8,13 +8,14 @@
* packages/ui/src/components/icon/sprite.ts.
*/
import { readFileSync, writeFileSync } from "node:fs"
import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(__dirname, "..")
const remixPath = resolve(repoRoot, "node_modules/@remixicon/react/index.mjs")
const outPath = resolve(repoRoot, "packages/ui/src/components/icon/sprite.ts")
const source = readFileSync(remixPath, "utf-8")
@@ -73,6 +74,35 @@ for (const entry of entries) {
}
}
const remixToSpriteName = (name) => {
// RiArrowDownSLine → arrow-down-s
// RiGithubFill → github-fill (keep Fill for fill variants)
return name
.replace(/^Ri/, "")
.replace(/Line$/, "")
.replace(/([a-z])([A-Z0-9])/g, "$1-$2")
.replace(/([0-9])([A-Z])/g, "$1-$2")
.toLowerCase()
}
const spriteNameToRi = new Map()
const hasRemixVariantSuffix = (name) => name.endsWith("Line") || name.endsWith("Fill")
const shouldPreferSpriteCandidate = (current, candidate) => {
if (!current) return true
if (!hasRemixVariantSuffix(candidate) && hasRemixVariantSuffix(current)) return true
if (!hasRemixVariantSuffix(current)) return false
if (candidate.endsWith("Line") && !current.endsWith("Line")) return true
return false
}
for (const iconName of nameToVar.keys()) {
const spriteName = remixToSpriteName(iconName)
const current = spriteNameToRi.get(spriteName)
if (shouldPreferSpriteCandidate(current, iconName)) {
spriteNameToRi.set(spriteName, iconName)
}
}
// --- Step 3: find which icons we actually use ---
const srcDir = resolve(repoRoot, "packages/ui/src")
@@ -92,40 +122,7 @@ function nameToRi(kebab) {
return result
}
const srcFiles = []
function walk(dir) {
const { readdirSync, statSync } = require("node:fs")
for (const entry of readdirSync(dir)) {
const full = resolve(dir, entry)
if (statSync(full).isDirectory()) {
if (entry === "node_modules") continue
walk(full)
} else if (/\.(tsx?|jsx?)$/.test(entry)) {
srcFiles.push(full)
}
}
}
import("node:fs").then(({ readdirSync, statSync: st }) => {
// Already imported above, use recursive function
function localWalk(dir) {
const { readdirSync: rd, statSync: s } = require("node:fs")
for (const entry of rd(dir)) {
const full = resolve(dir, entry)
try {
if (s(full).isDirectory()) {
if (entry === "node_modules") continue
localWalk(full)
} else if (/\.(tsx?)$/.test(entry)) {
srcFiles.push(full)
}
} catch {}
}
}
localWalk(srcDir)
})
// Finish step 3 synchronously with simpler approach
import { readdirSync, statSync } from "node:fs"
function findAllSourceFiles(dir) {
const results = []
for (const entry of readdirSync(dir)) {
@@ -135,7 +132,7 @@ function findAllSourceFiles(dir) {
if (st.isDirectory()) {
if (entry === "node_modules") continue
results.push(...findAllSourceFiles(full))
} else if (/\.(tsx?)$/.test(entry)) {
} else if (/\.(tsx?)$/.test(entry) && full !== outPath) {
results.push(full)
}
} catch { /* skip */ }
@@ -143,18 +140,157 @@ function findAllSourceFiles(dir) {
return results
}
// Helper: convert kebab-case name back to RiX name
function nameToRi(kebab) {
const parts = kebab.split("-")
let result = "Ri"
for (let i = 0; i < parts.length; i++) {
result += parts[i].charAt(0).toUpperCase() + parts[i].slice(1)
}
return result
}
const allSrcFiles = findAllSourceFiles(srcDir)
const usedIcons = new Set()
const addKebabIcon = (kebab) => {
const exactRiName = spriteNameToRi.get(kebab)
if (exactRiName && !hasRemixVariantSuffix(exactRiName)) {
usedIcons.add(exactRiName)
return true
}
for (const suffix of ["Line", "Fill", ""]) {
const riName = nameToRi(kebab) + suffix
if (nameToVar.has(riName)) {
usedIcons.add(riName)
return true
}
}
if (exactRiName) {
usedIcons.add(exactRiName)
return true
}
return false
}
const addIconLiterals = (content) => {
const iconLiteralRegex = /["']([a-z][a-z0-9-]*)["']/g
let literal
while ((literal = iconLiteralRegex.exec(content)) !== null) {
addKebabIcon(literal[1])
}
}
function findMatchingBrace(content, openBraceIndex) {
let depth = 0
let quote = null
let escaped = false
let lineComment = false
let blockComment = false
for (let i = openBraceIndex; i < content.length; i++) {
const char = content[i]
const next = content[i + 1]
if (lineComment) {
if (char === "\n") lineComment = false
continue
}
if (blockComment) {
if (char === "*" && next === "/") {
blockComment = false
i++
}
continue
}
if (quote) {
if (escaped) {
escaped = false
} else if (char === "\\") {
escaped = true
} else if (char === quote) {
quote = null
}
continue
}
if (char === "/" && next === "/") {
lineComment = true
i++
continue
}
if (char === "/" && next === "*") {
blockComment = true
i++
continue
}
if (char === "\"" || char === "'" || char === "`") {
quote = char
continue
}
if (char === "{") {
depth++
} else if (char === "}") {
depth--
if (depth === 0) return i
}
}
return -1
}
const addIconNameFunctionReturns = (content) => {
const functionRegex = /function\s+\w+\s*\([^)]*\)\s*:\s*IconName(?:\s*\|\s*null)?\s*{/g
let match
while ((match = functionRegex.exec(content)) !== null) {
const openBraceIndex = content.indexOf("{", match.index)
if (openBraceIndex === -1) continue
const closeBraceIndex = findMatchingBrace(content, openBraceIndex)
if (closeBraceIndex === -1) continue
const body = content.slice(openBraceIndex + 1, closeBraceIndex)
const returnRegex = /\breturn\s+["']([a-z][a-z0-9-]*)["']/g
let returnMatch
while ((returnMatch = returnRegex.exec(body)) !== null) {
addKebabIcon(returnMatch[1])
}
functionRegex.lastIndex = closeBraceIndex + 1
}
}
const addTypedIconNameRecords = (content) => {
const recordRegex = /:\s*Record<[^>]*IconName[^>]*>\s*=\s*{/g
let match
while ((match = recordRegex.exec(content)) !== null) {
const openBraceIndex = content.indexOf("{", match.index)
if (openBraceIndex === -1) continue
const closeBraceIndex = findMatchingBrace(content, openBraceIndex)
if (closeBraceIndex === -1) continue
addIconLiterals(content.slice(openBraceIndex + 1, closeBraceIndex))
recordRegex.lastIndex = closeBraceIndex + 1
}
}
const addIconNameVariableAssignments = (content) => {
if (!/<Icon\b/.test(content)) return
const variableRegex = /\b(?:const|let|var)\s+\w*IconName\b[^=]*=\s*([\s\S]*?);/g
let match
while ((match = variableRegex.exec(content)) !== null) {
const initializer = match[1]
const directLiteral = /^\s*["']([a-z][a-z0-9-]*)["']/.exec(initializer)
if (directLiteral) {
addKebabIcon(directLiteral[1])
}
const branchLiteralRegex = /(?:\?\?|[?:])\s*["']([a-z][a-z0-9-]*)["']/g
let branchLiteral
while ((branchLiteral = branchLiteralRegex.exec(initializer)) !== null) {
addKebabIcon(branchLiteral[1])
}
}
}
for (const file of allSrcFiles) {
const content = readFileSync(file, "utf-8")
// Match RiIcons from @remixicon/react imports
@@ -167,32 +303,29 @@ for (const file of allSrcFiles) {
}
// Also scan for <Icon name="..." /> patterns (already-migrated icons)
const iconNameRegex = /Icon\s+name="([^"]+)"/g
const iconNameRegex = /<Icon\b[^>]*\bname=(?:["']([^"']+)["']|{\s*["']([^"']+)["']\s*})/g
let nm
while ((nm = iconNameRegex.exec(content)) !== null) {
const kebab = nm[1]
for (const suffix of ["Line", "Fill", ""]) {
const riName = nameToRi(kebab) + suffix
if (nameToVar.has(riName)) {
usedIcons.add(riName)
break
}
}
addKebabIcon(nm[1] || nm[2])
}
// Also scan for icon: 'kebab-name' in object literals (e.g. MODALITY_ICON_MAP)
const iconPropRegex = /icon:\s*'([a-z][a-z0-9-]*)'/g
// Also scan for icon: 'kebab-name' / Icon: 'kebab-name' in object literals.
const iconPropRegex = /\b[Ii]con:\s*["']([a-z][a-z0-9-]*)["']/g
let ip
while ((ip = iconPropRegex.exec(content)) !== null) {
const kebab = ip[1]
for (const suffix of ["Line", "Fill", ""]) {
const riName = nameToRi(kebab) + suffix
if (nameToVar.has(riName)) {
usedIcons.add(riName)
break
}
}
addKebabIcon(ip[1])
}
// Also scan JSX props named icon/Icon with a string literal value.
const iconJsxPropRegex = /\b[Ii]con=(?:["']([^"']+)["']|{\s*["']([^"']+)["']\s*})/g
let jp
while ((jp = iconJsxPropRegex.exec(content)) !== null) {
addKebabIcon(jp[1] || jp[2])
}
addIconNameFunctionReturns(content)
addTypedIconNameRecords(content)
addIconNameVariableAssignments(content)
}
console.log(`Found ${usedIcons.size} unique remixicon names used in source`)
@@ -220,17 +353,6 @@ for (const iconName of [...usedIcons].sort()) {
}
// --- Step 5: write sprite.ts ---
const remixToSpriteName = (name) => {
// RiArrowDownSLine → arrow-down-s
// RiGithubFill → github-fill (keep Fill for fill variants)
return name
.replace(/^Ri/, "")
.replace(/Line$/, "")
.replace(/([a-z])([A-Z0-9])/g, "$1-$2")
.replace(/([0-9])([A-Z])/g, "$1-$2")
.toLowerCase()
}
const spriteLines = iconEntries.map(({ name, content }) => {
const spriteName = remixToSpriteName(name)
return ` "${spriteName}": \`${content}\`,`
@@ -239,12 +361,11 @@ const spriteLines = iconEntries.map(({ name, content }) => {
const spriteContent = `// This file is auto-generated by scripts/generate-icon-sprite.mjs
// Do not edit manually. Run the script to update.
export const iconSpriteData: Record<string, string> = {
export const iconSpriteData = {
${spriteLines.join("\n")}
};
} as const satisfies Record<string, string>;
`
const outPath = resolve(repoRoot, "packages/ui/src/components/icon/sprite.ts")
writeFileSync(outPath, spriteContent, "utf-8")
console.log(`\n✅ Generated sprite data for ${iconEntries.length} icons → ${outPath}`)
console.log(` Total sprite size: ${Buffer.byteLength(spriteContent).toLocaleString()} bytes`)