feat: Enhance CLI output handling with structured Intermediate Representation (IR)

- Introduced `CliOutputUnit` and `IOutputParser` interfaces for unified output processing.
- Implemented `PlainTextParser` and `JsonLinesParser` for parsing raw CLI output into structured units.
- Updated `executeCliTool` to utilize output parsers and handle structured output.
- Added `flattenOutputUnits` utility for extracting clean output from structured data.
- Enhanced `ConversationTurn` and `ExecutionRecord` interfaces to include structured output.
- Created comprehensive documentation for CLI Output Converter usage and integration.
- Improved error handling and type mapping for various output formats.
This commit is contained in:
catlog22
2026-01-08 17:26:40 +08:00
parent b86cdd6644
commit d0523684e5
22 changed files with 1618 additions and 111 deletions

View File

@@ -203,6 +203,168 @@
color: hsl(142 76% 36%);
}
/* Tool Tags - displayed in tool cards */
.tool-tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.25rem;
}
.tool-tag {
font-size: 0.5625rem;
font-weight: 500;
padding: 0.125rem 0.375rem;
background: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
border-radius: 0.25rem;
}
/* Tags Input - used in config modal */
.tags-input-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Unified tag input - tags and input in one container */
.tags-unified-input {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
min-height: 2.5rem;
padding: 0.375rem 0.5rem;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
cursor: text;
transition: border-color 0.15s ease;
}
.tags-unified-input:focus-within {
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.1);
}
.tag-inline-input {
flex: 1;
min-width: 120px;
border: none;
background: transparent;
outline: none;
font-size: 0.8125rem;
color: hsl(var(--foreground));
padding: 0.25rem 0;
}
.tag-inline-input::placeholder {
color: hsl(var(--muted-foreground) / 0.6);
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
min-height: 1.75rem;
padding: 0.25rem;
background: hsl(var(--muted) / 0.3);
border-radius: 0.375rem;
}
.tag-item {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
background: hsl(var(--primary) / 0.15);
color: hsl(var(--primary));
border-radius: 0.25rem;
}
.tag-remove {
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
padding: 0;
border: none;
background: transparent;
color: hsl(var(--primary) / 0.6);
cursor: pointer;
font-size: 0.875rem;
line-height: 1;
border-radius: 0.125rem;
}
.tag-remove:hover {
background: hsl(var(--destructive) / 0.2);
color: hsl(var(--destructive));
}
/* Predefined Tags Row - prominent quick add buttons */
.predefined-tags-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
}
.predefined-tag-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
padding: 0.375rem 0.625rem;
background: hsl(var(--muted));
color: hsl(var(--foreground));
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s ease;
}
.predefined-tag-btn:hover {
background: hsl(var(--primary) / 0.15);
color: hsl(var(--primary));
border-color: hsl(var(--primary) / 0.3);
}
.predefined-tag-btn i {
opacity: 0.7;
}
/* Legacy predefined tags */
.predefined-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
margin-top: 0.5rem;
}
.predefined-tag {
font-size: 0.6875rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
background: hsl(var(--muted) / 0.5);
color: hsl(var(--muted-foreground));
border: 1px dashed hsl(var(--border));
border-radius: 0.25rem;
cursor: pointer;
transition: all 0.15s ease;
}
.predefined-tag:hover {
background: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
border-color: hsl(var(--primary) / 0.3);
}
.tool-item-right {
display: flex;
align-items: center;

View File

@@ -549,6 +549,77 @@
color: hsl(200 70% 70%);
}
/* ===== Backend ChunkType Badges (CliOutputUnit.type) ===== */
/* Thought/Thinking Message (from JSONL parser) */
.cli-stream-line.formatted.thought {
background: hsl(280 50% 20% / 0.3);
border-left: 3px solid hsl(280 70% 65%);
font-style: italic;
}
.cli-msg-badge.cli-msg-thought {
background: hsl(280 70% 65% / 0.2);
color: hsl(280 70% 75%);
}
/* Code Block Message */
.cli-stream-line.formatted.code {
background: hsl(220 40% 18% / 0.4);
border-left: 3px solid hsl(220 60% 55%);
font-family: var(--font-mono, 'Consolas', 'Monaco', 'Courier New', monospace);
}
.cli-msg-badge.cli-msg-code {
background: hsl(220 60% 55% / 0.25);
color: hsl(220 60% 70%);
}
/* File Diff Message */
.cli-stream-line.formatted.file_diff {
background: hsl(35 50% 18% / 0.4);
border-left: 3px solid hsl(35 80% 55%);
}
.cli-msg-badge.cli-msg-file_diff {
background: hsl(35 80% 55% / 0.25);
color: hsl(35 80% 65%);
}
/* Progress Message */
.cli-stream-line.formatted.progress {
background: hsl(190 40% 18% / 0.3);
border-left: 3px solid hsl(190 70% 50%);
}
.cli-msg-badge.cli-msg-progress {
background: hsl(190 70% 50% / 0.2);
color: hsl(190 70% 65%);
}
/* Metadata Message */
.cli-stream-line.formatted.metadata {
background: hsl(250 30% 18% / 0.3);
border-left: 3px solid hsl(250 50% 60%);
font-size: 0.85em;
}
.cli-msg-badge.cli-msg-metadata {
background: hsl(250 50% 60% / 0.2);
color: hsl(250 50% 75%);
}
/* Stderr Message (Error) */
.cli-stream-line.formatted.stderr {
background: hsl(0 50% 20% / 0.4);
border-left: 3px solid hsl(0 70% 55%);
}
.cli-msg-badge.cli-msg-stderr {
background: hsl(0 70% 55% / 0.25);
color: hsl(0 70% 70%);
}
/* Inline Code */
.cli-inline-code {
padding: 1px 5px;

View File

@@ -346,16 +346,50 @@ function renderFormattedLine(line, searchFilter) {
// Format inline code
content = content.replace(/`([^`]+)`/g, '<code class="cli-inline-code">$1</code>');
// Build type badge if has prefix
const typeBadge = parsed.hasPrefix ?
`<span class="cli-msg-badge cli-msg-${parsed.type}">
// Type badge icons for backend chunkType (CliOutputUnit.type)
const CHUNK_TYPE_ICONS = {
thought: 'brain',
code: 'code',
file_diff: 'git-compare',
progress: 'loader',
system: 'settings',
stderr: 'alert-circle',
metadata: 'info'
};
// Type badge labels for backend chunkType
const CHUNK_TYPE_LABELS = {
thought: 'Thinking',
code: 'Code',
file_diff: 'Diff',
progress: 'Progress',
system: 'System',
stderr: 'Error',
metadata: 'Info'
};
// Build type badge - prioritize content prefix, then fall back to chunkType
let typeBadge = '';
let lineClass = '';
if (parsed.hasPrefix) {
// Content has Chinese prefix like [系统], [思考], etc.
typeBadge = `<span class="cli-msg-badge cli-msg-${parsed.type}">
<i data-lucide="${MESSAGE_TYPE_ICONS[parsed.type] || 'circle'}"></i>
<span>${parsed.label}</span>
</span>` : '';
// Determine line class based on original type and parsed type
const lineClass = parsed.hasPrefix ? `cli-stream-line formatted ${parsed.type}` :
`cli-stream-line ${line.type}`;
</span>`;
lineClass = `cli-stream-line formatted ${parsed.type}`;
} else if (line.type && line.type !== 'stdout' && CHUNK_TYPE_LABELS[line.type]) {
// No content prefix, but backend sent a meaningful chunkType
typeBadge = `<span class="cli-msg-badge cli-msg-${line.type}">
<i data-lucide="${CHUNK_TYPE_ICONS[line.type] || 'circle'}"></i>
<span>${CHUNK_TYPE_LABELS[line.type]}</span>
</span>`;
lineClass = `cli-stream-line formatted ${line.type}`;
} else {
// Plain stdout, no badge
lineClass = `cli-stream-line ${line.type || 'stdout'}`;
}
return `<div class="${lineClass}">${typeBadge}<span class="cli-msg-content">${content}</span></div>`;
}

View File

@@ -1721,6 +1721,28 @@ const i18n = {
'apiSettings.modelIdExists': 'Model ID already exists',
'apiSettings.useModelTreeToManage': 'Use the model tree to manage individual models',
// CLI Settings
'apiSettings.cliSettings': 'CLI Settings',
'apiSettings.addCliSettings': 'Add CLI Settings',
'apiSettings.editCliSettings': 'Edit CLI Settings',
'apiSettings.noCliSettings': 'No CLI settings configured',
'apiSettings.noCliSettingsSelected': 'No CLI Settings Selected',
'apiSettings.cliSettingsHint': 'Select a CLI settings endpoint or create a new one',
'apiSettings.cliProviderHint': 'Select an Anthropic provider to use its API key and base URL',
'apiSettings.noAnthropicProviders': 'No Anthropic providers configured. Please add one in the Providers tab first.',
'apiSettings.selectProviderFirst': 'Select a provider first',
'apiSettings.providerRequired': 'Provider is required',
'apiSettings.modelRequired': 'Model is required',
'apiSettings.providerNotFound': 'Provider not found',
'apiSettings.settingsSaved': 'Settings saved successfully',
'apiSettings.settingsDeleted': 'Settings deleted successfully',
'apiSettings.confirmDeleteSettings': 'Are you sure you want to delete this CLI settings?',
'apiSettings.endpointName': 'Endpoint Name',
'apiSettings.envSettings': 'Environment Settings',
'apiSettings.settingsFilePath': 'Settings File Path',
'apiSettings.nameRequired': 'Name is required',
'apiSettings.status': 'Status',
// Common
'common.cancel': 'Cancel',
'common.optional': '(Optional)',
@@ -3777,6 +3799,29 @@ const i18n = {
'apiSettings.modelIdExists': '模型 ID 已存在',
'apiSettings.useModelTreeToManage': '使用模型树管理各个模型',
// CLI Settings
'apiSettings.cliSettings': 'CLI 配置',
'apiSettings.addCliSettings': '添加 CLI 配置',
'apiSettings.editCliSettings': '编辑 CLI 配置',
'apiSettings.noCliSettings': '未配置 CLI 设置',
'apiSettings.noCliSettingsSelected': '未选择 CLI 配置',
'apiSettings.cliSettingsHint': '选择一个 CLI 配置端点或创建新的',
'apiSettings.cliProviderHint': '选择一个 Anthropic 供应商以使用其 API 密钥和基础 URL',
'apiSettings.noAnthropicProviders': '未配置 Anthropic 供应商。请先在供应商标签页中添加。',
'apiSettings.selectProviderFirst': '请先选择供应商',
'apiSettings.providerRequired': '供应商为必填项',
'apiSettings.modelRequired': '模型为必填项',
'apiSettings.providerNotFound': '未找到供应商',
'apiSettings.settingsSaved': '设置保存成功',
'apiSettings.settingsDeleted': '设置删除成功',
'apiSettings.confirmDeleteSettings': '确定要删除此 CLI 配置吗?',
'apiSettings.endpointName': '端点名称',
'apiSettings.envSettings': '环境变量设置',
'apiSettings.settingsFilePath': '配置文件路径',
'apiSettings.nameRequired': '名称为必填项',
'apiSettings.tokenRequired': 'API 令牌为必填项',
'apiSettings.status': '状态',
// Common
'common.cancel': '取消',
'common.optional': '(可选)',

View File

@@ -3737,23 +3737,94 @@ function renderCliSettingsEmptyState() {
if (window.lucide) lucide.createIcons();
}
/**
* Get available Anthropic providers
*/
function getAvailableAnthropicProviders() {
if (!apiSettingsData || !apiSettingsData.providers) {
return [];
}
return apiSettingsData.providers.filter(function(p) {
return p.type === 'anthropic' && p.enabled;
});
}
/**
* Build provider options HTML for CLI Settings
*/
function buildCliProviderOptions(selectedProviderId) {
var providers = getAvailableAnthropicProviders();
var optionsHtml = '<option value="">' + t('apiSettings.selectProvider') + '</option>';
providers.forEach(function(provider) {
var isSelected = provider.id === selectedProviderId ? ' selected' : '';
optionsHtml += '<option value="' + escapeHtml(provider.id) + '"' + isSelected + '>' + escapeHtml(provider.name) + '</option>';
});
return optionsHtml;
}
/**
* Build model options HTML for CLI Settings based on selected provider
*/
function buildCliModelOptions(providerId, selectedModel) {
var providers = getAvailableAnthropicProviders();
var provider = providers.find(function(p) { return p.id === providerId; });
if (!provider || !provider.llmModels || provider.llmModels.length === 0) {
return '<option value="">' + t('apiSettings.selectProviderFirst') + '</option>';
}
var optionsHtml = '';
provider.llmModels.forEach(function(model) {
var isSelected = model.id === selectedModel ? ' selected' : '';
optionsHtml += '<option value="' + escapeHtml(model.id) + '"' + isSelected + '>' + escapeHtml(model.name || model.id) + '</option>';
});
return optionsHtml;
}
/**
* Update CLI Settings model dropdown when provider changes
*/
function onCliProviderChange() {
var providerId = document.getElementById('cli-settings-provider').value;
var modelSelect = document.getElementById('cli-settings-model');
if (modelSelect) {
modelSelect.innerHTML = buildCliModelOptions(providerId, '');
}
}
/**
* Show Add CLI Settings Modal
*/
function showAddCliSettingsModal(existingEndpoint) {
var isEdit = !!existingEndpoint;
var settings = existingEndpoint ? existingEndpoint.settings : { env: {}, model: 'sonnet' };
var env = settings.env || {};
var settings = existingEndpoint ? existingEndpoint.settings : { env: {}, model: '' };
var selectedProviderId = settings.providerId || '';
var providerOptionsHtml = buildCliProviderOptions(selectedProviderId);
var modelOptionsHtml = buildCliModelOptions(selectedProviderId, settings.model);
// Check if any Anthropic providers are configured
var hasProviders = getAvailableAnthropicProviders().length > 0;
var noProvidersWarning = !hasProviders ?
'<div class="info-message" style="margin-bottom: 1rem;">' +
'<i data-lucide="alert-circle"></i>' +
'<span>' + t('apiSettings.noAnthropicProviders') + '</span>' +
'</div>' : '';
var modalHtml =
'<div class="modal-overlay" onclick="closeModal(event)">' +
'<div class="modal" onclick="event.stopPropagation()">' +
'<div class="modal-header">' +
'<h2>' + (isEdit ? t('apiSettings.editCliSettings') : t('apiSettings.addCliSettings')) + '</h2>' +
'<button class="modal-close" onclick="closeCliSettingsModal()">&times;</button>' +
'<div class="generic-modal-overlay active" id="cliSettingsModal">' +
'<div class="generic-modal">' +
'<div class="generic-modal-header">' +
'<h3 class="generic-modal-title">' + (isEdit ? t('apiSettings.editCliSettings') : t('apiSettings.addCliSettings')) + '</h3>' +
'<button class="generic-modal-close" onclick="closeCliSettingsModal()">&times;</button>' +
'</div>' +
'<div class="modal-body">' +
'<form id="cli-settings-form">' +
'<div class="generic-modal-body">' +
noProvidersWarning +
'<form id="cli-settings-form" class="api-settings-form">' +
(isEdit ? '<input type="hidden" id="cli-settings-id" value="' + existingEndpoint.id + '">' : '') +
'<div class="form-group">' +
'<label for="cli-settings-name">' + t('apiSettings.endpointName') + ' *</label>' +
@@ -3764,20 +3835,17 @@ function showAddCliSettingsModal(existingEndpoint) {
'<input type="text" id="cli-settings-description" class="cli-input" value="' + escapeHtml(existingEndpoint ? (existingEndpoint.description || '') : '') + '" />' +
'</div>' +
'<div class="form-group">' +
'<label for="cli-settings-model">' + t('apiSettings.model') + '</label>' +
'<select id="cli-settings-model" class="cli-select">' +
'<option value="opus"' + (settings.model === 'opus' ? ' selected' : '') + '>Claude Opus</option>' +
'<option value="sonnet"' + (settings.model === 'sonnet' ? ' selected' : '') + '>Claude Sonnet</option>' +
'<option value="haiku"' + (settings.model === 'haiku' ? ' selected' : '') + '>Claude Haiku</option>' +
'<label for="cli-settings-provider">' + t('apiSettings.provider') + ' *</label>' +
'<select id="cli-settings-provider" class="cli-input" onchange="onCliProviderChange()" required>' +
providerOptionsHtml +
'</select>' +
'<small class="form-hint">' + t('apiSettings.cliProviderHint') + '</small>' +
'</div>' +
'<div class="form-group">' +
'<label for="cli-settings-token">ANTHROPIC_AUTH_TOKEN *</label>' +
'<input type="password" id="cli-settings-token" class="cli-input" value="' + escapeHtml(env.ANTHROPIC_AUTH_TOKEN || '') + '" placeholder="sk-..." required />' +
'</div>' +
'<div class="form-group">' +
'<label for="cli-settings-base-url">ANTHROPIC_BASE_URL</label>' +
'<input type="text" id="cli-settings-base-url" class="cli-input" value="' + escapeHtml(env.ANTHROPIC_BASE_URL || '') + '" placeholder="https://api.anthropic.com/v1" />' +
'<label for="cli-settings-model">' + t('apiSettings.model') + ' *</label>' +
'<select id="cli-settings-model" class="cli-input" required>' +
modelOptionsHtml +
'</select>' +
'</div>' +
'<div class="form-group">' +
'<label class="checkbox-label">' +
@@ -3786,22 +3854,17 @@ function showAddCliSettingsModal(existingEndpoint) {
'</label>' +
'</div>' +
'</form>' +
'<div class="modal-actions">' +
'<button class="btn btn-secondary" onclick="closeCliSettingsModal()">' + t('common.cancel') + '</button>' +
'<button class="btn btn-primary" onclick="submitCliSettings()"' + (!hasProviders ? ' disabled' : '') + '>' + (isEdit ? t('common.save') : t('common.create')) + '</button>' +
'</div>' +
'<div class="modal-footer">' +
'<button class="btn btn-ghost" onclick="closeCliSettingsModal()">' + t('common.cancel') + '</button>' +
'<button class="btn btn-primary" onclick="submitCliSettings()">' + (isEdit ? t('common.save') : t('common.create')) + '</button>' +
'</div>' +
'</div>' +
'</div>';
// Append modal to body
var modalsContainer = document.getElementById('modals');
if (!modalsContainer) {
modalsContainer = document.createElement('div');
modalsContainer.id = 'modals';
document.body.appendChild(modalsContainer);
}
modalsContainer.innerHTML = modalHtml;
document.body.insertAdjacentHTML('beforeend', modalHtml);
if (window.lucide) lucide.createIcons();
}
/**
@@ -3821,10 +3884,8 @@ function editCliSettings(endpointId) {
* Close CLI Settings Modal
*/
function closeCliSettingsModal() {
var modalsContainer = document.getElementById('modals');
if (modalsContainer) {
modalsContainer.innerHTML = '';
}
var modal = document.getElementById('cliSettingsModal');
if (modal) modal.remove();
}
/**
@@ -3833,9 +3894,8 @@ function closeCliSettingsModal() {
async function submitCliSettings() {
var name = document.getElementById('cli-settings-name').value.trim();
var description = document.getElementById('cli-settings-description').value.trim();
var providerId = document.getElementById('cli-settings-provider').value;
var model = document.getElementById('cli-settings-model').value;
var token = document.getElementById('cli-settings-token').value.trim();
var baseUrl = document.getElementById('cli-settings-base-url').value.trim();
var enabled = document.getElementById('cli-settings-enabled').checked;
var idInput = document.getElementById('cli-settings-id');
var id = idInput ? idInput.value : null;
@@ -3845,26 +3905,42 @@ async function submitCliSettings() {
return;
}
if (!token) {
showRefreshToast(t('apiSettings.tokenRequired'), 'error');
if (!providerId) {
showRefreshToast(t('apiSettings.providerRequired'), 'error');
return;
}
if (!model) {
showRefreshToast(t('apiSettings.modelRequired'), 'error');
return;
}
// Get provider configuration
var providers = getAvailableAnthropicProviders();
var provider = providers.find(function(p) { return p.id === providerId; });
if (!provider) {
showRefreshToast(t('apiSettings.providerNotFound'), 'error');
return;
}
// Build settings from provider
var data = {
name: name,
description: description,
enabled: enabled,
settings: {
env: {
ANTHROPIC_AUTH_TOKEN: token,
ANTHROPIC_AUTH_TOKEN: provider.apiKey || '',
DISABLE_AUTOUPDATER: '1'
},
model: model
model: model,
providerId: providerId // Store for editing
}
};
if (baseUrl) {
data.settings.env.ANTHROPIC_BASE_URL = baseUrl;
if (provider.apiBase) {
data.settings.env.ANTHROPIC_BASE_URL = provider.apiBase;
}
if (id) {
@@ -3889,6 +3965,7 @@ window.showAddCliSettingsModal = showAddCliSettingsModal;
window.editCliSettings = editCliSettings;
window.closeCliSettingsModal = closeCliSettingsModal;
window.submitCliSettings = submitCliSettings;
window.onCliProviderChange = onCliProviderChange;
// ========== Utility Functions ==========

View File

@@ -330,6 +330,25 @@ function buildToolConfigModalContent(tool, config, models, status) {
'</div>' +
'</div>' +
// Tags Section - Unified input with inline tags
'<div class="tool-config-section">' +
'<h4>Tags <span class="text-muted">(optional labels)</span></h4>' +
'<div class="tags-unified-input" id="tagsUnifiedInput">' +
(config.tags || []).map(function(tag) {
return '<span class="tag-item">' + escapeHtml(tag) + '<button type="button" class="tag-remove" data-tag="' + escapeHtml(tag) + '">&times;</button></span>';
}).join('') +
'<input type="text" id="tagInput" class="tag-inline-input" placeholder="输入标签按 Enter 添加" />' +
'</div>' +
'<div class="predefined-tags-row">' +
'<button type="button" class="predefined-tag-btn" data-tag="分析"><i data-lucide="search" class="w-3 h-3"></i> 分析</button>' +
'<button type="button" class="predefined-tag-btn" data-tag="编码"><i data-lucide="code" class="w-3 h-3"></i> 编码</button>' +
'<button type="button" class="predefined-tag-btn" data-tag="Debug"><i data-lucide="bug" class="w-3 h-3"></i> Debug</button>' +
'<button type="button" class="predefined-tag-btn" data-tag="重构"><i data-lucide="refresh-cw" class="w-3 h-3"></i> 重构</button>' +
'<button type="button" class="predefined-tag-btn" data-tag="测试"><i data-lucide="check-square" class="w-3 h-3"></i> 测试</button>' +
'<button type="button" class="predefined-tag-btn" data-tag="文档"><i data-lucide="file-text" class="w-3 h-3"></i> 文档</button>' +
'</div>' +
'</div>' +
// Footer
'<div class="tool-config-footer">' +
'<button class="btn btn-outline" onclick="closeModal()">' + t('common.cancel') + '</button>' +
@@ -341,6 +360,79 @@ function buildToolConfigModalContent(tool, config, models, status) {
}
function initToolConfigModalEvents(tool, currentConfig, models) {
// Local tags state (copy from config)
var currentTags = (currentConfig.tags || []).slice();
// Helper to render tags inline with input
function renderTags() {
var container = document.getElementById('tagsUnifiedInput');
var input = document.getElementById('tagInput');
if (!container) return;
// Remove existing tag items but keep the input
container.querySelectorAll('.tag-item').forEach(function(el) { el.remove(); });
// Insert tags before the input
currentTags.forEach(function(tag) {
var tagEl = document.createElement('span');
tagEl.className = 'tag-item';
tagEl.innerHTML = escapeHtml(tag) + '<button type="button" class="tag-remove" data-tag="' + escapeHtml(tag) + '">&times;</button>';
container.insertBefore(tagEl, input);
});
// Re-attach remove handlers
container.querySelectorAll('.tag-remove').forEach(function(btn) {
btn.onclick = function(e) {
e.stopPropagation();
var tagToRemove = this.getAttribute('data-tag');
currentTags = currentTags.filter(function(t) { return t !== tagToRemove; });
renderTags();
};
});
}
// Click on unified input container focuses the input
var unifiedInput = document.getElementById('tagsUnifiedInput');
if (unifiedInput) {
unifiedInput.onclick = function(e) {
if (e.target === this) {
document.getElementById('tagInput').focus();
}
};
}
// Tag input handler
var tagInput = document.getElementById('tagInput');
if (tagInput) {
tagInput.onkeydown = function(e) {
if (e.key === 'Enter') {
e.preventDefault();
var newTag = this.value.trim();
if (newTag && currentTags.indexOf(newTag) === -1) {
currentTags.push(newTag);
renderTags();
}
this.value = '';
}
};
}
// Predefined tag click handlers
document.querySelectorAll('.predefined-tag-btn').forEach(function(btn) {
btn.onclick = function() {
var tag = this.getAttribute('data-tag');
if (tag && currentTags.indexOf(tag) === -1) {
currentTags.push(tag);
renderTags();
}
};
});
// Initialize tags display
renderTags();
// Initialize lucide icons for predefined buttons
if (window.lucide) lucide.createIcons();
// Toggle Enable/Disable
var toggleBtn = document.getElementById('toggleEnableBtn');
if (toggleBtn) {
@@ -426,10 +518,15 @@ function initToolConfigModalEvents(tool, currentConfig, models) {
try {
await updateCliToolConfig(tool, {
primaryModel: primaryModel,
secondaryModel: secondaryModel
secondaryModel: secondaryModel,
tags: currentTags
});
// Reload config to reflect changes
await loadCliToolConfig();
showRefreshToast('Configuration saved', 'success');
closeModal();
renderToolsSection();
if (window.lucide) lucide.createIcons();
} catch (err) {
showRefreshToast('Failed to save: ' + err.message, 'error');
}
@@ -554,35 +651,42 @@ function renderToolsSection() {
var toolDescriptions = {
gemini: t('cli.geminiDesc'),
qwen: t('cli.qwenDesc'),
codex: t('cli.codexDesc')
codex: t('cli.codexDesc'),
claude: t('cli.claudeDesc') || 'Anthropic Claude Code CLI for AI-assisted development',
opencode: t('cli.opencodeDesc') || 'OpenCode CLI - Multi-provider AI coding assistant'
};
var tools = ['gemini', 'qwen', 'codex'];
var tools = ['gemini', 'qwen', 'codex', 'claude', 'opencode'];
var available = Object.values(cliToolStatus).filter(function(t) { return t.available; }).length;
var toolsHtml = tools.map(function(tool) {
var status = cliToolStatus[tool] || {};
var isAvailable = status.available;
var isDefault = defaultCliTool === tool;
var toolConfig = cliToolConfig && cliToolConfig.tools ? cliToolConfig.tools[tool] : null;
var tags = toolConfig && toolConfig.tags ? toolConfig.tags : [];
// Build tags HTML
var tagsHtml = tags.length > 0
? '<div class="tool-tags">' + tags.map(function(tag) {
return '<span class="tool-tag">' + escapeHtml(tag) + '</span>';
}).join('') + '</div>'
: '';
return '<div class="tool-item clickable ' + (isAvailable ? 'available' : 'unavailable') + '" onclick="showToolConfigModal(\'' + tool + '\')">' +
'<div class="tool-item-left">' +
'<span class="tool-status-dot ' + (isAvailable ? 'status-available' : 'status-unavailable') + '"></span>' +
'<div class="tool-item-info">' +
'<div class="tool-item-name">' + tool.charAt(0).toUpperCase() + tool.slice(1) +
(isDefault ? '<span class="tool-default-badge">' + t('cli.default') + '</span>' : '') +
'<i data-lucide="settings" class="w-3 h-3 tool-config-icon"></i>' +
'</div>' +
'<div class="tool-item-desc">' + toolDescriptions[tool] + '</div>' +
tagsHtml +
'</div>' +
'</div>' +
'<div class="tool-item-right">' +
(isAvailable
? '<span class="tool-status-text success"><i data-lucide="check-circle" class="w-3.5 h-3.5"></i> ' + t('cli.ready') + '</span>'
: '<span class="tool-status-text muted"><i data-lucide="circle-dashed" class="w-3.5 h-3.5"></i> ' + t('cli.notInstalled') + '</span>') +
(isAvailable && !isDefault
? '<button class="btn-sm btn-outline" onclick="event.stopPropagation(); setDefaultCliTool(\'' + tool + '\')"><i data-lucide="star" class="w-3 h-3"></i> ' + t('cli.setDefault') + '</button>'
: '') +
'</div>' +
'</div>';
}).join('');