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
@@ -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'],