From 32217f87fd9b2548dc20b97c692e82e0fccd00a1 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Sat, 13 Dec 2025 17:28:39 +0800 Subject: [PATCH] feat(dashboard): CLI settings UI and Smart Context support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CLI Settings section with Prompt Format selector - Add Smart Context toggle and Max Files dropdown - Update smart-search with output_mode (full/files_only/count) - Add smart-context module for keyword extraction - Improve Status page styling (remove card wrappers) - Add i18n translations for new settings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- ccw/src/templates/dashboard-css/06-cards.css | 9 +- .../templates/dashboard-css/09-explorer.css | 18 +- ccw/src/templates/dashboard-css/10-cli.css | 177 +++++++++++--- .../dashboard-js/components/cli-status.js | 63 +++-- ccw/src/templates/dashboard-js/i18n.js | 18 ++ ccw/src/tools/smart-context.ts | 228 ++++++++++++++++++ ccw/src/tools/smart-search.ts | 44 +++- 7 files changed, 500 insertions(+), 57 deletions(-) create mode 100644 ccw/src/tools/smart-context.ts diff --git a/ccw/src/templates/dashboard-css/06-cards.css b/ccw/src/templates/dashboard-css/06-cards.css index 84fc7079..0df38ce8 100644 --- a/ccw/src/templates/dashboard-css/06-cards.css +++ b/ccw/src/templates/dashboard-css/06-cards.css @@ -1500,16 +1500,17 @@ code.ctx-meta-chip-value { /* Toast Notifications */ .status-toast { position: fixed; - bottom: 2rem; + bottom: 1.5rem; left: 50%; transform: translateX(-50%); - padding: 0.75rem 1.5rem; - border-radius: 0.5rem; - font-size: 0.85rem; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + font-size: 0.8125rem; font-weight: 500; z-index: 10000; animation: toastSlideUp 0.3s ease; box-shadow: 0 4px 12px hsl(0 0% 0% / 0.15); + max-width: 280px; } .status-toast.success { diff --git a/ccw/src/templates/dashboard-css/09-explorer.css b/ccw/src/templates/dashboard-css/09-explorer.css index 0ed00693..66bfbaee 100644 --- a/ccw/src/templates/dashboard-css/09-explorer.css +++ b/ccw/src/templates/dashboard-css/09-explorer.css @@ -1346,23 +1346,24 @@ color: hsl(var(--muted-foreground)); } -/* Toast Notification */ +/* Toast Notification - Compact */ .notif-toast { position: fixed; top: 70px; - right: 20px; + right: 16px; display: flex; align-items: center; - gap: 8px; - padding: 12px 16px; + gap: 6px; + padding: 8px 12px; background: hsl(var(--card)); border: 1px solid hsl(var(--border)); - border-radius: 8px; - box-shadow: 0 4px 16px hsl(var(--foreground) / 0.15); + border-radius: 6px; + box-shadow: 0 4px 12px hsl(var(--foreground) / 0.12); z-index: 1000; opacity: 0; transform: translateX(100%); transition: all 0.3s ease; + max-width: 260px; } .notif-toast.show { @@ -1379,12 +1380,13 @@ } .notif-toast .toast-icon { - font-size: 16px; + font-size: 14px; } .notif-toast .toast-message { - font-size: 13px; + font-size: 0.8125rem; color: hsl(var(--foreground)); + line-height: 1.3; } /* Mobile responsive for global notifications */ diff --git a/ccw/src/templates/dashboard-css/10-cli.css b/ccw/src/templates/dashboard-css/10-cli.css index 3370b18e..54a1a065 100644 --- a/ccw/src/templates/dashboard-css/10-cli.css +++ b/ccw/src/templates/dashboard-css/10-cli.css @@ -31,6 +31,27 @@ overflow: hidden; } +/* CLI Section - No card wrapper */ +.cli-section { + /* No background, border, or card styling */ +} + +.cli-section .section-header { + padding: 0 0 0.75rem 0; + border-bottom: none; + background: transparent; +} + +.cli-section .section-header h3 { + font-size: 0.9375rem; +} + +.cli-section .tools-list, +.cli-section .ccw-list, +.cli-section .endpoint-tools-grid { + padding: 0; +} + /* Section Header */ .section-header { display: flex; @@ -84,6 +105,8 @@ padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 0.375rem; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); transition: all 0.15s ease; } @@ -93,19 +116,19 @@ .tool-item:hover { background: hsl(var(--hover)); + border-color: hsl(var(--primary) / 0.3); } .tool-item.available { - border-left: 3px solid hsl(var(--success)); + /* No left border - use status dot instead */ } .tool-item.unavailable { - border-left: 3px solid hsl(var(--muted-foreground) / 0.3); opacity: 0.7; } .tool-item.endpoint { - border-left: 3px solid hsl(var(--indigo)); + /* No left border */ } .tool-item-left { @@ -209,6 +232,7 @@ padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 0.375rem; + background: hsl(var(--card)); border: 1px solid hsl(var(--border)); transition: all 0.15s ease; } @@ -1642,12 +1666,14 @@ justify-content: flex-end; } -/* Danger Button */ +/* Danger Button (icon style - subtle) */ .btn-icon.btn-danger { - color: hsl(var(--destructive)); + color: hsl(var(--muted-foreground)); + background: transparent; } .btn-icon.btn-danger:hover { + color: hsl(var(--destructive)); background: hsl(var(--destructive) / 0.1); } @@ -1754,7 +1780,7 @@ display: flex; flex-direction: column; padding: 0.875rem; - background: hsl(var(--background)); + background: hsl(var(--card)); border: 1px solid hsl(var(--border)); border-radius: 0.5rem; cursor: pointer; @@ -2232,6 +2258,23 @@ cursor: not-allowed; } +/* Override for icon-style danger buttons (subtle, not solid red) */ +.btn-icon.btn-danger, +.history-item-actions .btn-danger, +.cli-history-actions .btn-danger { + background: transparent; + color: hsl(var(--muted-foreground)); + border: none; +} + +.btn-icon.btn-danger:hover, +.history-item-actions .btn-danger:hover, +.cli-history-actions .btn-danger:hover { + background: hsl(var(--destructive) / 0.1); + color: hsl(var(--destructive)); + opacity: 1; +} + /* Multi-Select Checkbox */ .history-checkbox-wrapper { display: flex; @@ -2557,59 +2600,74 @@ * ======================================== */ .cli-settings-section { - margin-top: 1.5rem; - padding-top: 1.25rem; - border-top: 1px solid hsl(var(--border)); + /* No card wrapper - just title and cards */ } -.cli-settings-header { - margin-bottom: 1rem; +.cli-settings-section .section-header { + padding: 0 0 0.75rem 0; + border-bottom: none; + background: transparent; } -.cli-settings-header h4 { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.875rem; - font-weight: 600; - color: hsl(var(--foreground)); +.cli-settings-section .section-header h3 { + font-size: 0.9375rem; } .cli-settings-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 1rem; + grid-template-columns: repeat(4, 1fr); + gap: 0.75rem; +} + +@media (max-width: 1200px) { + .cli-settings-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 640px) { + .cli-settings-grid { + grid-template-columns: 1fr; + } } .cli-setting-item { - padding: 0.875rem; - background: hsl(var(--muted) / 0.3); + padding: 0.75rem; + background: hsl(var(--card)); border: 1px solid hsl(var(--border)); border-radius: 0.5rem; + display: flex; + flex-direction: column; + min-height: 90px; } .cli-setting-label { display: flex; align-items: center; gap: 0.375rem; - font-size: 0.75rem; + font-size: 0.6875rem; font-weight: 600; - color: hsl(var(--foreground)); + color: hsl(var(--muted-foreground)); + text-transform: uppercase; + letter-spacing: 0.025em; margin-bottom: 0.5rem; } .cli-setting-label i { color: hsl(var(--primary)); + width: 12px; + height: 12px; } .cli-setting-control { - margin-bottom: 0.375rem; + margin-bottom: 0.5rem; + flex-shrink: 0; } .cli-setting-select { width: 100%; - padding: 0.5rem 0.625rem; - font-size: 0.75rem; + padding: 0.4375rem 0.5rem; + font-size: 0.8125rem; background: hsl(var(--background)); border: 1px solid hsl(var(--border)); border-radius: 0.375rem; @@ -2631,5 +2689,68 @@ .cli-setting-desc { font-size: 0.6875rem; color: hsl(var(--muted-foreground)); - line-height: 1.4; + line-height: 1.3; + margin-top: auto; +} + +.cli-setting-value { + font-size: 0.875rem; + color: hsl(var(--foreground)); + font-weight: 500; +} + +/* Toggle Switch */ +.cli-toggle { + position: relative; + display: inline-block; + width: 36px; + height: 20px; +} + +.cli-toggle input { + opacity: 0; + width: 0; + height: 0; +} + +.cli-toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: hsl(var(--muted)); + transition: 0.3s; + border-radius: 20px; +} + +.cli-toggle-slider:before { + position: absolute; + content: ""; + height: 14px; + width: 14px; + left: 3px; + bottom: 3px; + background-color: white; + transition: 0.3s; + border-radius: 50%; +} + +.cli-toggle input:checked + .cli-toggle-slider { + background-color: hsl(var(--primary)); +} + +.cli-toggle input:checked + .cli-toggle-slider:before { + transform: translateX(16px); +} + +.cli-toggle input:focus + .cli-toggle-slider { + box-shadow: 0 0 0 2px hsl(var(--primary) / 0.2); +} + +/* Disabled state for settings */ +.cli-setting-item.disabled { + opacity: 0.5; + pointer-events: none; } diff --git a/ccw/src/templates/dashboard-js/components/cli-status.js b/ccw/src/templates/dashboard-js/components/cli-status.js index 604f8234..28462637 100644 --- a/ccw/src/templates/dashboard-js/components/cli-status.js +++ b/ccw/src/templates/dashboard-js/components/cli-status.js @@ -8,6 +8,10 @@ let semanticStatus = { available: false }; let defaultCliTool = 'gemini'; let promptConcatFormat = localStorage.getItem('ccw-prompt-format') || 'plain'; // plain, yaml, json +// Smart Context settings +let smartContextEnabled = localStorage.getItem('ccw-smart-context') === 'true'; +let smartContextMaxFiles = parseInt(localStorage.getItem('ccw-smart-context-max-files') || '10', 10); + // ========== Initialization ========== function initCliStatus() { // Load CLI status on init @@ -235,12 +239,36 @@ function renderCliStatus() { Storage Backend
- + + +
+

Auto-analyze prompt and add relevant file paths

+ +
+ +
+
-

History storage: SQLite for search, JSON for portability

+

Maximum files to include in smart context

@@ -280,20 +308,23 @@ function setPromptFormat(format) { showRefreshToast(`Prompt format set to ${format.toUpperCase()}`, 'success'); } -function setStorageBackendSetting(backend) { - storageBackend = backend; - localStorage.setItem('ccw-storage-backend', backend); - // Notify server about backend change - fetch('/api/cli/settings', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ storageBackend: backend }) - }).catch(err => console.error('Failed to update backend setting:', err)); - showRefreshToast(`Storage backend set to ${backend === 'sqlite' ? 'SQLite' : 'JSON'}`, 'success'); +function setSmartContextEnabled(enabled) { + smartContextEnabled = enabled; + localStorage.setItem('ccw-smart-context', enabled.toString()); + // Re-render the appropriate settings panel + if (typeof renderCliSettingsSection === 'function') { + renderCliSettingsSection(); + } else { + renderCliStatus(); + } + showRefreshToast(`Smart Context ${enabled ? 'enabled' : 'disabled'}`, 'success'); } -// Expose to window for select onchange -window.setStorageBackend = setStorageBackendSetting; +function setSmartContextMaxFiles(max) { + smartContextMaxFiles = parseInt(max, 10); + localStorage.setItem('ccw-smart-context-max-files', max); + showRefreshToast(`Smart Context max files set to ${max}`, 'success'); +} async function refreshAllCliStatus() { await Promise.all([loadCliToolStatus(), loadCodexLensStatus()]); diff --git a/ccw/src/templates/dashboard-js/i18n.js b/ccw/src/templates/dashboard-js/i18n.js index 76c24e43..439c8dce 100644 --- a/ccw/src/templates/dashboard-js/i18n.js +++ b/ccw/src/templates/dashboard-js/i18n.js @@ -200,6 +200,15 @@ const i18n = { 'cli.codexLensDescFull': 'Full-text code search engine', 'cli.semanticDesc': 'AI-powered code understanding', 'cli.semanticDescFull': 'Natural language code search', + 'cli.settings': 'CLI Execution Settings', + 'cli.promptFormat': 'Prompt Format', + 'cli.promptFormatDesc': 'Format for multi-turn conversation concatenation', + 'cli.storageBackend': 'Storage Backend', + 'cli.storageBackendDesc': 'CLI history stored in SQLite with FTS search', + 'cli.smartContext': 'Smart Context', + 'cli.smartContextDesc': 'Auto-analyze prompt and add relevant file paths', + 'cli.maxContextFiles': 'Max Context Files', + 'cli.maxContextFilesDesc': 'Maximum files to include in smart context', // CCW Install 'ccw.install': 'CCW Install', @@ -711,6 +720,15 @@ const i18n = { 'cli.codexLensDescFull': '全文代码搜索引擎', 'cli.semanticDesc': 'AI 驱动的代码理解', 'cli.semanticDescFull': '自然语言代码搜索', + 'cli.settings': 'CLI 调用设置', + 'cli.promptFormat': '提示词格式', + 'cli.promptFormatDesc': '多轮对话拼接格式', + 'cli.storageBackend': '存储后端', + 'cli.storageBackendDesc': 'CLI 历史使用 SQLite 存储,支持全文搜索', + 'cli.smartContext': '智能上下文', + 'cli.smartContextDesc': '自动分析提示词并添加相关文件路径', + 'cli.maxContextFiles': '最大上下文文件数', + 'cli.maxContextFilesDesc': '智能上下文包含的最大文件数', // CCW Install 'ccw.install': 'CCW 安装', diff --git a/ccw/src/tools/smart-context.ts b/ccw/src/tools/smart-context.ts new file mode 100644 index 00000000..ad6b20bb --- /dev/null +++ b/ccw/src/tools/smart-context.ts @@ -0,0 +1,228 @@ +/** + * Smart Context Generator + * Extracts keywords from prompts and finds relevant files via CodexLens + * Auto-generates contextual file references for CLI execution + */ + +import { executeCodexLens, ensureReady as ensureCodexLensReady } from './codex-lens.js'; + +// Options for smart context generation +export interface SmartContextOptions { + enabled: boolean; + maxFiles: number; // Default: 10 + searchMode: 'semantic' | 'text'; // Default: 'text' +} + +// Result of smart context generation +export interface SmartContextResult { + files: string[]; + keywords: string[]; + searchQuery: string; + searchMode: string; +} + +// Common stopwords to filter out +const STOPWORDS = new Set([ + 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being', + 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', + 'should', 'may', 'might', 'must', 'shall', 'can', 'need', 'to', 'of', + 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through', + 'during', 'before', 'after', 'above', 'below', 'between', 'under', + 'again', 'further', 'then', 'once', 'here', 'there', 'when', 'where', + 'why', 'how', 'all', 'each', 'few', 'more', 'most', 'other', 'some', + 'such', 'no', 'nor', 'not', 'only', 'own', 'same', 'so', 'than', 'too', + 'very', 'just', 'and', 'but', 'or', 'if', 'because', 'until', 'while', + 'this', 'that', 'these', 'those', 'what', 'which', 'who', 'whom', + 'i', 'you', 'he', 'she', 'it', 'we', 'they', 'my', 'your', 'his', 'her', + 'its', 'our', 'their', 'me', 'him', 'us', 'them', + 'please', 'help', 'want', 'like', 'make', 'use', 'file', 'code', 'add', + 'create', 'update', 'delete', 'remove', 'change', 'modify', 'fix', 'find', + 'get', 'set', 'show', 'display', 'list', 'new', 'now', 'also', 'any', +]); + +/** + * Extract meaningful keywords from prompt + * Uses simple NLP: remove stopwords, extract technical terms + */ +export function extractKeywords(prompt: string): string[] { + // Split into words, convert to lowercase, filter stopwords + const words = prompt + .toLowerCase() + .replace(/[^\w\s\-_./]/g, ' ') + .split(/\s+/) + .filter((w) => w.length > 2 && !STOPWORDS.has(w)); + + // Extract potential technical terms (camelCase, snake_case, paths) + const camelCaseMatches = prompt.match(/[a-z][a-z0-9]*[A-Z][a-zA-Z0-9]*/g) || []; + const snakeCaseMatches = prompt.match(/[a-z]+_[a-z_]+/g) || []; + const pathMatches = prompt.match(/[\w\-./]+\.(ts|js|tsx|jsx|py|go|rs|java|cpp|c|h)/g) || []; + const quotedMatches = prompt.match(/"([^"]+)"|'([^']+)'/g) || []; + + const technicalTerms = [ + ...camelCaseMatches.map((t) => t.toLowerCase()), + ...snakeCaseMatches, + ...pathMatches, + ...quotedMatches.map((t) => t.replace(/['"]/g, '')), + ]; + + // Combine and deduplicate, prioritize longer terms + const allKeywords = [...new Set([...technicalTerms, ...words])]; + + // Sort by length (longer terms are more specific) and take top 5 + return allKeywords + .sort((a, b) => b.length - a.length) + .slice(0, 5); +} + +/** + * Build search query from keywords + */ +function buildSearchQuery(keywords: string[]): string { + return keywords.join(' '); +} + +/** + * Extract file paths from various CodexLens result formats + * Handles nested structures like {files: {result: {files: [...]}}} + */ +function extractFilesFromResult(parsed: unknown): string[] { + if (!parsed || typeof parsed !== 'object') { + return []; + } + + const obj = parsed as Record; + + // Direct array of strings + if (Array.isArray(parsed)) { + return parsed + .map((item) => (typeof item === 'string' ? item : (item as Record)?.file || (item as Record)?.path || '')) + .filter((f) => f && f.length > 0); + } + + // {files: [...]} format + if (Array.isArray(obj.files)) { + return extractFilesFromResult(obj.files); + } + + // {files: {result: {files: [...]}}} nested format + if (obj.files && typeof obj.files === 'object') { + const filesObj = obj.files as Record; + if (filesObj.result && typeof filesObj.result === 'object') { + const resultObj = filesObj.result as Record; + if (Array.isArray(resultObj.files)) { + return resultObj.files.filter((f): f is string => typeof f === 'string' && f.length > 0); + } + } + // {files: {files: [...]}} + if (Array.isArray(filesObj.files)) { + return extractFilesFromResult(filesObj.files); + } + } + + // {results: [...]} format + if (Array.isArray(obj.results)) { + return obj.results + .map((r: Record) => r?.file || r?.path || '') + .filter((f) => f && f.length > 0); + } + + // {result: {files: [...]}} format + if (obj.result && typeof obj.result === 'object') { + return extractFilesFromResult(obj.result); + } + + return []; +} + +/** + * Generate smart context using CodexLens + * Uses multi-keyword search strategy: search each keyword and merge results + */ +export async function generateSmartContext( + prompt: string, + options: SmartContextOptions, + cwd: string +): Promise { + // Return empty result if disabled + if (!options.enabled) { + return { files: [], keywords: [], searchQuery: '', searchMode: '' }; + } + + // Extract keywords from prompt + const keywords = extractKeywords(prompt); + if (keywords.length === 0) { + return { files: [], keywords: [], searchQuery: '', searchMode: '' }; + } + + const searchMode = options.searchMode || 'text'; + const searchQuery = buildSearchQuery(keywords); + + try { + // Ensure CodexLens is ready + await ensureCodexLensReady(); + + // Search each keyword individually and collect unique files + const allFiles = new Set(); + const filesPerKeyword = Math.ceil(options.maxFiles / keywords.length); + + for (const keyword of keywords.slice(0, 3)) { // Limit to top 3 keywords + const args = [ + 'search', + keyword, + '--files-only', + '--limit', + filesPerKeyword.toString(), + '--json', + ]; + + const result = await executeCodexLens(args, { cwd }); + + if (result.success && result.output) { + try { + const parsed = JSON.parse(result.output); + const files = extractFilesFromResult(parsed); + files.forEach((f) => allFiles.add(f)); + } catch { + // Skip if parse fails + } + } + } + + // Convert to array and limit to maxFiles + const files = Array.from(allFiles).slice(0, options.maxFiles); + + return { files, keywords, searchQuery, searchMode }; + } catch (err) { + console.error('[Smart Context] Error:', err); + return { files: [], keywords, searchQuery, searchMode }; + } +} + +/** + * Format smart context as prompt appendage + */ +export function formatSmartContext(result: SmartContextResult): string { + if (result.files.length === 0) { + return ''; + } + + const lines = [ + '', + '--- SMART CONTEXT ---', + `Relevant files (searched: "${result.searchQuery}"):`, + ...result.files.map((f) => `- ${f}`), + '--- END SMART CONTEXT ---', + '', + ]; + + return lines.join('\n'); +} + +/** + * Default options for smart context + */ +export const defaultSmartContextOptions: SmartContextOptions = { + enabled: false, + maxFiles: 10, + searchMode: 'text', +}; diff --git a/ccw/src/tools/smart-search.ts b/ccw/src/tools/smart-search.ts index da9c4d39..d0724ec6 100644 --- a/ccw/src/tools/smart-search.ts +++ b/ccw/src/tools/smart-search.ts @@ -21,6 +21,7 @@ import { const ParamsSchema = z.object({ query: z.string().min(1, 'Query is required'), mode: z.enum(['auto', 'exact', 'fuzzy', 'semantic', 'graph']).default('auto'), + output_mode: z.enum(['full', 'files_only', 'count']).default('full'), paths: z.array(z.string()).default([]), contextLines: z.number().default(0), maxResults: z.number().default(100), @@ -616,6 +617,12 @@ Modes: auto (default), exact, fuzzy, semantic, graph`, description: 'Search mode (default: auto)', default: 'auto', }, + output_mode: { + type: 'string', + enum: ['full', 'files_only', 'count'], + description: 'Output mode: full (default), files_only (paths only), count (per-file counts)', + default: 'full', + }, paths: { type: 'array', description: 'Paths to search within (default: current directory)', @@ -644,6 +651,36 @@ Modes: auto (default), exact, fuzzy, semantic, graph`, }, }; +/** + * Transform results based on output_mode + */ +function transformOutput( + results: ExactMatch[] | SemanticMatch[] | GraphMatch[], + outputMode: 'full' | 'files_only' | 'count' +): unknown { + switch (outputMode) { + case 'files_only': { + // Extract unique file paths + const files = [...new Set(results.map((r) => r.file))]; + return { files, count: files.length }; + } + case 'count': { + // Count matches per file + const counts: Record = {}; + for (const r of results) { + counts[r.file] = (counts[r.file] || 0) + 1; + } + return { + files: Object.entries(counts).map(([file, count]) => ({ file, count })), + total: results.length, + }; + } + case 'full': + default: + return results; + } +} + // Handler function export async function handler(params: Record): Promise> { const parsed = ParamsSchema.safeParse(params); @@ -651,7 +688,7 @@ export async function handler(params: Record): Promise): Promise