@jhizzard/termdeck 0.7.3 → 0.9.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.
@@ -25,6 +25,7 @@
25
25
  async function init() {
26
26
  // Load config
27
27
  state.config = await api('GET', '/api/config');
28
+ updateRagIndicator();
28
29
 
29
30
  // Populate project dropdown
30
31
  const sel = document.getElementById('promptProject');
@@ -84,6 +85,10 @@
84
85
  // Sprint 19 T2: auto-open setup wizard if /api/setup reports firstRun.
85
86
  // Silent-fail if the endpoint isn't available yet (T1 not merged).
86
87
  maybeAutoOpenSetupWizard();
88
+
89
+ // Sprint 37 T1: orchestrator Guide right-rail. Lazy — fetches the doc
90
+ // on first expand to keep page load light.
91
+ setupGuideRail();
87
92
  }
88
93
 
89
94
  // ===== Create Terminal Panel =====
@@ -247,6 +252,16 @@
247
252
  case 'status_broadcast':
248
253
  updateGlobalStats(msg.sessions);
249
254
  break;
255
+ case 'config_changed':
256
+ // Sprint 36 T3 Deliverable A: server-broadcast on PATCH /api/config.
257
+ // Each open panel WebSocket receives one copy; the handler is
258
+ // idempotent so multiple receipts settle the same state.
259
+ if (msg.config) {
260
+ state.config = { ...state.config, ...msg.config };
261
+ if (typeof renderSettingsPanel === 'function') renderSettingsPanel();
262
+ if (typeof updateRagIndicator === 'function') updateRagIndicator();
263
+ }
264
+ break;
250
265
  }
251
266
  } catch (err) { console.error('[client] ws message parse failed:', err); }
252
267
  };
@@ -1437,6 +1452,7 @@
1437
1452
  if (prev && state.config.projects && state.config.projects[prev]) {
1438
1453
  sel.value = prev;
1439
1454
  }
1455
+ syncPreviewButton();
1440
1456
  }
1441
1457
 
1442
1458
  function openAddProjectModal() {
@@ -1508,6 +1524,411 @@
1508
1524
  }
1509
1525
  }
1510
1526
 
1527
+ // ===== Orchestration preview modal (Sprint 37 T3) =====
1528
+ // The preview button next to the project select shows what
1529
+ // `termdeck init --project <name>` would create for the currently
1530
+ // selected project. Disabled when no project is selected.
1531
+ function previewState() {
1532
+ if (!state.preview) state.preview = { current: null, busy: false };
1533
+ return state.preview;
1534
+ }
1535
+
1536
+ function syncPreviewButton() {
1537
+ const btn = document.getElementById('btnPreviewProject');
1538
+ if (!btn) return;
1539
+ const name = (document.getElementById('promptProject') || {}).value || '';
1540
+ btn.disabled = !name;
1541
+ btn.title = name
1542
+ ? `Preview orchestration scaffolding for "${name}"`
1543
+ : 'Select a project to preview its orchestration scaffolding';
1544
+ }
1545
+
1546
+ function setPpmStatus(msg, kind) {
1547
+ const el = document.getElementById('ppmStatus');
1548
+ if (!el) return;
1549
+ el.textContent = msg || '';
1550
+ el.classList.remove('error', 'ok');
1551
+ if (kind) el.classList.add(kind);
1552
+ }
1553
+
1554
+ function escHtml(s) {
1555
+ return String(s == null ? '' : s)
1556
+ .replace(/&/g, '&amp;')
1557
+ .replace(/</g, '&lt;')
1558
+ .replace(/>/g, '&gt;')
1559
+ .replace(/"/g, '&quot;')
1560
+ .replace(/'/g, '&#39;');
1561
+ }
1562
+
1563
+ function renderPreviewMeta(payload) {
1564
+ const el = document.getElementById('ppmMeta');
1565
+ if (!el) return;
1566
+ const tag = payload.exists
1567
+ ? '<span class="ppm-meta-tag exists">exists</span>'
1568
+ : '<span class="ppm-meta-tag fresh">fresh</span>';
1569
+ el.innerHTML =
1570
+ `<b>${escHtml(payload.projectName)}</b>${tag}<br>` +
1571
+ `→ ${escHtml(payload.targetPath)}`;
1572
+ }
1573
+
1574
+ function renderPreviewTree(payload) {
1575
+ const tree = document.getElementById('ppmTree');
1576
+ if (!tree) return;
1577
+ const total = (payload.wouldCreate || []).length + (payload.wouldSkip || []).length;
1578
+ if (total === 0) {
1579
+ tree.innerHTML = '<div class="ppm-empty">No templates returned. Check that the orchestration scaffolding is available on this server.</div>';
1580
+ return;
1581
+ }
1582
+
1583
+ const sections = [];
1584
+ if (payload.wouldCreate && payload.wouldCreate.length > 0) {
1585
+ sections.push(buildSection('Would create', 'create', payload.wouldCreate));
1586
+ }
1587
+ if (payload.created && payload.created.length > 0) {
1588
+ sections.push(buildSection('Created', 'create', payload.created));
1589
+ }
1590
+ if (payload.wouldSkip && payload.wouldSkip.length > 0) {
1591
+ sections.push(buildSection('Would skip', 'skip', payload.wouldSkip));
1592
+ }
1593
+ tree.innerHTML = sections.join('');
1594
+
1595
+ // Wire expand/collapse on row headers (event delegation).
1596
+ tree.querySelectorAll('.ppm-row-header').forEach((btn) => {
1597
+ btn.addEventListener('click', () => {
1598
+ const row = btn.closest('.ppm-row');
1599
+ if (row) row.classList.toggle('expanded');
1600
+ });
1601
+ });
1602
+ }
1603
+
1604
+ function buildSection(label, kind, entries) {
1605
+ const rows = entries.map((e) => buildRow(kind, e)).join('');
1606
+ return `<div class="ppm-section">
1607
+ <div class="ppm-section-label">${escHtml(label)} (${entries.length})</div>
1608
+ ${rows}
1609
+ </div>`;
1610
+ }
1611
+
1612
+ function buildRow(kind, entry) {
1613
+ const truncated = entry.totalLines > (entry.contentPreview || '').split('\n').length;
1614
+ const moreLines = truncated
1615
+ ? entry.totalLines - entry.contentPreview.split('\n').length
1616
+ : 0;
1617
+ const reason = entry.reason
1618
+ ? `<div class="ppm-row-skip-reason">${escHtml(entry.reason)}</div>`
1619
+ : '';
1620
+ const truncatedNote = moreLines > 0
1621
+ ? `<div class="ppm-row-truncated">… ${moreLines} more line${moreLines === 1 ? '' : 's'} (preview truncated)</div>`
1622
+ : '';
1623
+ return `<div class="ppm-row ${escHtml(kind)}">
1624
+ <button type="button" class="ppm-row-header" aria-label="Toggle preview">
1625
+ <span class="ppm-row-icon">▸</span>
1626
+ <span class="ppm-row-path">${escHtml(entry.path)}</span>
1627
+ <span class="ppm-row-meta">${entry.totalLines} ${entry.totalLines === 1 ? 'line' : 'lines'}</span>
1628
+ <span class="ppm-row-tag ${escHtml(kind)}">${escHtml(kind === 'skip' ? 'skip' : 'new')}</span>
1629
+ </button>
1630
+ <div class="ppm-row-body">
1631
+ ${reason}
1632
+ <pre>${escHtml(entry.contentPreview || '')}</pre>
1633
+ ${truncatedNote}
1634
+ </div>
1635
+ </div>`;
1636
+ }
1637
+
1638
+ async function loadPreview(name) {
1639
+ const ps = previewState();
1640
+ ps.current = name;
1641
+ setPpmStatus('Loading…', null);
1642
+ const tree = document.getElementById('ppmTree');
1643
+ if (tree) tree.innerHTML = '<div class="ppm-empty">Loading…</div>';
1644
+ const meta = document.getElementById('ppmMeta');
1645
+ if (meta) meta.textContent = '';
1646
+ const genBtn = document.getElementById('ppmGenerate');
1647
+ const forceCb = document.getElementById('ppmForce');
1648
+ if (genBtn) genBtn.disabled = true;
1649
+ if (forceCb) forceCb.checked = false;
1650
+
1651
+ try {
1652
+ const payload = await api('GET', `/api/projects/${encodeURIComponent(name)}/orchestration-preview`);
1653
+ if (!payload || payload.error) {
1654
+ setPpmStatus(payload && payload.error ? payload.error : 'Failed to load preview', 'error');
1655
+ return;
1656
+ }
1657
+ if (ps.current !== name) return; // user closed/changed before fetch returned
1658
+ renderPreviewMeta(payload);
1659
+ renderPreviewTree(payload);
1660
+ setPpmStatus('', null);
1661
+ if (genBtn) {
1662
+ // Enable Generate when there is at least one wouldCreate entry, OR
1663
+ // when the target dir exists (force overwrites preserved by checkbox).
1664
+ const hasNew = (payload.wouldCreate || []).length > 0;
1665
+ genBtn.disabled = !hasNew && !payload.exists;
1666
+ }
1667
+ } catch (err) {
1668
+ setPpmStatus(`Failed: ${(err && err.message) || err}`, 'error');
1669
+ }
1670
+ }
1671
+
1672
+ function openPreviewModal() {
1673
+ const sel = document.getElementById('promptProject');
1674
+ const name = sel && sel.value;
1675
+ if (!name) return;
1676
+ const modal = document.getElementById('previewProjectModal');
1677
+ if (!modal) return;
1678
+ modal.classList.add('open');
1679
+ loadPreview(name);
1680
+ }
1681
+
1682
+ function closePreviewModal() {
1683
+ const modal = document.getElementById('previewProjectModal');
1684
+ if (modal) modal.classList.remove('open');
1685
+ previewState().current = null;
1686
+ }
1687
+
1688
+ async function submitGenerate() {
1689
+ const ps = previewState();
1690
+ if (ps.busy || !ps.current) return;
1691
+ const force = !!document.getElementById('ppmForce').checked;
1692
+ const confirmMsg = force
1693
+ ? `Overwrite scaffolding files in "${ps.current}"? Existing files will be replaced.`
1694
+ : `Generate orchestration scaffolding for "${ps.current}"?`;
1695
+ if (!window.confirm(confirmMsg)) return;
1696
+
1697
+ const genBtn = document.getElementById('ppmGenerate');
1698
+ ps.busy = true;
1699
+ if (genBtn) genBtn.disabled = true;
1700
+ setPpmStatus('Generating…', null);
1701
+ try {
1702
+ const result = await api('POST',
1703
+ `/api/projects/${encodeURIComponent(ps.current)}/orchestration-preview/generate`,
1704
+ { force });
1705
+ if (!result || result.error) {
1706
+ setPpmStatus(result && result.error ? result.error : 'Generate failed', 'error');
1707
+ ps.busy = false;
1708
+ if (genBtn) genBtn.disabled = false;
1709
+ return;
1710
+ }
1711
+ renderPreviewMeta(result);
1712
+ renderPreviewTree(result);
1713
+ const count = (result.created || []).length;
1714
+ setPpmStatus(`Generated ${count} file${count === 1 ? '' : 's'} ✓`, 'ok');
1715
+ // Refresh the preview so the user sees the post-write state (every
1716
+ // file is now wouldSkip). Small delay so the success message reads.
1717
+ setTimeout(() => { if (ps.current) loadPreview(ps.current); }, 800);
1718
+ } catch (err) {
1719
+ setPpmStatus(`Failed: ${(err && err.message) || err}`, 'error');
1720
+ } finally {
1721
+ ps.busy = false;
1722
+ if (genBtn) genBtn.disabled = false;
1723
+ }
1724
+ }
1725
+
1726
+ // ===== Sprint runner modal (Sprint 37 T4) =====
1727
+ // Lets the user define a 4+1 sprint (name, version, goal, T1-T4 lanes,
1728
+ // worktree opt-in), POST /api/sprints to scaffold + spawn + inject, then
1729
+ // tail STATUS.md while lanes work.
1730
+ function sprintState() {
1731
+ if (!state.sprint) {
1732
+ state.sprint = { pollTimer: null, currentSprintName: null, currentProject: null };
1733
+ }
1734
+ return state.sprint;
1735
+ }
1736
+
1737
+ function setSprintStatus(msg, kind) {
1738
+ const el = document.getElementById('sprintStatusMsg');
1739
+ if (!el) return;
1740
+ el.textContent = msg || '';
1741
+ el.classList.remove('error', 'ok');
1742
+ if (kind) el.classList.add(kind);
1743
+ }
1744
+
1745
+ function openSprintModal() {
1746
+ const modal = document.getElementById('sprintModal');
1747
+ // Populate project dropdown from loaded config.
1748
+ const sel = document.getElementById('sprintProject');
1749
+ sel.innerHTML = '';
1750
+ const projects = Object.keys(state.config.projects || {});
1751
+ if (projects.length === 0) {
1752
+ const opt = document.createElement('option');
1753
+ opt.value = '';
1754
+ opt.textContent = '— add a project first —';
1755
+ sel.appendChild(opt);
1756
+ sel.disabled = true;
1757
+ } else {
1758
+ sel.disabled = false;
1759
+ for (const name of projects) {
1760
+ const opt = document.createElement('option');
1761
+ opt.value = name;
1762
+ opt.textContent = name;
1763
+ sel.appendChild(opt);
1764
+ }
1765
+ }
1766
+ // Reset form.
1767
+ document.getElementById('sprintName').value = '';
1768
+ document.getElementById('sprintTargetVersion').value = '';
1769
+ document.getElementById('sprintGoal').value = '';
1770
+ document.querySelectorAll('#sprintModal .sprint-lane-name').forEach((el) => { el.value = ''; });
1771
+ document.querySelectorAll('#sprintModal .sprint-lane-goal').forEach((el) => { el.value = ''; });
1772
+ document.getElementById('sprintWorktree').checked = true;
1773
+ document.getElementById('sprintAutoInject').checked = true;
1774
+ document.getElementById('sprintFormBody').style.display = '';
1775
+ document.getElementById('sprintResultPanel').style.display = 'none';
1776
+ setSprintStatus('', null);
1777
+ modal.classList.add('open');
1778
+ setTimeout(() => document.getElementById('sprintName').focus(), 50);
1779
+ }
1780
+
1781
+ function closeSprintModal() {
1782
+ const s = sprintState();
1783
+ if (s.pollTimer) {
1784
+ clearInterval(s.pollTimer);
1785
+ s.pollTimer = null;
1786
+ }
1787
+ document.getElementById('sprintModal').classList.remove('open');
1788
+ }
1789
+
1790
+ function readSprintLanes() {
1791
+ const lanes = [];
1792
+ const nameInputs = document.querySelectorAll('#sprintModal .sprint-lane-name');
1793
+ const goalInputs = document.querySelectorAll('#sprintModal .sprint-lane-goal');
1794
+ for (let i = 0; i < 4; i++) {
1795
+ lanes.push({
1796
+ name: (nameInputs[i] && nameInputs[i].value || '').trim(),
1797
+ goal: (goalInputs[i] && goalInputs[i].value || '').trim(),
1798
+ });
1799
+ }
1800
+ return lanes;
1801
+ }
1802
+
1803
+ async function submitSprint() {
1804
+ const project = document.getElementById('sprintProject').value;
1805
+ const name = document.getElementById('sprintName').value.trim();
1806
+ const targetVersion = document.getElementById('sprintTargetVersion').value.trim();
1807
+ const goal = document.getElementById('sprintGoal').value.trim();
1808
+ const worktree = document.getElementById('sprintWorktree').checked;
1809
+ const autoInject = document.getElementById('sprintAutoInject').checked;
1810
+ const lanes = readSprintLanes();
1811
+
1812
+ if (!project) { setSprintStatus('Pick a project.', 'error'); return; }
1813
+ if (!name) { setSprintStatus('Sprint name is required.', 'error'); return; }
1814
+ if (!/^[a-z0-9][a-z0-9-]{0,40}$/.test(name)) {
1815
+ setSprintStatus('Name must be a slug (lowercase a-z0-9 + hyphens, ≤40 chars).', 'error');
1816
+ return;
1817
+ }
1818
+ for (let i = 0; i < 4; i++) {
1819
+ if (!lanes[i].name) {
1820
+ setSprintStatus(`T${i + 1} lane name is required.`, 'error');
1821
+ return;
1822
+ }
1823
+ }
1824
+
1825
+ const btn = document.getElementById('sprintKickoff');
1826
+ btn.disabled = true;
1827
+ setSprintStatus('Scaffolding sprint, spawning panels, injecting boot prompts…', null);
1828
+
1829
+ try {
1830
+ const result = await api('POST', '/api/sprints', {
1831
+ project, name, targetVersion, goal, lanes, worktree, autoInject,
1832
+ });
1833
+ if (result && result.error) {
1834
+ setSprintStatus(result.error, 'error');
1835
+ btn.disabled = false;
1836
+ return;
1837
+ }
1838
+ renderSprintResult(result, project);
1839
+ // Reload sessions in the dashboard so the four new panels appear.
1840
+ try {
1841
+ const liveSessions = await api('GET', '/api/sessions');
1842
+ for (const s of liveSessions) {
1843
+ if (s.meta.status !== 'exited' && !state.sessions.has(s.id)) {
1844
+ createTerminalPanel(s);
1845
+ }
1846
+ }
1847
+ } catch {
1848
+ // Non-fatal — user can refresh.
1849
+ }
1850
+ startSprintStatusPoll(project, name);
1851
+ } catch (err) {
1852
+ setSprintStatus(`Failed: ${err && err.message || err}`, 'error');
1853
+ btn.disabled = false;
1854
+ }
1855
+ }
1856
+
1857
+ function renderSprintResult(result, project) {
1858
+ document.getElementById('sprintFormBody').style.display = 'none';
1859
+ const panel = document.getElementById('sprintResultPanel');
1860
+ panel.style.display = '';
1861
+ const meta = document.getElementById('sprintResultMeta');
1862
+ const sids = result.sessionIds || {};
1863
+ const wt = result.worktree ? 'on' : 'off';
1864
+ const inject = result.inject || {};
1865
+ const verifiedCount = Array.isArray(inject.lanes) ? inject.lanes.filter((l) => l.verified).length : 0;
1866
+ const pokedCount = Array.isArray(inject.lanes) ? inject.lanes.filter((l) => l.poked).length : 0;
1867
+ meta.innerHTML = [
1868
+ `<div>sprint dir: <code>${result.sprintDir}</code></div>`,
1869
+ `<div>worktree isolation: ${wt}</div>`,
1870
+ `<div>panels spawned: T1=${sids.T1 || '—'} · T2=${sids.T2 || '—'} · T3=${sids.T3 || '—'} · T4=${sids.T4 || '—'}</div>`,
1871
+ `<div>boot inject: verified ${verifiedCount}/4 · auto-poked ${pokedCount}</div>`,
1872
+ ].join('');
1873
+ // Reset lane status tiles to a "polling…" state.
1874
+ ['T1', 'T2', 'T3', 'T4'].forEach((laneId) => {
1875
+ const tile = panel.querySelector(`.sprint-lane-status[data-lane="${laneId}"]`);
1876
+ if (!tile) return;
1877
+ tile.querySelector('.counts').textContent = '—';
1878
+ tile.querySelector('.last-entry').textContent = 'polling…';
1879
+ });
1880
+ document.getElementById('sprintTail').textContent = '(tail loads after first STATUS.md write)';
1881
+ }
1882
+
1883
+ async function pollSprintStatus(project, sprintName) {
1884
+ try {
1885
+ const [statusRes, tailRes] = await Promise.all([
1886
+ fetch(`${API}/api/sprints/${encodeURIComponent(sprintName)}/status?project=${encodeURIComponent(project)}`),
1887
+ fetch(`${API}/api/sprints/${encodeURIComponent(sprintName)}/tail?project=${encodeURIComponent(project)}&lines=80`),
1888
+ ]);
1889
+ if (statusRes.ok) {
1890
+ const status = await statusRes.json();
1891
+ renderSprintLaneCounts(status);
1892
+ }
1893
+ if (tailRes.ok) {
1894
+ const tail = await tailRes.json();
1895
+ if (tail && typeof tail.tail === 'string') {
1896
+ document.getElementById('sprintTail').textContent = tail.tail;
1897
+ }
1898
+ }
1899
+ } catch {
1900
+ // Silently ignore poll errors; next tick retries.
1901
+ }
1902
+ }
1903
+
1904
+ function renderSprintLaneCounts(status) {
1905
+ const panel = document.getElementById('sprintResultPanel');
1906
+ if (!panel) return;
1907
+ ['T1', 'T2', 'T3', 'T4'].forEach((laneId) => {
1908
+ const tile = panel.querySelector(`.sprint-lane-status[data-lane="${laneId}"]`);
1909
+ if (!tile) return;
1910
+ const lane = status && status.lanes && status.lanes[laneId];
1911
+ if (!lane) {
1912
+ tile.querySelector('.counts').textContent = '—';
1913
+ tile.querySelector('.last-entry').textContent = 'awaiting first entry';
1914
+ return;
1915
+ }
1916
+ tile.querySelector('.counts').textContent =
1917
+ `${lane.finding} finding · ${lane.fixProposed} fix · ${lane.done} done`;
1918
+ tile.querySelector('.last-entry').textContent =
1919
+ lane.lastEntryAt ? `last: ${lane.lastEntryAt}` : 'awaiting first entry';
1920
+ });
1921
+ }
1922
+
1923
+ function startSprintStatusPoll(project, sprintName) {
1924
+ const s = sprintState();
1925
+ if (s.pollTimer) clearInterval(s.pollTimer);
1926
+ s.currentProject = project;
1927
+ s.currentSprintName = sprintName;
1928
+ pollSprintStatus(project, sprintName);
1929
+ s.pollTimer = setInterval(() => pollSprintStatus(project, sprintName), 3000);
1930
+ }
1931
+
1511
1932
  // ===== Rumen insights badge + briefing modal =====
1512
1933
  function rumenState() {
1513
1934
  if (!state.rumen) {
@@ -2299,8 +2720,17 @@
2299
2720
  // Explicitly show the spotlight. CSS default is `display:none` so the
2300
2721
  // 9999px box-shadow doesn't darken the page before/after a tour runs.
2301
2722
  document.getElementById('tourSpotlight').style.display = 'block';
2302
- await ensurePanelForTour();
2303
- renderTourStep();
2723
+ // Defensive cleanup: if ensurePanelForTour or renderTourStep throws
2724
+ // after the spotlight is shown, the 9999px box-shadow stays up with no
2725
+ // tooltip on top — that's the "dark veil" symptom users hit with no
2726
+ // visible way out. Roll back to a clean state on any failure.
2727
+ try {
2728
+ await ensurePanelForTour();
2729
+ renderTourStep();
2730
+ } catch (err) {
2731
+ console.error('[tour] start failed, rolling back:', err);
2732
+ endTour();
2733
+ }
2304
2734
  }
2305
2735
 
2306
2736
  function nextTourStep() {
@@ -2523,6 +2953,7 @@
2523
2953
  <button type="button" class="setup-close" id="setupClose" aria-label="Close">×</button>
2524
2954
  </header>
2525
2955
  <div class="setup-body">
2956
+ <div class="setup-settings" id="setupSettings"></div>
2526
2957
  <div class="setup-tiers" id="setupTiers">
2527
2958
  <div class="setup-loading">Checking tier status…</div>
2528
2959
  </div>
@@ -2553,6 +2984,7 @@
2553
2984
  ensureSetupModal();
2554
2985
  document.getElementById('setupModal').classList.add('open');
2555
2986
  setupModalOpen = true;
2987
+ renderSettingsPanel();
2556
2988
  await refreshSetupStatus();
2557
2989
  }
2558
2990
 
@@ -2562,6 +2994,96 @@
2562
2994
  setupModalOpen = false;
2563
2995
  }
2564
2996
 
2997
+ // ===== Settings panel inside the setup modal (Sprint 36 T3 Deliverable A) =====
2998
+ // Renders the writable subset of /api/config — currently just the RAG toggle.
2999
+ // Body is mutated in place; the panel is idempotent so config_changed WS
3000
+ // events can call it without reflow flicker.
3001
+ function renderSettingsPanel() {
3002
+ const el = document.getElementById('setupSettings');
3003
+ if (!el) return;
3004
+ const cfg = state.config || {};
3005
+ const intent = !!cfg.ragConfigEnabled;
3006
+ const effective = !!cfg.ragEnabled;
3007
+ const supabaseConfigured = !!cfg.ragSupabaseConfigured;
3008
+
3009
+ // Mismatch: user enabled RAG in config but Supabase isn't wired → show
3010
+ // a hint so the toggle's "ON but not pushing" state is explainable.
3011
+ const mismatch = intent && !effective && !supabaseConfigured;
3012
+
3013
+ const offCopy = 'MCP-only mode. Memory tools available through Claude Code; the in-CLI <code>termdeck flashback</code> command and the hybrid search are disabled. Faster boot, slimmer surface.';
3014
+ const onCopy = 'Enables <code>termdeck flashback</code> and the in-CLI hybrid search. Requires a Mnestra connection at boot — adds a few hundred ms to startup.';
3015
+
3016
+ el.innerHTML = `
3017
+ <div class="settings-section">
3018
+ <h4 class="settings-heading">RAG mode</h4>
3019
+ <div class="settings-row">
3020
+ <label class="toggle" for="settingsRagToggle">
3021
+ <input type="checkbox" id="settingsRagToggle" ${intent ? 'checked' : ''}>
3022
+ <span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span>
3023
+ <span class="toggle-label">${intent ? 'On' : 'Off'}</span>
3024
+ </label>
3025
+ <p class="settings-copy">${intent ? onCopy : offCopy}</p>
3026
+ </div>
3027
+ ${mismatch ? `
3028
+ <div class="settings-warn">
3029
+ RAG is enabled in <code>config.yaml</code> but Supabase isn't configured yet, so it isn't actually pushing.
3030
+ Configure Tier 2 below or run <code>npx @jhizzard/termdeck-stack</code>.
3031
+ </div>
3032
+ ` : ''}
3033
+ </div>
3034
+ `;
3035
+
3036
+ const toggle = document.getElementById('settingsRagToggle');
3037
+ if (toggle) {
3038
+ toggle.addEventListener('change', async (e) => {
3039
+ const desired = !!e.target.checked;
3040
+ // Optimistic UI: lock the toggle while the round-trip is in flight.
3041
+ toggle.disabled = true;
3042
+ try {
3043
+ const updated = await api('PATCH', '/api/config', { rag: { enabled: desired } });
3044
+ state.config = { ...state.config, ...updated };
3045
+ renderSettingsPanel();
3046
+ updateRagIndicator();
3047
+ } catch (err) {
3048
+ console.error('[settings] PATCH /api/config failed:', err);
3049
+ // Revert: refetch and re-render.
3050
+ try {
3051
+ state.config = await api('GET', '/api/config');
3052
+ renderSettingsPanel();
3053
+ } catch {}
3054
+ } finally {
3055
+ const t = document.getElementById('settingsRagToggle');
3056
+ if (t) t.disabled = false;
3057
+ }
3058
+ });
3059
+ }
3060
+ }
3061
+
3062
+ // Topbar RAG indicator. The #stat-rag stub in index.html was hidden by
3063
+ // Sprint 9 T2; re-purpose it as a live state line so users can see, at a
3064
+ // glance, what the toggle is doing without opening Settings each time.
3065
+ function updateRagIndicator() {
3066
+ const el = document.getElementById('stat-rag');
3067
+ if (!el) return;
3068
+ const cfg = state.config || {};
3069
+ const intent = !!cfg.ragConfigEnabled;
3070
+ const effective = !!cfg.ragEnabled;
3071
+ el.style.display = '';
3072
+ if (effective) {
3073
+ el.textContent = 'RAG · on';
3074
+ el.className = 'topbar-stat rag-on';
3075
+ el.title = 'Mnestra hybrid search + termdeck flashback enabled';
3076
+ } else if (intent) {
3077
+ el.textContent = 'RAG · pending';
3078
+ el.className = 'topbar-stat rag-pending';
3079
+ el.title = 'RAG enabled in config.yaml but Supabase not wired — see Settings';
3080
+ } else {
3081
+ el.textContent = 'RAG · mcp-only';
3082
+ el.className = 'topbar-stat rag-off';
3083
+ el.title = 'MCP-only mode; toggle in Settings to enable';
3084
+ }
3085
+ }
3086
+
2565
3087
  async function refreshSetupStatus() {
2566
3088
  const tiersEl = document.getElementById('setupTiers');
2567
3089
  const subtitle = document.getElementById('setupSubtitle');
@@ -3142,6 +3664,18 @@
3142
3664
  if (e.key === 'Escape') { e.preventDefault(); closeAddProjectModal(); }
3143
3665
  });
3144
3666
 
3667
+ // Orchestration preview modal wiring (Sprint 37 T3)
3668
+ document.getElementById('btnPreviewProject').addEventListener('click', openPreviewModal);
3669
+ document.getElementById('promptProject').addEventListener('change', syncPreviewButton);
3670
+ document.getElementById('ppmClose').addEventListener('click', closePreviewModal);
3671
+ document.getElementById('ppmCancel').addEventListener('click', closePreviewModal);
3672
+ document.querySelector('#previewProjectModal .preview-project-backdrop').addEventListener('click', closePreviewModal);
3673
+ document.getElementById('ppmGenerate').addEventListener('click', submitGenerate);
3674
+ document.getElementById('previewProjectModal').addEventListener('keydown', (e) => {
3675
+ if (e.key === 'Escape') { e.preventDefault(); closePreviewModal(); }
3676
+ });
3677
+ syncPreviewButton();
3678
+
3145
3679
  // Status + config dropdowns (Sprint 9 T2): btn-status/btn-config were
3146
3680
  // stubs with no listeners. Each now opens a dropdown with live data
3147
3681
  // fetched from /api/status and /api/config. Reuses .health-dropdown
@@ -3156,6 +3690,16 @@
3156
3690
  // legacy config dropdown (renderConfigDropdown is kept as dead code).
3157
3691
  document.getElementById('btn-config').addEventListener('click', openSetupModal);
3158
3692
 
3693
+ // Sprint runner modal wiring (Sprint 37 T4)
3694
+ document.getElementById('btn-sprint').addEventListener('click', openSprintModal);
3695
+ document.getElementById('sprintCancel').addEventListener('click', closeSprintModal);
3696
+ document.getElementById('sprintResultClose').addEventListener('click', closeSprintModal);
3697
+ document.getElementById('sprintBackdrop').addEventListener('click', closeSprintModal);
3698
+ document.getElementById('sprintKickoff').addEventListener('click', submitSprint);
3699
+ document.getElementById('sprintModal').addEventListener('keydown', (e) => {
3700
+ if (e.key === 'Escape') { e.preventDefault(); closeSprintModal(); }
3701
+ });
3702
+
3159
3703
  // Onboarding tour wiring
3160
3704
  document.getElementById('btn-how').addEventListener('click', startTour);
3161
3705
  document.getElementById('tourNextBtn').addEventListener('click', nextTourStep);
@@ -3765,5 +4309,354 @@
3765
4309
  });
3766
4310
  }
3767
4311
 
4312
+ // ===== Orchestrator Guide right-rail (Sprint 37 T1) =====
4313
+ // Lazy-load docs/orchestrator-guide.md on first expand, render with a
4314
+ // small purpose-built markdown converter, build TOC from H2 headings,
4315
+ // wire search + contextual auto-expand. No external markdown library —
4316
+ // the no-build-step ethos rules out webpack/parcel pulls.
4317
+ const guideRailState = {
4318
+ loaded: false,
4319
+ loading: false,
4320
+ sections: [], // [{id, title, el, text}]
4321
+ activeSection: null,
4322
+ };
4323
+
4324
+ function setupGuideRail() {
4325
+ const rail = document.getElementById('guideRail');
4326
+ const toggle = document.getElementById('guideRailToggle');
4327
+ const closeBtn = document.getElementById('guideRailClose');
4328
+ const search = document.getElementById('guideSearch');
4329
+ if (!rail || !toggle) return;
4330
+
4331
+ toggle.addEventListener('click', () => toggleGuideRail());
4332
+ if (closeBtn) closeBtn.addEventListener('click', () => setGuideRailCollapsed(true));
4333
+ if (search) search.addEventListener('input', () => filterGuideSections(search.value));
4334
+
4335
+ // Keyboard: 'g' opens/closes the Guide when not in an input. Skip when
4336
+ // a modifier is held to avoid stomping browser shortcuts.
4337
+ document.addEventListener('keydown', (e) => {
4338
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
4339
+ if (e.key !== 'g' && e.key !== 'G') return;
4340
+ const tgt = e.target;
4341
+ const tag = (tgt && tgt.tagName) || '';
4342
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || (tgt && tgt.isContentEditable)) return;
4343
+ // xterm.js attaches a hidden textarea inside .term-panel; skip when
4344
+ // the focus is inside a terminal panel.
4345
+ if (tgt && typeof tgt.closest === 'function' && tgt.closest('.term-panel')) return;
4346
+ e.preventDefault();
4347
+ toggleGuideRail();
4348
+ });
4349
+
4350
+ // Contextual auto-expand on terminal focus. Reuses the existing
4351
+ // focusSessionById path: when a panel is focused, scroll the right-rail
4352
+ // to the "4+1 pattern" section so its content is one glance away.
4353
+ document.addEventListener('click', (e) => {
4354
+ if (rail.dataset.collapsed === 'true') return;
4355
+ const panel = e.target && typeof e.target.closest === 'function' && e.target.closest('.term-panel');
4356
+ if (panel) scrollGuideToSection('the-4-1-pattern');
4357
+ }, true);
4358
+ }
4359
+
4360
+ function toggleGuideRail() {
4361
+ const rail = document.getElementById('guideRail');
4362
+ if (!rail) return;
4363
+ const collapsed = rail.dataset.collapsed !== 'false';
4364
+ setGuideRailCollapsed(!collapsed);
4365
+ }
4366
+
4367
+ function setGuideRailCollapsed(collapsed) {
4368
+ const rail = document.getElementById('guideRail');
4369
+ const toggle = document.getElementById('guideRailToggle');
4370
+ if (!rail) return;
4371
+ rail.dataset.collapsed = collapsed ? 'true' : 'false';
4372
+ if (toggle) toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
4373
+ if (!collapsed && !guideRailState.loaded && !guideRailState.loading) {
4374
+ loadGuideDoc();
4375
+ }
4376
+ }
4377
+
4378
+ async function loadGuideDoc() {
4379
+ const content = document.getElementById('guideContent');
4380
+ const toc = document.getElementById('guideToc');
4381
+ if (!content) return;
4382
+ guideRailState.loading = true;
4383
+ try {
4384
+ const res = await fetch('/docs/orchestrator-guide.md');
4385
+ if (!res.ok) throw new Error('HTTP ' + res.status);
4386
+ const md = await res.text();
4387
+ const html = renderGuideMarkdown(md);
4388
+ content.innerHTML = html;
4389
+ wrapGuideSections(content);
4390
+ if (toc) toc.innerHTML = buildGuideToc(content);
4391
+ bindGuideTocClicks(toc);
4392
+ observeGuideScroll(content);
4393
+ guideRailState.loaded = true;
4394
+ } catch (err) {
4395
+ content.innerHTML = '<div class="guide-loading">Couldn\'t load Guide: ' + escapeHtml(String(err && err.message || err)) + '</div>';
4396
+ } finally {
4397
+ guideRailState.loading = false;
4398
+ }
4399
+ }
4400
+
4401
+ // Wrap each H2 + its trailing siblings (until next H2) in a <section>
4402
+ // element, so search/filtering can hide/show whole sections at once.
4403
+ function wrapGuideSections(root) {
4404
+ const nodes = Array.from(root.children);
4405
+ const sections = [];
4406
+ let current = null;
4407
+ for (const node of nodes) {
4408
+ if (node.tagName === 'H2') {
4409
+ if (current) sections.push(current);
4410
+ current = { heading: node, els: [node] };
4411
+ } else if (current) {
4412
+ current.els.push(node);
4413
+ }
4414
+ }
4415
+ if (current) sections.push(current);
4416
+
4417
+ // Replace flat children with <section> wrappers
4418
+ guideRailState.sections = [];
4419
+ for (const sec of sections) {
4420
+ const wrapper = document.createElement('section');
4421
+ wrapper.className = 'guide-section';
4422
+ const slug = (sec.heading.id) || slugify(sec.heading.textContent);
4423
+ wrapper.id = 'guide-sec-' + slug;
4424
+ sec.heading.id = slug; // anchor for TOC links
4425
+ sec.heading.parentNode.insertBefore(wrapper, sec.heading);
4426
+ for (const el of sec.els) wrapper.appendChild(el);
4427
+ guideRailState.sections.push({
4428
+ id: slug,
4429
+ title: sec.heading.textContent,
4430
+ el: wrapper,
4431
+ text: wrapper.textContent.toLowerCase(),
4432
+ });
4433
+ }
4434
+ }
4435
+
4436
+ function buildGuideToc(root) {
4437
+ const headings = root.querySelectorAll('section.guide-section > h2');
4438
+ const links = [];
4439
+ headings.forEach(h => {
4440
+ links.push('<a href="#' + escapeAttr(h.id) + '" data-section="' + escapeAttr(h.id) + '">' + escapeHtml(h.textContent) + '</a>');
4441
+ });
4442
+ return links.join('');
4443
+ }
4444
+
4445
+ function bindGuideTocClicks(toc) {
4446
+ if (!toc) return;
4447
+ toc.addEventListener('click', (e) => {
4448
+ const a = e.target && e.target.closest && e.target.closest('a[data-section]');
4449
+ if (!a) return;
4450
+ e.preventDefault();
4451
+ scrollGuideToSection(a.dataset.section);
4452
+ });
4453
+ }
4454
+
4455
+ function scrollGuideToSection(sectionId) {
4456
+ const content = document.getElementById('guideContent');
4457
+ if (!content) return;
4458
+ const target = document.getElementById(sectionId);
4459
+ if (!target) return;
4460
+ const rect = target.getBoundingClientRect();
4461
+ const containerRect = content.getBoundingClientRect();
4462
+ content.scrollTop += (rect.top - containerRect.top) - 8;
4463
+ markActiveSection(sectionId);
4464
+ }
4465
+
4466
+ function markActiveSection(sectionId) {
4467
+ const toc = document.getElementById('guideToc');
4468
+ if (!toc) return;
4469
+ toc.querySelectorAll('a[data-section]').forEach(a => {
4470
+ a.classList.toggle('active', a.dataset.section === sectionId);
4471
+ });
4472
+ guideRailState.activeSection = sectionId;
4473
+ }
4474
+
4475
+ function observeGuideScroll(content) {
4476
+ // Lightweight scroll-spy — on every scroll, find the topmost visible
4477
+ // H2 and mark its TOC entry active.
4478
+ let raf = 0;
4479
+ content.addEventListener('scroll', () => {
4480
+ if (raf) return;
4481
+ raf = requestAnimationFrame(() => {
4482
+ raf = 0;
4483
+ const rect = content.getBoundingClientRect();
4484
+ const headings = content.querySelectorAll('section.guide-section > h2');
4485
+ let topId = null;
4486
+ for (const h of headings) {
4487
+ const hRect = h.getBoundingClientRect();
4488
+ if (hRect.top - rect.top <= 24) topId = h.id;
4489
+ else break;
4490
+ }
4491
+ if (topId) markActiveSection(topId);
4492
+ });
4493
+ });
4494
+ }
4495
+
4496
+ function filterGuideSections(query) {
4497
+ const content = document.getElementById('guideContent');
4498
+ const toc = document.getElementById('guideToc');
4499
+ if (!content) return;
4500
+ const q = (query || '').trim().toLowerCase();
4501
+ content.classList.toggle('has-filter', !!q);
4502
+ let anyMatch = false;
4503
+ const matchedIds = new Set();
4504
+ for (const sec of guideRailState.sections) {
4505
+ const match = !q || sec.text.includes(q) || sec.title.toLowerCase().includes(q);
4506
+ sec.el.classList.toggle('hidden', !match);
4507
+ if (match) { anyMatch = true; matchedIds.add(sec.id); }
4508
+ }
4509
+ // Sync TOC visibility with section matches
4510
+ if (toc) {
4511
+ toc.querySelectorAll('a[data-section]').forEach(a => {
4512
+ const id = a.dataset.section;
4513
+ a.classList.toggle('hidden', !!q && !matchedIds.has(id));
4514
+ });
4515
+ }
4516
+ // Show "no matches" hint if needed
4517
+ let hint = content.querySelector('em.no-match');
4518
+ if (q && !anyMatch) {
4519
+ if (!hint) {
4520
+ hint = document.createElement('em');
4521
+ hint.className = 'no-match';
4522
+ content.appendChild(hint);
4523
+ }
4524
+ hint.textContent = 'No Guide section matches "' + query + '".';
4525
+ } else if (hint) {
4526
+ hint.remove();
4527
+ }
4528
+ }
4529
+
4530
+ function slugify(text) {
4531
+ return String(text || '')
4532
+ .toLowerCase()
4533
+ .replace(/[^\w\s-]/g, '')
4534
+ .trim()
4535
+ .replace(/\s+/g, '-')
4536
+ .replace(/-+/g, '-');
4537
+ }
4538
+
4539
+ // Tiny markdown converter — handles only what orchestrator-guide.md uses:
4540
+ // ATX headings, paragraphs, blockquotes, fenced code, bullet/numbered
4541
+ // lists, hr, tables (header + separator), bold, italic, inline code,
4542
+ // links. Resilient enough for our authored Guide; not a general renderer.
4543
+ function renderGuideMarkdown(md) {
4544
+ const lines = md.replace(/\r\n/g, '\n').split('\n');
4545
+ const out = [];
4546
+ let i = 0;
4547
+ while (i < lines.length) {
4548
+ const line = lines[i];
4549
+ // Fenced code block
4550
+ if (/^```/.test(line)) {
4551
+ const lang = line.replace(/^```/, '').trim();
4552
+ const buf = [];
4553
+ i++;
4554
+ while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i++; }
4555
+ i++; // skip closing fence
4556
+ out.push('<pre><code' + (lang ? ' class="lang-' + escapeAttr(lang) + '"' : '') + '>' + escapeHtml(buf.join('\n')) + '</code></pre>');
4557
+ continue;
4558
+ }
4559
+ // Horizontal rule
4560
+ if (/^---\s*$/.test(line)) { out.push('<hr/>'); i++; continue; }
4561
+ // ATX headings
4562
+ const h = line.match(/^(#{1,6})\s+(.+)$/);
4563
+ if (h) {
4564
+ const level = h[1].length;
4565
+ out.push('<h' + level + '>' + renderInline(h[2]) + '</h' + level + '>');
4566
+ i++;
4567
+ continue;
4568
+ }
4569
+ // Blockquote (collect consecutive > lines)
4570
+ if (/^>\s?/.test(line)) {
4571
+ const buf = [];
4572
+ while (i < lines.length && /^>\s?/.test(lines[i])) {
4573
+ buf.push(lines[i].replace(/^>\s?/, ''));
4574
+ i++;
4575
+ }
4576
+ out.push('<blockquote>' + renderInline(buf.join(' ')) + '</blockquote>');
4577
+ continue;
4578
+ }
4579
+ // Table: header line then separator line then body until blank
4580
+ if (/\|/.test(line) && i + 1 < lines.length && /^\s*\|?\s*:?-+:?(\s*\|\s*:?-+:?)+\s*\|?\s*$/.test(lines[i + 1])) {
4581
+ const header = splitTableRow(line);
4582
+ i += 2;
4583
+ const rows = [];
4584
+ while (i < lines.length && /\|/.test(lines[i]) && lines[i].trim() !== '') {
4585
+ rows.push(splitTableRow(lines[i]));
4586
+ i++;
4587
+ }
4588
+ let html = '<table><thead><tr>';
4589
+ for (const cell of header) html += '<th>' + renderInline(cell) + '</th>';
4590
+ html += '</tr></thead><tbody>';
4591
+ for (const row of rows) {
4592
+ html += '<tr>';
4593
+ for (const cell of row) html += '<td>' + renderInline(cell) + '</td>';
4594
+ html += '</tr>';
4595
+ }
4596
+ html += '</tbody></table>';
4597
+ out.push(html);
4598
+ continue;
4599
+ }
4600
+ // Bullet list
4601
+ if (/^\s*[-*]\s+/.test(line)) {
4602
+ const buf = [];
4603
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
4604
+ buf.push(lines[i].replace(/^\s*[-*]\s+/, ''));
4605
+ i++;
4606
+ }
4607
+ out.push('<ul>' + buf.map(x => '<li>' + renderInline(x) + '</li>').join('') + '</ul>');
4608
+ continue;
4609
+ }
4610
+ // Numbered list
4611
+ if (/^\s*\d+\.\s+/.test(line)) {
4612
+ const buf = [];
4613
+ while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
4614
+ buf.push(lines[i].replace(/^\s*\d+\.\s+/, ''));
4615
+ i++;
4616
+ }
4617
+ out.push('<ol>' + buf.map(x => '<li>' + renderInline(x) + '</li>').join('') + '</ol>');
4618
+ continue;
4619
+ }
4620
+ // Blank line
4621
+ if (line.trim() === '') { i++; continue; }
4622
+ // Paragraph (collect consecutive non-special lines)
4623
+ const buf = [line];
4624
+ i++;
4625
+ while (i < lines.length && lines[i].trim() !== '' &&
4626
+ !/^(#{1,6}\s|>\s?|```|---\s*$|\s*[-*]\s+|\s*\d+\.\s+)/.test(lines[i]) &&
4627
+ !(/\|/.test(lines[i]) && i + 1 < lines.length && /^\s*\|?\s*:?-+:?/.test(lines[i + 1]))) {
4628
+ buf.push(lines[i]);
4629
+ i++;
4630
+ }
4631
+ out.push('<p>' + renderInline(buf.join(' ')) + '</p>');
4632
+ }
4633
+ return out.join('\n');
4634
+ }
4635
+
4636
+ function splitTableRow(line) {
4637
+ let s = line.trim();
4638
+ if (s.startsWith('|')) s = s.slice(1);
4639
+ if (s.endsWith('|')) s = s.slice(0, -1);
4640
+ return s.split('|').map(c => c.trim());
4641
+ }
4642
+
4643
+ // Inline markdown: code, bold, italic, links. Run on already-escaped HTML
4644
+ // so we don't expose injection paths via the source markdown — the Guide
4645
+ // is repo-controlled, but defense-in-depth is cheap.
4646
+ function renderInline(text) {
4647
+ let s = escapeHtml(text);
4648
+ // Inline code first (so its contents aren't re-processed for bold/italic)
4649
+ s = s.replace(/`([^`]+)`/g, (_, code) => '<code>' + code + '</code>');
4650
+ // Links [text](url)
4651
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
4652
+ return '<a href="' + escapeAttr(url) + '" target="_blank" rel="noopener">' + label + '</a>';
4653
+ });
4654
+ // Bold **text**
4655
+ s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
4656
+ // Italic *text* (avoid matching bold leftovers)
4657
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>');
4658
+ return s;
4659
+ }
4660
+
3768
4661
  // Boot
3769
4662
  init();