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
-
+ CLI history stored in SQLite with FTS search
+
+
+
+
+
+
+
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