agentlytics 0.0.1 → 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
@@ -1,301 +1,106 @@
1
- # npx agentlytics
1
+ <p align="center">
2
+ <img src="misc/logo.svg" width="120" alt="Agentlytics">
3
+ </p>
2
4
 
3
- **Comprehensive analytics dashboard for your AI coding agents.**
5
+ <h1 align="center">Agentlytics</h1>
4
6
 
5
- Agentlytics reads local chat history from every major AI coding assistant — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravity, and OpenCode — and presents a unified analytics dashboard in your browser.
7
+ <p align="center">
8
+ <strong>Unified analytics for your AI coding agents</strong><br>
9
+ <sub>Cursor · Windsurf · Claude Code · VS Code Copilot · Zed · Antigravity · OpenCode · Gemini CLI · Copilot CLI</sub>
10
+ </p>
6
11
 
7
- No data ever leaves your machine. Everything runs locally against SQLite databases and local files.
12
+ <p align="center">
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-11-818cf8" alt="editors"></a>
15
+ <a href="#license"><img src="https://img.shields.io/badge/license-MIT-green" alt="license"></a>
16
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%E2%89%A518-brightgreen" alt="node"></a>
17
+ </p>
8
18
 
9
- <video src="misc/agentlytics.mp4" autoplay loop muted playsinline width="100%"></video>
19
+ <p align="center">
20
+ <img src="https://github.com/user-attachments/assets/fdb0acb2-db0f-4091-af23-949ca0fae9c8" alt="Agentlytics demo" width="100%">
21
+ </p>
10
22
 
11
23
  ---
12
24
 
13
- ## Features
14
-
15
- - **Dashboard** — KPI cards (total sessions, daily avg, current month), activity heatmap, editor breakdown with click-to-filter, mode distribution, top projects
16
- - **Sessions** — Paginated list of every chat session with editor/project/mode badges, search, and editor filter. Click any session to see the full conversation with syntax-highlighted markdown, expandable tool call details, and diff views
17
- - **Projects** — Per-project analytics: sessions, messages, tokens, tool calls, models, and editor breakdown. Filterable by editor and searchable
18
- - **Deep Analysis** — Aggregated tool call frequency, model distribution (doughnut chart), token breakdown (input/output/cache read/write). Click any tool bar to drill down into individual calls with full arguments
19
- - **Compare** — Side-by-side editor comparison: totals, efficiency ratios (avg msgs/session, output/input ratio, cache hit rate), grouped bar charts, tool and model breakdowns
20
- - **Refetch** — One-click cache rebuild with live SSE progress streaming
21
-
22
- ### Supported Editors
23
-
24
- | Editor | Source ID | Data Location | Messages | Tool Args | Models | Tokens |
25
- |--------|-----------|---------------|----------|-----------|--------|--------|
26
- | **Cursor** | `cursor` | `~/.cursor/chats/` + `~/Library/Application Support/Cursor/` | ✅ | ✅ | ⚠️ provider only | ⚠️ partial |
27
- | **Windsurf** | `windsurf` | ConnectRPC from running language server | ✅ | ✅ | ✅ | ✅ |
28
- | **Windsurf Next** | `windsurf-next` | ConnectRPC from running language server | ✅ | ✅ | ✅ | ✅ |
29
- | **Antigravity** | `antigravity` | ConnectRPC from running language server (HTTPS) | ✅ | ✅ | ✅ | ✅ |
30
- | **Claude Code** | `claude-code` | `~/.claude/projects/` | ✅ | ✅ | ✅ | ✅ |
31
- | **VS Code** | `vscode` | `~/Library/Application Support/Code/` | ✅ | ✅ | ✅ | ✅ |
32
- | **VS Code Insiders** | `vscode-insiders` | `~/Library/Application Support/Code - Insiders/` | ✅ | ✅ | ✅ | ✅ |
33
- | **Zed** | `zed` | `~/Library/Application Support/Zed/threads/threads.db` | ✅ | ✅ | ✅ | ❌ |
34
- | **OpenCode** | `opencode` | `~/.local/share/opencode/opencode.db` | ✅ | ✅ | ✅ | ✅ |
35
-
36
- > **Note:** Windsurf, Windsurf Next, and Antigravity require their app to be running during scan — they expose data via a local ConnectRPC API from the language server process.
37
-
38
- ---
25
+ Agentlytics reads local chat history from every major AI coding assistant and presents a unified analytics dashboard in your browser. **No data ever leaves your machine.**
39
26
 
40
27
  ## Quick Start
41
28
 
42
- ### Prerequisites
43
-
44
- - **Node.js** ≥ 18
45
- - **macOS** (currently the only supported platform — all editor paths are macOS-specific)
46
-
47
- ### Install & Run
48
-
49
29
  ```bash
50
- git clone <repo-url> agentlytics
51
- cd agentlytics
52
-
53
- # Install backend dependencies
54
- npm install
55
-
56
- # Build the frontend
57
- cd ui && npm install && npm run build && cd ..
58
-
59
- # Start the dashboard
60
- npm start
30
+ npx agentlytics
61
31
  ```
62
32
 
63
- The dashboard will open automatically at **http://localhost:4637**.
64
-
65
- On first run, Agentlytics scans all detected editors and populates a local SQLite cache at `~/.agentlytics/cache.db`. Subsequent launches skip unchanged sessions.
66
-
67
- ### Options
68
-
69
- ```bash
70
- npm start # normal start (uses cache)
71
- npm start -- --no-cache # wipe cache and rescan everything
72
- ```
33
+ Opens at **http://localhost:4637**. Requires Node.js ≥ 18, macOS.
73
34
 
74
- ### Development
75
-
76
- ```bash
77
- # Start Vite dev server with hot reload (port 5173)
78
- cd ui && npm run dev
79
-
80
- # In another terminal, start the backend (port 4637)
81
- npm start
82
- ```
83
-
84
- The Vite dev server proxies API requests to the backend via `vite.config.js`.
85
-
86
- ---
35
+ ## Features
87
36
 
88
- ### Data Flow
37
+ - **Dashboard** — KPIs, activity heatmap, editor breakdown, coding streaks, token economy, peak hours, top models & tools
38
+ - **Sessions** — Search, filter, full conversation viewer with syntax highlighting and diff views
39
+ - **Projects** — Per-project analytics: sessions, messages, tokens, models, editor breakdown
40
+ - **Deep Analysis** — Tool frequency, model distribution, token breakdown with drill-down
41
+ - **Compare** — Side-by-side editor comparison with efficiency ratios
42
+ - **Refetch** — One-click cache rebuild with live progress
43
+
44
+ ## Supported Editors
45
+
46
+ | Editor | ID | Msgs | Tools | Models | Tokens |
47
+ |--------|----|:----:|:-----:|:------:|:------:|
48
+ | **Cursor** | `cursor` | ✅ | ✅ | ⚠️ | ⚠️ |
49
+ | **Windsurf** | `windsurf` | ✅ | ✅ | ✅ | ✅ |
50
+ | **Windsurf Next** | `windsurf-next` | ✅ | ✅ | ✅ | ✅ |
51
+ | **Antigravity** | `antigravity` | ✅ | ✅ | ✅ | ✅ |
52
+ | **Claude Code** | `claude-code` | ✅ | ✅ | ✅ | ✅ |
53
+ | **VS Code** | `vscode` | ✅ | ✅ | ✅ | ✅ |
54
+ | **VS Code Insiders** | `vscode-insiders` | ✅ | ✅ | ✅ | ✅ |
55
+ | **Zed** | `zed` | ✅ | ✅ | ✅ | ❌ |
56
+ | **OpenCode** | `opencode` | ✅ | ✅ | ✅ | ✅ |
57
+ | **Gemini CLI** | `gemini-cli` | ✅ | ✅ | ✅ | ✅ |
58
+ | **Copilot CLI** | `copilot-cli` | ✅ | ✅ | ✅ | ✅ |
59
+
60
+ > Windsurf, Windsurf Next, and Antigravity must be running during scan.
61
+
62
+ ## How It Works
89
63
 
90
64
  ```
91
- Editor Files/APIs ──► editors/*.js ──► cache.js (SQLite) ──► server.js (REST API) ──► ui/ (React SPA)
65
+ Editor files/APIs editors/*.js cache.js (SQLite) server.js (REST) React SPA
92
66
  ```
93
67
 
94
- 1. **Editor adapters** read chat data from local files, databases, or running processes
95
- 2. **Cache layer** normalizes everything into a single SQLite DB (`~/.agentlytics/cache.db`) with tables for `chats`, `messages`, `chat_stats`, and `tool_calls`
96
- 3. **Express server** exposes read-only REST endpoints against the cache
97
- 4. **React frontend** fetches data from the API and renders charts via Chart.js
98
-
99
- ---
100
-
101
- ## API Reference
102
-
103
- All endpoints are `GET` and return JSON.
104
-
105
- | Endpoint | Description | Query Params |
106
- |----------|-------------|--------------|
107
- | `/api/overview` | Dashboard overview: totals, editors, modes, monthly trend, top projects | `editor` |
108
- | `/api/daily-activity` | Daily activity data for heatmap | `editor` |
109
- | `/api/chats` | Paginated chat list | `editor`, `folder`, `named`, `limit`, `offset` |
110
- | `/api/chats/:id` | Full chat detail with messages and stats | — |
111
- | `/api/projects` | All projects with aggregated analytics | — |
112
- | `/api/deep-analytics` | Aggregated tool/model/token analytics | `editor`, `folder`, `limit` |
113
- | `/api/tool-calls` | Individual tool call instances | `name` (required), `folder`, `limit` |
114
- | `/api/refetch` | SSE stream: wipe cache and rescan all editors | — |
115
-
116
- ---
117
-
118
- ## Cache Database Schema
119
-
120
- Location: `~/.agentlytics/cache.db`
68
+ All data is normalized into a local SQLite cache at `~/.agentlytics/cache.db`. The Express server exposes read-only REST endpoints consumed by the React frontend.
121
69
 
122
- ### `chats`
123
- Stores one row per chat session, normalized across all editors.
70
+ ## API
124
71
 
125
- | Column | Type | Description |
126
- |--------|------|-------------|
127
- | `id` | TEXT PK | Unique chat ID (composerId) |
128
- | `source` | TEXT | Editor identifier (`cursor`, `windsurf`, `claude-code`, etc.) |
129
- | `name` | TEXT | Chat title |
130
- | `mode` | TEXT | Session mode (`agent`, `edit`, `chat`, `ask`, etc.) |
131
- | `folder` | TEXT | Project working directory |
132
- | `created_at` | INTEGER | Creation timestamp (ms) |
133
- | `last_updated_at` | INTEGER | Last update timestamp (ms) |
134
- | `bubble_count` | INTEGER | Number of messages/bubbles |
135
- | `encrypted` | INTEGER | 1 if content is encrypted |
72
+ | Endpoint | Description |
73
+ |----------|-------------|
74
+ | `GET /api/overview` | Dashboard KPIs, editors, modes, trends |
75
+ | `GET /api/daily-activity` | Daily counts for heatmap |
76
+ | `GET /api/dashboard-stats` | Hourly, weekday, streaks, tokens, velocity |
77
+ | `GET /api/chats` | Paginated session list |
78
+ | `GET /api/chats/:id` | Full chat with messages |
79
+ | `GET /api/projects` | Project-level aggregations |
80
+ | `GET /api/deep-analytics` | Tool/model/token breakdowns |
81
+ | `GET /api/tool-calls` | Individual tool call instances |
82
+ | `GET /api/refetch` | SSE: wipe cache and rescan |
136
83
 
137
- ### `messages`
138
- Individual messages per chat, stored with truncation at 50K characters.
84
+ All endpoints accept optional `editor` filter. See **[API.md](API.md)** for full request/response documentation.
139
85
 
140
- | Column | Type | Description |
141
- |--------|------|-------------|
142
- | `chat_id` | TEXT FK | References `chats.id` |
143
- | `seq` | INTEGER | Message sequence number |
144
- | `role` | TEXT | `user`, `assistant`, `system`, or `tool` |
145
- | `content` | TEXT | Message content (may contain `[tool-call:]` / `[tool-result:]` markers) |
146
- | `model` | TEXT | Model name (if available) |
147
- | `input_tokens` | INTEGER | Input token count |
148
- | `output_tokens` | INTEGER | Output token count |
86
+ ## Roadmap
149
87
 
150
- ### `chat_stats`
151
- Pre-aggregated statistics per chat, computed during analysis.
88
+ - [ ] **Offline Windsurf/Antigravity support** — Read cascade data from local file structure instead of requiring the app to be running (see below)
89
+ - [ ] **LLM-powered insights** — Use an LLM to analyze session patterns, generate summaries, detect coding habits, and surface actionable recommendations
90
+ - [ ] **Linux & Windows support** — Adapt editor paths for non-macOS platforms
91
+ - [ ] **Export & reports** — PDF/CSV export of analytics and session data
92
+ - [ ] **Cost tracking** — Estimate API costs per editor/model based on token usage
152
93
 
153
- | Column | Type | Description |
154
- |--------|------|-------------|
155
- | `chat_id` | TEXT PK | References `chats.id` |
156
- | `total_messages` | INTEGER | Total message count |
157
- | `user_messages` | INTEGER | User message count |
158
- | `assistant_messages` | INTEGER | Assistant message count |
159
- | `tool_calls` | TEXT | JSON array of tool call names |
160
- | `models` | TEXT | JSON array of model names |
161
- | `total_input_tokens` | INTEGER | Sum of input tokens |
162
- | `total_output_tokens` | INTEGER | Sum of output tokens |
163
- | `total_cache_read` | INTEGER | Sum of cache read tokens |
164
- | `total_cache_write` | INTEGER | Sum of cache write tokens |
94
+ ## Contributions Needed
165
95
 
166
- ### `tool_calls`
167
- Individual tool call records with full argument JSON.
96
+ **Windsurf / Windsurf Next / Antigravity offline reading** — Currently these editors require their app to be running because data is fetched via ConnectRPC from the language server process. Unlike Cursor or Claude Code, there's no known local file structure to read cascade history from. If you know where Windsurf stores trajectory data on disk, or can help reverse-engineer the storage format, contributions are very welcome.
168
97
 
169
- | Column | Type | Description |
170
- |--------|------|-------------|
171
- | `chat_id` | TEXT FK | References `chats.id` |
172
- | `tool_name` | TEXT | Tool function name |
173
- | `args_json` | TEXT | Full arguments as JSON |
174
- | `source` | TEXT | Editor source |
175
- | `folder` | TEXT | Project directory |
176
- | `timestamp` | INTEGER | Call timestamp (ms) |
98
+ **LLM-based analytics** We'd love to add intelligent analysis on top of the raw data — session summaries, coding pattern detection, productivity insights, and natural language queries over your agent history. If you have ideas or want to build this, open an issue or PR.
177
99
 
178
- ---
179
-
180
- ## Editor Adapters — Technical Details
181
-
182
- ### Cursor
183
-
184
- Reads from **two separate data stores**:
185
-
186
- 1. **Agent Store** (`~/.cursor/chats/<workspace>/<chatId>/store.db`)
187
- - SQLite with `meta` table (hex-encoded JSON) and `blobs` table (content-addressed SHA-256 tree)
188
- - Meta contains: `agentId`, `latestRootBlobId`, `name`, `createdAt`
189
- - Messages are retrieved by walking the blob tree: tree nodes contain message refs and child refs
190
- - Tool calls extracted from OpenAI-format `tool_calls` array on assistant messages
191
-
192
- 2. **Workspace Composers** (`~/Library/Application Support/Cursor/User/`)
193
- - `workspaceStorage/<hash>/state.vscdb` — `composer.composerData` key holds all composer headers
194
- - `globalStorage/state.vscdb` — `cursorDiskKV` table with `bubbleId:<composerId>:<n>` keys
195
- - Each bubble is a JSON object with `type` (1=user, 2=assistant), `text`, `toolFormerData`, `tokenCount`
196
- - Tool args extracted from `toolFormerData.rawArgs` with fallback to `toolFormerData.params`
197
-
198
- **Limitations:** Cursor does not persist model names per chat or per message. Provider name (e.g., "anthropic") is extracted from `providerOptions` when available.
199
-
200
- ### Windsurf / Windsurf Next / Antigravity
201
-
202
- Connects to the **running language server** via ConnectRPC (buf Connect protocol):
203
-
204
- 1. Discovers process via `ps aux` — finds `language_server_macos_arm` with `--csrf_token`
205
- 2. Extracts CSRF token and PID, finds listening port via `lsof`
206
- 3. Calls `GetAllCascadeTrajectories` for session summaries
207
- 4. Calls `GetCascadeTrajectory` per session for full conversation data
208
-
209
- **Requires the application to be running** — data is served from the language server process, not from files on disk.
210
-
211
- ### Claude Code
212
-
213
- Reads from `~/.claude/projects/<encoded-path>/`:
214
- - `sessions-index.json` — session index with titles and timestamps
215
- - Individual `.jsonl` session files — each line is a JSON message with `type`, `role`, `content`, `model`, `usage`
216
- - Tool calls extracted from `tool_use` content blocks and `tool_result` messages
217
-
218
- ### VS Code / VS Code Insiders
219
-
220
- Reads from `~/Library/Application Support/{Code,Code - Insiders}/User/`:
221
- - `workspaceStorage/<hash>/state.vscdb` — workspace-to-folder mapping
222
- - Chat sessions stored as `.jsonl` files in the Copilot Chat extension directory
223
- - JSONL reconstruction: `kind:0` = init state, `kind:1` = JSON patch at key path
224
- - Messages, tool calls, and token usage extracted from reconstructed chat state
225
-
226
- ### Zed
227
-
228
- Reads from `~/Library/Application Support/Zed/threads/threads.db`:
229
- - SQLite database with `threads` table containing zstd-compressed JSON blobs
230
- - Each thread decompressed via `zstd` CLI to extract messages, tool calls, and model info
231
- - Messages in OpenAI format with `tool_calls` array on assistant messages
232
-
233
- ### OpenCode
100
+ ## Contributing
234
101
 
235
- Reads from `~/.local/share/opencode/opencode.db`:
236
- - SQLite database with `session`, `message`, and `project` tables
237
- - Messages queried directly via SQL with full content, model, and token data
238
-
239
- ---
240
-
241
- ## Adding a New Editor
242
-
243
- 1. Create `editors/<name>.js` exporting:
244
-
245
- ```javascript
246
- const name = 'my-editor';
247
-
248
- function getChats() {
249
- // Return array of chat objects:
250
- return [{
251
- source: name, // editor identifier
252
- composerId: '...', // unique chat ID
253
- name: '...', // chat title (nullable)
254
- createdAt: 1234567, // timestamp in ms (nullable)
255
- lastUpdatedAt: 1234567, // timestamp in ms (nullable)
256
- mode: 'agent', // session mode (nullable)
257
- folder: '/path/to/project', // working directory (nullable)
258
- encrypted: false, // true if messages can't be read
259
- bubbleCount: 10, // message count hint (nullable)
260
- }];
261
- }
262
-
263
- function getMessages(chat) {
264
- // Return array of message objects:
265
- return [{
266
- role: 'user', // 'user' | 'assistant' | 'system' | 'tool'
267
- content: '...', // message text
268
- _model: 'gpt-4', // model name (optional)
269
- _inputTokens: 500, // input token count (optional)
270
- _outputTokens: 200, // output token count (optional)
271
- _cacheRead: 100, // cache read tokens (optional)
272
- _cacheWrite: 50, // cache write tokens (optional)
273
- _toolCalls: [{ // tool calls (optional)
274
- name: 'read_file',
275
- args: { path: '/foo.js' },
276
- }],
277
- }];
278
- }
279
-
280
- module.exports = { name, getChats, getMessages };
281
- ```
282
-
283
- 2. Register in `editors/index.js`:
284
-
285
- ```javascript
286
- const myEditor = require('./my-editor');
287
- const editors = [...existingEditors, myEditor];
288
- ```
289
-
290
- 3. Add color and label in `ui/src/lib/constants.js`:
291
-
292
- ```javascript
293
- export const EDITOR_COLORS = { ..., 'my-editor': '#hex' };
294
- export const EDITOR_LABELS = { ..., 'my-editor': 'My Editor' };
295
- ```
296
-
297
- ---
102
+ See **[CONTRIBUTING.md](CONTRIBUTING.md)** for development setup, editor adapter details, database schema, and how to add support for new editors.
298
103
 
299
104
  ## License
300
105
 
301
- MIT
106
+ MIT — Built by [@f](https://github.com/f)
@@ -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.1",
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
+ }