codedash-app 1.7.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codedash-app",
3
- "version": "1.7.0",
3
+ "version": "2.0.0",
4
4
  "description": "Termius-style browser dashboard for Claude Code sessions. View, search, resume, and delete sessions with a dark-themed UI.",
5
5
  "bin": {
6
6
  "codedash": "./bin/cli.js"
package/src/data.js CHANGED
@@ -437,65 +437,195 @@ function getSessionPreview(sessionId, project, limit) {
437
437
  return messages;
438
438
  }
439
439
 
440
- // ── Full-text search across all sessions ──────────────────
440
+ // ── Full-text search index ─────────────────────────────────
441
+ //
442
+ // Built once on first search, then cached in memory.
443
+ // Each entry: { sessionId, texts: [{role, content}] }
444
+ // Total text is kept lowercase for fast substring matching.
441
445
 
442
- function searchFullText(query, sessions) {
443
- if (!query || query.length < 2) return [];
444
- const q = query.toLowerCase();
445
- const results = [];
446
+ let searchIndex = null;
447
+ let searchIndexBuiltAt = 0;
448
+ const INDEX_TTL = 60000; // rebuild every 60s
449
+
450
+ function buildSearchIndex(sessions) {
451
+ const startMs = Date.now();
452
+ const index = [];
446
453
 
447
454
  for (const s of sessions) {
448
- if (s.tool !== 'claude' || !s.has_detail) continue;
455
+ if (!s.has_detail) continue;
449
456
 
450
- const projectKey = s.project.replace(/[\/\.]/g, '-');
451
- const sessionFile = path.join(PROJECTS_DIR, projectKey, `${s.id}.jsonl`);
452
- if (!fs.existsSync(sessionFile)) continue;
457
+ const found = findSessionFile(s.id, s.project);
458
+ if (!found) continue;
453
459
 
454
460
  try {
455
- const data = fs.readFileSync(sessionFile, 'utf8');
456
- // Quick check before parsing
457
- if (data.toLowerCase().indexOf(q) === -1) continue;
461
+ const lines = fs.readFileSync(found.file, 'utf8').split('\n').filter(Boolean);
462
+ const texts = [];
458
463
 
459
- // Find matching messages
460
- const lines = data.split('\n').filter(Boolean);
461
- const matches = [];
462
464
  for (const line of lines) {
463
- if (matches.length >= 3) break; // max 3 matches per session
464
465
  try {
465
466
  const entry = JSON.parse(line);
466
- if (entry.type !== 'user' && entry.type !== 'assistant') continue;
467
- const msg = entry.message || {};
468
- let content = msg.content || '';
469
- if (Array.isArray(content)) {
470
- content = content
471
- .map(b => (typeof b === 'string' ? b : (b.type === 'text' ? b.text : '')))
472
- .filter(Boolean)
473
- .join('\n');
467
+ let role, content;
468
+
469
+ if (found.format === 'claude') {
470
+ if (entry.type !== 'user' && entry.type !== 'assistant') continue;
471
+ role = entry.type;
472
+ content = extractContent((entry.message || {}).content);
473
+ } else {
474
+ if (entry.type !== 'response_item' || !entry.payload) continue;
475
+ role = entry.payload.role;
476
+ if (role !== 'user' && role !== 'assistant') continue;
477
+ content = extractContent(entry.payload.content);
474
478
  }
475
- if (content.toLowerCase().indexOf(q) >= 0) {
476
- // Extract snippet around match
477
- const idx = content.toLowerCase().indexOf(q);
478
- const start = Math.max(0, idx - 50);
479
- const end = Math.min(content.length, idx + q.length + 50);
480
- matches.push({
481
- role: entry.type,
482
- snippet: (start > 0 ? '...' : '') + content.slice(start, end) + (end < content.length ? '...' : ''),
483
- });
479
+
480
+ if (content && !isSystemMessage(content)) {
481
+ texts.push({ role, content: content.slice(0, 500) });
484
482
  }
485
483
  } catch {}
486
484
  }
487
485
 
488
- if (matches.length > 0) {
489
- results.push({ sessionId: s.id, matches });
486
+ if (texts.length > 0) {
487
+ // Pre-compute lowercase full text for fast matching
488
+ const fullText = texts.map(t => t.content).join(' ').toLowerCase();
489
+ index.push({ sessionId: s.id, texts, fullText });
490
490
  }
491
491
  } catch {}
492
492
  }
493
493
 
494
+ const elapsed = Date.now() - startMs;
495
+ console.log(` \x1b[2mSearch index: ${index.length} sessions, ${elapsed}ms\x1b[0m`);
496
+ return index;
497
+ }
498
+
499
+ function getSearchIndex(sessions) {
500
+ const now = Date.now();
501
+ if (!searchIndex || (now - searchIndexBuiltAt) > INDEX_TTL) {
502
+ searchIndex = buildSearchIndex(sessions);
503
+ searchIndexBuiltAt = now;
504
+ }
505
+ return searchIndex;
506
+ }
507
+
508
+ function searchFullText(query, sessions) {
509
+ if (!query || query.length < 2) return [];
510
+ const q = query.toLowerCase();
511
+ const index = getSearchIndex(sessions);
512
+ const results = [];
513
+
514
+ for (const entry of index) {
515
+ if (entry.fullText.indexOf(q) === -1) continue;
516
+
517
+ // Find matching messages with snippets
518
+ const matches = [];
519
+ for (const t of entry.texts) {
520
+ if (matches.length >= 3) break;
521
+ const idx = t.content.toLowerCase().indexOf(q);
522
+ if (idx >= 0) {
523
+ const start = Math.max(0, idx - 50);
524
+ const end = Math.min(t.content.length, idx + q.length + 50);
525
+ matches.push({
526
+ role: t.role,
527
+ snippet: (start > 0 ? '...' : '') + t.content.slice(start, end) + (end < t.content.length ? '...' : ''),
528
+ });
529
+ }
530
+ }
531
+
532
+ if (matches.length > 0) {
533
+ results.push({ sessionId: entry.sessionId, matches });
534
+ }
535
+ }
536
+
494
537
  return results;
495
538
  }
496
539
 
497
540
  // ── Exports ────────────────────────────────────────────────
498
541
 
542
+ // ── Active sessions detection ─────────────────────────────
543
+
544
+ function getActiveSessions() {
545
+ const active = [];
546
+ const sessionsDir = path.join(CLAUDE_DIR, 'sessions');
547
+
548
+ // Read ~/.claude/sessions/<PID>.json files
549
+ if (fs.existsSync(sessionsDir)) {
550
+ for (const file of fs.readdirSync(sessionsDir)) {
551
+ if (!file.endsWith('.json')) continue;
552
+ try {
553
+ const data = JSON.parse(fs.readFileSync(path.join(sessionsDir, file), 'utf8'));
554
+ const pid = data.pid;
555
+ if (!pid) continue;
556
+
557
+ // Check if process is alive + get CPU
558
+ try {
559
+ const psOut = execSync(`ps -p ${pid} -o pid=,%cpu=,rss=,stat= 2>/dev/null`, { encoding: 'utf8', timeout: 2000 }).trim();
560
+ if (!psOut) continue;
561
+
562
+ const parts = psOut.trim().split(/\s+/);
563
+ const cpu = parseFloat(parts[1]) || 0;
564
+ const rss = parseInt(parts[2]) || 0; // KB
565
+ const stat = parts[3] || '';
566
+
567
+ // Determine status
568
+ let status = 'active';
569
+ if (cpu < 1 && (stat.includes('S') || stat.includes('T'))) {
570
+ status = 'waiting'; // idle/sleeping — likely waiting for user input
571
+ }
572
+
573
+ active.push({
574
+ pid: pid,
575
+ sessionId: data.sessionId,
576
+ cwd: data.cwd || '',
577
+ startedAt: data.startedAt || 0,
578
+ kind: data.kind || 'interactive',
579
+ entrypoint: data.entrypoint || '',
580
+ status: status,
581
+ cpu: cpu,
582
+ memoryMB: Math.round(rss / 1024),
583
+ });
584
+ } catch {
585
+ // Process not found — stale file, skip
586
+ }
587
+ } catch {}
588
+ }
589
+ }
590
+
591
+ // Also check Codex processes
592
+ try {
593
+ const codexPs = execSync('ps aux 2>/dev/null | grep "[c]odex" || true', { encoding: 'utf8', timeout: 2000 });
594
+ for (const line of codexPs.split('\n').filter(Boolean)) {
595
+ const parts = line.trim().split(/\s+/);
596
+ if (parts.length < 11) continue;
597
+ const pid = parseInt(parts[1]);
598
+ const cpu = parseFloat(parts[2]) || 0;
599
+ const rss = parseInt(parts[5]) || 0;
600
+
601
+ // Skip if already found via claude sessions
602
+ if (active.find(a => a.pid === pid)) continue;
603
+
604
+ // Try to get cwd
605
+ let cwd = '';
606
+ try {
607
+ const lsofOut = execSync(`lsof -d cwd -p ${pid} -Fn 2>/dev/null`, { encoding: 'utf8', timeout: 2000 });
608
+ const match = lsofOut.match(/\nn(\/[^\n]+)/);
609
+ if (match) cwd = match[1];
610
+ } catch {}
611
+
612
+ active.push({
613
+ pid: pid,
614
+ sessionId: '',
615
+ cwd: cwd,
616
+ startedAt: 0,
617
+ kind: 'codex',
618
+ entrypoint: 'codex',
619
+ status: cpu < 1 ? 'waiting' : 'active',
620
+ cpu: cpu,
621
+ memoryMB: Math.round(rss / 1024),
622
+ });
623
+ }
624
+ } catch {}
625
+
626
+ return active;
627
+ }
628
+
499
629
  module.exports = {
500
630
  loadSessions,
501
631
  loadSessionDetail,
@@ -504,6 +634,7 @@ module.exports = {
504
634
  exportSessionMarkdown,
505
635
  getSessionPreview,
506
636
  searchFullText,
637
+ getActiveSessions,
507
638
  CLAUDE_DIR,
508
639
  CODEX_DIR,
509
640
  HISTORY_FILE,
@@ -18,6 +18,7 @@ let selectedIds = new Set();
18
18
  let focusedIndex = -1;
19
19
  let availableTerminals = [];
20
20
  let pendingDelete = null;
21
+ let activeSessions = {}; // sessionId -> {status, cpu, memoryMB, pid}
21
22
 
22
23
  // Persisted in localStorage
23
24
  let stars = JSON.parse(localStorage.getItem('codedash-stars') || '[]');
@@ -104,10 +105,20 @@ function showTagDropdown(event, sessionId) {
104
105
  document.querySelectorAll('.tag-dropdown').forEach(function(el) { el.remove(); });
105
106
  var dd = document.createElement('div');
106
107
  dd.className = 'tag-dropdown';
108
+ var existingTags = tags[sessionId] || [];
107
109
  dd.innerHTML = TAG_OPTIONS.map(function(t) {
108
- return '<div class="tag-dropdown-item" onclick="event.stopPropagation();addTag(\'' + sessionId + '\',\'' + t + '\')">' + t + '</div>';
110
+ var has = existingTags.indexOf(t) >= 0;
111
+ return '<div class="tag-dropdown-item" onclick="event.stopPropagation();' +
112
+ (has ? 'removeTag' : 'addTag') + '(\'' + sessionId + '\',\'' + t + '\')">' +
113
+ (has ? '&#10003; ' : '') + t + '</div>';
109
114
  }).join('');
110
- event.target.parentElement.appendChild(dd);
115
+
116
+ // Position near the button
117
+ var rect = event.target.getBoundingClientRect();
118
+ dd.style.top = (rect.bottom + 4) + 'px';
119
+ dd.style.left = rect.left + 'px';
120
+
121
+ document.body.appendChild(dd);
111
122
  setTimeout(function() {
112
123
  document.addEventListener('click', function() { dd.remove(); }, { once: true });
113
124
  }, 0);
@@ -186,6 +197,45 @@ function saveTerminalPref(val) {
186
197
  localStorage.setItem('codedash-terminal', val);
187
198
  }
188
199
 
200
+ // ── Active sessions polling ───────────────────────────────────
201
+
202
+ async function pollActiveSessions() {
203
+ try {
204
+ var resp = await fetch('/api/active');
205
+ var data = await resp.json();
206
+ activeSessions = {};
207
+ data.forEach(function(a) {
208
+ if (a.sessionId) {
209
+ activeSessions[a.sessionId] = a;
210
+ }
211
+ });
212
+ // Update badges without full re-render
213
+ document.querySelectorAll('.card').forEach(function(card) {
214
+ var id = card.getAttribute('data-id');
215
+ var existing = card.querySelector('.live-badge');
216
+ if (existing) existing.remove();
217
+ if (activeSessions[id]) {
218
+ var a = activeSessions[id];
219
+ var badge = document.createElement('span');
220
+ badge.className = 'live-badge live-' + a.status;
221
+ badge.textContent = a.status === 'waiting' ? 'WAITING' : 'LIVE';
222
+ badge.title = 'PID ' + a.pid + ' | CPU ' + a.cpu.toFixed(1) + '% | ' + a.memoryMB + 'MB';
223
+ var top = card.querySelector('.card-top');
224
+ if (top) top.insertBefore(badge, top.firstChild);
225
+ }
226
+ });
227
+ } catch {}
228
+ }
229
+
230
+ var activeInterval = null;
231
+ function startActivePolling() {
232
+ pollActiveSessions();
233
+ activeInterval = setInterval(pollActiveSessions, 5000);
234
+ }
235
+ function stopActivePolling() {
236
+ if (activeInterval) clearInterval(activeInterval);
237
+ }
238
+
189
239
  // ── Trigram search ─────────────────────────────────────────────
190
240
 
191
241
  function trigrams(str) {
@@ -1338,6 +1388,30 @@ document.addEventListener('keydown', function(e) {
1338
1388
  }
1339
1389
  });
1340
1390
 
1391
+ // ── Export/Import dialog ──────────────────────────────────────
1392
+
1393
+ function showExportDialog() {
1394
+ var overlay = document.getElementById('confirmOverlay');
1395
+ document.getElementById('confirmTitle').textContent = 'Export / Import Sessions';
1396
+ document.getElementById('confirmText').innerHTML =
1397
+ '<strong>Export</strong> all sessions to migrate to another PC:<br>' +
1398
+ '<code style="display:block;margin:8px 0;padding:8px;background:var(--bg-card);border-radius:6px;font-size:12px">codedash export</code>' +
1399
+ 'Creates a tar.gz with all Claude &amp; Codex session data.<br><br>' +
1400
+ '<strong>Import</strong> on the new machine:<br>' +
1401
+ '<code style="display:block;margin:8px 0;padding:8px;background:var(--bg-card);border-radius:6px;font-size:12px">codedash import &lt;file.tar.gz&gt;</code>' +
1402
+ '<br><em style="color:var(--text-muted);font-size:12px">Don\'t forget to clone your git repos separately.</em>';
1403
+ document.getElementById('confirmId').textContent = '';
1404
+ document.getElementById('confirmAction').textContent = 'Copy Export Command';
1405
+ document.getElementById('confirmAction').className = 'launch-btn btn-primary';
1406
+ document.getElementById('confirmAction').onclick = function() {
1407
+ navigator.clipboard.writeText('codedash export').then(function() {
1408
+ showToast('Copied: codedash export');
1409
+ });
1410
+ closeConfirm();
1411
+ };
1412
+ if (overlay) overlay.style.display = 'flex';
1413
+ }
1414
+
1341
1415
  // ── Update check ──────────────────────────────────────────────
1342
1416
 
1343
1417
  async function checkForUpdates() {
@@ -1392,6 +1466,7 @@ function dismissUpdate() {
1392
1466
  loadTerminals();
1393
1467
  checkForUpdates();
1394
1468
  initHoverPreview();
1469
+ startActivePolling();
1395
1470
 
1396
1471
  // Apply saved theme
1397
1472
  var savedTheme = localStorage.getItem('codedash-theme') || 'dark';
@@ -44,6 +44,11 @@
44
44
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
45
45
  Codex
46
46
  </div>
47
+ <div class="sidebar-divider"></div>
48
+ <div class="sidebar-item" onclick="showExportDialog()">
49
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
50
+ Export / Import
51
+ </div>
47
52
  <div class="sidebar-settings">
48
53
  <label>Terminal</label>
49
54
  <select id="terminalSelect" onchange="saveTerminalPref(this.value)">
@@ -484,20 +484,15 @@ body {
484
484
  }
485
485
 
486
486
  .tag-dropdown {
487
- position: absolute;
488
- top: 100%;
489
- left: 0;
490
- margin-top: 4px;
487
+ position: fixed;
491
488
  background: var(--bg-secondary);
492
489
  border: 1px solid var(--border);
493
490
  border-radius: 8px;
494
491
  padding: 6px;
495
492
  min-width: 140px;
496
493
  box-shadow: 0 8px 24px rgba(0,0,0,0.3);
497
- z-index: 50;
498
- display: none;
494
+ z-index: 150;
499
495
  }
500
- .tag-dropdown.open { display: block; }
501
496
 
502
497
  .tag-dropdown-item {
503
498
  display: flex;
@@ -1390,6 +1385,36 @@ body {
1390
1385
  color: #fff;
1391
1386
  }
1392
1387
 
1388
+ /* ── Live session badges ────────────────────────────────────── */
1389
+
1390
+ .live-badge {
1391
+ font-size: 9px;
1392
+ font-weight: 700;
1393
+ letter-spacing: 0.5px;
1394
+ padding: 2px 7px;
1395
+ border-radius: 4px;
1396
+ text-transform: uppercase;
1397
+ animation: pulse 2s infinite;
1398
+ }
1399
+
1400
+ .live-active {
1401
+ background: rgba(74, 222, 128, 0.2);
1402
+ color: var(--accent-green);
1403
+ border: 1px solid rgba(74, 222, 128, 0.3);
1404
+ }
1405
+
1406
+ .live-waiting {
1407
+ background: rgba(251, 191, 36, 0.15);
1408
+ color: #fbbf24;
1409
+ border: 1px solid rgba(251, 191, 36, 0.25);
1410
+ animation: pulse 1.5s infinite;
1411
+ }
1412
+
1413
+ @keyframes pulse {
1414
+ 0%, 100% { opacity: 1; }
1415
+ 50% { opacity: 0.6; }
1416
+ }
1417
+
1393
1418
  /* ── Card expand preview ────────────────────────────────────── */
1394
1419
 
1395
1420
  .card-expand-btn {
package/src/server.js CHANGED
@@ -3,7 +3,7 @@ const http = require('http');
3
3
  const https = require('https');
4
4
  const { URL } = require('url');
5
5
  const { exec } = require('child_process');
6
- const { loadSessions, loadSessionDetail, deleteSession, getGitCommits, exportSessionMarkdown, getSessionPreview, searchFullText } = require('./data');
6
+ const { loadSessions, loadSessionDetail, deleteSession, getGitCommits, exportSessionMarkdown, getSessionPreview, searchFullText, getActiveSessions } = require('./data');
7
7
  const { detectTerminals, openInTerminal } = require('./terminals');
8
8
  const { getHTML } = require('./html');
9
9
 
@@ -104,6 +104,12 @@ function startServer(port, openBrowser = true) {
104
104
  json(res, commits);
105
105
  }
106
106
 
107
+ // ── Active sessions ─────────────────────
108
+ else if (req.method === 'GET' && pathname === '/api/active') {
109
+ const active = getActiveSessions();
110
+ json(res, active);
111
+ }
112
+
107
113
  // ── Session preview ─────────────────────
108
114
  else if (req.method === 'GET' && pathname.startsWith('/api/preview/')) {
109
115
  const sessionId = pathname.split('/').pop();