diff --git a/CLAUDE.md b/CLAUDE.md index 93af05a..09ed7be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## What is this -CodeDash (`codedash-app` on npm) is a zero-dependency Node.js browser dashboard for managing AI coding agent sessions. Supports 6 agents: Claude Code, Codex, Cursor, OpenCode, Kiro CLI, Kilo CLI. Single `npm i -g codedash-app && codedash run` opens a local web UI. +CodeDash (`codedash-app` on npm) is a zero-dependency Node.js browser dashboard for managing AI coding agent sessions. Supports 7 agents: Claude Code, Codex, Cursor, OpenCode, Kiro CLI, Kilo CLI, Copilot Chat. Single `npm i -g codedash-app && codedash run` opens a local web UI. ## Project structure @@ -10,7 +10,7 @@ CodeDash (`codedash-app` on npm) is a zero-dependency Node.js browser dashboard bin/cli.js CLI entry point (run/list/stats/search/show/handoff/convert/export/import/update/restart/stop) src/ server.js HTTP server + all API routes - data.js Session loading, search index, cost calculation, active detection for all 5 agents + data.js Session loading, search index, cost calculation, active detection for all 6 agents terminals.js Terminal detection (iTerm2/Terminal.app/Warp/Kitty/cmux) + launch/focus html.js Assembles HTML by inlining CSS+JS into template migrate.js Export/import sessions as tar.gz @@ -37,6 +37,7 @@ docs/ | OpenCode | SQLite | `~/.local/share/opencode/opencode.db` | tables: session, message, part | | Kiro CLI | SQLite | `~/Library/Application Support/kiro-cli/data.sqlite3` | table: conversations_v2 | | Kilo CLI | SQLite | `~/.local/share/kilo/kilo.db` | tables: session, message, part, project | +| Copilot Chat | JSON/JSONL | `~/.config/Code/User/workspaceStorage/*/chatSessions/` | `{version, requests: [{message, response}]}` | ## Key architecture decisions diff --git a/README.md b/README.md index 6182fde..441beea 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Codbash -Control room for AI coding sessions. Search, replay, and resume Claude Code, Codex, Cursor, OpenCode, and Kiro sessions without digging through scattered logs. +Control room for AI coding sessions. Search, replay, and resume Claude Code, Codex, Qwen, Cursor, OpenCode, Kiro, Kilo, and Copilot Chat sessions without digging through scattered logs. [Russian / Русский](docs/README_RU.md) | [Chinese / 中文](docs/README_ZH.md) @@ -24,6 +24,7 @@ codedash run | Cursor | JSONL | Yes | Yes | Yes | - | Yes | Open in Cursor | | OpenCode | SQLite | Yes | Yes | Yes | - | Yes | Terminal | | Kiro CLI | SQLite | Yes | Yes | Yes | - | Yes | Terminal | +| Copilot Chat | JSON/JSONL | Yes | Yes | - | - | Yes | - | Also detects Claude Code running inside Cursor (via `claude-vscode` entrypoint). @@ -40,7 +41,7 @@ Also detects Claude Code running inside Cursor (via `claude-vscode` entrypoint). - Themes: Dark, Light, System **Live Monitoring** -- LIVE/WAITING badges on all 5 agent types +- LIVE/WAITING badges on all agent types - Animated border on active session cards - Running view with CPU, Memory, PID, Uptime - Focus Terminal / Open in Cursor buttons @@ -54,7 +55,7 @@ Also detects Claude Code running inside Cursor (via `claude-vscode` entrypoint). **Cross-Agent** - Convert sessions between Claude Code and Codex - Handoff: generate context document to continue in any agent -- Install Agents: one-click install commands for all 5 agents +- Install Agents: one-click install commands for all agents **CLI** ```bash @@ -82,6 +83,10 @@ codedash stop ~/.cursor/projects/*/agent-transcripts/ Cursor agent sessions ~/.local/share/opencode/opencode.db OpenCode (SQLite) ~/Library/Application Support/kiro-cli/ Kiro CLI (SQLite) +/workspaceStorage/ Copilot Chat (JSON/JSONL) + # Linux: ~/.config/Code + # macOS: ~/Library/Application Support/Code + # Windows: %APPDATA%\Code ``` Zero dependencies. Everything runs on `localhost`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1343794..1d70008 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ ## Overview -CodeDash is a zero-dependency Node.js dashboard for AI coding agent sessions. Supports 6 agents: Claude Code, Claude Extension, Codex, Cursor, OpenCode, Kiro. Single process serves a web UI at `localhost:3847`. +CodeDash is a zero-dependency Node.js dashboard for AI coding agent sessions. Supports 8 agents: Claude Code, Claude Extension, Codex, Cursor, OpenCode, Kiro, Kilo, Copilot Chat. Single process serves a web UI at `localhost:3847`. ``` Browser (localhost:3847) Node.js Server @@ -21,10 +21,11 @@ Browser (localhost:3847) Node.js Server | convert/export/ | | +-- changelog.js | | import/update | +-------------------------------+ +-------------------+ | - reads from 5 locations: + reads from 6 locations: ~/.claude/ ~/.codex/ ~/.cursor/ ~/.local/share/opencode/opencode.db ~/Library/Application Support/kiro-cli/data.sqlite3 + ~/.config/Code/User/workspaceStorage/*/chatSessions/ ``` ## Project Structure @@ -197,6 +198,40 @@ FROM conversations_v2 ORDER BY updated_at DESC } ``` +### 7. Copilot (VS Code Extension) + +| Item | Location | +|------|----------| +| Sessions | `~/.config/Code/User/workspaceStorage/[hash]/chatSessions/` (JSON/JSONL) | + +**Storage formats**: Two file formats coexist in `chatSessions/`: +- **`.json`** — complete session state as a single JSON object +- **`.jsonl`** — mutation-based format (kind:0 init, kind:1 set, kind:2 splice) + +**Session JSON structure**: +```json +{ + "version": 3, + "creationDate": 1772452223289, + "requests": [ + { + "requestId": "request_uuid", + "message": {"text": "user prompt"}, + "response": [ + {"kind": "text", "value": "assistant response"}, + {"kind": "thinking", "value": "..."}, + {"kind": "toolInvocationSerialized", "value": {...}} + ], + "modelId": "copilot/claude-sonnet-4.6" + } + ] +} +``` + +**Project mapping**: `workspaceStorage/[hash]/workspace.json` contains `folder` URI → decoded to local path. + +**Cost**: No token usage stored locally — returns empty cost. + --- ## Data Flow @@ -209,6 +244,7 @@ FROM conversations_v2 ORDER BY updated_at DESC 3. scanOpenCodeSessions() → merge (tool: "opencode") 4. scanCursorSessions() → merge (tool: "cursor") 5. scanKiroSessions() → merge (tool: "kiro") +5a. scanCopilotSessions() → merge (tool: "copilot-chat") 6. Enrich Claude sessions with detail files: - Count messages, get file size - Check entrypoint → change tool to "claude-ext" if not "cli" diff --git a/docs/README_RU.md b/docs/README_RU.md index ba383f5..ec8ddcd 100644 --- a/docs/README_RU.md +++ b/docs/README_RU.md @@ -1,6 +1,6 @@ # CodeDash -Дашборд + CLI для сессий AI-агентов. 5 агентов: Claude Code, Codex, Cursor, OpenCode, Kiro. +Дашборд + CLI для сессий AI-агентов. 7 агентов: Claude Code, Codex, Cursor, OpenCode, Kiro, Kilo, Copilot Chat. [English](../README.md) | [Chinese / 中文](README_ZH.md) @@ -19,12 +19,13 @@ npm i -g codedash-app && codedash run | Cursor | JSONL | LIVE/WAITING | - | Open in Cursor | | OpenCode | SQLite | LIVE/WAITING | - | Терминал | | Kiro CLI | SQLite | LIVE/WAITING | - | Терминал | +| Copilot Chat | JSON/JSONL | - | - | - | ## Возможности - Grid/List, группировка по проектам, trigram поиск + deep search - GitHub-стиль SVG heatmap активности со стриками -- LIVE/WAITING бейджи для всех 5 агентов, анимированная рамка +- LIVE/WAITING бейджи для всех агентов, анимированная рамка - Session Replay с ползунком, hover превью, раскрытие карточек - Аналитика стоимости из реальных usage данных - Конвертация сессий Claude <-> Codex, Handoff между агентами diff --git a/docs/README_ZH.md b/docs/README_ZH.md index 8c66673..6fd8611 100644 --- a/docs/README_ZH.md +++ b/docs/README_ZH.md @@ -1,6 +1,6 @@ # CodeDash -AI 编程代理会话仪表板 + CLI。支持 5 个代理:Claude Code、Codex、Cursor、OpenCode、Kiro。 +AI 编程代理会话仪表板 + CLI。支持 7 个代理:Claude Code、Codex、Cursor、OpenCode、Kiro、Kilo、Copilot Chat。 [English](../README.md) | [Russian / Русский](README_RU.md) @@ -19,12 +19,13 @@ npm i -g codedash-app && codedash run | Cursor | JSONL | LIVE/WAITING | - | 在 Cursor 中打开 | | OpenCode | SQLite | LIVE/WAITING | - | 终端 | | Kiro CLI | SQLite | LIVE/WAITING | - | 终端 | +| Copilot Chat | JSON/JSONL | - | - | - | ## 功能 - 网格/列表视图、项目分组、Trigram 搜索 + 深度搜索 - GitHub 风格 SVG 活动热力图 -- 所有 5 个代理的 LIVE/WAITING 徽章 +- 所有代理的 LIVE/WAITING 徽章 - 会话回放、成本分析、跨代理转换和交接 - 导出/导入迁移、Dark/Light/System 主题 diff --git a/src/data.js b/src/data.js index 5fae4e7..7830f42 100644 --- a/src/data.js +++ b/src/data.js @@ -164,6 +164,13 @@ const CURSOR_APP_DATA = process.platform === 'darwin' : path.join(ALL_HOMES[0], '.config', 'Cursor'); const CURSOR_GLOBAL_DB = path.join(CURSOR_APP_DATA, 'User', 'globalStorage', 'state.vscdb'); const CURSOR_WORKSPACE_STORAGE = path.join(CURSOR_APP_DATA, 'User', 'workspaceStorage'); +// VS Code storage for Copilot Chat sessions (same path structure as Cursor but for Code) +const VSCODE_APP_DATA = process.platform === 'darwin' + ? path.join(ALL_HOMES[0], 'Library', 'Application Support', 'Code') + : process.platform === 'win32' + ? path.join(ALL_HOMES[0], 'AppData', 'Roaming', 'Code') + : path.join(ALL_HOMES[0], '.config', 'Code'); +const VSCODE_WORKSPACE_STORAGE = path.join(VSCODE_APP_DATA, 'User', 'workspaceStorage'); const HISTORY_FILE = path.join(CLAUDE_DIR, 'history.jsonl'); const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects'); @@ -1218,6 +1225,369 @@ function loadKiroDetail(conversationId) { } } +// ── Copilot Chat (VS Code extension) ───────────────────────── + +// Build workspace-hash -> project path mapping for VS Code workspaceStorage +let _copilotWsMapCache = null; +const COPILOT_WS_MAP_CACHE_FILE = path.join(os.tmpdir(), 'codedash-copilot-ws-map.json'); +const COPILOT_WS_MAP_TTL = 600000; // 10 minutes + +function buildCopilotWorkspaceMap() { + if (_copilotWsMapCache) return _copilotWsMapCache; + + // Current mtime of VS Code workspaceStorage — cache is invalidated if it changed + let currentWsMtime = 0; + try { + if (fs.existsSync(VSCODE_WORKSPACE_STORAGE)) { + currentWsMtime = fs.statSync(VSCODE_WORKSPACE_STORAGE).mtimeMs; + } + } catch {} + + // Try disk cache first — valid only if TTL not expired AND workspaceStorage mtime unchanged + try { + if (fs.existsSync(COPILOT_WS_MAP_CACHE_FILE)) { + const cached = JSON.parse(fs.readFileSync(COPILOT_WS_MAP_CACHE_FILE, 'utf8')); + const fresh = cached._ts && (Date.now() - cached._ts) < COPILOT_WS_MAP_TTL; + const mtimeMatches = cached._wsMtime === currentWsMtime; + if (fresh && mtimeMatches) { + delete cached._ts; + delete cached._wsMtime; + _copilotWsMapCache = cached; + return cached; + } + } + } catch {} + + const map = {}; // hash -> { folder, chatDir } + if (!fs.existsSync(VSCODE_WORKSPACE_STORAGE)) return map; + + try { + for (const hash of fs.readdirSync(VSCODE_WORKSPACE_STORAGE)) { + const chatDir = path.join(VSCODE_WORKSPACE_STORAGE, hash, 'chatSessions'); + if (!fs.existsSync(chatDir)) continue; + + // Read workspace.json for project path + let folder = ''; + try { + const wsJson = path.join(VSCODE_WORKSPACE_STORAGE, hash, 'workspace.json'); + const wsData = JSON.parse(fs.readFileSync(wsJson, 'utf8')); + folder = wsData.folder || ''; + if (folder.startsWith('file://')) { + folder = decodeURIComponent(folder.replace('file://', '')); + // Windows: /D:/path -> D:/path + if (process.platform === 'win32' && /^\/[a-zA-Z]:/.test(folder)) { + folder = folder.slice(1); + } + } else if (folder.startsWith('vscode-remote://')) { + const m = folder.match(/vscode-remote:\/\/[^/]+(\/.*)/); + folder = m ? decodeURIComponent(m[1]) : ''; + } + } catch {} + + map[hash] = { folder, chatDir }; + } + } catch {} + + _copilotWsMapCache = map; + + try { + const toSave = {}; + for (const k of Object.keys(map)) toSave[k] = map[k]; + toSave._ts = Date.now(); + toSave._wsMtime = currentWsMtime; + fs.writeFileSync(COPILOT_WS_MAP_CACHE_FILE, JSON.stringify(toSave)); + } catch {} + + return map; +} + +// Extract text from a Copilot response array (mix of text, thinking, tool invocations) +function extractCopilotResponseText(response) { + if (!Array.isArray(response)) return ''; + const parts = []; + for (const item of response) { + // Text responses: kind is absent, 'text', or 'markdownContent' + if ((!item.kind || item.kind === 'text' || item.kind === 'markdownContent') && typeof item.value === 'string') { + parts.push(item.value); + } + } + return parts.join('').trim(); +} + +// Parse a Copilot JSON session file — returns { requests, creationDate, sessionId } +// Note: large files (30+ MB) are slow because of embedded image attachments in variableData. +// JSON.parse is unavoidable here, but Node handles it in ~1-2s for typical files. +function parseCopilotJson(filePath) { + const raw = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(raw); +} + +// Disk cache for Copilot session metadata (avoids re-scanning large files) +const COPILOT_PARSED_CACHE_FILE = path.join(os.tmpdir(), 'codedash-copilot-parsed-cache.json'); +let _copilotParsedCache = null; + +function _loadCopilotParsedCache() { + if (_copilotParsedCache) return; + try { + if (fs.existsSync(COPILOT_PARSED_CACHE_FILE)) { + _copilotParsedCache = JSON.parse(fs.readFileSync(COPILOT_PARSED_CACHE_FILE, 'utf8')); + } else { + _copilotParsedCache = {}; + } + } catch { _copilotParsedCache = {}; } +} + +function _saveCopilotParsedCache() { + try { fs.writeFileSync(COPILOT_PARSED_CACHE_FILE, JSON.stringify(_copilotParsedCache)); } catch {} +} + +// Scan metadata for a Copilot JSON file. Uses disk cache keyed by path|mtime|size. +function scanCopilotJsonMetadata(filePath, stat) { + _loadCopilotParsedCache(); + const cacheKey = filePath + '|' + stat.mtimeMs + '|' + stat.size; + if (_copilotParsedCache[cacheKey]) return _copilotParsedCache[cacheKey]; + + let result; + if (stat.size < 1048576) { + // Small file (<1MB): full parse is fast + const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const reqs = data.requests || []; + result = { + creationDate: data.creationDate || 0, + msgCount: reqs.length, + firstMsg: (reqs[0] && reqs[0].message && reqs[0].message.text || '').slice(0, 200), + }; + } else { + // Large file: peek first 4KB for metadata, estimate msg count + const fd = fs.openSync(filePath, 'r'); + const peekBuf = Buffer.alloc(Math.min(4096, stat.size)); + fs.readSync(fd, peekBuf, 0, peekBuf.length, 0); + fs.closeSync(fd); + const peek = peekBuf.toString('utf8'); + let creationDate = 0; + const cdMatch = peek.match(/"creationDate"\s*:\s*(\d+)/); + if (cdMatch) creationDate = parseInt(cdMatch[1]); + let firstMsg = ''; + const fmMatch = peek.match(/"text"\s*:\s*"((?:[^"\\]|\\.)*)"/); + if (fmMatch) try { firstMsg = JSON.parse('"' + fmMatch[1] + '"').slice(0, 200); } catch {} + // For large files, estimate message count (actual count loaded on detail view) + // Average Copilot request is ~200KB-2MB with attachments + result = { creationDate, msgCount: Math.max(1, Math.round(stat.size / 500000)), firstMsg }; + } + + _copilotParsedCache[cacheKey] = result; + return result; +} + +// Scan metadata for a Copilot JSONL file. Uses disk cache. +function scanCopilotJsonlMetadata(filePath, stat) { + _loadCopilotParsedCache(); + const cacheKey = filePath + '|' + stat.mtimeMs + '|' + stat.size; + if (_copilotParsedCache[cacheKey]) return _copilotParsedCache[cacheKey]; + + let creationDate = 0, msgCount = 0, firstMsg = ''; + const lines = readLines(filePath); + for (const line of lines) { + if (line.startsWith('{"kind":0')) { + const cdMatch = line.match(/"creationDate"\s*:\s*(\d+)/); + if (cdMatch) creationDate = parseInt(cdMatch[1]); + continue; + } + if (!_isCopilotLineRelevant(line)) continue; + let entry; + try { entry = JSON.parse(line); } catch { continue; } + if (entry.kind === 2 && Array.isArray(entry.k) && entry.k.length === 1 && entry.k[0] === 'requests' && Array.isArray(entry.v)) { + for (const req of entry.v) { + msgCount++; + if (!firstMsg && req.message && req.message.text) { + firstMsg = req.message.text.slice(0, 200); + } + } + } + } + + const result = { creationDate, msgCount, firstMsg }; + _copilotParsedCache[cacheKey] = result; + return result; +} + +// Quick string check: does this JSONL line contain data we need? +// Skip kind:1 mutations (inputState, modelState, hasPendingEdits, responderUsername, etc.) +// and kind:2 splices to non-request paths — these often contain huge image byte arrays. +function _isCopilotLineRelevant(line) { + // kind:0 (init) — always relevant + if (line.startsWith('{"kind":0')) return true; + // kind:2 with "requests" in path — relevant + if (line.startsWith('{"kind":2') && line.includes('"requests"')) return true; + // Everything else (kind:1, kind:2 without requests) — skip + return false; +} + +// Parse a Copilot JSONL mutation file — targeted extraction of requests & responses +// Performance: skips irrelevant lines (inputState, attachments with image byte arrays) +// BEFORE calling JSON.parse, avoiding parsing multi-MB attachment lines. +function parseCopilotJsonl(filePath) { + const lines = readLines(filePath); + let base = { requests: [] }; + const extraResponses = {}; // requestIndex -> [response items...] + + for (const line of lines) { + if (!_isCopilotLineRelevant(line)) continue; + + let entry; + try { entry = JSON.parse(line); } catch { continue; } + + if (entry.kind === 0) { + // Initial state — extract only what we need, discard attachments + const v = entry.v || {}; + base = { requests: v.requests || [], creationDate: v.creationDate, sessionId: v.sessionId }; + } else if (entry.kind === 2) { + const k = entry.k || []; + if (k.length === 1 && k[0] === 'requests' && Array.isArray(entry.v)) { + // Splice new requests into requests array + for (const req of entry.v) { + base.requests.push(req); + } + } else if (k.length === 3 && k[0] === 'requests' && k[2] === 'response' && Array.isArray(entry.v)) { + // Append response items to a specific request + const idx = parseInt(k[1]); + if (!isNaN(idx)) { + if (!extraResponses[idx]) extraResponses[idx] = []; + for (const item of entry.v) extraResponses[idx].push(item); + } + } + } + } + + // Merge extra responses into requests + for (const idx of Object.keys(extraResponses)) { + const i = parseInt(idx); + if (base.requests[i]) { + if (!base.requests[i].response) base.requests[i].response = []; + for (const item of extraResponses[idx]) { + base.requests[i].response.push(item); + } + } + } + + return base; +} + +function scanCopilotSessions() { + const sessions = []; + if (!fs.existsSync(VSCODE_WORKSPACE_STORAGE)) return sessions; + + const wsMap = buildCopilotWorkspaceMap(); + + for (const hash of Object.keys(wsMap)) { + const { folder, chatDir } = wsMap[hash]; + let files; + try { files = fs.readdirSync(chatDir); } catch { continue; } + + for (const file of files) { + if (!file.endsWith('.json') && !file.endsWith('.jsonl')) continue; + const filePath = path.join(chatDir, file); + let stat; + try { stat = fs.statSync(filePath); } catch { continue; } + if (stat.size < 10) continue; // skip empty files + + let firstMsg = ''; + let msgCount = 0; + let creationDate = 0; + + try { + const meta = file.endsWith('.json') + ? scanCopilotJsonMetadata(filePath, stat) + : scanCopilotJsonlMetadata(filePath, stat); + creationDate = meta.creationDate; + msgCount = meta.msgCount; + firstMsg = meta.firstMsg; + } catch {} + + if (msgCount === 0) continue; // skip sessions with no user messages + + const sessionId = 'copilot-' + file.replace(/\.(json|jsonl)$/, ''); + sessions.push({ + id: sessionId, + tool: 'copilot-chat', + project: folder, + project_short: folder.replace(os.homedir(), '~'), + first_ts: creationDate || stat.birthtimeMs || stat.mtimeMs, + last_ts: stat.mtimeMs, + messages: msgCount * 2, // each request has user + assistant + first_message: firstMsg, + has_detail: true, + file_size: stat.size, + detail_messages: msgCount * 2, + _file: filePath, + }); + } + } + + _saveCopilotParsedCache(); + return sessions; +} + +function loadCopilotDetail(sessionId) { + // Find file: check loaded sessions first, then scan + const sessions = _sessionsCache || []; + let filePath = null; + for (const s of (Array.isArray(sessions) ? sessions : Object.values(sessions))) { + if (s.id === sessionId && s._file) { filePath = s._file; break; } + } + + if (!filePath) { + // Fallback: search workspace storage + const baseName = sessionId.replace(/^copilot-/, ''); + const wsMap = buildCopilotWorkspaceMap(); + for (const hash of Object.keys(wsMap)) { + const { chatDir } = wsMap[hash]; + for (const ext of ['.json', '.jsonl']) { + const candidate = path.join(chatDir, baseName + ext); + if (fs.existsSync(candidate)) { filePath = candidate; break; } + } + if (filePath) break; + } + } + + if (!filePath || !fs.existsSync(filePath)) return { messages: [] }; + + try { + let data; + if (filePath.endsWith('.jsonl')) { + data = parseCopilotJsonl(filePath); + } else { + data = parseCopilotJson(filePath); + } + + const messages = []; + for (const req of (data.requests || [])) { + // User message + if (req.message && req.message.text) { + messages.push({ + role: 'user', + content: req.message.text.slice(0, 2000), + uuid: req.requestId || '', + }); + } + + // Assistant response: concatenate text parts from response array + const respText = extractCopilotResponseText(req.response); + if (respText) { + messages.push({ + role: 'assistant', + content: respText.slice(0, 2000), + uuid: req.responseId || '', + }); + } + } + + return { messages: messages.slice(0, 200) }; + } catch { + return { messages: [] }; + } +} + // Cursor stores each workspace under ~/.cursor/projects// where is the // absolute path with / and . replaced by -. Hyphens inside a directory name are // preserved, so splitting on "-" cannot recover the path. Decode by @@ -2128,6 +2498,14 @@ function loadSessions() { } } catch {} + // Load Copilot Chat sessions + try { + const copilotSessions = scanCopilotSessions(); + for (const cs of copilotSessions) { + sessions[cs.id] = cs; + } + } catch {} + // WSL: also load from Windows-side dirs for (const extraClaudeDir of EXTRA_CLAUDE_DIRS) { try { @@ -2358,6 +2736,11 @@ function loadSessionDetail(sessionId, project) { return loadKiloCliDetail(sessionId); } + // Copilot Chat (JSON/JSONL) + if (found.format === 'copilot-chat') { + return loadCopilotDetail(sessionId); + } + const messages = []; const lines = readLines(found.file); @@ -2669,6 +3052,26 @@ function _buildSessionFileIndex() { } catch {} } + // Index Copilot chat session files + if (fs.existsSync(VSCODE_WORKSPACE_STORAGE)) { + try { + const wsMap = buildCopilotWorkspaceMap(); + for (const hash of Object.keys(wsMap)) { + const { chatDir } = wsMap[hash]; + try { + for (const f of fs.readdirSync(chatDir)) { + if (f.endsWith('.json') || f.endsWith('.jsonl')) { + const sid = 'copilot-' + f.replace(/\.(json|jsonl)$/, ''); + if (!_sessionFileIndex[sid]) { + _sessionFileIndex[sid] = { file: path.join(chatDir, f), format: 'copilot-chat' }; + } + } + } + } catch {} + } + } catch {} + } + _sessionFileIndexTs = now; } @@ -2832,6 +3235,19 @@ function findSessionFile(sessionId, project) { } catch {} } + // Try Copilot Chat (file-based, prefixed IDs) + if (sessionId.startsWith('copilot-')) { + const baseName = sessionId.replace(/^copilot-/, ''); + const wsMap = buildCopilotWorkspaceMap(); + for (const hash of Object.keys(wsMap)) { + const { chatDir } = wsMap[hash]; + for (const ext of ['.json', '.jsonl']) { + const candidate = path.join(chatDir, baseName + ext); + if (fs.existsSync(candidate)) return { file: candidate, format: 'copilot-chat' }; + } + } + } + return null; } @@ -3420,7 +3836,7 @@ function computeSessionCost(sessionId, project) { if (!found) { _costMemCache[sessionId] = EMPTY_COST; return EMPTY_COST; } // Skip formats that never have cost data - if (found.format === 'cursor' || found.format === 'kiro') { _costMemCache[sessionId] = EMPTY_COST; return EMPTY_COST; } + if (found.format === 'cursor' || found.format === 'kiro' || found.format === 'copilot-chat') { _costMemCache[sessionId] = EMPTY_COST; return EMPTY_COST; } // Check disk cache (keyed by file path + mtime + size for JSONL, sessionId for SQLite) _loadCostDiskCache(); @@ -4261,7 +4677,32 @@ function _saveDailyStatsDiskCache() { function _computeSessionDailyBreakdown(s, found) { const msgsByDay = {}; const tsByDay = {}; + + const addMsg = (day, ts) => { + msgsByDay[day] = (msgsByDay[day] || 0) + 1; + if (!tsByDay[day]) tsByDay[day] = { first: ts, last: ts }; + if (ts < tsByDay[day].first) tsByDay[day].first = ts; + if (ts > tsByDay[day].last) tsByDay[day].last = ts; + }; + try { + // Copilot: use optimized parser instead of line-by-line generic JSONL scan + if (found.format === 'copilot-chat') { + let data; + if (found.file.endsWith('.jsonl')) { + data = parseCopilotJsonl(found.file); + } else { + data = parseCopilotJson(found.file); + } + for (const req of (data.requests || [])) { + if (!req.message || !req.message.text || !req.message.text.trim()) continue; + const ts = req.timestamp || data.creationDate || s.first_ts; + const day = ts ? fmtLocalDay(ts) : (s.date || fmtLocalDay(s.last_ts)); + addMsg(day, ts || s.first_ts); + } + return { msgsByDay, tsByDay }; + } + const lines = readLines(found.file); for (const line of lines) { try { @@ -4301,10 +4742,7 @@ function _computeSessionDailyBreakdown(s, found) { if (!isUser || !hasText) continue; if (!ts || ts < 1000000000000) ts = s.first_ts; const day = (found.format === 'claude' && ts) ? fmtLocalDay(ts) : (s.date || fmtLocalDay(s.last_ts)); - msgsByDay[day] = (msgsByDay[day] || 0) + 1; - if (!tsByDay[day]) tsByDay[day] = { first: ts, last: ts }; - if (ts < tsByDay[day].first) tsByDay[day].first = ts; - if (ts > tsByDay[day].last) tsByDay[day].last = ts; + addMsg(day, ts || s.first_ts); } catch {} } } catch {} diff --git a/src/frontend/app.js b/src/frontend/app.js index f639d59..880e59a 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -111,7 +111,9 @@ var TOOL_META = { qwen: { label: 'Qwen Code', shortLabel: 'qwen', color: '#fbbf24' }, cursor: { label: 'Cursor', shortLabel: 'cursor', color: '#4a9eff' }, opencode: { label: 'OpenCode', shortLabel: 'opencode', color: '#c084fc' }, - kiro: { label: 'Kiro', shortLabel: 'kiro', color: '#fb923c' } + kiro: { label: 'Kiro', shortLabel: 'kiro', color: '#fb923c' }, + kilo: { label: 'Kilo CLI', shortLabel: 'kilo', color: '#34d399' }, + 'copilot-chat': { label: 'Copilot Chat', shortLabel: 'copilot', color: '#8b6fc0' } }; function getToolLabel(tool, shortLabel) { @@ -1972,6 +1974,12 @@ var AGENT_INSTALL = { alt: null, url: 'https://kilo.ai', }, + 'copilot-chat': { + name: 'Copilot Chat (VS Code)', + cmd: null, + alt: null, + url: 'https://github.com/features/copilot', + }, }; function installAgent(agent) { diff --git a/src/frontend/calendar.js b/src/frontend/calendar.js index f0f3ce8..ba814cf 100644 --- a/src/frontend/calendar.js +++ b/src/frontend/calendar.js @@ -193,6 +193,9 @@ function setView(view) { } else if (view === 'kiro-only') { toolFilter = toolFilter === 'kiro' ? null : 'kiro'; currentView = 'sessions'; + } else if (view === 'copilot-chat-only') { + toolFilter = toolFilter === 'copilot-chat' ? null : 'copilot-chat'; + currentView = 'sessions'; } else if (view === 'opencode-only') { toolFilter = toolFilter === 'opencode' ? null : 'opencode'; currentView = 'sessions'; diff --git a/src/frontend/heatmap.js b/src/frontend/heatmap.js index 0f6f8a3..ee6d4f1 100644 --- a/src/frontend/heatmap.js +++ b/src/frontend/heatmap.js @@ -187,7 +187,8 @@ function renderHeatmap(container) { cursor: '#4a9eff', opencode: '#c084fc', kiro: '#fb923c', - kilo: '#34d399' + kilo: '#34d399', + 'copilot-chat': '#8b6fc0' }; html += '
'; Object.keys(toolTotals).sort(function(a,b) { return toolTotals[b] - toolTotals[a]; }).forEach(function(tool) { diff --git a/src/frontend/index.html b/src/frontend/index.html index 605e803..73253fa 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -72,6 +72,10 @@ Cursor
+