diff --git a/CLAUDE.md b/CLAUDE.md index c630bd1..fc44a03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,6 +85,38 @@ GET /api/session/:id/export Download session as Markdown - System messages from Codex/Kiro (AGENTS.md, permissions, exit) are filtered via `isSystemMessage()` - Cursor `` wrappers are stripped in `loadCursorDetail()` +## Git workflow + +**`main` branch is protected.** All changes go through feature branches + pull requests. + +```bash +# 1. Create a feature branch +git checkout -b feat/my-feature # or fix/bug-name, chore/cleanup + +# 2. Make changes, commit +git add && git commit -m "feat: description" + +# 3. Push and create PR +git push -u origin feat/my-feature +gh pr create --title "feat: description" --body "..." + +# 4. After review/approval, merge via GitHub +gh pr merge --squash +``` + +**Branch naming:** +- `feat/` — new features +- `fix/` — bug fixes +- `chore/` — refactoring, docs, CI +- `release/` — version bumps + publish + +**Commit messages:** Use conventional format: `feat:`, `fix:`, `chore:`, `docs:`, `perf:`. + +**PR rules:** +- 1 approval required to merge into main +- Keep PRs small and focused — one feature/fix per PR +- Large PRs touching 5+ files should be split + ## Versioning rules **IMPORTANT: Do not bump versions aggressively.** @@ -100,11 +132,12 @@ Before bumping minor/major, ask: "Does this really warrant a version bump, or ca ## Publishing ```bash -# Bump version in package.json, then: -git add -A && git commit && git push && npm publish --access public - -# Also sync to ~/codedash: -cp -r src/* ~/codedash/src/ && cp bin/cli.js ~/codedash/bin/ && cp package.json ~/codedash/ +# From a release branch: +git checkout -b release/6.x.x +# Bump version in package.json, commit, push, create PR +# After merge to main: +git checkout main && git pull +npm publish --access public ``` Package name: `codedash-app`, binary name: `codedash` diff --git a/README.md b/README.md index 91e8ce1..2956888 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,23 @@ curl -fsSL https://opencode.ai/install | bash # OpenCode - At least one AI coding agent installed - macOS / Linux / Windows +## Contributing + +`main` is protected. All changes go through feature branches and pull requests. + +```bash +git checkout -b fix/my-fix +# make changes +git push -u origin fix/my-fix +gh pr create +``` + +- **Branch naming:** `feat/`, `fix/`, `chore/`, `release/` +- **1 approval** required to merge +- Keep PRs small and focused + +See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for codebase details. + ## License MIT diff --git a/bin/cli.js b/bin/cli.js index 57e4452..eb626d0 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -7,6 +7,7 @@ const { convertSession } = require('../src/convert'); const { generateHandoff, quickHandoff } = require('../src/handoff'); const DEFAULT_PORT = 3847; +const DEFAULT_HOST = 'localhost'; const args = process.argv.slice(2); const command = args[0] || 'help'; @@ -15,8 +16,10 @@ switch (command) { case 'start': { const portArg = args.find(a => a.startsWith('--port=')); const port = portArg ? parseInt(portArg.split('=')[1]) : (parseInt(args[1]) || DEFAULT_PORT); + const hostArg = args.find(a => a.startsWith('--host=')); + const host = hostArg ? hostArg.split('=')[1] : (process.env.CODEDASH_HOST || DEFAULT_HOST); const noBrowser = args.includes('--no-browser'); - startServer(port, !noBrowser); + startServer(host, port, !noBrowser); break; } @@ -253,6 +256,8 @@ switch (command) { const { execSync } = require('child_process'); const portArg = args.find(a => a.startsWith('--port=')); const port = portArg ? parseInt(portArg.split('=')[1]) : DEFAULT_PORT; + const hostArg = args.find(a => a.startsWith('--host=')); + const host = hostArg ? hostArg.split('=')[1] : (process.env.CODEDASH_HOST || DEFAULT_HOST); console.log(`\n Stopping codedash on port ${port}...`); try { execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null`, { stdio: 'pipe' }); @@ -263,7 +268,7 @@ switch (command) { setTimeout(() => { console.log(' Starting...\n'); const noBrowser = args.includes('--no-browser'); - startServer(port, !noBrowser); + startServer(host, port, !noBrowser); }, 500); break; } @@ -328,9 +333,18 @@ switch (command) { codedash help Show this help codedash version Show version + \x1b[1mServer options:\x1b[0m + --port=N Listen on port N (default: ${DEFAULT_PORT}) + --host=ADDR Bind to address (default: localhost) + --no-browser Don't open browser on start + + \x1b[1mEnvironment variables:\x1b[0m + CODEDASH_HOST Bind address (same as --host) + \x1b[1mExamples:\x1b[0m codedash run Start on port ${DEFAULT_PORT} codedash run --port=4000 Start on port 4000 + codedash run --host=0.0.0.0 Listen on all interfaces codedash run --no-browser Start without opening browser codedash list 50 Show last 50 sessions codedash ls Alias for list diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index af44398..1343794 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -323,3 +323,29 @@ GET /api/version Current + latest npm version GET /api/changelog Changelog entries GET /api/terminals Available terminal apps ``` + +--- + +## Contributing + +### Git Workflow + +`main` is protected. All changes require a pull request with 1 approval. + +``` +main (protected) + ├── feat/session-titles → PR → merge + ├── fix/cursor-path → PR → merge + └── release/6.4.0 → PR → merge + npm publish +``` + +**Branch naming:** `feat/`, `fix/`, `chore/`, `release/` + +**Commit format:** Conventional — `feat:`, `fix:`, `chore:`, `docs:`, `perf:` + +### PR Guidelines + +- One feature or fix per PR +- Keep PRs under 5 files when possible +- Large features should be split into incremental PRs +- Test locally with `node -e "require('./src/server')"` before pushing diff --git a/package.json b/package.json index e256cc4..3a2e7c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codedash-app", - "version": "6.3.2", + "version": "6.8.3", "description": "Dashboard + CLI for Claude Code, Codex & OpenCode sessions. View, search, resume, convert, handoff between agents.", "bin": { "codedash": "./bin/cli.js" diff --git a/src/data.js b/src/data.js index 83b08af..7f7d4cc 100644 --- a/src/data.js +++ b/src/data.js @@ -71,21 +71,166 @@ function readLines(filePath) { return fs.readFileSync(filePath, 'utf8').split('\n').map(l => l.replace(/\r$/, '')).filter(Boolean); } +function parseClaudeSessionFile(sessionFile) { + if (!fs.existsSync(sessionFile)) return null; + + let stat; + let lines; + try { + stat = fs.statSync(sessionFile); + lines = readLines(sessionFile); + } catch { + return null; + } + let projectPath = ''; + let tool = 'claude'; + let msgCount = 0; + let firstMsg = ''; + let customTitle = ''; + let firstTs = stat.mtimeMs; + let lastTs = stat.mtimeMs; + let entrypointFound = false; + let worktreeOriginalCwd = ''; + + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === 'user' || entry.type === 'assistant') msgCount++; + if (entry.timestamp) { + if (entry.timestamp < firstTs) firstTs = entry.timestamp; + if (entry.timestamp > lastTs) lastTs = entry.timestamp; + } + if (!projectPath && entry.type === 'user' && entry.cwd) { + projectPath = entry.cwd; + } + // worktree-state is written by Claude Code when a session runs inside a git worktree. + // originalCwd is the main checkout directory — safe to use in containers (no git needed). + if (!worktreeOriginalCwd && entry.type === 'worktree-state' && entry.worktreeSession && entry.worktreeSession.originalCwd) { + worktreeOriginalCwd = entry.worktreeSession.originalCwd; + } + if (!entrypointFound && entry.type === 'user' && entry.entrypoint) { + entrypointFound = true; + if (entry.entrypoint !== 'cli') tool = 'claude-ext'; + } + if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') { + const title = entry.customTitle.trim(); + if (title) customTitle = title.slice(0, 200); + } + if (!firstMsg && entry.type === 'user' && entry.message && entry.message.content) { + const content = extractContent(entry.message.content).trim(); + if (content) firstMsg = content.slice(0, 200); + } + } catch {} + } + + return { + projectPath, + tool, + msgCount, + firstMsg, + customTitle, + firstTs, + lastTs, + fileSize: stat.size, + worktreeOriginalCwd, + }; +} + +function mergeClaudeSessionDetail(session, summary, sessionFile) { + if (!session || !summary) return; + + session.tool = summary.tool || session.tool; + session.has_detail = true; + session.file_size = summary.fileSize; + session.detail_messages = summary.msgCount; + session._session_file = sessionFile; + + if (!session.project && summary.projectPath) { + session.project = summary.projectPath; + session.project_short = summary.projectPath.replace(os.homedir(), '~'); + } + + if (summary.worktreeOriginalCwd) { + session.worktree_original_cwd = summary.worktreeOriginalCwd; + } + + if (summary.customTitle) { + session.first_message = summary.customTitle; + } +} + +function parseCodexSessionIndex(codexDir) { + const titles = {}; + const titleMeta = {}; + const indexFile = path.join(codexDir, 'session_index.jsonl'); + if (!fs.existsSync(indexFile)) return titles; + + const parseUpdatedAt = (value) => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return NaN; + if (/^\d+$/.test(trimmed)) return Number(trimmed); + return Date.parse(trimmed); + } + return NaN; + }; + + let lines; + try { + lines = readLines(indexFile); + } catch { + return titles; + } + + for (const line of lines) { + try { + const entry = JSON.parse(line); + const sid = entry.id || entry.session_id || entry.sessionId; + if (!sid || typeof entry.thread_name !== 'string') continue; + const title = entry.thread_name.trim(); + if (!title) continue; + + const updatedAt = parseUpdatedAt(entry.updated_at); + const hasUpdatedAt = Number.isFinite(updatedAt); + const existing = titleMeta[sid]; + + if (!existing) { + titles[sid] = title.slice(0, 200); + titleMeta[sid] = { updatedAt, hasUpdatedAt }; + continue; + } + + if ( + (hasUpdatedAt && !existing.hasUpdatedAt) || + (hasUpdatedAt && existing.hasUpdatedAt && updatedAt >= existing.updatedAt) || + (!hasUpdatedAt && !existing.hasUpdatedAt) + ) { + titles[sid] = title.slice(0, 200); + titleMeta[sid] = { updatedAt, hasUpdatedAt }; + } + } catch {} + } + + return titles; +} + function scanOpenCodeSessions() { const sessions = []; if (!fs.existsSync(OPENCODE_DB)) return sessions; try { - // Use sqlite3 CLI to avoid Node version dependency + // Use sqlite3 CLI with tab separator — session titles can contain pipes + // (e.g. "review changes [commit|branch|pr]") which break the default | separator const rows = execSync( - `sqlite3 "${OPENCODE_DB}" "SELECT s.id, s.title, s.directory, s.time_created, s.time_updated, COUNT(m.id) as msg_count FROM session s LEFT JOIN message m ON m.session_id = s.id GROUP BY s.id ORDER BY s.time_updated DESC"`, + `sqlite3 -separator $'\\t' "${OPENCODE_DB}" "SELECT s.id, s.title, s.directory, s.time_created, s.time_updated, COUNT(m.id) as msg_count FROM session s LEFT JOIN message m ON m.session_id = s.id GROUP BY s.id ORDER BY s.time_updated DESC"`, { encoding: 'utf8', timeout: 5000 } ).trim(); if (!rows) return sessions; for (const row of rows.split('\n')) { - const parts = row.split('|'); + const parts = row.split('\t'); if (parts.length < 6) continue; const [id, title, directory, timeCreated, timeUpdated, msgCount] = parts; @@ -185,14 +330,14 @@ function scanKiroSessions() { try { const rows = execSync( - `sqlite3 "${KIRO_DB}" "SELECT key, conversation_id, created_at, updated_at, substr(value, 1, 500), length(value) FROM conversations_v2 ORDER BY updated_at DESC"`, + `sqlite3 -separator $'\\t' "${KIRO_DB}" "SELECT key, conversation_id, created_at, updated_at, substr(value, 1, 500), length(value) FROM conversations_v2 ORDER BY updated_at DESC"`, { encoding: 'utf8', timeout: 5000 } ).trim(); if (!rows) return sessions; for (const row of rows.split('\n')) { - const parts = row.split('|'); + const parts = row.split('\t'); if (parts.length < 5) continue; const [directory, convId, createdAt, updatedAt, valuePeek, valueLen] = parts; @@ -283,7 +428,7 @@ function decodeCursorProjectFolderKey(proj) { for (var j = 0; j < dirs.length; j++) { var d = dirs[j]; // Cursor encodes both / and . as -, so compare against encoded dir name - var encoded = d.replace(/[\/\.]/g, '-'); + var encoded = d.replace(/[^a-zA-Z0-9-]/g, '-'); if (enc === encoded || (enc.startsWith(encoded) && (enc.length === encoded.length || enc[encoded.length] === '-'))) { matched = d; break; @@ -473,8 +618,74 @@ function loadCursorDetail(sessionId) { return { messages: messages.slice(0, 200) }; } +function parseCodexSessionFile(sessionFile) { + if (!fs.existsSync(sessionFile)) return null; + + let stat; + let lines; + try { + stat = fs.statSync(sessionFile); + lines = readLines(sessionFile); + } catch { + return null; + } + + const parseTimestamp = (value) => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return NaN; + if (/^\d+$/.test(trimmed)) return Number(trimmed); + return Date.parse(trimmed); + } + return NaN; + }; + + let projectPath = ''; + let msgCount = 0; + let firstMsg = ''; + let firstTs = stat.mtimeMs; + let lastTs = stat.mtimeMs; + + for (const line of lines) { + try { + const entry = JSON.parse(line); + const ts = parseTimestamp(entry.timestamp || entry.ts); + if (Number.isFinite(ts)) { + if (ts < firstTs) firstTs = ts; + if (ts > lastTs) lastTs = ts; + } + + if (entry.type === 'session_meta' && entry.payload && entry.payload.cwd && !projectPath) { + projectPath = entry.payload.cwd; + continue; + } + + if (entry.type !== 'response_item' || !entry.payload) continue; + const role = entry.payload.role; + if (role !== 'user' && role !== 'assistant') continue; + + const content = extractContent(entry.payload.content); + if (!content || isSystemMessage(content)) continue; + + msgCount++; + if (!firstMsg) firstMsg = content.slice(0, 200); + } catch {} + } + + return { + projectPath, + msgCount, + firstMsg, + firstTs, + lastTs, + fileSize: stat.size, + }; +} + function scanCodexSessions() { const sessions = []; + const codexTitles = parseCodexSessionIndex(CODEX_DIR); const codexHistory = path.join(CODEX_DIR, 'history.jsonl'); if (fs.existsSync(codexHistory)) { const lines = readLines(codexHistory); @@ -494,7 +705,7 @@ function scanCodexSessions() { first_ts: ts, last_ts: ts, messages: 1, - first_message: d.text || d.display || d.prompt || '', + first_message: codexTitles[sid] || d.text || d.display || d.prompt || '', has_detail: false, file_size: 0, detail_messages: 0, @@ -520,43 +731,44 @@ function scanCodexSessions() { walkDir(codexSessionsDir); for (const f of files) { - const stat = fs.statSync(f); // Extract session ID from filename (rollout-DATE-UUID.jsonl) const basename = path.basename(f, '.jsonl'); const uuidMatch = basename.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/); if (!uuidMatch) continue; const sid = uuidMatch[1]; - // Try to extract cwd from session_meta - let cwd = ''; - try { - const firstLine = fs.readFileSync(f, 'utf8').split('\n')[0].replace(/\r$/, ''); - const meta = JSON.parse(firstLine); - if (meta.type === 'session_meta' && meta.payload && meta.payload.cwd) { - cwd = meta.payload.cwd; - } - } catch {} + const summary = parseCodexSessionFile(f); + if (!summary) continue; const existing = sessions.find(s => s.id === sid); if (existing) { existing.has_detail = true; - existing.file_size = stat.size; - if (cwd && !existing.project) { - existing.project = cwd; - existing.project_short = cwd.replace(os.homedir(), '~'); + existing.file_size = summary.fileSize; + existing.messages = summary.msgCount; + existing.detail_messages = summary.msgCount; + if (codexTitles[sid]) { + existing.first_message = codexTitles[sid]; + } else if (summary.firstMsg && !existing.first_message) { + existing.first_message = summary.firstMsg; + } + if (summary.projectPath && !existing.project) { + existing.project = summary.projectPath; + existing.project_short = summary.projectPath.replace(os.homedir(), '~'); } + existing.first_ts = Math.min(existing.first_ts, summary.firstTs); + existing.last_ts = Math.max(existing.last_ts, summary.lastTs); } else { sessions.push({ id: sid, tool: 'codex', - project: cwd, - project_short: cwd ? cwd.replace(os.homedir(), '~') : '', - first_ts: stat.mtimeMs, - last_ts: stat.mtimeMs, - messages: 0, - first_message: '', + project: summary.projectPath, + project_short: summary.projectPath ? summary.projectPath.replace(os.homedir(), '~') : '', + first_ts: summary.firstTs, + last_ts: summary.lastTs, + messages: summary.msgCount, + first_message: codexTitles[sid] || summary.firstMsg || '', has_detail: true, - file_size: stat.size, - detail_messages: 0, + file_size: summary.fileSize, + detail_messages: summary.msgCount, }); } } @@ -566,6 +778,73 @@ function scanCodexSessions() { return sessions; } +// ── Git root resolver ─────────────────────────────────────── +// +// Priority order for determining the git root of a session: +// 1. worktree-state.originalCwd — written by Claude Code into the JSONL when +// the session runs inside a git worktree. Container-safe: no git required. +// 2. git rev-parse --show-toplevel — resolves the root at runtime. Fails +// gracefully (returns '') in containerized setups where git repos are not +// mounted; the try/catch ensures it never crashes the server. +// 3. Path heuristic in the frontend (getGitProjectName) — parses /.claude/worktrees/ +// from the session cwd string. Works without git for standard worktree layouts. + +const _gitRootCache = {}; + +function resolveGitRoot(projectPath) { + if (!projectPath) return ''; + if (_gitRootCache[projectPath] !== undefined) return _gitRootCache[projectPath]; + try { + const root = execSync(`git -C "${projectPath}" rev-parse --show-toplevel 2>/dev/null`, { + encoding: 'utf8', timeout: 2000 + }).trim(); + _gitRootCache[projectPath] = root; + return root; + } catch { + // git not available or project path not mounted (e.g. containerised env) — fall back gracefully + _gitRootCache[projectPath] = ''; + return ''; + } +} + +const _gitInfoCache = {}; +const GIT_INFO_CACHE_TTL = 30000; // 30 seconds + +function getProjectGitInfo(projectPath) { + if (!projectPath || !fs.existsSync(projectPath)) return null; + if (process.platform === 'win32') return null; + + const now = Date.now(); + const cached = _gitInfoCache[projectPath]; + if (cached && (now - cached._ts) < GIT_INFO_CACHE_TTL) return cached; + + const gitRoot = resolveGitRoot(projectPath); + if (!gitRoot) return null; + + const cwd = gitRoot; + const opts = { encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }; + const info = { gitRoot, branch: '', remoteUrl: '', lastCommit: '', lastCommitDate: '', isDirty: false, _ts: now }; + + try { info.branch = execSync(`git -C "${cwd}" rev-parse --abbrev-ref HEAD 2>/dev/null`, opts).trim(); } catch {} + try { info.remoteUrl = execSync(`git -C "${cwd}" config --get remote.origin.url 2>/dev/null`, opts).trim(); } catch {} + try { + const log = execSync(`git -C "${cwd}" log -1 --format="%h %s" 2>/dev/null`, opts).trim(); + if (log) { + const sp = log.indexOf(' '); + info.lastCommit = sp > 0 ? log.slice(sp + 1).slice(0, 80) : log; + info.lastCommitHash = sp > 0 ? log.slice(0, sp) : ''; + } + } catch {} + try { info.lastCommitDate = execSync(`git -C "${cwd}" log -1 --format="%ci" 2>/dev/null`, opts).trim(); } catch {} + try { + const status = execSync(`git -C "${cwd}" status --porcelain 2>/dev/null`, opts).trim(); + info.isDirty = status.length > 0; + } catch {} + + _gitInfoCache[projectPath] = info; + return info; +} + // ── Public API ───────────────────────────────────────────── let _sessionsCache = null; @@ -598,6 +877,7 @@ function loadSessions() { last_ts: d.timestamp, messages: 0, first_message: '', + _claude_dir: CLAUDE_DIR, }; } @@ -654,17 +934,22 @@ function loadSessions() { if (fs.existsSync(extraHistory)) { const lines = readLines(extraHistory); for (const line of lines) { + let d; try { - const d = JSON.parse(line); + d = JSON.parse(line); const sid = d.sessionId; - if (!sid || sessions[sid]) continue; - sessions[sid] = { - id: sid, tool: 'claude', - project: d.project || '', project_short: (d.project || '').replace(os.homedir(), '~'), - first_ts: d.timestamp, last_ts: d.timestamp, - messages: 0, first_message: '', - }; + if (!sid) continue; + if (!sessions[sid]) { + sessions[sid] = { + id: sid, tool: 'claude', + project: d.project || '', project_short: (d.project || '').replace(os.homedir(), '~'), + first_ts: d.timestamp, last_ts: d.timestamp, + messages: 0, first_message: '', + _claude_dir: extraClaudeDir, + }; + } } catch {} + if (!d || !d.sessionId) continue; const s = sessions[d.sessionId]; if (s) { s.last_ts = Math.max(s.last_ts, d.timestamp); s.first_ts = Math.min(s.first_ts, d.timestamp); s.messages++; if (d.display && d.display !== 'exit' && !s.first_message) s.first_message = d.display.slice(0, 200); } } @@ -678,28 +963,35 @@ function loadSessions() { for (const file of fs.readdirSync(projDir)) { if (!file.endsWith('.jsonl')) continue; const sid = file.replace('.jsonl', ''); - if (sessions[sid]) { if (!sessions[sid].has_detail) { sessions[sid].has_detail = true; sessions[sid].file_size = fs.statSync(path.join(projDir, file)).size; } continue; } const fp = path.join(projDir, file); - const stat = fs.statSync(fp); - let projectPath = '', tool = 'claude', msgCount = 0, firstMsg = '', firstTs = stat.mtimeMs, lastTs = stat.mtimeMs; - try { - const sLines = readLines(fp); - let entrypointFound = false; - for (const sl of sLines) { - try { - const entry = JSON.parse(sl); - if (entry.type === 'user' || entry.type === 'assistant') msgCount++; - if (entry.timestamp) { if (entry.timestamp < firstTs) firstTs = entry.timestamp; if (entry.timestamp > lastTs) lastTs = entry.timestamp; } - if (!projectPath && entry.type === 'user' && entry.cwd) projectPath = entry.cwd; - if (!entrypointFound && entry.type === 'user' && entry.entrypoint) { entrypointFound = true; if (entry.entrypoint !== 'cli') tool = 'claude-ext'; } - if (!firstMsg && entry.type === 'user' && entry.message && entry.message.content) { - const content = entry.message.content; - if (typeof content === 'string') firstMsg = content.slice(0, 200); - } - } catch {} + if (sessions[sid]) { + const summary = parseClaudeSessionFile(fp); + if (summary) mergeClaudeSessionDetail(sessions[sid], summary, fp); + else if (!sessions[sid].has_detail) { + sessions[sid].has_detail = true; + sessions[sid].file_size = fs.statSync(fp).size; + sessions[sid]._session_file = fp; } - } catch {} - sessions[sid] = { id: sid, tool, project: projectPath, project_short: projectPath.replace(os.homedir(), '~'), first_ts: firstTs, last_ts: lastTs, messages: msgCount, first_message: firstMsg, has_detail: true, file_size: stat.size, detail_messages: msgCount }; + continue; + } + const summary = parseClaudeSessionFile(fp); + if (!summary) continue; + sessions[sid] = { + id: sid, + tool: summary.tool, + project: summary.projectPath, + project_short: summary.projectPath.replace(os.homedir(), '~'), + first_ts: summary.firstTs, + last_ts: summary.lastTs, + messages: summary.msgCount, + first_message: summary.customTitle || summary.firstMsg, + has_detail: true, + file_size: summary.fileSize, + detail_messages: summary.msgCount, + _claude_dir: extraClaudeDir, + _session_file: fp, + worktree_original_cwd: summary.worktreeOriginalCwd || '', + }; } } } @@ -708,24 +1000,31 @@ function loadSessions() { // Enrich Claude sessions with detail file info for (const [sid, s] of Object.entries(sessions)) { - if (s.tool !== 'claude') continue; - const projectKey = s.project.replace(/[\/\.]/g, '-'); - const sessionFile = path.join(PROJECTS_DIR, projectKey, `${sid}.jsonl`); + if (s.tool !== 'claude' && s.tool !== 'claude-ext') continue; + let sessionFile = ''; + if (s._session_file && fs.existsSync(s._session_file)) { + sessionFile = s._session_file; + } else if (s.project) { + const claudeDir = s._claude_dir || CLAUDE_DIR; + const projectsDir = path.join(claudeDir, 'projects'); + const projectKey = s.project.replace(/[^a-zA-Z0-9-]/g, '-'); + const candidate = path.join(projectsDir, projectKey, `${sid}.jsonl`); + if (fs.existsSync(candidate)) sessionFile = candidate; + } + if (!sessionFile) { + const found = findSessionFile(sid, s.project); + if (found && found.format === 'claude') sessionFile = found.file; + } + if (fs.existsSync(sessionFile)) { - s.has_detail = true; - s.file_size = fs.statSync(sessionFile).size; - try { - let msgCount = 0; - const sLines = readLines(sessionFile); - for (const sl of sLines) { - try { - const entry = JSON.parse(sl); - if (entry.type === 'user' || entry.type === 'assistant') msgCount++; - } catch {} - } - s.detail_messages = msgCount; - } catch { s.detail_messages = 0; } - } else { + const summary = parseClaudeSessionFile(sessionFile); + if (summary) mergeClaudeSessionDetail(s, summary, sessionFile); + else { + s.has_detail = true; + s.file_size = fs.statSync(sessionFile).size; + s._session_file = sessionFile; + } + } else if (!s.has_detail) { s.has_detail = false; s.file_size = 0; s.detail_messages = 0; @@ -741,57 +1040,29 @@ function loadSessions() { for (const file of fs.readdirSync(projDir)) { if (!file.endsWith('.jsonl')) continue; const sid = file.replace('.jsonl', ''); - if (sessions[sid]) continue; // already loaded const filePath = path.join(projDir, file); - const stat = fs.statSync(filePath); - let projectPath = ''; - let tool = 'claude'; - let msgCount = 0; - let firstMsg = ''; - let firstTs = stat.mtimeMs; - let lastTs = stat.mtimeMs; - try { - const sLines = readLines(filePath); - let entrypointFound = false; - for (const sl of sLines) { - try { - const entry = JSON.parse(sl); - if (entry.type === 'user' || entry.type === 'assistant') msgCount++; - if (entry.timestamp) { - if (entry.timestamp < firstTs) firstTs = entry.timestamp; - if (entry.timestamp > lastTs) lastTs = entry.timestamp; - } - if (!projectPath && entry.type === 'user' && entry.cwd) { - projectPath = entry.cwd; - } - if (!entrypointFound && entry.type === 'user' && entry.entrypoint) { - entrypointFound = true; - if (entry.entrypoint !== 'cli') tool = 'claude-ext'; - } - if (!firstMsg && entry.type === 'user' && entry.message && entry.message.content) { - const content = entry.message.content; - if (typeof content === 'string') firstMsg = content.slice(0, 200); - else if (Array.isArray(content)) { - for (let ci = 0; ci < content.length; ci++) { - if (content[ci].type === 'text' && content[ci].text) { firstMsg = content[ci].text.slice(0, 200); break; } - } - } - } - } catch {} - } - } catch {} + if (sessions[sid]) { + const summary = parseClaudeSessionFile(filePath); + if (summary) mergeClaudeSessionDetail(sessions[sid], summary, filePath); + continue; + } + const summary = parseClaudeSessionFile(filePath); + if (!summary) continue; sessions[sid] = { id: sid, - tool: tool, - project: projectPath, - project_short: projectPath.replace(os.homedir(), '~'), - first_ts: firstTs, - last_ts: lastTs, - messages: msgCount, - first_message: firstMsg, + tool: summary.tool, + project: summary.projectPath, + project_short: summary.projectPath.replace(os.homedir(), '~'), + first_ts: summary.firstTs, + last_ts: summary.lastTs, + messages: summary.msgCount, + first_message: summary.customTitle || summary.firstMsg, has_detail: true, - file_size: stat.size, - detail_messages: msgCount, + file_size: summary.fileSize, + detail_messages: summary.msgCount, + _claude_dir: CLAUDE_DIR, + _session_file: filePath, + worktree_original_cwd: summary.worktreeOriginalCwd || '', }; } } @@ -800,11 +1071,17 @@ function loadSessions() { const result = Object.values(sessions).sort((a, b) => b.last_ts - a.last_ts); + // Collect unique project paths and resolve git roots in one pass + const uniquePaths = [...new Set(result.map(s => s.project).filter(Boolean))]; + for (const p of uniquePaths) resolveGitRoot(p); + for (const s of result) { s.first_time = new Date(s.first_ts).toLocaleString('sv-SE').slice(0, 16); s.last_time = new Date(s.last_ts).toLocaleString('sv-SE').slice(0, 16); const dt = new Date(s.last_ts); s.date = dt.getFullYear() + '-' + String(dt.getMonth()+1).padStart(2,'0') + '-' + String(dt.getDate()).padStart(2,'0'); + // Priority: worktree-state.originalCwd (container-safe) > git rev-parse > path heuristic (frontend) + s.git_root = s.worktree_original_cwd || (s.project ? (_gitRootCache[s.project] || '') : ''); } _sessionsCache = result; @@ -866,7 +1143,7 @@ function deleteSession(sessionId, project) { const deleted = []; // 1. Remove session JSONL file from project dir - const projectKey = project.replace(/[\/\.]/g, '-'); + const projectKey = project.replace(/[^a-zA-Z0-9-]/g, '-'); const sessionFile = path.join(PROJECTS_DIR, projectKey, `${sessionId}.jsonl`); if (fs.existsSync(sessionFile)) { fs.unlinkSync(sessionFile); @@ -935,15 +1212,16 @@ function getGitCommits(projectDir, fromTs, toTs) { } function exportSessionMarkdown(sessionId, project) { - const projectKey = project.replace(/[\/\.]/g, '-'); - const sessionFile = path.join(PROJECTS_DIR, projectKey, `${sessionId}.jsonl`); - - if (!fs.existsSync(sessionFile)) { + const found = findSessionFile(sessionId, project); + if (!found || found.format !== 'claude' || !fs.existsSync(found.file)) { return `# Session ${sessionId}\n\nSession file not found.\n`; } + const sessionFile = found.file; + const summary = parseClaudeSessionFile(sessionFile); const lines = readLines(sessionFile); - const parts = [`# Session ${sessionId}\n\n**Project:** ${project}\n`]; + const projectLabel = project || (summary && summary.projectPath) || '(none)'; + const parts = [`# Session ${sessionId}\n\n**Project:** ${projectLabel}\n`]; for (const line of lines) { try { @@ -971,7 +1249,7 @@ function exportSessionMarkdown(sessionId, project) { function findSessionFile(sessionId, project) { // Try Claude projects dir if (project) { - const projectKey = project.replace(/[\/\.]/g, '-'); + const projectKey = project.replace(/[^a-zA-Z0-9-]/g, '-'); const claudeFile = path.join(PROJECTS_DIR, projectKey, `${sessionId}.jsonl`); if (fs.existsSync(claudeFile)) return { file: claudeFile, format: 'claude' }; } @@ -1300,6 +1578,8 @@ function getSessionReplay(sessionId, project) { }; } +const CONTEXT_WINDOW = 200_000; // Claude's max context window (tokens) + // ── Pricing per model (per token, April 2026) ───────────── const MODEL_PRICING = { @@ -1329,13 +1609,60 @@ function getModelPricing(model) { function computeSessionCost(sessionId, project) { const found = findSessionFile(sessionId, project); - if (!found) return { cost: 0, inputTokens: 0, outputTokens: 0, model: '' }; + if (!found) return { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: '' }; let totalCost = 0; let totalInput = 0; let totalOutput = 0; + let totalCacheRead = 0; + let totalCacheCreate = 0; + let contextPctSum = 0; + let contextTurnCount = 0; let model = ''; + // OpenCode: query SQLite directly for token data + if (found.format === 'opencode') { + const safeId = /^[a-zA-Z0-9_-]+$/.test(found.sessionId) ? found.sessionId : ''; + if (!safeId) return { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: '' }; + try { + const rows = execSync( + `sqlite3 "${OPENCODE_DB}" "SELECT data FROM message WHERE session_id = '${safeId}' AND json_extract(data, '$.role') = 'assistant' ORDER BY time_created"`, + { encoding: 'utf8', timeout: 10000 } + ).trim(); + if (rows) { + for (const row of rows.split('\n')) { + try { + const msgData = JSON.parse(row); + const t = msgData.tokens || {}; + if (!model && msgData.modelID) model = msgData.modelID; + const inp = t.input || 0; + const out = (t.output || 0) + (t.reasoning || 0); + const cacheRead = (t.cache && t.cache.read) || 0; + const cacheCreate = (t.cache && t.cache.write) || 0; + if (inp === 0 && out === 0) continue; + + const pricing = getModelPricing(msgData.modelID || model); + totalInput += inp; + totalOutput += out; + totalCacheRead += cacheRead; + totalCacheCreate += cacheCreate; + totalCost += inp * pricing.input + + cacheCreate * pricing.cache_create + + cacheRead * pricing.cache_read + + out * pricing.output; + + const contextThisTurn = inp + cacheCreate + cacheRead; + if (contextThisTurn > 0) { + contextPctSum += (contextThisTurn / CONTEXT_WINDOW) * 100; + contextTurnCount++; + } + } catch {} + } + } + } catch {} + return { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, cacheReadTokens: totalCacheRead, cacheCreateTokens: totalCacheCreate, contextPctSum, contextTurnCount, model }; + } + try { const lines = readLines(found.file); for (const line of lines) { @@ -1353,12 +1680,21 @@ function computeSessionCost(sessionId, project) { const cacheRead = u.cache_read_input_tokens || 0; const out = u.output_tokens || 0; - totalInput += inp + cacheCreate + cacheRead; + totalInput += inp; totalOutput += out; + totalCacheRead += cacheRead; + totalCacheCreate += cacheCreate; totalCost += inp * pricing.input + cacheCreate * pricing.cache_create + cacheRead * pricing.cache_read + out * pricing.output; + + // Track per-turn context window usage (average, not peak) + const contextThisTurn = inp + cacheCreate + cacheRead; + if (contextThisTurn > 0) { + contextPctSum += (contextThisTurn / CONTEXT_WINDOW) * 100; + contextTurnCount++; + } } // Codex: estimate from file size (no token usage in session files) } catch {} @@ -1377,7 +1713,7 @@ function computeSessionCost(sessionId, project) { } catch {} } - return { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, model }; + return { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, cacheReadTokens: totalCacheRead, cacheCreateTokens: totalCacheCreate, contextPctSum, contextTurnCount, model }; } // ── Cost analytics ──────────────────────────────────────── @@ -1386,20 +1722,102 @@ function getCostAnalytics(sessions) { const byDay = {}; const byProject = {}; const byWeek = {}; + const byAgent = {}; let totalCost = 0; let totalTokens = 0; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheReadTokens = 0; + let totalCacheCreateTokens = 0; + let globalContextPctSum = 0; + let globalContextTurnCount = 0; + let firstDate = null; + let lastDate = null; + let sessionsWithData = 0; + const agentNoCostData = {}; + for (const s of sessions) { + if (!byAgent[s.tool]) byAgent[s.tool] = { cost: 0, sessions: 0, tokens: 0, estimated: false }; + } const sessionCosts = []; + // Pre-compute OpenCode costs in one batch query (avoids O(n) execSync calls) + const opencodeCostCache = {}; + const opencodeSessions = sessions.filter(s => s.tool === 'opencode'); + if (opencodeSessions.length > 0 && fs.existsSync(OPENCODE_DB)) { + try { + const batchRows = execSync( + `sqlite3 "${OPENCODE_DB}" "SELECT session_id, data FROM message WHERE json_extract(data, '$.role') = 'assistant' ORDER BY time_created"`, + { encoding: 'utf8', timeout: 30000 } + ).trim(); + if (batchRows) { + for (const row of batchRows.split('\n')) { + const sepIdx = row.indexOf('|'); + if (sepIdx < 0) continue; + const sessId = row.slice(0, sepIdx); + const jsonStr = row.slice(sepIdx + 1); + try { + const msgData = JSON.parse(jsonStr); + const t = msgData.tokens || {}; + const inp = t.input || 0; + const out = (t.output || 0) + (t.reasoning || 0); + const cacheRead = (t.cache && t.cache.read) || 0; + const cacheCreate = (t.cache && t.cache.write) || 0; + if (inp === 0 && out === 0) continue; + if (!opencodeCostCache[sessId]) opencodeCostCache[sessId] = { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: '' }; + const c = opencodeCostCache[sessId]; + if (!c.model && msgData.modelID) c.model = msgData.modelID; + const pricing = getModelPricing(msgData.modelID || c.model); + c.inputTokens += inp; + c.outputTokens += out; + c.cacheReadTokens += cacheRead; + c.cacheCreateTokens += cacheCreate; + c.cost += inp * pricing.input + cacheCreate * pricing.cache_create + cacheRead * pricing.cache_read + out * pricing.output; + const ctx = inp + cacheCreate + cacheRead; + if (ctx > 0) { c.contextPctSum += (ctx / CONTEXT_WINDOW) * 100; c.contextTurnCount++; } + } catch {} + } + } + } catch {} + } + for (const s of sessions) { - const costData = computeSessionCost(s.id, s.project); + const costData = (s.tool === 'opencode' && opencodeCostCache[s.id]) + ? opencodeCostCache[s.id] + : computeSessionCost(s.id, s.project); const cost = costData.cost; - const tokens = costData.inputTokens + costData.outputTokens; - if (cost === 0 && tokens === 0) continue; + const tokens = costData.inputTokens + costData.outputTokens + costData.cacheReadTokens + costData.cacheCreateTokens; + if (cost === 0 && tokens === 0) { + if (!agentNoCostData[s.tool]) agentNoCostData[s.tool] = 0; + agentNoCostData[s.tool]++; + continue; + } + sessionsWithData++; totalCost += cost; totalTokens += tokens; - - // By day + totalInputTokens += costData.inputTokens; + totalOutputTokens += costData.outputTokens; + totalCacheReadTokens += costData.cacheReadTokens; + totalCacheCreateTokens += costData.cacheCreateTokens; + + // Per-agent breakdown + const agent = s.tool || 'unknown'; + if (!byAgent[agent]) byAgent[agent] = { cost: 0, sessions: 0, tokens: 0, estimated: false }; + byAgent[agent].cost += cost; + byAgent[agent].sessions++; + byAgent[agent].tokens += tokens; + if (agent === 'codex') byAgent[agent].estimated = true; + if (agent === 'opencode' && !costData.model) byAgent[agent].estimated = true; + + // Context % across all turns + globalContextPctSum += costData.contextPctSum; + globalContextTurnCount += costData.contextTurnCount; + + // Date range const day = s.date || 'unknown'; + if (s.date) { + if (!firstDate || s.date < firstDate) firstDate = s.date; + if (!lastDate || s.date > lastDate) lastDate = s.date; + } if (!byDay[day]) byDay[day] = { cost: 0, sessions: 0, tokens: 0 }; byDay[day].cost += cost; byDay[day].sessions++; @@ -1423,20 +1841,67 @@ function getCostAnalytics(sessions) { byProject[proj].sessions++; byProject[proj].tokens += tokens; - sessionCosts.push({ id: s.id, cost, project: proj, date: s.date }); + sessionCosts.push({ id: s.id, cost, project: proj, date: s.date, last_ts: s.last_ts || 0 }); } // Sort top sessions by cost sessionCosts.sort((a, b) => b.cost - a.cost); + const days = firstDate && lastDate + ? Math.max(1, Math.round((new Date(lastDate) - new Date(firstDate)) / 86400000) + 1) + : 1; + + // Burn rate: derived from already-computed sessionCosts — no extra IO + const now = Date.now(); + const todayStr = new Date().toISOString().slice(0, 10); + const hoursElapsedToday = (now - new Date(todayStr).getTime()) / 3600000; + let last1hCost = 0; + let todayCost = 0; + for (const sc of sessionCosts) { + if (sc.last_ts >= now - 3600000) last1hCost += sc.cost; + if (sc.date === todayStr) todayCost += sc.cost; + } + + // Cost breakdown by token type (approximated using Sonnet pricing as baseline). + // Not perfectly accurate for mixed-model usage, but directionally correct for attribution. + const p = MODEL_PRICING['claude-sonnet-4-6']; + const inputCostEst = totalInputTokens * p.input; + const outputCostEst = totalOutputTokens * p.output; + const cacheReadCostEst = totalCacheReadTokens * p.cache_read; + const cacheCreateCostEst = totalCacheCreateTokens * p.cache_create; + const cacheSavings = totalCacheReadTokens * (p.input - p.cache_read); + const totalInputSide = totalInputTokens + totalCacheReadTokens + totalCacheCreateTokens; + const cacheHitRate = totalInputSide > 0 + ? Math.round(totalCacheReadTokens / totalInputSide * 100) : 0; + return { totalCost, totalTokens, - totalSessions: sessions.length, + totalInputTokens, + totalOutputTokens, + totalCacheReadTokens, + totalCacheCreateTokens, + avgContextPct: globalContextTurnCount > 0 ? Math.round(globalContextPctSum / globalContextTurnCount) : 0, + dailyRate: totalCost / days, + firstDate, + lastDate, + days, + totalSessions: sessionsWithData, byDay, byWeek, byProject, topSessions: sessionCosts.slice(0, 10), + byAgent, + agentNoCostData, + last1hCost, + todayCost, + hoursElapsedToday: Math.max(1, hoursElapsedToday), + inputCostEst, + outputCostEst, + cacheReadCostEst, + cacheCreateCostEst, + cacheSavings, + cacheHitRate, }; } @@ -1468,10 +1933,13 @@ function getActiveSessions() { { pattern: 'cursor-agent', tool: 'cursor', match: /cursor-agent/ }, ]; + // Skip process scanning on Windows (no ps/grep) + if (process.platform === 'win32') return active; + try { const psOut = execSync( 'ps aux 2>/dev/null | grep -E "claude|codex|opencode|kiro-cli|cursor-agent" | grep -v grep || true', - { encoding: 'utf8', timeout: 3000 } + { encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] } ); for (const line of psOut.split('\n').filter(Boolean)) { @@ -1513,7 +1981,7 @@ function getActiveSessions() { // Try to get cwd from lsof if not from PID file if (!cwd) { try { - const lsofOut = execSync(`lsof -d cwd -p ${pid} -Fn 2>/dev/null`, { encoding: 'utf8', timeout: 2000 }); + const lsofOut = execSync(`lsof -d cwd -p ${pid} -Fn 2>/dev/null`, { encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] }); const match = lsofOut.match(/\nn(\/[^\n]+)/); if (match) cwd = match[1]; } catch {} @@ -1560,6 +2028,7 @@ function getActiveSessions() { module.exports = { loadSessions, loadSessionDetail, + getProjectGitInfo, deleteSession, getGitCommits, exportSessionMarkdown, diff --git a/src/frontend/app.js b/src/frontend/app.js index 3519af5..0372526 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -55,6 +55,20 @@ function getProjectName(fullPath) { return parts[parts.length - 1] || 'unknown'; } +// Returns the git repo name from session data. +// Prefers s.git_root resolved by the backend (git rev-parse --show-toplevel), +// falls back to path-based heuristic for sessions without it. +function getGitProjectName(fullPath, gitRoot) { + if (gitRoot) return gitRoot.replace(/\/+$/, '').split('/').pop() || 'unknown'; + if (!fullPath) return 'unknown'; + var cleaned = fullPath.replace(/\/+$/, ''); + var wt = cleaned.match(/^(.*?)\/.claude\/worktrees\//); + if (wt) return wt[1].split('/').pop() || 'unknown'; + var codex = cleaned.match(/^(.*?)\/.codex\//); + if (codex) return codex[1].split('/').pop() || 'unknown'; + return cleaned.split('/').pop() || 'unknown'; +} + // ── Utilities ────────────────────────────────────────────────── function timeAgo(dateStr) { @@ -100,6 +114,92 @@ function estimateCost(fileSize) { return tokens * 0.3 * (3.0 / 1e6) + tokens * 0.7 * (15.0 / 1e6); } +// ── Subscription service plans (pricing as of 2025) ───────────── +var SERVICE_PLANS = { + 'Claude': { label: 'Claude (Anthropic)', plans: [ + { name: 'Pro', price: 20 }, + { name: 'Max 5×', price: 100 }, + { name: 'Max 20×', price: 200 } + ]}, + 'OpenAI': { label: 'OpenAI (ChatGPT)', plans: [ + { name: 'Plus', price: 20 }, + { name: 'Pro', price: 200 } + ]}, + 'Cursor': { label: 'Cursor', plans: [ + { name: 'Pro', price: 20 }, + { name: 'Pro+', price: 60 }, + { name: 'Ultra', price: 200 } + ]}, + 'Kiro': { label: 'Kiro', plans: [ + { name: 'Pro', price: 20 }, + { name: 'Pro+', price: 40 }, + { name: 'Power', price: 200 } + ]}, + 'OpenCode': { label: 'OpenCode', plans: [ + { name: 'Go', price: 10 } + ]} +}; + +function onSubServiceChange() { + var serviceEl = document.getElementById('sub-new-service'); + var planEl = document.getElementById('sub-new-plan'); + var paidEl = document.getElementById('sub-new-paid'); + var service = serviceEl ? serviceEl.value : ''; + if (!planEl) return; + planEl.innerHTML = ''; + paidEl.value = ''; + if (service && SERVICE_PLANS[service]) { + SERVICE_PLANS[service].plans.forEach(function(p) { + var opt = document.createElement('option'); + opt.value = p.name; + opt.textContent = p.name + ' ($' + p.price + '/mo)'; + planEl.appendChild(opt); + }); + } +} + +function onSubPlanChange() { + var serviceEl = document.getElementById('sub-new-service'); + var planEl = document.getElementById('sub-new-plan'); + var paidEl = document.getElementById('sub-new-paid'); + var service = serviceEl ? serviceEl.value : ''; + var planName = planEl ? planEl.value : ''; + if (service && planName && SERVICE_PLANS[service]) { + var found = SERVICE_PLANS[service].plans.find(function(p) { return p.name === planName; }); + if (found && paidEl) paidEl.value = found.price; + } +} + +// ── Subscription config helpers ────────────────────────────────── +function getSubscriptionConfig() { + var raw = JSON.parse(localStorage.getItem('codedash-subscription') || 'null'); + if (!raw) return { entries: [] }; + // Migrate old single-entry format {plan, paid} → new multi-period {entries: [...]} + if (!raw.entries) return { entries: [{ plan: raw.plan || 'Subscription', paid: raw.paid || 0, from: '' }] }; + return raw; +} +function saveSubscriptionConfig(cfg) { localStorage.setItem('codedash-subscription', JSON.stringify(cfg)); } +function subTotalPaid(entries) { return entries.reduce(function(s,e){return s+(parseFloat(e.paid)||0);},0); } +function addSubEntry() { + var service = (document.getElementById('sub-new-service').value || '').trim(); + var planEl = document.getElementById('sub-new-plan'); + var plan = planEl ? planEl.value.trim() : ''; + var paid = parseFloat(document.getElementById('sub-new-paid').value) || 0; + var from = (document.getElementById('sub-new-from').value || '').trim(); + if (!paid) return; + var cfg = getSubscriptionConfig(); + cfg.entries.push({ service: service || '', plan: plan || 'Subscription', paid: paid, from: from }); + cfg.entries.sort(function(a,b){return (a.from||'').localeCompare(b.from||'');}); + saveSubscriptionConfig(cfg); + render(); +} +function removeSubEntry(idx) { + var cfg = getSubscriptionConfig(); + cfg.entries.splice(idx, 1); + saveSubscriptionConfig(cfg); + render(); +} + async function loadRealCost(sessionId, project) { try { var resp = await fetch('/api/cost/' + sessionId + '?project=' + encodeURIComponent(project)); @@ -170,19 +270,17 @@ function toggleAITitles(checked) { render(); } -function openLLMSettings() { - document.getElementById('llmSettingsOverlay').style.display = 'flex'; +function loadLLMSettings() { fetch('/api/llm-config').then(function(r) { return r.json(); }).then(function(c) { - document.getElementById('llmUrl').value = c.url || ''; - document.getElementById('llmApiKey').value = c.apiKey || ''; - document.getElementById('llmModel').value = c.model || ''; + var u = document.getElementById('llmUrl'); + var k = document.getElementById('llmApiKey'); + var m = document.getElementById('llmModel'); + if (u) u.value = c.url || ''; + if (k) k.value = c.apiKey || ''; + if (m) m.value = c.model || ''; }); } -function closeLLMSettings() { - document.getElementById('llmSettingsOverlay').style.display = 'none'; -} - function saveLLMSettings() { var config = { url: document.getElementById('llmUrl').value.trim(), @@ -194,7 +292,6 @@ function saveLLMSettings() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config), }).then(function() { - closeLLMSettings(); showToast('LLM settings saved'); }); } @@ -453,8 +550,7 @@ function applyFilters() { // Tool filter if (toolFilter) { - // claude-ext sessions show under both 'claude' and 'cursor' filters - var toolMatch = s.tool === toolFilter || (s.tool === 'claude-ext' && (toolFilter === 'cursor' || toolFilter === 'claude')); + var toolMatch = s.tool === toolFilter || (s.tool === 'claude-ext' && toolFilter === 'claude'); if (!toolMatch) continue; } @@ -510,9 +606,179 @@ function onTagFilter(val) { } function onDateFilter() { - dateFrom = document.getElementById('dateFrom').value || ''; - dateTo = document.getElementById('dateTo').value || ''; applyFilters(); + updateDateBtn(); +} + +// ── Calendar ───────────────────────────────────────────────── + +var calYear = new Date().getFullYear(); +var calMonth = new Date().getMonth(); +var calStart = null; +var calEnd = null; +var calSelecting = false; + +function toggleCalendar() { + var popup = document.getElementById('calendarPopup'); + var btn = document.getElementById('dateBtn'); + if (!popup || !btn) return; + if (popup.classList.contains('open')) { + popup.classList.remove('open'); + return; + } + renderCalendar(); + // Position popup below the button + var rect = btn.getBoundingClientRect(); + var popupWidth = 280; + var left = rect.left; + // Keep within viewport + if (left + popupWidth > window.innerWidth - 8) { + left = window.innerWidth - popupWidth - 8; + } + popup.style.left = left + 'px'; + popup.style.top = (rect.bottom + 4) + 'px'; + popup.classList.add('open'); + setTimeout(function() { + document.addEventListener('click', closeCalendarOutside, { once: true }); + }, 0); +} + +function closeCalendarOutside(e) { + var popup = document.getElementById('calendarPopup'); + var btn = document.getElementById('dateBtn'); + if (popup && !popup.contains(e.target) && btn && !btn.contains(e.target)) { + popup.classList.remove('open'); + } else if (popup && popup.classList.contains('open')) { + document.addEventListener('click', closeCalendarOutside, { once: true }); + } +} + +function renderCalendar() { + var popup = document.getElementById('calendarPopup'); + if (!popup) return; + + var monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December']; + var firstDay = new Date(calYear, calMonth, 1); + var lastDay = new Date(calYear, calMonth + 1, 0); + var startWeekday = (firstDay.getDay() + 6) % 7; // Monday = 0 + var daysInMonth = lastDay.getDate(); + var today = new Date(); + var todayStr = today.getFullYear() + '-' + String(today.getMonth()+1).padStart(2,'0') + '-' + String(today.getDate()).padStart(2,'0'); + + var html = '
'; + html += ''; + html += '' + monthNames[calMonth] + ' ' + calYear + ''; + html += ''; + html += '
'; + + html += '
MoTuWeThFrSaSu
'; + html += '
'; + + var prevLastDay = new Date(calYear, calMonth, 0).getDate(); + for (var i = startWeekday - 1; i >= 0; i--) { + html += '
' + (prevLastDay - i) + '
'; + } + + for (var d = 1; d <= daysInMonth; d++) { + var dateStr = calYear + '-' + String(calMonth+1).padStart(2,'0') + '-' + String(d).padStart(2,'0'); + var cls = 'cal-day'; + if (dateStr === todayStr) cls += ' today'; + if (calStart && calEnd) { + if (dateStr === calStart) cls += ' range-start'; + if (dateStr === calEnd) cls += ' range-end'; + if (dateStr > calStart && dateStr < calEnd) cls += ' in-range'; + if (calStart === calEnd && dateStr === calStart) cls += ' range-start range-end'; + } else if (calStart && dateStr === calStart) { + cls += ' range-start range-end'; + } + html += '
' + d + '
'; + } + + var totalCells = startWeekday + daysInMonth; + var remaining = (7 - (totalCells % 7)) % 7; + for (var n = 1; n <= remaining; n++) { + html += '
' + n + '
'; + } + html += '
'; + + html += '
'; + var presets = [['All',''],['Today','0'],['7d','7'],['30d','30'],['90d','90']]; + presets.forEach(function(p) { + html += ''; + }); + html += '
'; + + popup.innerHTML = html; +} + +function calNav(dir) { + calMonth += dir; + if (calMonth < 0) { calMonth = 11; calYear--; } + if (calMonth > 11) { calMonth = 0; calYear++; } + renderCalendar(); +} + +function calPickDay(dateStr) { + if (!calSelecting) { + calStart = dateStr; + calEnd = null; + calSelecting = true; + } else { + if (dateStr < calStart) { + calEnd = calStart; + calStart = dateStr; + } else { + calEnd = dateStr; + } + calSelecting = false; + } + renderCalendar(); + dateFrom = calStart || ''; + dateTo = calEnd || calStart || ''; + onDateFilter(); +} + +function calPreset(days) { + calSelecting = false; + if (!days) { + calStart = null; + calEnd = null; + dateFrom = ''; + dateTo = ''; + } else { + var now = new Date(); + calEnd = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0'); + if (days === '0') { + calStart = calEnd; + } else { + var from = new Date(now.getTime() - parseInt(days) * 86400000); + calStart = from.getFullYear() + '-' + String(from.getMonth()+1).padStart(2,'0') + '-' + String(from.getDate()).padStart(2,'0'); + } + dateFrom = calStart; + dateTo = calEnd; + } + renderCalendar(); + onDateFilter(); + var popup = document.getElementById('calendarPopup'); + if (popup) popup.classList.remove('open'); +} + +function updateDateBtn() { + var btn = document.getElementById('dateBtn'); + var label = document.getElementById('dateBtnLabel'); + if (!btn || !label) return; + if (!dateFrom && !dateTo) { + label.textContent = 'All time'; + btn.classList.remove('has-filter'); + } else if (dateFrom === dateTo) { + label.textContent = dateFrom; + btn.classList.add('has-filter'); + } else { + var f = dateFrom.slice(5) || ''; + var t = dateTo.slice(5) || ''; + label.textContent = f + ' \u2014 ' + t; + btn.classList.add('has-filter'); + } } function toggleGroup() { @@ -613,9 +879,9 @@ function renderCard(s, idx) { html += ''; html += ''; if (s.has_detail) { - if (!sessionTitles[s.id]) { - html += ''; - } + var btnTitle = sessionTitles[s.id] ? 'Regenerate AI title' : 'Generate AI title'; + var btnIcon = sessionTitles[s.id] ? '↻' : '⚛'; + html += ''; html += ''; } html += ''; @@ -806,6 +1072,11 @@ function render() { return; } + if (currentView === 'settings') { + renderSettings(content); + return; + } + if (currentView === 'running') { renderRunning(content, sessions); return; @@ -927,16 +1198,37 @@ function renderTimeline(container, sessions) { container.innerHTML = html; } +function renderQACard(s, idx) { + var isStarred = stars.indexOf(s.id) >= 0; + var toolLabel = s.tool === 'claude-ext' ? 'claude ext' : s.tool; + var toolClass = 'tool-' + s.tool; + var cost = estimateCost(s.file_size); + var costStr = cost > 0 ? '~$' + cost.toFixed(2) : ''; + var classes = 'qa-item' + (selectedIds.has(s.id) ? ' selected' : ''); + + var html = '
'; + html += '' + escHtml(toolLabel) + ''; + html += '' + escHtml((s.first_message || '').slice(0, 160)) + ''; + html += ''; + html += '' + s.messages + ' msgs'; + if (costStr) html += '' + costStr + ''; + html += '' + timeAgo(s.last_ts) + ''; + html += ''; + html += ''; + html += '
'; + return html; +} + function renderProjects(container, sessions) { - var byProject = {}; + var byGit = {}; sessions.forEach(function(s) { - var p = getProjectName(s.project); - if (!byProject[p]) byProject[p] = { sessions: [], project: s.project }; - byProject[p].sessions.push(s); + var name = getGitProjectName(s.project, s.git_root); + if (!byGit[name]) byGit[name] = []; + byGit[name].push(s); }); - var sorted = Object.entries(byProject).sort(function(a, b) { - return b[1].sessions.length - a[1].sessions.length; + var sorted = Object.entries(byGit).sort(function(a, b) { + return b[1][0].last_ts - a[1][0].last_ts; }); if (sorted.length === 0) { @@ -944,26 +1236,26 @@ function renderProjects(container, sessions) { return; } - var html = '
'; + var globalIdx = 0; + var html = '
'; sorted.forEach(function(entry) { var name = entry[0]; - var info = entry[1]; + var list = entry[1].slice().sort(function(a, b) { return b.last_ts - a.last_ts; }); var color = getProjectColor(name); - var totalMsgs = info.sessions.reduce(function(sum, s) { return sum + (s.messages || 0); }, 0); - var totalSize = info.sessions.reduce(function(sum, s) { return sum + (s.file_size || 0); }, 0); - var latest = info.sessions[0]; + var totalMsgs = list.reduce(function(s, e) { return s + (e.messages || 0); }, 0); + var totalCost = list.reduce(function(s, e) { return s + estimateCost(e.file_size); }, 0); + var costLabel = totalCost > 0 ? ' · ~$' + totalCost.toFixed(2) : ''; - html += '
'; - html += '
'; + html += '
'; + html += '
'; html += ''; - html += '' + escHtml(name) + ''; + html += '' + escHtml(name) + ''; + html += '' + list.length + ' sessions · ' + totalMsgs + ' msgs' + escHtml(costLabel) + ''; + html += ''; html += '
'; - html += '
'; - html += '' + info.sessions.length + ' sessions'; - html += '' + totalMsgs + ' msgs'; - html += '' + formatBytes(totalSize) + ''; + html += '
'; + list.forEach(function(s) { html += renderQACard(s, globalIdx++); }); html += '
'; - html += '
Last: ' + timeAgo(latest.last_ts) + '
'; html += '
'; }); html += '
'; @@ -1188,14 +1480,16 @@ async function openDetail(s) { var infoHtml = '
'; // AI Title row var aiTitle = sessionTitles[s.id]; + var escProject = escHtml(s.project || '').replace(/'/g, "\\'"); if (aiTitle) { - infoHtml += '
AI Title' + escHtml(aiTitle) + '
'; + infoHtml += '
AI Title' + escHtml(aiTitle) + '
'; } else if (s.has_detail) { - infoHtml += '
AI Title
'; + infoHtml += '
AI Title
'; } var detailToolLabel = s.tool === 'claude-ext' ? 'claude ext' : s.tool; infoHtml += '
Tool' + escHtml(detailToolLabel) + '
'; infoHtml += '
Project' + escHtml(s.project_short || s.project || '') + '
'; + infoHtml += '
'; infoHtml += '
Session ID' + escHtml(s.id) + '
'; infoHtml += '
First seen' + escHtml(s.first_time || '') + '
'; infoHtml += '
Last seen' + escHtml(s.last_time || '') + ' (' + timeAgo(s.last_ts) + ')
'; @@ -1277,10 +1571,13 @@ async function openDetail(s) { var row = document.getElementById('detail-real-cost'); if (row) { row.style.display = ''; + var cacheStr = ''; + if ((costData.cacheReadTokens || 0) + (costData.cacheCreateTokens || 0) > 0) + cacheStr = ' / ' + formatTokens((costData.cacheReadTokens||0) + (costData.cacheCreateTokens||0)) + ' cache'; row.querySelector('span:last-child').innerHTML = '$' + costData.cost.toFixed(2) + '' + ' ' + - formatTokens(costData.inputTokens) + ' in / ' + formatTokens(costData.outputTokens) + ' out' + + formatTokens(costData.inputTokens) + ' in / ' + formatTokens(costData.outputTokens) + ' out' + cacheStr + (costData.model ? ' (' + costData.model + ')' : '') + ''; } // Update estimated badge to show it was estimated @@ -1288,6 +1585,32 @@ async function openDetail(s) { if (estBadge) estBadge.style.opacity = '0.5'; }); + // Load git info + if (s.project) { + fetch('/api/git-info?project=' + encodeURIComponent(s.project)) + .then(function(r) { return r.json(); }) + .then(function(git) { + var el = document.getElementById('detail-git-info'); + if (!el || git.error) return; + var html = ''; + if (git.branch) { + html += '
Branch' + escHtml(git.branch); + if (git.isDirty) html += ' *'; + html += '
'; + } + if (git.lastCommit) { + html += '
Last commit'; + if (git.lastCommitHash) html += '' + escHtml(git.lastCommitHash) + ' '; + html += escHtml(git.lastCommit) + '
'; + } + if (git.remoteUrl) { + var displayUrl = git.remoteUrl.replace(/\.git$/, '').replace(/^https?:\/\//, '').replace(/^git@([^:]+):/, '$1/'); + html += '
Remote' + escHtml(displayUrl) + '
'; + } + el.innerHTML = html; + }).catch(function() {}); + } + // Load git commits if (s.project) { var commits = await loadGitCommits(s.project, s.first_ts, s.last_ts); @@ -1313,6 +1636,41 @@ function closeDetail() { if (overlay) overlay.classList.remove('open'); } +// ── Detail panel resize ─────────────────────────────────────── +(function initDetailResize() { + var handle = document.getElementById('detailResizeHandle'); + var panel = document.getElementById('detailPanel'); + if (!handle || !panel) return; + + var startX, startW; + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + startX = e.clientX; + startW = panel.offsetWidth; + panel.classList.add('resizing'); + handle.classList.add('active'); + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + function onMove(e) { + var newW = startW + (startX - e.clientX); + newW = Math.max(400, Math.min(newW, window.innerWidth * 0.9)); + panel.style.width = newW + 'px'; + panel.style.right = '0'; + } + function onUp() { + panel.classList.remove('resizing'); + handle.classList.remove('active'); + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + localStorage.setItem('codedash-detail-width', panel.style.width); + } + + // Restore saved width + var saved = localStorage.getItem('codedash-detail-width'); + if (saved) panel.style.width = saved; +})(); + async function loadGitCommits(project, fromTs, toTs) { try { var resp = await fetch('/api/git-commits?project=' + encodeURIComponent(project) + '&from=' + fromTs + '&to=' + toTs); @@ -1633,72 +1991,117 @@ document.addEventListener('keydown', function(e) { } }); -// ── Running Sessions View ────────────────────────────────────── +// ── Running Sessions View (Kanban) ───────────────────────────── -function renderRunning(container, sessions) { - var activeIds = Object.keys(activeSessions); +function renderRunningCard(a, s) { + var projName = s ? getProjectName(s.project) : (a.cwd ? a.cwd.split('/').pop() : 'unknown'); + var projColor = getProjectColor(projName); + var statusClass = a.status === 'waiting' ? 'running-waiting' : 'running-active'; + var uptime = a.startedAt ? formatDuration(Date.now() - a.startedAt) : ''; + var sid = a.sessionId; + + var html = '
'; + html += '
'; + html += '' + (a.status === 'waiting' ? 'WAITING' : 'LIVE') + ''; + html += '' + escHtml(projName) + ''; + html += '' + escHtml(a.entrypoint || a.kind || 'claude') + ''; + html += '
'; + html += '
'; + html += '
' + a.cpu.toFixed(1) + '%CPU
'; + html += '
' + a.memoryMB + 'MBMEM
'; + if (uptime) html += '
' + uptime + 'Uptime
'; + html += '
'; + if (s && s.first_message) html += '
' + escHtml(s.first_message.slice(0, 120)) + '
'; + html += '
'; + html += ''; + if (s) { + html += ''; + html += ''; + } + html += '
'; + html += '
'; + return html; +} + +function renderDoneCard(s) { + var projName = getProjectName(s.project); + var projColor = getProjectColor(projName); + var html = '
'; + html += '
'; + html += 'DONE'; + html += '' + escHtml(projName) + ''; + html += '' + escHtml(s.tool || 'claude') + ''; + html += '
'; + if (s.first_message) html += '
' + escHtml(s.first_message.slice(0, 120)) + '
'; + html += '
'; + html += '
' + (s.messages || 0) + 'msgs
'; + if (s.last_time) html += '
' + s.last_time.slice(11) + 'ended
'; + html += '
'; + html += '
'; + html += ''; + html += '
'; + html += '
'; + return html; +} - if (activeIds.length === 0) { +function renderRunning(container, sessions) { + var allActiveIds = Object.keys(activeSessions); + var running = allActiveIds.filter(function(sid) { return activeSessions[sid].status !== 'waiting'; }); + var waiting = allActiveIds.filter(function(sid) { return activeSessions[sid].status === 'waiting'; }); + var cutoff = Date.now() - 4 * 3600 * 1000; + var done = sessions.filter(function(s) { + return !activeSessions[s.id] && s.last_ts >= cutoff; + }).slice(0, 8); + + if (allActiveIds.length === 0 && done.length === 0) { container.innerHTML = '
No running sessions detected.
Start a Claude Code or Codex session and it will appear here.
'; return; } - // Running cards at top var html = '
'; - html += '

Running Sessions (' + activeIds.length + ')

'; - html += '
'; - - activeIds.forEach(function(sid) { - var a = activeSessions[sid]; - var s = allSessions.find(function(x) { return x.id === sid; }); - var projName = s ? getProjectName(s.project) : (a.cwd ? a.cwd.split('/').pop() : 'unknown'); - var projColor = getProjectColor(projName); - var statusClass = a.status === 'waiting' ? 'running-waiting' : 'running-active'; - var uptime = a.startedAt ? formatDuration(Date.now() - a.startedAt) : ''; - - html += '
'; - html += '
'; - html += '' + (a.status === 'waiting' ? 'WAITING' : 'LIVE') + ''; - html += '' + escHtml(projName) + ''; - html += '' + escHtml(a.entrypoint || a.kind || 'claude') + ''; - html += '
'; - - html += '
'; - html += '
' + a.cpu.toFixed(1) + '%CPU
'; - html += '
' + a.memoryMB + 'MBMemory
'; - html += '
' + a.pid + 'PID
'; - if (uptime) { - html += '
' + uptime + 'Uptime
'; - } - html += '
'; - - if (s && s.first_message) { - html += '
' + escHtml(s.first_message.slice(0, 150)) + '
'; - } - - html += '
'; - html += ''; - if (s) { - html += ''; - html += ''; - } - html += '
'; - html += '
'; - }); - + html += '

Agent Board

'; + html += '
'; + + // ── Running column ────────────────────────────────────────── + html += '
'; + html += '
Running' + running.length + '
'; + if (running.length === 0) { + html += '
No active sessions
'; + } else { + running.forEach(function(sid) { + var a = activeSessions[sid]; + var s = allSessions.find(function(x) { return x.id === sid; }); + html += renderRunningCard(a, s); + }); + } html += '
'; - // Also show recent non-active sessions below - var recentInactive = sessions.filter(function(s) { return !activeSessions[s.id]; }).slice(0, 6); - if (recentInactive.length > 0) { - html += '

Recently Inactive

'; - html += '
'; - var idx = 0; - recentInactive.forEach(function(s) { html += renderCard(s, idx++); }); - html += '
'; + // ── Waiting column ────────────────────────────────────────── + html += '
'; + html += '
Waiting for input' + waiting.length + '
'; + if (waiting.length === 0) { + html += '
No sessions waiting
'; + } else { + waiting.forEach(function(sid) { + var a = activeSessions[sid]; + var s = allSessions.find(function(x) { return x.id === sid; }); + html += renderRunningCard(a, s); + }); } + html += '
'; + // ── Done column ───────────────────────────────────────────── + html += '
'; + html += '
Done (last 4h)' + done.length + '
'; + if (done.length === 0) { + html += '
No recent sessions
'; + } else { + done.forEach(function(s) { html += renderDoneCard(s); }); + } html += '
'; + + html += '
'; // kanban-board + html += '
'; // running-container container.innerHTML = html; } @@ -1811,6 +2214,16 @@ function formatDuration(ms) { // ── Cost Analytics ──────────────────────────────────────────── +function switchAnalyticsTab(tab) { + document.querySelectorAll('.atab-pane').forEach(function(el) { + el.style.display = el.dataset.tab === tab ? 'block' : 'none'; + }); + document.querySelectorAll('.atab-btn').forEach(function(el) { + el.classList.toggle('active', el.dataset.tab === tab); + }); + localStorage.setItem('codedash-analytics-tab', tab); +} + async function renderAnalytics(container) { container.innerHTML = '
Loading analytics...
'; @@ -1826,17 +2239,223 @@ async function renderAnalytics(container) { var html = '
'; html += '

Cost Analytics

'; - // Summary cards + // ── Tab bar ──────────────────────────────────────────────── + html += '
'; + html += ''; + html += ''; + html += ''; + html += '
'; + + // ══ TAB: Overview ══════════════════════════════════════════ + html += '
'; + + // ── Summary cards ────────────────────────────────────────── html += '
'; - html += '
~$' + data.totalCost.toFixed(2) + 'Total estimated cost
'; + html += '
$' + data.totalCost.toFixed(2) + 'Total cost (API-equivalent)
'; html += '
' + formatTokens(data.totalTokens) + 'Total tokens
'; + html += '
$' + (data.dailyRate || 0).toFixed(2) + 'Avg per day (' + (data.days || 1) + ' days)
'; html += '
' + data.totalSessions + 'Sessions
'; - html += '
~$' + (data.totalCost / Math.max(data.totalSessions, 1)).toFixed(2) + 'Avg per session
'; html += '
'; - // Cost by day chart (bar chart) - var days = Object.keys(data.byDay).sort(); - var last30 = days.slice(-30); + // ── Burn rate ────────────────────────────────────────────── + var todayCost = data.todayCost || 0; + var last1hCost = data.last1hCost || 0; + var dailyRate = data.dailyRate || 0; + var hoursElapsed = data.hoursElapsedToday || 1; + // Project today's pace to a full day for comparison + var projectedDaily = todayCost / (hoursElapsed / 24); + var paceRatio = dailyRate > 0 ? projectedDaily / dailyRate : 0; + var burnClass = paceRatio >= 2 ? 'burn-high' : paceRatio >= 1.3 ? 'burn-medium' : 'burn-low'; + var paceLabel = paceRatio >= 2 ? '🔥 ' + Math.round(paceRatio) + 'x avg' : paceRatio >= 1.3 ? '↑ ' + paceRatio.toFixed(1) + 'x avg' : dailyRate > 0 ? '✓ normal' : ''; + html += '
'; + html += '
Burn Rate
'; + html += '
'; + html += '
$' + todayCost.toFixed(3) + 'today' + (paceLabel ? '' + paceLabel + '' : '') + '
'; + html += '
$' + last1hCost.toFixed(3) + 'last hour
'; + if (dailyRate > 0) { + html += '
$' + projectedDaily.toFixed(2) + 'projected today
'; + } + html += '
'; + html += '
'; + + // ── Data coverage note ──────────────────────────────────── + if (data.byAgent || data.agentNoCostData) { + var coverageparts = []; + var byAgent = data.byAgent || {}; + var noCost = data.agentNoCostData || {}; + if (byAgent['claude'] && byAgent['claude'].sessions > 0) + coverageparts.push('Claude Code \u2713'); + if (byAgent['claude-ext'] && byAgent['claude-ext'].sessions > 0) + coverageparts.push('Claude Extension \u2713'); + if (byAgent['codex'] && byAgent['codex'].sessions > 0) + coverageparts.push('Codex ~est.'); + if (byAgent['opencode'] && byAgent['opencode'].sessions > 0) + coverageparts.push(byAgent['opencode'].estimated + ? 'OpenCode ~est.' + : 'OpenCode \u2713'); + ['cursor', 'kiro'].forEach(function(a) { + if (noCost[a] > 0) + coverageparts.push('' + a + ' \u2717 (no token data)'); + }); + if (noCost['opencode'] > 0 && !(byAgent['opencode'] && byAgent['opencode'].sessions > 0)) + coverageparts.push('opencode \u2717 (no token data)'); + if (coverageparts.length > 0) { + html += '
Cost data: ' + coverageparts.join(' \u00b7 ') + '
'; + } + } + + // ── Cost by agent (overview) ─────────────────────────────── + var agentEntriesOv = Object.entries(data.byAgent || {}).filter(function(e) { return e[1].sessions > 0; }); + if (agentEntriesOv.length > 1) { + agentEntriesOv.sort(function(a, b) { return b[1].cost - a[1].cost; }); + html += '

Cost by Agent

'; + html += '
'; + var maxAgentCostOv = agentEntriesOv[0][1].cost || 1; + agentEntriesOv.forEach(function(entry) { + var name = entry[0]; var info = entry[1]; + var pct = maxAgentCostOv > 0 ? (info.cost / maxAgentCostOv * 100) : 0; + var label = { 'claude': 'Claude Code', 'claude-ext': 'Claude Ext', 'codex': 'Codex', 'opencode': 'OpenCode', 'cursor': 'Cursor', 'kiro': 'Kiro' }[name] || name; + var estMark = info.estimated ? ' ~est.' : ''; + html += '
'; + html += '' + label + estMark + ''; + html += '
'; + html += '$' + info.cost.toFixed(2) + ' (' + info.sessions + ' sess.)'; + html += '
'; + }); + html += '
'; + } + + html += '
'; // end atab-pane overview + + // ══ TAB: Breakdown ═════════════════════════════════════════ + html += '
'; + + // ── Token breakdown ──────────────────────────────────────── + if (data.totalInputTokens !== undefined) { + var totalTok = data.totalInputTokens + data.totalOutputTokens + data.totalCacheReadTokens + data.totalCacheCreateTokens; + var pctOf = function(n) { return totalTok > 0 ? Math.round(n / totalTok * 100) : 0; }; + html += '
'; + html += '

Token Breakdown

'; + html += '
'; + html += '
' + formatTokens(data.totalInputTokens) + 'Input' + pctOf(data.totalInputTokens) + '%
'; + html += '
' + formatTokens(data.totalOutputTokens) + 'Output' + pctOf(data.totalOutputTokens) + '%
'; + html += '
' + formatTokens(data.totalCacheReadTokens) + 'Cache read' + pctOf(data.totalCacheReadTokens) + '%
'; + html += '
' + formatTokens(data.totalCacheCreateTokens) + 'Cache write' + pctOf(data.totalCacheCreateTokens) + '%
'; + if (data.avgContextPct > 0) { + html += '
' + data.avgContextPct + '%Avg context usedof 200K
'; + } + html += '
'; + + // ── Cost attribution stacked bar ────────────────────────── + // Uses Sonnet-baseline ratios projected onto actual totalCost. + // Ratios are model-agnostic (Claude output/input is ~5:1 across all tiers). + if (data.outputCostEst !== undefined && data.totalCost > 0) { + var estTotal = data.inputCostEst + data.outputCostEst + data.cacheReadCostEst + data.cacheCreateCostEst; + var sharePct = function(v) { return estTotal > 0 ? (v / estTotal * 100) : 0; }; + var actualOf = function(v) { return (sharePct(v) / 100 * data.totalCost); }; + + var outPct = sharePct(data.outputCostEst).toFixed(1); + var inPct = sharePct(data.inputCostEst).toFixed(1); + var cwPct = sharePct(data.cacheCreateCostEst).toFixed(1); + var crPct = sharePct(data.cacheReadCostEst).toFixed(1); + + html += '
'; + html += '
Where your money goes
'; + html += '
'; + if (parseFloat(outPct) > 0) html += '
'; + if (parseFloat(inPct) > 0) html += '
'; + if (parseFloat(cwPct) > 0) html += '
'; + if (parseFloat(crPct) > 0) html += '
'; + html += '
'; + html += '
'; + html += 'Output ~' + outPct + '% (~$' + actualOf(data.outputCostEst).toFixed(2) + ')'; + html += 'Input ~' + inPct + '% (~$' + actualOf(data.inputCostEst).toFixed(2) + ')'; + if (parseFloat(cwPct) > 0) html += 'Cache write ~' + cwPct + '%'; + if (parseFloat(crPct) > 0) html += 'Cache read ~' + crPct + '%'; + html += '
'; + + if (data.cacheHitRate > 0 || data.cacheSavings > 0) { + html += '
'; + if (data.cacheHitRate > 0) { + var hitColor = data.cacheHitRate >= 60 ? 'var(--accent-green)' : data.cacheHitRate >= 30 ? '#f59e0b' : 'var(--text-muted)'; + html += 'Cache hit rate: ' + data.cacheHitRate + '%'; + } + if (data.cacheSavings > 0.001) { + html += 'Cache saved ~$' + data.cacheSavings.toFixed(0) + ' vs no-cache'; + } + html += '
'; + } + html += '
'; + } + + html += '
'; // chart-section + } + + html += '
'; // end atab-pane breakdown + + // ══ TAB: History ═══════════════════════════════════════════ + html += '
'; + + // ── Subscription vs API ──────────────────────────────────── + var sub = getSubscriptionConfig(); + var subEntries = (sub && sub.entries) || []; + var totalPaid = subTotalPaid(subEntries); + html += '
'; + html += '

Subscription vs API

'; + + if (totalPaid > 0) { + var savings = data.totalCost - totalPaid; + var multiplier = data.totalCost / totalPaid; + var savingsPositive = savings > 0; + var breakdown = subEntries.map(function(e) { + return escHtml(e.plan || 'Sub') + ' $' + parseFloat(e.paid).toFixed(0); + }).join(' + '); + html += '
'; + html += '
$' + totalPaid.toFixed(2) + 'Paid (' + breakdown + ')
'; + html += '
$' + data.totalCost.toFixed(2) + 'Would cost at API rates
'; + html += '
' + (savingsPositive ? '+' : '') + '$' + Math.abs(savings).toFixed(2) + '' + (savingsPositive ? 'Saved (' + multiplier.toFixed(1) + '\u00d7 ROI)' : 'API would be cheaper') + '
'; + html += '
'; + var barPct = Math.min(100, data.totalCost > 0 ? (totalPaid / data.totalCost * 100) : 100); + html += '
'; + html += '
'; + html += '
'; + } else { + html += '

Add your subscription periods below to see how much you\'re saving vs API rates.

'; + } + + // Period list + html += '
'; + if (subEntries.length > 0) { + subEntries.forEach(function(e, i) { + var serviceLabel = e.service && SERVICE_PLANS[e.service] ? SERVICE_PLANS[e.service].label : e.service || ''; + html += '
'; + if (serviceLabel) html += '' + escHtml(serviceLabel) + ''; + html += '' + escHtml(e.plan || '\u2014') + ''; + html += '$' + parseFloat(e.paid || 0).toFixed(2) + '/mo'; + html += '' + (e.from ? 'from ' + e.from : 'no date') + ''; + html += ''; + html += '
'; + }); + } + html += '
'; + + // Add form + var serviceOptions = '' + + Object.keys(SERVICE_PLANS).map(function(k) { + return ''; + }).join(''); + html += '
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
'; + html += '
'; + + // ── Daily cost chart ─────────────────────────────────────── + var dayKeys = Object.keys(data.byDay).sort(); + var last30 = dayKeys.slice(-30); if (last30.length > 0) { var maxCost = Math.max.apply(null, last30.map(function(d) { return data.byDay[d].cost; })); html += '

Daily Cost (last 30 days)

'; @@ -1845,7 +2464,7 @@ async function renderAnalytics(container) { var c = data.byDay[d]; var pct = maxCost > 0 ? (c.cost / maxCost * 100) : 0; var label = d.slice(5); // MM-DD - html += '
'; + html += '
'; html += '
'; html += '
' + label + '
'; html += '
'; @@ -1853,7 +2472,7 @@ async function renderAnalytics(container) { html += '
'; } - // Cost by project (horizontal bars) + // ── Cost by project ──────────────────────────────────────── var projects = Object.entries(data.byProject).sort(function(a, b) { return b[1].cost - a[1].cost; }); var topProjects = projects.slice(0, 10); if (topProjects.length > 0) { @@ -1867,19 +2486,19 @@ async function renderAnalytics(container) { html += '
'; html += '' + escHtml(name) + ''; html += '
'; - html += '~$' + info.cost.toFixed(2) + ''; + html += '$' + info.cost.toFixed(2) + ''; html += '
'; }); html += '
'; } - // Top expensive sessions + // ── Top expensive sessions ───────────────────────────────── if (data.topSessions && data.topSessions.length > 0) { html += '

Most Expensive Sessions

'; html += '
'; data.topSessions.forEach(function(s) { html += '
'; - html += '~$' + s.cost.toFixed(2) + ''; + html += '$' + s.cost.toFixed(2) + ''; html += '' + escHtml(s.project) + ''; html += '' + (s.date || '') + ''; html += '' + s.id.slice(0, 8) + ''; @@ -1888,8 +2507,13 @@ async function renderAnalytics(container) { html += '
'; } - html += '
'; + html += '
'; // end atab-pane history + html += '
'; // analytics-container container.innerHTML = html; + + // Activate the stored (or default) tab + var activeTab = localStorage.getItem('codedash-analytics-tab') || 'overview'; + switchAnalyticsTab(activeTab); } catch (e) { container.innerHTML = '
Failed to load analytics.
'; } @@ -1926,6 +2550,70 @@ function focusSession(sessionId) { // ── Changelog view ──────────────────────────────────────────── +function renderSettings(container) { + var savedTheme = localStorage.getItem('codedash-theme') || 'dark'; + var savedTerminal = localStorage.getItem('codedash-terminal') || ''; + var aiTitlesOn = localStorage.getItem('codedash-ai-titles') === 'true'; + + var html = '
'; + html += '

Settings

'; + + // Theme + html += '
'; + html += ''; + html += '
'; + ['dark', 'light', 'system'].forEach(function(t) { + var active = savedTheme === t ? ' active' : ''; + html += ''; + }); + html += '
'; + html += '
'; + + // Terminal + html += '
'; + html += ''; + html += ''; + html += '
'; + + // AI Titles + html += '
'; + html += ''; + html += '
'; + html += ''; + html += 'Show generated titles'; + html += '
'; + html += '
'; + + // LLM Configuration + html += '
'; + html += ''; + html += '

OpenAI-compatible API for session title generation

'; + html += '
'; + html += ''; + html += ''; + html += ''; + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + html += '
'; + container.innerHTML = html; + + // Load LLM config into the inputs + loadLLMSettings(); +} + async function renderChangelog(container) { container.innerHTML = '
Loading changelog...
'; try { diff --git a/src/frontend/index.html b/src/frontend/index.html index 8662d48..dfe59ba 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -66,19 +66,19 @@
-