@jhizzard/termdeck 0.3.0 → 0.3.2

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": "@jhizzard/termdeck",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
5
5
  "bin": {
6
6
  "termdeck": "./packages/cli/src/index.js"
@@ -86,6 +86,7 @@ for (let i = 0; i < args.length; i++) {
86
86
 
87
87
  // Load and start server
88
88
  const { createServer, loadConfig } = require(path.join(__dirname, '..', '..', 'server', 'src', 'index.js'));
89
+ const { runPreflight, printHealthBanner } = require(path.join(__dirname, '..', '..', 'server', 'src', 'preflight'));
89
90
 
90
91
  // Flag-driven env vars must be set BEFORE loadConfig() so any module that
91
92
  // reads process.env at require-time sees them.
@@ -116,6 +117,13 @@ server.listen(port, host, async () => {
116
117
  ╚══════════════════════════════════════╝
117
118
  `);
118
119
 
120
+ // Run preflight health checks (non-blocking — warn but don't prevent startup)
121
+ runPreflight(config).then((result) => {
122
+ printHealthBanner(result);
123
+ }).catch((err) => {
124
+ console.error(` \x1b[31m[health] Preflight failed: ${err.message}\x1b[0m\n`);
125
+ });
126
+
119
127
  // Skip auto-open in Codespaces/CI (port forwarding handles it)
120
128
  const isCodespaces = !!process.env.CODESPACES || !!process.env.GITHUB_CODESPACE_TOKEN;
121
129
  const isCI = !!process.env.CI;
@@ -66,6 +66,12 @@
66
66
  // Rumen insights badge + briefing (no-op when server reports enabled:false)
67
67
  setupRumen();
68
68
 
69
+ // Health badge (Sprint 6 T4) — polls /api/health every 30s
70
+ setupHealthBadge();
71
+
72
+ // Transcript recovery UI (Sprint 6 T4) — depends on T3 endpoints
73
+ setupTranscriptUI();
74
+
69
75
  // First-run onboarding tour. Fires on the first visit only; never again
70
76
  // unless the user explicitly clicks "how this works" in the top toolbar.
71
77
  try {
@@ -2311,5 +2317,484 @@
2311
2317
  }
2312
2318
  }, 30000);
2313
2319
 
2320
+ // ===== Health Badge (Sprint 6 T4) =====
2321
+ const healthState = {
2322
+ available: false, // false until first successful /api/health response
2323
+ pollTimer: null,
2324
+ dropdownOpen: false,
2325
+ lastResult: null
2326
+ };
2327
+
2328
+ function setupHealthBadge() {
2329
+ // Inject badge into topbar-stats, after the rumen badge
2330
+ const statsDiv = document.getElementById('globalStats');
2331
+ if (!statsDiv) return;
2332
+
2333
+ const badge = document.createElement('button');
2334
+ badge.type = 'button';
2335
+ badge.className = 'health-badge';
2336
+ badge.id = 'healthBadge';
2337
+ badge.title = 'Stack health';
2338
+ badge.setAttribute('aria-haspopup', 'true');
2339
+ badge.innerHTML = `<span class="hb-icon" aria-hidden="true">&#x1F6E1;</span> <span id="healthBadgeLabel">checking…</span>`;
2340
+ badge.style.display = 'none'; // hidden until first successful poll
2341
+ statsDiv.appendChild(badge);
2342
+
2343
+ // Dropdown
2344
+ const dropdown = document.createElement('div');
2345
+ dropdown.className = 'health-dropdown';
2346
+ dropdown.id = 'healthDropdown';
2347
+ dropdown.innerHTML = '<div class="hd-loading">Loading…</div>';
2348
+ document.body.appendChild(dropdown);
2349
+
2350
+ badge.addEventListener('click', (e) => {
2351
+ e.stopPropagation();
2352
+ toggleHealthDropdown();
2353
+ });
2354
+ document.addEventListener('click', (e) => {
2355
+ if (healthState.dropdownOpen && !dropdown.contains(e.target) && e.target !== badge) {
2356
+ closeHealthDropdown();
2357
+ }
2358
+ });
2359
+
2360
+ // Initial fetch + poll
2361
+ fetchHealth();
2362
+ healthState.pollTimer = setInterval(fetchHealth, 30000);
2363
+ }
2364
+
2365
+ async function fetchHealth() {
2366
+ try {
2367
+ const res = await fetch(`${API}/api/health`);
2368
+ if (res.status === 404) {
2369
+ // Server doesn't have health endpoint — hide badge entirely
2370
+ hideHealthBadge();
2371
+ return;
2372
+ }
2373
+ if (!res.ok) {
2374
+ showHealthOffline();
2375
+ return;
2376
+ }
2377
+ const data = await res.json();
2378
+ healthState.available = true;
2379
+ healthState.lastResult = data;
2380
+ renderHealthBadge(data);
2381
+ } catch {
2382
+ showHealthOffline();
2383
+ }
2384
+ }
2385
+
2386
+ function hideHealthBadge() {
2387
+ healthState.available = false;
2388
+ const badge = document.getElementById('healthBadge');
2389
+ if (badge) badge.style.display = 'none';
2390
+ if (healthState.pollTimer) {
2391
+ clearInterval(healthState.pollTimer);
2392
+ healthState.pollTimer = null;
2393
+ }
2394
+ }
2395
+
2396
+ function showHealthOffline() {
2397
+ healthState.available = true;
2398
+ healthState.lastResult = null;
2399
+ const badge = document.getElementById('healthBadge');
2400
+ if (!badge) return;
2401
+ badge.style.display = '';
2402
+ badge.className = 'health-badge hb-red';
2403
+ document.getElementById('healthBadgeLabel').textContent = 'Health: offline';
2404
+ }
2405
+
2406
+ // Tier 2/3 checks only shown when the user has configured those tiers.
2407
+ // Without DATABASE_URL, mnestra/rumen/database checks are irrelevant noise.
2408
+ const TIER1_CHECKS = new Set(['project_paths', 'shell_sanity']);
2409
+ const TIER23_CHECKS = new Set(['mnestra_reachable', 'mnestra_has_memories', 'rumen_recent', 'database_url']);
2410
+
2411
+ function filterChecksByTier(checks) {
2412
+ const hasDb = checks.some(c => c.name === 'database_url' && c.passed);
2413
+ if (hasDb) return checks; // full stack configured — show everything
2414
+ // No DATABASE_URL: only show Tier 1 checks
2415
+ return checks.filter(c => TIER1_CHECKS.has(c.name));
2416
+ }
2417
+
2418
+ function renderHealthBadge(data) {
2419
+ const badge = document.getElementById('healthBadge');
2420
+ if (!badge) return;
2421
+ badge.style.display = '';
2422
+
2423
+ const allChecks = data.checks || [];
2424
+ const checks = filterChecksByTier(allChecks);
2425
+ const total = checks.length;
2426
+ const passed = checks.filter(c => c.passed).length;
2427
+ const allOk = passed === total && total > 0;
2428
+ const tierLabel = total < allChecks.length ? 'Tier 1' : 'Stack';
2429
+
2430
+ if (allOk) {
2431
+ badge.className = 'health-badge hb-green';
2432
+ document.getElementById('healthBadgeLabel').textContent = `${tierLabel}: OK`;
2433
+ } else if (total === 0) {
2434
+ badge.className = 'health-badge hb-amber';
2435
+ document.getElementById('healthBadgeLabel').textContent = 'Stack: ?';
2436
+ } else {
2437
+ badge.className = 'health-badge hb-red';
2438
+ document.getElementById('healthBadgeLabel').textContent = `${tierLabel}: ${passed}/${total}`;
2439
+ }
2440
+
2441
+ // Update dropdown content — pass filtered checks
2442
+ renderHealthDropdown({ ...data, checks });
2443
+ }
2444
+
2445
+ function renderHealthDropdown(data) {
2446
+ const dropdown = document.getElementById('healthDropdown');
2447
+ if (!dropdown) return;
2448
+ const checks = data.checks || [];
2449
+ if (checks.length === 0) {
2450
+ dropdown.innerHTML = '<div class="hd-empty">No health checks reported</div>';
2451
+ return;
2452
+ }
2453
+
2454
+ let html = '';
2455
+ for (const check of checks) {
2456
+ const icon = check.passed ? '✓' : '✗';
2457
+ const cls = check.passed ? 'hd-ok' : 'hd-fail';
2458
+ const name = check.name || 'Unknown';
2459
+ const detail = check.detail || '';
2460
+ const remediation = check.passed ? '' : (check.remediation ? `<div class="hd-remediation">${escapeHtml(check.remediation)}</div>` : '');
2461
+ html += `<div class="hd-check ${cls}">
2462
+ <span class="hd-icon">${icon}</span>
2463
+ <span class="hd-name">${escapeHtml(name)}</span>
2464
+ <span class="hd-dots"></span>
2465
+ <span class="hd-status">${check.passed ? 'OK' : 'FAIL'}</span>
2466
+ <span class="hd-detail">${escapeHtml(detail)}</span>
2467
+ ${remediation}
2468
+ </div>`;
2469
+ }
2470
+ dropdown.innerHTML = html;
2471
+ }
2472
+
2473
+ function escapeHtml(str) {
2474
+ const div = document.createElement('div');
2475
+ div.textContent = str;
2476
+ return div.innerHTML;
2477
+ }
2478
+
2479
+ function toggleHealthDropdown() {
2480
+ if (healthState.dropdownOpen) {
2481
+ closeHealthDropdown();
2482
+ } else {
2483
+ openHealthDropdown();
2484
+ }
2485
+ }
2486
+
2487
+ function openHealthDropdown() {
2488
+ const badge = document.getElementById('healthBadge');
2489
+ const dropdown = document.getElementById('healthDropdown');
2490
+ if (!badge || !dropdown) return;
2491
+
2492
+ const rect = badge.getBoundingClientRect();
2493
+ dropdown.style.top = `${rect.bottom + 4}px`;
2494
+ dropdown.style.left = `${Math.max(8, rect.left - 100)}px`;
2495
+ dropdown.classList.add('open');
2496
+ healthState.dropdownOpen = true;
2497
+ }
2498
+
2499
+ function closeHealthDropdown() {
2500
+ const dropdown = document.getElementById('healthDropdown');
2501
+ if (dropdown) dropdown.classList.remove('open');
2502
+ healthState.dropdownOpen = false;
2503
+ }
2504
+
2505
+ // ===== Transcript Recovery UI (Sprint 6 T4) =====
2506
+ const transcriptState = {
2507
+ available: false,
2508
+ modalOpen: false,
2509
+ view: 'recent', // 'recent' | 'search' | 'replay'
2510
+ recentData: null,
2511
+ searchResults: null,
2512
+ replaySession: null,
2513
+ replayData: null
2514
+ };
2515
+
2516
+ function setupTranscriptUI() {
2517
+ // Inject "Transcripts" button into topbar-right, before the "status" button
2518
+ const topbarRight = document.querySelector('.topbar-right');
2519
+ const btnStatus = document.getElementById('btn-status');
2520
+ if (!topbarRight || !btnStatus) return;
2521
+
2522
+ const btn = document.createElement('button');
2523
+ btn.id = 'btn-transcripts';
2524
+ btn.textContent = 'transcripts';
2525
+ btn.title = 'Session transcript recovery';
2526
+ btn.style.display = 'none'; // hidden until we confirm endpoints exist
2527
+ topbarRight.insertBefore(btn, btnStatus);
2528
+
2529
+ // Create the modal
2530
+ const modal = document.createElement('div');
2531
+ modal.className = 'transcript-modal';
2532
+ modal.id = 'transcriptModal';
2533
+ modal.innerHTML = `
2534
+ <div class="transcript-backdrop" id="transcriptBackdrop"></div>
2535
+ <div class="transcript-card">
2536
+ <header>
2537
+ <h3>Session Transcripts</h3>
2538
+ <div class="transcript-tabs">
2539
+ <button class="transcript-tab active" data-view="recent">Recent</button>
2540
+ <button class="transcript-tab" data-view="search">Search</button>
2541
+ </div>
2542
+ </header>
2543
+ <div class="transcript-search-bar" id="transcriptSearchBar" style="display:none">
2544
+ <input type="text" id="transcriptSearchInput" placeholder="Search transcript content…" class="ctrl-input" />
2545
+ </div>
2546
+ <div class="transcript-body" id="transcriptBody">
2547
+ <div class="transcript-loading">Checking transcript endpoints…</div>
2548
+ </div>
2549
+ <footer>
2550
+ <button class="transcript-back" id="transcriptBack" style="display:none">← Back</button>
2551
+ <button class="rm-close" id="transcriptClose">Close</button>
2552
+ </footer>
2553
+ </div>
2554
+ `;
2555
+ document.body.appendChild(modal);
2556
+
2557
+ // Events
2558
+ btn.addEventListener('click', openTranscriptModal);
2559
+ document.getElementById('transcriptBackdrop').addEventListener('click', closeTranscriptModal);
2560
+ document.getElementById('transcriptClose').addEventListener('click', closeTranscriptModal);
2561
+ document.getElementById('transcriptBack').addEventListener('click', transcriptGoBack);
2562
+
2563
+ modal.addEventListener('keydown', (e) => {
2564
+ if (e.key === 'Escape') { e.preventDefault(); closeTranscriptModal(); }
2565
+ });
2566
+
2567
+ // Tab switching
2568
+ modal.querySelectorAll('.transcript-tab').forEach(tab => {
2569
+ tab.addEventListener('click', () => {
2570
+ const view = tab.dataset.view;
2571
+ transcriptSwitchView(view);
2572
+ });
2573
+ });
2574
+
2575
+ // Search input
2576
+ let searchDebounce = null;
2577
+ document.getElementById('transcriptSearchInput').addEventListener('input', (e) => {
2578
+ clearTimeout(searchDebounce);
2579
+ searchDebounce = setTimeout(() => {
2580
+ const q = e.target.value.trim();
2581
+ if (q.length >= 2) transcriptSearch(q);
2582
+ }, 400);
2583
+ });
2584
+
2585
+ // Probe for endpoint availability
2586
+ probeTranscriptEndpoints();
2587
+ }
2588
+
2589
+ async function probeTranscriptEndpoints() {
2590
+ try {
2591
+ const res = await fetch(`${API}/api/transcripts/recent?minutes=1`);
2592
+ if (res.status === 404) {
2593
+ // Endpoints not available — keep button hidden
2594
+ transcriptState.available = false;
2595
+ return;
2596
+ }
2597
+ // Endpoint exists (even if empty)
2598
+ transcriptState.available = true;
2599
+ const btn = document.getElementById('btn-transcripts');
2600
+ if (btn) btn.style.display = '';
2601
+ } catch {
2602
+ transcriptState.available = false;
2603
+ }
2604
+ }
2605
+
2606
+ function openTranscriptModal() {
2607
+ if (!transcriptState.available) return;
2608
+ transcriptState.modalOpen = true;
2609
+ document.getElementById('transcriptModal').classList.add('open');
2610
+ transcriptSwitchView('recent');
2611
+ fetchRecentTranscripts();
2612
+ }
2613
+
2614
+ function closeTranscriptModal() {
2615
+ transcriptState.modalOpen = false;
2616
+ document.getElementById('transcriptModal').classList.remove('open');
2617
+ }
2618
+
2619
+ function transcriptGoBack() {
2620
+ if (transcriptState.view === 'replay') {
2621
+ transcriptState.replaySession = null;
2622
+ transcriptState.replayData = null;
2623
+ // Go back to whichever list view was active
2624
+ transcriptSwitchView(transcriptState.searchResults ? 'search' : 'recent');
2625
+ if (transcriptState.view === 'recent') renderRecentTranscripts();
2626
+ else renderSearchResults();
2627
+ }
2628
+ }
2629
+
2630
+ function transcriptSwitchView(view) {
2631
+ transcriptState.view = view;
2632
+ const tabs = document.querySelectorAll('.transcript-tab');
2633
+ tabs.forEach(t => t.classList.toggle('active', t.dataset.view === view));
2634
+ const searchBar = document.getElementById('transcriptSearchBar');
2635
+ const backBtn = document.getElementById('transcriptBack');
2636
+ searchBar.style.display = view === 'search' ? '' : 'none';
2637
+ backBtn.style.display = view === 'replay' ? '' : 'none';
2638
+
2639
+ if (view === 'recent') fetchRecentTranscripts();
2640
+ if (view === 'search') {
2641
+ const input = document.getElementById('transcriptSearchInput');
2642
+ input.focus();
2643
+ if (transcriptState.searchResults) renderSearchResults();
2644
+ else document.getElementById('transcriptBody').innerHTML = '<div class="transcript-empty">Type to search transcript content</div>';
2645
+ }
2646
+ }
2647
+
2648
+ async function fetchRecentTranscripts() {
2649
+ const body = document.getElementById('transcriptBody');
2650
+ body.innerHTML = '<div class="transcript-loading">Loading recent transcripts…</div>';
2651
+ try {
2652
+ const res = await fetch(`${API}/api/transcripts/recent?minutes=60`);
2653
+ if (!res.ok) throw new Error('fetch failed');
2654
+ const data = await res.json();
2655
+ transcriptState.recentData = data;
2656
+ renderRecentTranscripts();
2657
+ } catch {
2658
+ body.innerHTML = '<div class="transcript-empty">Failed to load transcripts</div>';
2659
+ }
2660
+ }
2661
+
2662
+ function renderRecentTranscripts() {
2663
+ const body = document.getElementById('transcriptBody');
2664
+ const data = transcriptState.recentData;
2665
+ if (!data || !data.sessions || data.sessions.length === 0) {
2666
+ body.innerHTML = '<div class="transcript-empty">No recent transcript activity</div>';
2667
+ return;
2668
+ }
2669
+ let html = '';
2670
+ for (const sess of data.sessions) {
2671
+ const id = sess.sessionId || sess.session_id || 'unknown';
2672
+ const shortId = id.slice(0, 8);
2673
+ const type = sess.type || 'shell';
2674
+ const project = sess.project || '';
2675
+ const lines = sess.lines || sess.preview || [];
2676
+ const lineCount = sess.totalLines || lines.length;
2677
+ html += `<div class="transcript-session" data-session-id="${escapeHtml(id)}">
2678
+ <div class="ts-header">
2679
+ <span class="ts-id">${escapeHtml(shortId)}</span>
2680
+ <span class="ts-type">${escapeHtml(type)}</span>
2681
+ ${project ? `<span class="ts-project">${escapeHtml(project)}</span>` : ''}
2682
+ <span class="ts-lines">${lineCount} lines</span>
2683
+ </div>
2684
+ <pre class="ts-preview">${escapeHtml(lines.slice(-6).join('\n'))}</pre>
2685
+ </div>`;
2686
+ }
2687
+ body.innerHTML = html;
2688
+
2689
+ // Click to replay
2690
+ body.querySelectorAll('.transcript-session').forEach(el => {
2691
+ el.addEventListener('click', () => {
2692
+ const sid = el.dataset.sessionId;
2693
+ loadTranscriptReplay(sid);
2694
+ });
2695
+ });
2696
+ }
2697
+
2698
+ async function transcriptSearch(query) {
2699
+ const body = document.getElementById('transcriptBody');
2700
+ body.innerHTML = '<div class="transcript-loading">Searching…</div>';
2701
+ try {
2702
+ const res = await fetch(`${API}/api/transcripts/search?q=${encodeURIComponent(query)}`);
2703
+ if (!res.ok) throw new Error('search failed');
2704
+ const data = await res.json();
2705
+ transcriptState.searchResults = data;
2706
+ renderSearchResults();
2707
+ } catch {
2708
+ body.innerHTML = '<div class="transcript-empty">Search failed</div>';
2709
+ }
2710
+ }
2711
+
2712
+ function renderSearchResults() {
2713
+ const body = document.getElementById('transcriptBody');
2714
+ const data = transcriptState.searchResults;
2715
+ if (!data || !data.results || data.results.length === 0) {
2716
+ body.innerHTML = '<div class="transcript-empty">No matches found</div>';
2717
+ return;
2718
+ }
2719
+ let html = '';
2720
+ for (const result of data.results) {
2721
+ const id = result.sessionId || result.session_id || 'unknown';
2722
+ const shortId = id.slice(0, 8);
2723
+ const line = result.line || result.content || '';
2724
+ const ts = result.timestamp ? new Date(result.timestamp).toLocaleTimeString() : '';
2725
+ html += `<div class="transcript-result" data-session-id="${escapeHtml(id)}">
2726
+ <div class="tr-meta">
2727
+ <span class="tr-session">${escapeHtml(shortId)}</span>
2728
+ ${ts ? `<span class="tr-time">${escapeHtml(ts)}</span>` : ''}
2729
+ </div>
2730
+ <pre class="tr-line">${highlightMatch(escapeHtml(line), escapeHtml(document.getElementById('transcriptSearchInput').value))}</pre>
2731
+ </div>`;
2732
+ }
2733
+ body.innerHTML = html;
2734
+
2735
+ // Click to replay
2736
+ body.querySelectorAll('.transcript-result').forEach(el => {
2737
+ el.addEventListener('click', () => {
2738
+ const sid = el.dataset.sessionId;
2739
+ loadTranscriptReplay(sid);
2740
+ });
2741
+ });
2742
+ }
2743
+
2744
+ function highlightMatch(text, query) {
2745
+ if (!query) return text;
2746
+ try {
2747
+ const re = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
2748
+ return text.replace(re, '<mark class="tr-highlight">$1</mark>');
2749
+ } catch {
2750
+ return text;
2751
+ }
2752
+ }
2753
+
2754
+ async function loadTranscriptReplay(sessionId) {
2755
+ transcriptState.view = 'replay';
2756
+ transcriptState.replaySession = sessionId;
2757
+ const body = document.getElementById('transcriptBody');
2758
+ const backBtn = document.getElementById('transcriptBack');
2759
+ const searchBar = document.getElementById('transcriptSearchBar');
2760
+ backBtn.style.display = '';
2761
+ searchBar.style.display = 'none';
2762
+ body.innerHTML = '<div class="transcript-loading">Loading full transcript…</div>';
2763
+
2764
+ try {
2765
+ const res = await fetch(`${API}/api/transcripts/${encodeURIComponent(sessionId)}`);
2766
+ if (!res.ok) throw new Error('fetch failed');
2767
+ const data = await res.json();
2768
+ transcriptState.replayData = data;
2769
+ renderTranscriptReplay(data);
2770
+ } catch {
2771
+ body.innerHTML = '<div class="transcript-empty">Failed to load transcript</div>';
2772
+ }
2773
+ }
2774
+
2775
+ function renderTranscriptReplay(data) {
2776
+ const body = document.getElementById('transcriptBody');
2777
+ const content = data.content || data.lines?.join('\n') || '';
2778
+ const sessionId = transcriptState.replaySession || 'unknown';
2779
+ body.innerHTML = `
2780
+ <div class="transcript-replay-header">
2781
+ <span class="tr-replay-id">Session: ${escapeHtml(sessionId.slice(0, 12))}</span>
2782
+ <button class="transcript-copy" id="transcriptCopyBtn">Copy to clipboard</button>
2783
+ </div>
2784
+ <pre class="transcript-replay-content">${escapeHtml(content)}</pre>
2785
+ `;
2786
+ document.getElementById('transcriptCopyBtn').addEventListener('click', () => {
2787
+ navigator.clipboard.writeText(content).then(() => {
2788
+ const btn = document.getElementById('transcriptCopyBtn');
2789
+ btn.textContent = 'Copied!';
2790
+ btn.classList.add('copied');
2791
+ setTimeout(() => {
2792
+ btn.textContent = 'Copy to clipboard';
2793
+ btn.classList.remove('copied');
2794
+ }, 2000);
2795
+ }).catch(() => {});
2796
+ });
2797
+ }
2798
+
2314
2799
  // Boot
2315
2800
  init();