agentlytics 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,12 +6,12 @@
6
6
 
7
7
  <p align="center">
8
8
  <strong>Unified analytics for your AI coding agents</strong><br>
9
- <sub>Cursor · Windsurf · Claude Code · VS Code Copilot · Zed · Antigravity · OpenCode</sub>
9
+ <sub>Cursor · Windsurf · Claude Code · VS Code Copilot · Zed · Antigravity · OpenCode · Gemini CLI · Copilot CLI</sub>
10
10
  </p>
11
11
 
12
12
  <p align="center">
13
13
  <a href="https://www.npmjs.com/package/agentlytics"><img src="https://img.shields.io/npm/v/agentlytics?color=6366f1&label=npm" alt="npm"></a>
14
- <a href="#supported-editors"><img src="https://img.shields.io/badge/editors-9-818cf8" alt="editors"></a>
14
+ <a href="#supported-editors"><img src="https://img.shields.io/badge/editors-11-818cf8" alt="editors"></a>
15
15
  <a href="#license"><img src="https://img.shields.io/badge/license-MIT-green" alt="license"></a>
16
16
  <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%E2%89%A518-brightgreen" alt="node"></a>
17
17
  </p>
@@ -54,6 +54,8 @@ Opens at **http://localhost:4637**. Requires Node.js ≥ 18, macOS.
54
54
  | **VS Code Insiders** | `vscode-insiders` | ✅ | ✅ | ✅ | ✅ |
55
55
  | **Zed** | `zed` | ✅ | ✅ | ✅ | ❌ |
56
56
  | **OpenCode** | `opencode` | ✅ | ✅ | ✅ | ✅ |
57
+ | **Gemini CLI** | `gemini-cli` | ✅ | ✅ | ✅ | ✅ |
58
+ | **Copilot CLI** | `copilot-cli` | ✅ | ✅ | ✅ | ✅ |
57
59
 
58
60
  > Windsurf, Windsurf Next, and Antigravity must be running during scan.
59
61
 
@@ -0,0 +1,174 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+
5
+ const COPILOT_DIR = path.join(os.homedir(), '.copilot');
6
+ const SESSION_STATE_DIR = path.join(COPILOT_DIR, 'session-state');
7
+
8
+ // ============================================================
9
+ // Adapter interface
10
+ // ============================================================
11
+
12
+ const name = 'copilot-cli';
13
+
14
+ /**
15
+ * Parse workspace.yaml from a session directory.
16
+ * Fields: id, cwd, git_root, repository, branch, summary, created_at, updated_at
17
+ */
18
+ function parseWorkspace(sessionDir) {
19
+ const yamlPath = path.join(sessionDir, 'workspace.yaml');
20
+ if (!fs.existsSync(yamlPath)) return null;
21
+ try {
22
+ const raw = fs.readFileSync(yamlPath, 'utf-8');
23
+ // Simple YAML parsing — handle key: value lines
24
+ const meta = {};
25
+ for (const line of raw.split('\n')) {
26
+ const match = line.match(/^(\w+):\s*(.*)$/);
27
+ if (match) meta[match[1]] = match[2].trim();
28
+ }
29
+ return meta;
30
+ } catch { return null; }
31
+ }
32
+
33
+ /**
34
+ * Parse events.jsonl and extract user/assistant messages.
35
+ * Event types: session.start, user.message, assistant.message,
36
+ * assistant.turn_start, assistant.turn_end, session.shutdown
37
+ */
38
+ function parseEvents(sessionDir) {
39
+ const eventsPath = path.join(sessionDir, 'events.jsonl');
40
+ if (!fs.existsSync(eventsPath)) return [];
41
+ try {
42
+ const raw = fs.readFileSync(eventsPath, 'utf-8');
43
+ return raw.split('\n').filter(Boolean).map(line => {
44
+ try { return JSON.parse(line); } catch { return null; }
45
+ }).filter(Boolean);
46
+ } catch { return []; }
47
+ }
48
+
49
+ function getChats() {
50
+ const chats = [];
51
+ if (!fs.existsSync(SESSION_STATE_DIR)) return chats;
52
+
53
+ let sessionDirs;
54
+ try { sessionDirs = fs.readdirSync(SESSION_STATE_DIR); } catch { return chats; }
55
+
56
+ for (const dirName of sessionDirs) {
57
+ const sessionDir = path.join(SESSION_STATE_DIR, dirName);
58
+ try { if (!fs.statSync(sessionDir).isDirectory()) continue; } catch { continue; }
59
+
60
+ const meta = parseWorkspace(sessionDir);
61
+ if (!meta) continue;
62
+
63
+ const events = parseEvents(sessionDir);
64
+ const userMessages = events.filter(e => e.type === 'user.message');
65
+ const assistantMessages = events.filter(e => e.type === 'assistant.message');
66
+ const firstUser = userMessages[0];
67
+
68
+ // Count meaningful messages (user + assistant)
69
+ const bubbleCount = userMessages.length + assistantMessages.length;
70
+ if (bubbleCount === 0) continue;
71
+
72
+ // Extract model from shutdown event or assistant messages
73
+ const shutdown = events.find(e => e.type === 'session.shutdown');
74
+ const model = shutdown?.data?.currentModel || null;
75
+
76
+ chats.push({
77
+ source: 'copilot-cli',
78
+ composerId: meta.id || dirName,
79
+ name: meta.summary || cleanPrompt(firstUser?.data?.content),
80
+ createdAt: meta.created_at ? new Date(meta.created_at).getTime() : null,
81
+ lastUpdatedAt: meta.updated_at ? new Date(meta.updated_at).getTime() : null,
82
+ mode: 'copilot',
83
+ folder: meta.cwd || meta.git_root || null,
84
+ encrypted: false,
85
+ bubbleCount,
86
+ _sessionDir: sessionDir,
87
+ _model: model,
88
+ _shutdownData: shutdown?.data || null,
89
+ });
90
+ }
91
+
92
+ return chats;
93
+ }
94
+
95
+ function cleanPrompt(text) {
96
+ if (!text) return null;
97
+ return text.replace(/\s+/g, ' ').trim().substring(0, 120) || null;
98
+ }
99
+
100
+ function getMessages(chat) {
101
+ const sessionDir = chat._sessionDir;
102
+ if (!sessionDir || !fs.existsSync(sessionDir)) return [];
103
+
104
+ const events = parseEvents(sessionDir);
105
+ const result = [];
106
+
107
+ // Aggregate token usage from shutdown event's modelMetrics
108
+ const shutdown = events.find(e => e.type === 'session.shutdown');
109
+ const modelMetrics = shutdown?.data?.modelMetrics || {};
110
+
111
+ // Build total token counts from model metrics
112
+ let totalInput = 0, totalOutput = 0, totalCacheRead = 0;
113
+ for (const metrics of Object.values(modelMetrics)) {
114
+ const u = metrics.usage || {};
115
+ totalInput += u.inputTokens || 0;
116
+ totalOutput += u.outputTokens || 0;
117
+ totalCacheRead += u.cacheReadTokens || 0;
118
+ }
119
+
120
+ for (const event of events) {
121
+ if (event.type === 'user.message') {
122
+ const content = event.data?.content;
123
+ if (content) result.push({ role: 'user', content });
124
+
125
+ } else if (event.type === 'assistant.message') {
126
+ const data = event.data || {};
127
+ const parts = [];
128
+ const toolCalls = [];
129
+
130
+ // Main text content
131
+ if (data.content) parts.push(data.content);
132
+
133
+ // Tool requests
134
+ if (data.toolRequests && Array.isArray(data.toolRequests)) {
135
+ for (const tr of data.toolRequests) {
136
+ const tcName = tr.name || tr.toolName || 'unknown';
137
+ const args = tr.args || tr.arguments || tr.input || {};
138
+ const parsedArgs = typeof args === 'string' ? safeParse(args) : args;
139
+ const argKeys = typeof parsedArgs === 'object' ? Object.keys(parsedArgs).join(', ') : '';
140
+ parts.push(`[tool-call: ${tcName}(${argKeys})]`);
141
+ toolCalls.push({ name: tcName, args: parsedArgs });
142
+ }
143
+ }
144
+
145
+ if (parts.length > 0) {
146
+ result.push({
147
+ role: 'assistant',
148
+ content: parts.join('\n'),
149
+ _model: shutdown?.data?.currentModel || chat._model,
150
+ _outputTokens: data.outputTokens,
151
+ _toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
152
+ });
153
+ }
154
+ }
155
+ }
156
+
157
+ // Attach aggregate token info to the first assistant message if available
158
+ if (result.length > 0 && totalInput > 0) {
159
+ const firstAssistant = result.find(m => m.role === 'assistant');
160
+ if (firstAssistant) {
161
+ firstAssistant._inputTokens = totalInput;
162
+ if (!firstAssistant._outputTokens) firstAssistant._outputTokens = totalOutput;
163
+ if (totalCacheRead) firstAssistant._cacheRead = totalCacheRead;
164
+ }
165
+ }
166
+
167
+ return result;
168
+ }
169
+
170
+ function safeParse(str) {
171
+ try { return JSON.parse(str); } catch { return {}; }
172
+ }
173
+
174
+ module.exports = { name, getChats, getMessages };
@@ -0,0 +1,174 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+
5
+ const GEMINI_DIR = path.join(os.homedir(), '.gemini');
6
+ const TMP_DIR = path.join(GEMINI_DIR, 'tmp');
7
+ const PROJECTS_JSON = path.join(GEMINI_DIR, 'projects.json');
8
+
9
+ // ============================================================
10
+ // Adapter interface
11
+ // ============================================================
12
+
13
+ const name = 'gemini-cli';
14
+
15
+ /**
16
+ * Load project path mapping from ~/.gemini/projects.json
17
+ * Format: { "projects": { "/Users/dev/Code/myapp": "myapp" } }
18
+ * Returns Map<projectName, folderPath>
19
+ */
20
+ function loadProjectMap() {
21
+ const map = new Map();
22
+ try {
23
+ const data = JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
24
+ if (data.projects) {
25
+ for (const [folderPath, projName] of Object.entries(data.projects)) {
26
+ map.set(projName, folderPath);
27
+ }
28
+ }
29
+ } catch {}
30
+ return map;
31
+ }
32
+
33
+ function getChats() {
34
+ const chats = [];
35
+ if (!fs.existsSync(TMP_DIR)) return chats;
36
+
37
+ const projectMap = loadProjectMap();
38
+
39
+ // Each subdirectory under tmp/ is a project name (e.g. "codename-share")
40
+ let projectDirs;
41
+ try { projectDirs = fs.readdirSync(TMP_DIR); } catch { return chats; }
42
+
43
+ for (const projName of projectDirs) {
44
+ const projDir = path.join(TMP_DIR, projName);
45
+ try { if (!fs.statSync(projDir).isDirectory()) continue; } catch { continue; }
46
+
47
+ // Sessions are in <projDir>/chats/session-*.json
48
+ const chatsDir = path.join(projDir, 'chats');
49
+ if (!fs.existsSync(chatsDir)) continue;
50
+
51
+ let files;
52
+ try {
53
+ files = fs.readdirSync(chatsDir).filter(f => f.startsWith('session-') && f.endsWith('.json'));
54
+ } catch { continue; }
55
+
56
+ // Resolve folder from projects.json mapping
57
+ const folder = projectMap.get(projName) || null;
58
+
59
+ for (const file of files) {
60
+ const fullPath = path.join(chatsDir, file);
61
+ try {
62
+ const raw = fs.readFileSync(fullPath, 'utf-8');
63
+ const record = JSON.parse(raw);
64
+ if (!record || !record.messages) continue;
65
+
66
+ const sessionId = record.sessionId || file.replace('.json', '');
67
+ const messages = record.messages || [];
68
+
69
+ // Extract first user prompt for title
70
+ const firstUser = messages.find(m => m.type === 'user');
71
+ const firstPrompt = extractTextContent(firstUser?.content);
72
+
73
+ chats.push({
74
+ source: 'gemini-cli',
75
+ composerId: sessionId,
76
+ name: firstPrompt ? cleanPrompt(firstPrompt) : null,
77
+ createdAt: record.startTime ? new Date(record.startTime).getTime() : null,
78
+ lastUpdatedAt: record.lastUpdated ? new Date(record.lastUpdated).getTime() : null,
79
+ mode: 'gemini',
80
+ folder,
81
+ encrypted: false,
82
+ bubbleCount: messages.length,
83
+ _fullPath: fullPath,
84
+ });
85
+ } catch { /* skip malformed files */ }
86
+ }
87
+ }
88
+
89
+ return chats;
90
+ }
91
+
92
+ function cleanPrompt(text) {
93
+ if (!text) return null;
94
+ return text.replace(/\s+/g, ' ').trim().substring(0, 120) || null;
95
+ }
96
+
97
+ function extractTextContent(content) {
98
+ if (!content) return '';
99
+ if (typeof content === 'string') return content;
100
+ // Array of { text } parts (user messages)
101
+ if (Array.isArray(content)) {
102
+ return content
103
+ .filter(p => p.text)
104
+ .map(p => p.text)
105
+ .join('\n') || '';
106
+ }
107
+ if (content.text) return content.text;
108
+ return '';
109
+ }
110
+
111
+ function getMessages(chat) {
112
+ const filePath = chat._fullPath;
113
+ if (!filePath || !fs.existsSync(filePath)) return [];
114
+
115
+ let record;
116
+ try {
117
+ record = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
118
+ } catch { return []; }
119
+
120
+ if (!record || !record.messages) return [];
121
+
122
+ const result = [];
123
+ for (const msg of record.messages) {
124
+ const type = msg.type;
125
+ const text = extractTextContent(msg.content || msg.displayContent);
126
+
127
+ if (type === 'user') {
128
+ if (text) result.push({ role: 'user', content: text });
129
+ } else if (type === 'gemini') {
130
+ const parts = [];
131
+ const toolCalls = [];
132
+
133
+ // Thoughts have { subject, description, timestamp }
134
+ if (msg.thoughts && Array.isArray(msg.thoughts)) {
135
+ for (const t of msg.thoughts) {
136
+ const thought = t.description || t.subject || '';
137
+ if (thought) parts.push(`[thinking] ${thought}`);
138
+ }
139
+ }
140
+
141
+ // Main text content (string for gemini messages)
142
+ if (text) parts.push(text);
143
+
144
+ // Tool calls
145
+ if (msg.toolCalls && Array.isArray(msg.toolCalls)) {
146
+ for (const tc of msg.toolCalls) {
147
+ const args = tc.args || {};
148
+ const argKeys = typeof args === 'object' ? Object.keys(args).join(', ') : '';
149
+ parts.push(`[tool-call: ${tc.name || 'unknown'}(${argKeys})]`);
150
+ toolCalls.push({ name: tc.name || 'unknown', args });
151
+ }
152
+ }
153
+
154
+ if (parts.length > 0) {
155
+ const tokens = msg.tokens || {};
156
+ result.push({
157
+ role: 'assistant',
158
+ content: parts.join('\n'),
159
+ _model: msg.model,
160
+ _inputTokens: tokens.input,
161
+ _outputTokens: tokens.output,
162
+ _cacheRead: tokens.cached,
163
+ _toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
164
+ });
165
+ }
166
+ } else if (type === 'info' || type === 'error' || type === 'warning') {
167
+ if (text) result.push({ role: 'system', content: `[${type}] ${text}` });
168
+ }
169
+ }
170
+
171
+ return result;
172
+ }
173
+
174
+ module.exports = { name, getChats, getMessages };
package/editors/index.js CHANGED
@@ -4,8 +4,10 @@ const claude = require('./claude');
4
4
  const vscode = require('./vscode');
5
5
  const zed = require('./zed');
6
6
  const opencode = require('./opencode');
7
+ const gemini = require('./gemini');
8
+ const copilot = require('./copilot');
7
9
 
8
- const editors = [cursor, windsurf, claude, vscode, zed, opencode];
10
+ const editors = [cursor, windsurf, claude, vscode, zed, opencode, gemini, copilot];
9
11
 
10
12
  /**
11
13
  * Get all chats from all editor adapters, sorted by most recent first.
package/index.js CHANGED
@@ -94,7 +94,7 @@ console.log(chalk.dim(' Initializing cache database...'));
94
94
  cache.initDb();
95
95
 
96
96
  // Scan all editors and populate cache
97
- console.log(chalk.dim(' Scanning editors: Cursor, Windsurf, Claude Code, VS Code, Zed, Antigravity, OpenCode'));
97
+ console.log(chalk.dim(' Scanning editors: Cursor, Windsurf, Claude Code, VS Code, Zed, Antigravity, OpenCode, Gemini CLI, Copilot CLI'));
98
98
  const startTime = Date.now();
99
99
  const result = cache.scanAll((progress) => {
100
100
  process.stdout.write(chalk.dim(`\r Scanning: ${progress.scanned}/${progress.total} chats (${progress.analyzed} analyzed, ${progress.skipped} cached)`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlytics",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravity, OpenCode",
5
5
  "main": "index.js",
6
6
  "bin": {
package/server.js CHANGED
@@ -112,6 +112,42 @@ app.get('/api/tool-calls', (req, res) => {
112
112
  }
113
113
  });
114
114
 
115
+ app.post('/api/query', (req, res) => {
116
+ try {
117
+ const { sql } = req.body;
118
+ if (!sql || typeof sql !== 'string') return res.status(400).json({ error: 'sql string required' });
119
+ // Only allow SELECT / PRAGMA / EXPLAIN / WITH statements
120
+ const trimmed = sql.trim().replace(/^--.*$/gm, '').trim();
121
+ const first = trimmed.split(/\s+/)[0].toUpperCase();
122
+ if (!['SELECT', 'PRAGMA', 'EXPLAIN', 'WITH'].includes(first)) {
123
+ return res.status(403).json({ error: 'Only SELECT queries are allowed' });
124
+ }
125
+ const db = cache.getDb();
126
+ if (!db) return res.status(500).json({ error: 'Database not initialized' });
127
+ const stmt = db.prepare(sql);
128
+ const rows = stmt.all();
129
+ const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
130
+ res.json({ columns, rows, count: rows.length });
131
+ } catch (err) {
132
+ res.status(400).json({ error: err.message });
133
+ }
134
+ });
135
+
136
+ app.get('/api/schema', (req, res) => {
137
+ try {
138
+ const db = cache.getDb();
139
+ if (!db) return res.status(500).json({ error: 'Database not initialized' });
140
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all();
141
+ const schema = {};
142
+ for (const { name } of tables) {
143
+ schema[name] = db.prepare(`PRAGMA table_info(${name})`).all();
144
+ }
145
+ res.json({ tables: tables.map(t => t.name), schema });
146
+ } catch (err) {
147
+ res.status(500).json({ error: err.message });
148
+ }
149
+ });
150
+
115
151
  app.get('/api/refetch', async (req, res) => {
116
152
  res.writeHead(200, {
117
153
  'Content-Type': 'text/event-stream',
package/ui/src/App.jsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useState, useEffect } from 'react'
2
2
  import { Routes, Route, NavLink } from 'react-router-dom'
3
- import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal } from 'lucide-react'
3
+ import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database } from 'lucide-react'
4
4
  import { fetchOverview, refetchAgents } from './lib/api'
5
5
  import { useTheme } from './lib/theme'
6
6
  import Dashboard from './pages/Dashboard'
@@ -9,6 +9,7 @@ import DeepAnalysis from './pages/DeepAnalysis'
9
9
  import Compare from './pages/Compare'
10
10
  import ChatDetail from './pages/ChatDetail'
11
11
  import Projects from './pages/Projects'
12
+ import SqlViewer from './pages/SqlViewer'
12
13
 
13
14
  export default function App() {
14
15
  const [overview, setOverview] = useState(null)
@@ -35,6 +36,7 @@ export default function App() {
35
36
  { to: '/sessions', icon: MessageSquare, label: 'Sessions' },
36
37
  { to: '/analysis', icon: BarChart3, label: 'Analysis' },
37
38
  { to: '/compare', icon: GitCompare, label: 'Compare' },
39
+ { to: '/sql', icon: Database, label: 'SQL' },
38
40
  ]
39
41
 
40
42
  return (
@@ -100,6 +102,7 @@ export default function App() {
100
102
  <Route path="/sessions/:id" element={<ChatDetail />} />
101
103
  <Route path="/analysis" element={<DeepAnalysis overview={overview} />} />
102
104
  <Route path="/compare" element={<Compare overview={overview} />} />
105
+ <Route path="/sql" element={<SqlViewer />} />
103
106
  </Routes>
104
107
  </main>
105
108
 
@@ -38,11 +38,11 @@ export default function ActivityHeatmap({ dailyData }) {
38
38
  if (d.total > maxCount) maxCount = d.total
39
39
  }
40
40
 
41
- // End on today, start 52 weeks back
41
+ // End on Saturday of the current week, start WEEK_COLS-1 weeks before
42
42
  const today = new Date()
43
43
  today.setHours(0, 0, 0, 0)
44
44
  const start = new Date(today)
45
- start.setDate(start.getDate() - (WEEK_COLS * 7 - 1) - start.getDay())
45
+ start.setDate(start.getDate() - start.getDay() - (WEEK_COLS - 1) * 7)
46
46
 
47
47
  const weeks = []
48
48
  const months = []
package/ui/src/lib/api.js CHANGED
@@ -69,6 +69,20 @@ export async function fetchDashboardStats(params = {}) {
69
69
  return res.json();
70
70
  }
71
71
 
72
+ export async function executeQuery(sql) {
73
+ const res = await fetch(`${BASE}/api/query`, {
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/json' },
76
+ body: JSON.stringify({ sql }),
77
+ });
78
+ return res.json();
79
+ }
80
+
81
+ export async function fetchSchema() {
82
+ const res = await fetch(`${BASE}/api/schema`);
83
+ return res.json();
84
+ }
85
+
72
86
  export async function fetchToolCalls(name, opts = {}) {
73
87
  const q = new URLSearchParams({ name });
74
88
  if (opts.limit) q.set('limit', opts.limit);
@@ -9,6 +9,8 @@ export const EDITOR_COLORS = {
9
9
  'vscode-insiders': '#60a5fa',
10
10
  'zed': '#10b981',
11
11
  'opencode': '#ec4899',
12
+ 'gemini-cli': '#4285f4',
13
+ 'copilot-cli': '#8957e5',
12
14
  };
13
15
 
14
16
  export const EDITOR_LABELS = {
@@ -22,6 +24,8 @@ export const EDITOR_LABELS = {
22
24
  'vscode-insiders': 'VS Code Insiders',
23
25
  'zed': 'Zed',
24
26
  'opencode': 'OpenCode',
27
+ 'gemini-cli': 'Gemini CLI',
28
+ 'copilot-cli': 'Copilot CLI',
25
29
  };
26
30
 
27
31
  export function editorColor(src) {
@@ -226,7 +226,7 @@ export default function Dashboard({ overview }) {
226
226
 
227
227
  {/* Activity Heatmap */}
228
228
  <div className="card p-3">
229
- <SectionTitle>activity</SectionTitle>
229
+ <SectionTitle>agentic coding activity</SectionTitle>
230
230
  {dailyData ? <ActivityHeatmap dailyData={dailyData} /> : <div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>loading...</div>}
231
231
  </div>
232
232
 
@@ -0,0 +1,322 @@
1
+ import { useState, useEffect, useRef, useMemo } from 'react'
2
+ import { Play, Database, Table2, ChevronDown, Copy, Download, BarChart3, LineChart, PieChart } from 'lucide-react'
3
+ import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement, Filler } from 'chart.js'
4
+ import { Bar, Line, Doughnut } from 'react-chartjs-2'
5
+ import { executeQuery, fetchSchema } from '../lib/api'
6
+ import { useTheme } from '../lib/theme'
7
+
8
+ ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement, Filler)
9
+
10
+ const EXAMPLE_QUERIES = [
11
+ { label: 'Sessions per editor', sql: `SELECT source, COUNT(*) as count FROM chats GROUP BY source ORDER BY count DESC` },
12
+ { label: 'Top 10 projects', sql: `SELECT folder, COUNT(*) as sessions, SUM(bubble_count) as messages FROM chats WHERE folder IS NOT NULL GROUP BY folder ORDER BY sessions DESC LIMIT 10` },
13
+ { label: 'Messages per day', sql: `SELECT date(created_at/1000, 'unixepoch') as day, COUNT(*) as count FROM chats WHERE created_at IS NOT NULL GROUP BY day ORDER BY day` },
14
+ { label: 'Top models', sql: `SELECT model, COUNT(*) as count FROM messages WHERE model IS NOT NULL GROUP BY model ORDER BY count DESC LIMIT 10` },
15
+ { label: 'Top tools', sql: `SELECT tool_name, COUNT(*) as count FROM tool_calls GROUP BY tool_name ORDER BY count DESC LIMIT 15` },
16
+ { label: 'Token usage by editor', sql: `SELECT c.source, SUM(cs.total_input_tokens) as input_tokens, SUM(cs.total_output_tokens) as output_tokens FROM chat_stats cs JOIN chats c ON c.id = cs.chat_id GROUP BY c.source ORDER BY input_tokens DESC` },
17
+ { label: 'Sessions by mode', sql: `SELECT mode, COUNT(*) as count FROM chats WHERE mode IS NOT NULL GROUP BY mode ORDER BY count DESC` },
18
+ { label: 'Hourly distribution', sql: `SELECT CAST(strftime('%H', created_at/1000, 'unixepoch') AS INTEGER) as hour, COUNT(*) as count FROM chats WHERE created_at IS NOT NULL GROUP BY hour ORDER BY hour` },
19
+ ]
20
+
21
+ export default function SqlViewer() {
22
+ const { dark } = useTheme()
23
+ const [sql, setSql] = useState(EXAMPLE_QUERIES[0].sql)
24
+ const [result, setResult] = useState(null)
25
+ const [error, setError] = useState(null)
26
+ const [loading, setLoading] = useState(false)
27
+ const [schema, setSchema] = useState(null)
28
+ const [showSchema, setShowSchema] = useState(false)
29
+ const [chartType, setChartType] = useState('bar')
30
+ const [elapsed, setElapsed] = useState(null)
31
+ const textareaRef = useRef(null)
32
+
33
+ useEffect(() => {
34
+ fetchSchema().then(setSchema).catch(() => {})
35
+ }, [])
36
+
37
+ const runQuery = async () => {
38
+ setLoading(true)
39
+ setError(null)
40
+ setResult(null)
41
+ const t0 = performance.now()
42
+ try {
43
+ const data = await executeQuery(sql.trim())
44
+ setElapsed(((performance.now() - t0)).toFixed(0))
45
+ if (data.error) {
46
+ setError(data.error)
47
+ } else {
48
+ setResult(data)
49
+ }
50
+ } catch (e) {
51
+ setError(e.message)
52
+ setElapsed(null)
53
+ }
54
+ setLoading(false)
55
+ }
56
+
57
+ const handleKeyDown = (e) => {
58
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
59
+ e.preventDefault()
60
+ runQuery()
61
+ }
62
+ }
63
+
64
+ const copyResults = () => {
65
+ if (!result) return
66
+ const header = result.columns.join('\t')
67
+ const rows = result.rows.map(r => result.columns.map(c => r[c] ?? '').join('\t'))
68
+ navigator.clipboard.writeText([header, ...rows].join('\n'))
69
+ }
70
+
71
+ const downloadCsv = () => {
72
+ if (!result) return
73
+ const escape = (v) => {
74
+ const s = String(v ?? '')
75
+ return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s
76
+ }
77
+ const header = result.columns.map(escape).join(',')
78
+ const rows = result.rows.map(r => result.columns.map(c => escape(r[c])).join(','))
79
+ const blob = new Blob([header + '\n' + rows.join('\n')], { type: 'text/csv' })
80
+ const url = URL.createObjectURL(blob)
81
+ const a = document.createElement('a')
82
+ a.href = url
83
+ a.download = 'query-results.csv'
84
+ a.click()
85
+ URL.revokeObjectURL(url)
86
+ }
87
+
88
+ // Auto-detect chartable data
89
+ const chartData = useMemo(() => {
90
+ if (!result || result.columns.length < 2 || result.rows.length === 0) return null
91
+ const cols = result.columns
92
+ // Find a label column (string-like) and value columns (numeric)
93
+ const labelCol = cols.find(c => result.rows.every(r => typeof r[c] === 'string' || r[c] === null)) || cols[0]
94
+ const valueCols = cols.filter(c => c !== labelCol && result.rows.some(r => typeof r[c] === 'number'))
95
+ if (valueCols.length === 0) return null
96
+
97
+ const labels = result.rows.map(r => String(r[labelCol] ?? ''))
98
+ const palette = ['#6366f1', '#f59e0b', '#06b6d4', '#10b981', '#f97316', '#ec4899', '#8b5cf6', '#3b82f6', '#ef4444', '#14b8a6']
99
+
100
+ const datasets = valueCols.map((col, i) => ({
101
+ label: col,
102
+ data: result.rows.map(r => r[col] ?? 0),
103
+ backgroundColor: chartType === 'doughnut'
104
+ ? palette.slice(0, labels.length)
105
+ : palette[i % palette.length] + '99',
106
+ borderColor: palette[i % palette.length],
107
+ borderWidth: chartType === 'line' ? 2 : 1,
108
+ borderRadius: chartType === 'bar' ? 4 : 0,
109
+ tension: 0.3,
110
+ fill: chartType === 'line',
111
+ pointRadius: chartType === 'line' ? 3 : 0,
112
+ }))
113
+
114
+ return { labels, datasets }
115
+ }, [result, chartType])
116
+
117
+ const chartOptions = {
118
+ responsive: true,
119
+ maintainAspectRatio: false,
120
+ plugins: {
121
+ legend: { display: chartData?.datasets?.length > 1 || chartType === 'doughnut', labels: { color: dark ? '#ccc' : '#555', font: { size: 11 } } },
122
+ tooltip: { backgroundColor: dark ? '#1e1e2e' : '#fff', titleColor: dark ? '#fff' : '#111', bodyColor: dark ? '#ccc' : '#555', borderColor: dark ? '#333' : '#ddd', borderWidth: 1 },
123
+ },
124
+ scales: chartType !== 'doughnut' ? {
125
+ x: { ticks: { color: dark ? '#888' : '#666', font: { size: 10 }, maxRotation: 45 }, grid: { color: dark ? '#ffffff08' : '#00000008' } },
126
+ y: { ticks: { color: dark ? '#888' : '#666', font: { size: 10 } }, grid: { color: dark ? '#ffffff08' : '#00000008' } },
127
+ } : undefined,
128
+ }
129
+
130
+ const txtStyle = { color: 'var(--c-text)' }
131
+ const txt2Style = { color: 'var(--c-text2)' }
132
+ const cardBg = { background: 'var(--c-card)', border: '1px solid var(--c-border)' }
133
+
134
+ return (
135
+ <div className="space-y-3">
136
+ {/* Header */}
137
+ <div className="flex items-center justify-between">
138
+ <div className="flex items-center gap-2">
139
+ <Database size={16} style={txtStyle} />
140
+ <h1 className="text-sm font-semibold" style={txtStyle}>SQL Viewer</h1>
141
+ <span className="text-[10px] px-1.5 py-0.5 rounded" style={{ ...txt2Style, background: 'var(--c-bg2)' }}>cache.db</span>
142
+ </div>
143
+ <button
144
+ onClick={() => setShowSchema(!showSchema)}
145
+ className="flex items-center gap-1 text-[11px] px-2 py-1 rounded transition hover:bg-[var(--c-card)]"
146
+ style={txt2Style}
147
+ >
148
+ <Table2 size={12} />
149
+ Schema
150
+ <ChevronDown size={10} className={`transition ${showSchema ? 'rotate-180' : ''}`} />
151
+ </button>
152
+ </div>
153
+
154
+ {/* Schema panel */}
155
+ {showSchema && schema && (
156
+ <div className="rounded-lg p-3 space-y-2 text-[11px]" style={cardBg}>
157
+ <div className="flex flex-wrap gap-4">
158
+ {schema.tables.map(table => (
159
+ <div key={table} className="min-w-[180px]">
160
+ <div className="font-semibold mb-1" style={txtStyle}>{table}</div>
161
+ <div className="space-y-0.5">
162
+ {schema.schema[table]?.map(col => (
163
+ <div key={col.name} className="flex gap-2" style={txt2Style}>
164
+ <span style={txtStyle}>{col.name}</span>
165
+ <span className="text-[10px] opacity-60">{col.type}</span>
166
+ {col.pk ? <span className="text-[9px] px-1 rounded" style={{ background: '#6366f133', color: '#6366f1' }}>PK</span> : null}
167
+ </div>
168
+ ))}
169
+ </div>
170
+ </div>
171
+ ))}
172
+ </div>
173
+ </div>
174
+ )}
175
+
176
+ {/* Example queries */}
177
+ <div className="flex flex-wrap gap-1">
178
+ {EXAMPLE_QUERIES.map((q, i) => (
179
+ <button
180
+ key={i}
181
+ onClick={() => setSql(q.sql)}
182
+ className="text-[10px] px-2 py-0.5 rounded transition hover:bg-[var(--c-card)]"
183
+ style={{ ...txt2Style, border: '1px solid var(--c-border)' }}
184
+ >
185
+ {q.label}
186
+ </button>
187
+ ))}
188
+ </div>
189
+
190
+ {/* SQL editor */}
191
+ <div className="rounded-lg overflow-hidden" style={cardBg}>
192
+ <div className="flex items-center justify-between px-3 py-1.5" style={{ borderBottom: '1px solid var(--c-border)' }}>
193
+ <span className="text-[10px] font-mono" style={txt2Style}>SQL</span>
194
+ <div className="flex items-center gap-1">
195
+ <span className="text-[10px]" style={txt2Style}>⌘+Enter to run</span>
196
+ </div>
197
+ </div>
198
+ <textarea
199
+ ref={textareaRef}
200
+ value={sql}
201
+ onChange={e => setSql(e.target.value)}
202
+ onKeyDown={handleKeyDown}
203
+ spellCheck={false}
204
+ className="w-full p-3 text-[12px] font-mono resize-y outline-none"
205
+ style={{ background: 'transparent', color: 'var(--c-text)', minHeight: 80, maxHeight: 300 }}
206
+ rows={4}
207
+ />
208
+ <div className="flex items-center gap-2 px-3 py-1.5" style={{ borderTop: '1px solid var(--c-border)' }}>
209
+ <button
210
+ onClick={runQuery}
211
+ disabled={loading || !sql.trim()}
212
+ className="flex items-center gap-1.5 px-3 py-1 text-[11px] font-medium rounded transition"
213
+ style={{ background: '#6366f1', color: '#fff', opacity: loading ? 0.5 : 1 }}
214
+ >
215
+ <Play size={11} />
216
+ {loading ? 'Running...' : 'Run Query'}
217
+ </button>
218
+ {elapsed && result && (
219
+ <span className="text-[10px]" style={txt2Style}>
220
+ {result.count} row{result.count !== 1 ? 's' : ''} in {elapsed}ms
221
+ </span>
222
+ )}
223
+ {result && (
224
+ <div className="ml-auto flex items-center gap-1">
225
+ <button onClick={copyResults} className="p-1 rounded hover:bg-[var(--c-bg2)] transition" style={txt2Style} title="Copy to clipboard">
226
+ <Copy size={12} />
227
+ </button>
228
+ <button onClick={downloadCsv} className="p-1 rounded hover:bg-[var(--c-bg2)] transition" style={txt2Style} title="Download CSV">
229
+ <Download size={12} />
230
+ </button>
231
+ </div>
232
+ )}
233
+ </div>
234
+ </div>
235
+
236
+ {/* Error */}
237
+ {error && (
238
+ <div className="rounded-lg px-3 py-2 text-[11px]" style={{ background: '#ef444420', border: '1px solid #ef444440', color: '#ef4444' }}>
239
+ {error}
240
+ </div>
241
+ )}
242
+
243
+ {/* Results table */}
244
+ {result && result.rows.length > 0 && (
245
+ <div className="rounded-lg overflow-hidden" style={cardBg}>
246
+ <div className="overflow-x-auto" style={{ maxHeight: 400 }}>
247
+ <table className="w-full text-[11px]" style={{ borderCollapse: 'collapse' }}>
248
+ <thead>
249
+ <tr style={{ borderBottom: '1px solid var(--c-border)' }}>
250
+ {result.columns.map(col => (
251
+ <th key={col} className="text-left px-3 py-1.5 font-semibold sticky top-0" style={{ ...txtStyle, background: 'var(--c-card)' }}>
252
+ {col}
253
+ </th>
254
+ ))}
255
+ </tr>
256
+ </thead>
257
+ <tbody>
258
+ {result.rows.map((row, i) => (
259
+ <tr key={i} className="hover:bg-[var(--c-bg2)] transition" style={{ borderBottom: '1px solid var(--c-border)' }}>
260
+ {result.columns.map(col => (
261
+ <td key={col} className="px-3 py-1 font-mono" style={txt2Style}>
262
+ {formatCell(row[col])}
263
+ </td>
264
+ ))}
265
+ </tr>
266
+ ))}
267
+ </tbody>
268
+ </table>
269
+ </div>
270
+ </div>
271
+ )}
272
+
273
+ {result && result.rows.length === 0 && (
274
+ <div className="rounded-lg px-3 py-6 text-center text-[11px]" style={{ ...cardBg, ...txt2Style }}>
275
+ Query returned 0 rows
276
+ </div>
277
+ )}
278
+
279
+ {/* Chart visualization */}
280
+ {chartData && (
281
+ <div className="rounded-lg p-4" style={cardBg}>
282
+ <div className="flex items-center gap-2 mb-3">
283
+ <span className="text-[11px] font-semibold" style={txtStyle}>Visualization</span>
284
+ <div className="flex gap-0.5 ml-2" style={{ border: '1px solid var(--c-border)', borderRadius: 6 }}>
285
+ {[
286
+ { type: 'bar', icon: BarChart3 },
287
+ { type: 'line', icon: LineChart },
288
+ { type: 'doughnut', icon: PieChart },
289
+ ].map(({ type, icon: Icon }) => (
290
+ <button
291
+ key={type}
292
+ onClick={() => setChartType(type)}
293
+ className="p-1.5 transition"
294
+ style={{
295
+ background: chartType === type ? 'var(--c-bg2)' : 'transparent',
296
+ color: chartType === type ? 'var(--c-white)' : 'var(--c-text3)',
297
+ borderRadius: 4,
298
+ }}
299
+ >
300
+ <Icon size={12} />
301
+ </button>
302
+ ))}
303
+ </div>
304
+ </div>
305
+ <div style={{ height: chartType === 'doughnut' ? 280 : 260 }}>
306
+ {chartType === 'bar' && <Bar data={chartData} options={chartOptions} />}
307
+ {chartType === 'line' && <Line data={chartData} options={chartOptions} />}
308
+ {chartType === 'doughnut' && <Doughnut data={chartData} options={chartOptions} />}
309
+ </div>
310
+ </div>
311
+ )}
312
+ </div>
313
+ )
314
+ }
315
+
316
+ function formatCell(value) {
317
+ if (value === null || value === undefined) return <span className="opacity-30">NULL</span>
318
+ if (typeof value === 'number') return value.toLocaleString()
319
+ const str = String(value)
320
+ if (str.length > 120) return str.substring(0, 120) + '…'
321
+ return str
322
+ }