claude-code-memory-explorer 1.1.2 → 1.3.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/public/app.js CHANGED
@@ -5,6 +5,10 @@ let stackData = [];
5
5
  let summaryData = null;
6
6
  let selectedFileId = null;
7
7
  let healthViewOpen = false; // opt-in view; the Analyze button toggles it, selecting a file exits it
8
+ let homeData = null;
9
+ let homeOpen = false; // the user scope opens on the home cards; a card or the health view drills into the tree
10
+ let homeFocus = null; // scopes a home card drilled into; the tree shows only these until cleared
11
+ let previewPath = null; // a file outside the stack (settings, output style) shown read-only in the preview
8
12
 
9
13
  // #endregion STATE
10
14
 
@@ -202,10 +206,23 @@ async function fetchJSON(url) {
202
206
 
203
207
  // #region PROJECT
204
208
 
205
- async function loadProject() {
206
- projectData = await fetchJSON('/api/project');
209
+ // The user scope rides in the recents list as this entry, so the boot restore brings it back.
210
+ const USER_ROW = '::user::';
211
+
212
+ function projectKey(info) {
213
+ return info?.user ? USER_ROW : info?.path;
214
+ }
215
+
216
+ async function loadProject(info) {
217
+ const wasUser = !!projectData?.user;
218
+ projectData = info || (await fetchJSON('/api/project'));
207
219
  document.getElementById('projectName').textContent = projectData.name;
208
- document.getElementById('projectBtn').title = projectData.path;
220
+ document.getElementById('projectBtn').title = projectData.user
221
+ ? `User scope · ${projectData.configDir}`
222
+ : projectData.path;
223
+ document.body.classList.toggle('user-scope', !!projectData.user);
224
+ if (projectData.user !== wasUser) homeOpen = !!projectData.user;
225
+ homeFocus = null;
209
226
  }
210
227
 
211
228
  // Shared by the project picker, the boot restore, and the hub project shim. Throws with the
@@ -216,12 +233,13 @@ async function putProject(dirPath) {
216
233
  const res = await fetch('/api/project', {
217
234
  method: 'PUT',
218
235
  headers: { 'Content-Type': 'application/json' },
219
- body: JSON.stringify({ path: dirPath }),
236
+ body: JSON.stringify(dirPath === USER_ROW ? { user: true } : { path: dirPath }),
220
237
  });
221
238
  if (!res.ok) {
222
239
  const err = await res.json().catch(() => ({}));
223
240
  throw new Error(err.error || `${res.status}`);
224
241
  }
242
+ return res.json();
225
243
  }
226
244
 
227
245
  function changeProject() {
@@ -238,11 +256,15 @@ async function submitProjectPicker() {
238
256
  btn.disabled = true;
239
257
  btn.textContent = 'Switching...';
240
258
  try {
241
- await putProject(dirPath);
259
+ const info = await putProject(dirPath);
260
+ // A later hub push of the project this app left must still apply, not be deduped.
261
+ lastAppliedProject = dirPath;
242
262
  closeModal('projectPickerModal');
243
263
  addRecentProject(dirPath);
244
- await Promise.all([loadProject(), loadData()]);
245
- showToast('Project switched', 'success');
264
+ // loadData reads projectData to decide whether the home cards need /api/home.
265
+ await loadProject(info);
266
+ await loadData();
267
+ showToast(dirPath === USER_ROW ? 'Switched to user scope' : 'Project switched', 'success');
246
268
  } catch (err) {
247
269
  showToast(err.message, 'error');
248
270
  } finally {
@@ -291,6 +313,17 @@ function getRecentProjects() {
291
313
  }
292
314
  }
293
315
 
316
+ // A fresh app (no recents) starts on the user home, so the hub's replayed project does not apply either.
317
+ function userScopeIsHead() {
318
+ const head = getRecentProjects()[0];
319
+ return !head || head === USER_ROW;
320
+ }
321
+
322
+ async function isUserScopeRemembered() {
323
+ await projectInfoPromise;
324
+ return userScopeIsHead();
325
+ }
326
+
294
327
  function addRecentProject(p) {
295
328
  const recent = getRecentProjects().filter((r) => r !== p);
296
329
  recent.unshift(p);
@@ -305,7 +338,7 @@ function setPickerMode(add) {
305
338
  document.getElementById('projectPickerFilter').hidden = add;
306
339
  document.getElementById('projectPickerAddForm').hidden = !add;
307
340
  const input = document.getElementById(add ? 'projectPathInput' : 'projectPickerInput');
308
- if (add) input.value = document.getElementById('projectBtn').title;
341
+ if (add) input.value = projectData?.path || '';
309
342
  // The overlay fades in, so a focus() on a field that is still hidden is dropped.
310
343
  setTimeout(() => input.focus(), 100);
311
344
  }
@@ -319,19 +352,29 @@ function splitProjectPath(p) {
319
352
  function renderProjectPicker() {
320
353
  const list = document.getElementById('projectPickerList');
321
354
  const q = document.getElementById('projectPickerInput').value.trim().toLowerCase();
322
- const current = document.getElementById('projectBtn').title;
323
- const recent = getRecentProjects();
324
- pickerRows = q ? recent.filter((p) => p.toLowerCase().includes(q)) : recent;
355
+ const current = projectKey(projectData);
356
+ const recent = getRecentProjects().filter((p) => p !== USER_ROW);
357
+ const userMatches = !q || 'user (no project)'.includes(q);
358
+ pickerRows = [
359
+ ...(userMatches ? [USER_ROW] : []),
360
+ ...(q ? recent.filter((p) => p.toLowerCase().includes(q)) : recent),
361
+ ];
325
362
  if (!pickerRows.length) {
326
363
  pickerIdx = -1;
327
- const msg = recent.length ? 'No projects match' : 'No recent projects — add one below';
328
- list.innerHTML = `<div class="picker-empty">${msg}</div>`;
364
+ list.innerHTML = '<div class="picker-empty">No projects match</div>';
329
365
  return;
330
366
  }
331
367
  list.innerHTML = pickerRows
332
368
  .map((p, i) => {
333
- const { name, parent } = splitProjectPath(p);
334
369
  const cur = p === current;
370
+ if (p === USER_ROW) {
371
+ return `<div class="picker-row picker-user${cur ? ' current' : ''}" onclick="_pickProject(${i})" title="Sources every session loads, whatever the project">
372
+ <span class="picker-name">User (no project)</span>
373
+ <span class="picker-parent">${esc(claudeConfigDir)}</span>
374
+ ${cur ? '<span class="picker-tag">current</span>' : ''}
375
+ </div>`;
376
+ }
377
+ const { name, parent } = splitProjectPath(p);
335
378
  return `<div class="picker-row${cur ? ' current' : ''}" onclick="_pickProject(${i})" title="${esc(p)}">
336
379
  <span class="picker-name">${esc(name)}</span>
337
380
  <span class="picker-parent">${esc(parent)}</span>
@@ -363,7 +406,7 @@ function _pickProject(idx) {
363
406
  function _removeRecentProject(idx, e) {
364
407
  e.stopPropagation();
365
408
  const target = pickerRows[idx];
366
- if (!target) return;
409
+ if (!target || target === USER_ROW) return;
367
410
  localStorage.setItem(recentsKey(), JSON.stringify(getRecentProjects().filter((p) => p !== target)));
368
411
  renderProjectPicker();
369
412
  }
@@ -452,11 +495,29 @@ const SHORTCUT_PAIRS = [
452
495
  title: 'Hub',
453
496
  hub: true,
454
497
  rows: [
455
- { keys: ['Ctrl', 'Alt', '←/→'], combo: true, label: 'Previous / next hub app' },
456
- { keys: ['Alt', '1…9'], combo: true, label: 'Jump to hub app by number' },
457
- { keys: ['Ctrl', 'Alt', 'P'], combo: true, label: 'Project picker' },
458
- { keys: ['Ctrl', 'Alt', 'W'], combo: true, label: 'Config dir picker' },
459
- { keys: ['Ctrl', 'Alt', 'A'], combo: true, label: 'App launcher' },
498
+ {
499
+ keys: ['Ctrl', 'Alt', '←/→'],
500
+ action: ['hub.prevApp', 'hub.nextApp'],
501
+ combo: true,
502
+ hubMod: true,
503
+ label: 'Previous / next hub app',
504
+ },
505
+ {
506
+ keys: ['Alt', '1…9'],
507
+ action: 'hub.appByNumber',
508
+ combo: true,
509
+ hubMod: true,
510
+ label: 'Jump to hub app by number',
511
+ },
512
+ { keys: ['Ctrl', 'Alt', 'P'], action: 'hub.projectPicker', combo: true, hubMod: true, label: 'Project picker' },
513
+ {
514
+ keys: ['Ctrl', 'Alt', 'W'],
515
+ action: 'hub.configDirPicker',
516
+ combo: true,
517
+ hubMod: true,
518
+ label: 'Config dir picker',
519
+ },
520
+ { keys: ['Ctrl', 'Alt', 'A'], action: 'hub.appLauncher', combo: true, hubMod: true, label: 'App launcher' },
460
521
  ],
461
522
  },
462
523
  ],
@@ -464,6 +525,21 @@ const SHORTCUT_PAIRS = [
464
525
 
465
526
  const EMPTY_GROUP = { title: '', rows: [] };
466
527
 
528
+ // macOS names Control, Option and Shift by their symbols.
529
+ const IS_MAC = /^Mac/i.test(navigator.userAgentData?.platform || navigator.platform || '');
530
+ const MAC_KEYS = { Ctrl: '⌃', Alt: '⌥', Shift: '⇧' };
531
+
532
+ // The keys of a help row as they read on this system. The hub's modifier is Ctrl+Alt, and on
533
+ // macOS Control+Option for the tab numbers too (Windows and Linux use bare Alt for those). A hub
534
+ // row shows the keys the hub reports, which the user can change; row.keys until it answers.
535
+ function helpKeys(row, mac = IS_MAC, keyLabel = hub.keyLabel) {
536
+ const live = row.action && keyLabel(row.action);
537
+ if (live) return live;
538
+ if (!mac) return row.keys;
539
+ if (row.hubMod) return ['⌃', '⌥', ...row.keys.filter((k) => k !== 'Ctrl' && k !== 'Alt')];
540
+ return row.keys.map((k) => MAC_KEYS[k] || k);
541
+ }
542
+
467
543
  // Interleaves each pair's rows left-then-right so CSS grid auto-placement lands
468
544
  // them on shared row tracks (see .shortcuts in style.css).
469
545
  function buildHelpShortcuts() {
@@ -480,9 +556,10 @@ function buildHelpShortcuts() {
480
556
  for (let i = 0; i < Math.max(left.rows.length, right.rows.length); i++) {
481
557
  for (const [group, side] of sides) {
482
558
  const row = group.rows[i];
483
- if (!row) continue;
559
+ const parts = row && helpKeys(row);
560
+ if (!parts?.length) continue;
484
561
  const sep = row.combo ? '<span class="sc-plus">+</span>' : '<span class="sc-or">/</span>';
485
- const keys = row.keys.map((k) => `<kbd>${esc(k)}</kbd>`).join(sep);
562
+ const keys = parts.map((k) => `<kbd>${esc(k)}</kbd>`).join(sep);
486
563
  const cls = side + (group.hub ? ' sc-hub' : '');
487
564
  cells.push(`<dt class="${esc(cls)}">${keys}</dt><dd class="${esc(cls)}">${esc(row.label)}</dd>`);
488
565
  }
@@ -499,7 +576,8 @@ function toggleHelpModal() {
499
576
  }
500
577
 
501
578
  function openHealthView() {
502
- healthViewOpen = !healthViewOpen;
579
+ healthViewOpen = homeOpen || !healthViewOpen;
580
+ leaveHome();
503
581
  document.getElementById('analyzeBtn')?.classList.toggle('active', healthViewOpen);
504
582
  renderPreview();
505
583
  if (healthViewOpen) refreshAnalysis();
@@ -509,13 +587,14 @@ function openHealthView() {
509
587
 
510
588
  // #region RENDER_TREE
511
589
 
512
- const SCOPE_ORDER = ['policy', 'user', 'project', 'rule', 'memory', 'skill', 'agent-memory'];
590
+ const SCOPE_ORDER = ['policy', 'user', 'project', 'rule', 'memory', 'skill', 'agent', 'agent-memory'];
513
591
  const SCOPE_LABELS = {
514
592
  policy: 'Managed Policy',
515
593
  user: 'User',
516
594
  project: 'Project',
517
595
  rule: 'Rules',
518
596
  skill: 'Skills',
597
+ agent: 'Agents',
519
598
  memory: 'Auto Memory',
520
599
  'agent-memory': 'Agent Memory',
521
600
  };
@@ -612,9 +691,10 @@ function renderTree() {
612
691
  }
613
692
 
614
693
  let html = '';
694
+ if (projectData?.user) html += treeHomeLink();
615
695
  for (const scope of SCOPE_ORDER) {
616
696
  const items = groups[scope];
617
- if (!items) continue;
697
+ if (!items || !inFocus(scope)) continue;
618
698
  const label = SCOPE_LABELS[scope] || scope;
619
699
  const collapsible = scope === 'agent-memory';
620
700
  const collapsed = collapsible && isGroupCollapsed(scope);
@@ -703,13 +783,16 @@ function pushFileState(id) {
703
783
  }
704
784
 
705
785
  function selectFile(id, pushState = true) {
786
+ leaveHome();
706
787
  if (healthViewOpen) {
707
788
  healthViewOpen = false;
708
789
  document.getElementById('analyzeBtn')?.classList.remove('active');
709
790
  }
710
791
  selectedFileId = selectedFileId === id ? null : id;
792
+ previewPath = null;
711
793
  if (selectedFileId) {
712
794
  const item = stackData.find((s) => s.id === selectedFileId);
795
+ if (item && !inFocus(getItemScope(item))) homeFocus = null;
713
796
  if (item) {
714
797
  expandGroupForItem(item);
715
798
  expandItemAncestors(item);
@@ -749,7 +832,7 @@ function getVisibleOrder() {
749
832
  }
750
833
  };
751
834
  for (const scope of SCOPE_ORDER) {
752
- if (isGroupCollapsed(scope)) continue;
835
+ if (isGroupCollapsed(scope) || !inFocus(scope)) continue;
753
836
  const items = groups[scope];
754
837
  if (items) for (const item of items) walk(item);
755
838
  }
@@ -848,7 +931,7 @@ async function renderPreview() {
848
931
  if (lastAnalysisSt) rerenderAnalysis();
849
932
  return;
850
933
  }
851
- const source = stackData.find((s) => s.id === selectedFileId);
934
+ const source = stackData.find((s) => s.id === selectedFileId) || previewPath;
852
935
  if (!source) {
853
936
  panel.innerHTML =
854
937
  '<div class="preview-empty"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.3"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v4c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/><path d="M3 9v4c0 1.66 4.03 3 9 3s9-1.34 9-3V9"/><path d="M3 13v4c0 1.66 4.03 3 9 3s9-1.34 9-3v-4"/></svg><span>Select a file to preview</span></div>';
@@ -859,9 +942,11 @@ async function renderPreview() {
859
942
  try {
860
943
  fileData = await fetchJSON(`/api/file?path=${encodeURIComponent(source.path)}`);
861
944
  } catch {
862
- panel.innerHTML = '<div class="preview-empty"><span>Failed to load file</span></div>';
945
+ const msg = source.outside ? `${source.path} does not exist` : 'Failed to load file';
946
+ panel.innerHTML = `<div class="preview-empty"><span>${esc(msg)}</span></div>`;
863
947
  return;
864
948
  }
949
+ if (source.outside) Object.assign(source, { lines: fileData.lines, bytes: fileData.bytes });
865
950
 
866
951
  let html = '<div class="preview-header">';
867
952
  html += '<div class="preview-title">';
@@ -875,7 +960,8 @@ async function renderPreview() {
875
960
  }
876
961
  }
877
962
  html += `<button class="action-btn small" onclick="openInEditor('${escAttrJs(source.path)}')" title="Open in VS Code"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M17.583 2.207a1.1 1.1 0 0 1 1.541.033l2.636 2.636a1.1 1.1 0 0 1 .033 1.541L10.68 17.53a1.1 1.1 0 0 1-.345.247l-4.56 1.903a.55.55 0 0 1-.725-.725l1.903-4.56a1.1 1.1 0 0 1 .247-.345zm.902 1.87-8.794 8.793-.946 2.268 2.268-.946 8.794-8.793z"/></svg></button>`;
878
- html += `<button class="action-btn small" onclick="_confirmDeleteFile('${escAttrJs(source.path)}','${escAttrJs(source.name)}')" title="Delete file"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>`;
963
+ if (!source.outside)
964
+ html += `<button class="action-btn small" onclick="_confirmDeleteFile('${escAttrJs(source.path)}','${escAttrJs(source.name)}')" title="Delete file"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>`;
879
965
  html += '</div></div>';
880
966
 
881
967
  // Description gets its own full-width line; the rest stay as key:value chips
@@ -1080,6 +1166,7 @@ async function openInEditor(filePath) {
1080
1166
 
1081
1167
  // #region ANALYZER
1082
1168
 
1169
+ const SEV_ORDER = { high: 0, med: 1, low: 2, clean: 3 };
1083
1170
  let analysisPollTimer = null;
1084
1171
  let lastAnalysisSt = null;
1085
1172
  let analysisFindings = []; // findings of the rendered result, addressed by index from handlers
@@ -1236,6 +1323,7 @@ async function refreshAnalysis() {
1236
1323
  renderTree();
1237
1324
  if (isNew && !healthViewOpen && selectedFileId) renderPreview();
1238
1325
  rerenderAnalysis(st);
1326
+ if (homeOpen) renderHome();
1239
1327
  }
1240
1328
  lastPollKey = pollKey;
1241
1329
  clearTimeout(analysisPollTimer);
@@ -1312,8 +1400,11 @@ function analysisScopeGroups() {
1312
1400
  function _toggleScopePanel() {
1313
1401
  scopePanelOpen = !scopePanelOpen;
1314
1402
  if (scopePanelOpen && !scopeChecked) {
1315
- // default scope = auto memory only (skills, agent memory, CLAUDE.md are opt-in)
1316
- scopeChecked = new Set(stackData.filter((s) => s.scope === 'memory').map((s) => s.id));
1403
+ // default scope = auto memory only (skills, agent memory, CLAUDE.md are opt-in);
1404
+ // the user scope has no auto memory, so it starts on the user CLAUDE.md
1405
+ const memory = stackData.filter((s) => s.scope === 'memory');
1406
+ const fallback = stackData.filter((s) => s.scope === 'user' && !s.parentId);
1407
+ scopeChecked = new Set((memory.length ? memory : fallback).map((s) => s.id));
1317
1408
  }
1318
1409
  rerenderAnalysis();
1319
1410
  }
@@ -1709,14 +1800,13 @@ function renderMemoryMap(shown) {
1709
1800
  }
1710
1801
  }
1711
1802
  }
1712
- const sevRank = { high: 0, med: 1, low: 2, clean: 3 };
1713
1803
  const cellSev = (s) => {
1714
1804
  const c = perFile.get(s.id);
1715
1805
  return c ? (c.high ? 'high' : c.med ? 'med' : 'low') : 'clean';
1716
1806
  };
1717
1807
  let html = '<div class="hv-map-label">Memory map — colored by worst finding</div><div class="hv-map">';
1718
1808
  for (const s of [...audited].sort(
1719
- (a, b) => sevRank[cellSev(a)] - sevRank[cellSev(b)] || a.name.localeCompare(b.name),
1809
+ (a, b) => SEV_ORDER[cellSev(a)] - SEV_ORDER[cellSev(b)] || a.name.localeCompare(b.name),
1720
1810
  )) {
1721
1811
  const c = perFile.get(s.id);
1722
1812
  const sev = cellSev(s);
@@ -1781,10 +1871,9 @@ function _toggleFixSelect(idx) {
1781
1871
  }
1782
1872
 
1783
1873
  function _copyFixPlan() {
1784
- const order = { high: 0, med: 1, low: 2 };
1785
1874
  const picked = analysisFindings
1786
1875
  .filter((f) => fixSelected.has(findingKey(f)))
1787
- .sort((a, b) => order[a.severity] - order[b.severity]);
1876
+ .sort((a, b) => SEV_ORDER[a.severity] - SEV_ORDER[b.severity]);
1788
1877
  if (!picked.length) return;
1789
1878
  const plan = [
1790
1879
  `Fix plan — ${picked.length} finding${picked.length > 1 ? 's' : ''} from a Claude Code memory audit, ordered by severity. Apply each in order.`,
@@ -1968,6 +2057,11 @@ function renderAnalysis(st) {
1968
2057
 
1969
2058
  // #region RENDER_BUDGET
1970
2059
 
2060
+ // Listed descriptions: [scope, summary key, label]. Each item's cost is its description, not its file.
2061
+ const DESC_SEGMENTS = [
2062
+ ['skill', 'skillDesc', 'Skill descriptions'],
2063
+ ['agent', 'agentDesc', 'Agent descriptions'],
2064
+ ];
1971
2065
  let footprintHighlight = false;
1972
2066
  let footprintScope = null; // null = whole footprint; a scope name = just that segment's files
1973
2067
  let footprintIds = new Set(); // source ids the server counted into the footprint
@@ -1989,7 +2083,7 @@ function _toggleFootprint(scope = null, ev) {
1989
2083
  }
1990
2084
 
1991
2085
  function footprintHit(item) {
1992
- if (footprintScope === 'skill') return item.scope === 'skill' && !item.parentId;
2086
+ if (DESC_SEGMENTS.some(([scope]) => scope === footprintScope)) return item.scope === footprintScope && !item.parentId;
1993
2087
  if (footprintScope) return footprintIds.has(item.id) && item.scope === footprintScope;
1994
2088
  return footprintIds.has(item.id);
1995
2089
  }
@@ -2019,7 +2113,6 @@ function renderBudget() {
2019
2113
  // the footprint filter, the client only renders its per-scope totals.
2020
2114
  const segContainer = document.getElementById('budgetSegments');
2021
2115
  const scopeTotals = summaryData.scopeChars || {};
2022
- const skillDescChars = summaryData.skillDesc?.chars || 0;
2023
2116
  const totalChars = summaryData.totalChars || 1;
2024
2117
  let html = '';
2025
2118
  for (const scope of SCOPE_ORDER) {
@@ -2028,9 +2121,11 @@ function renderBudget() {
2028
2121
  const pct = (chars / totalChars) * 100;
2029
2122
  html += `<div class="budget-segment" data-scope="${esc(scope)}" onclick="_toggleFootprint('${escAttrJs(scope)}', event)" style="width:${pct}%;background:var(--scope-${scope})" title="${SCOPE_LABELS[scope] || scope}: ${chars.toLocaleString()} chars (${pct.toFixed(1)}%) — click to highlight"></div>`;
2030
2123
  }
2031
- if (skillDescChars) {
2032
- const pct = (skillDescChars / totalChars) * 100;
2033
- html += `<div class="budget-segment" data-scope="skill" onclick="_toggleFootprint('skill', event)" style="width:${pct}%;background:var(--scope-skill)" title="Skill descriptions (${esc(summaryData.skillDesc.count)} enabled): ${skillDescChars.toLocaleString()} chars (${pct.toFixed(1)}%) — click to highlight"></div>`;
2124
+ for (const [scope, desc, label] of DESC_SEGMENTS) {
2125
+ const chars = summaryData[desc]?.chars || 0;
2126
+ if (!chars) continue;
2127
+ const pct = (chars / totalChars) * 100;
2128
+ html += `<div class="budget-segment" data-scope="${esc(scope)}" onclick="_toggleFootprint('${escAttrJs(scope)}', event)" style="width:${pct}%;background:var(--scope-${scope})" title="${esc(label)} (${esc(summaryData[desc].count)}): ${chars.toLocaleString()} chars (${pct.toFixed(1)}%) — click to highlight"></div>`;
2034
2129
  }
2035
2130
  segContainer.innerHTML = html;
2036
2131
  applySegmentState();
@@ -2038,11 +2133,470 @@ function renderBudget() {
2038
2133
 
2039
2134
  // #endregion RENDER_BUDGET
2040
2135
 
2136
+ // #region HOME
2137
+
2138
+ const svgIcon = (body) => `<svg class="home-ic" viewBox="0 0 24 24">${body}</svg>`;
2139
+ const HOME_ICON = svgIcon('<path d="M3 10l9-7 9 7v10a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><path d="M9 22V12h6v10"/>');
2140
+ const LOCK_ICON = svgIcon('<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/>');
2141
+ const SPARKLE_ICON = svgIcon(
2142
+ '<path d="M12 3l1.9 5.7L19.6 10l-5.7 1.9L12 17.6l-1.9-5.7L4.4 10l5.7-1.9z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
2143
+ );
2144
+
2145
+ function applyLayout() {
2146
+ const on = homeOpen && !!projectData?.user;
2147
+ document.body.classList.toggle('home-mode', on);
2148
+ if (on) renderHome();
2149
+ }
2150
+
2151
+ function leaveHome() {
2152
+ if (!homeOpen) return;
2153
+ homeOpen = false;
2154
+ applyLayout();
2155
+ }
2156
+
2157
+ function showHome() {
2158
+ homeOpen = true;
2159
+ homeFocus = null;
2160
+ previewPath = null;
2161
+ healthViewOpen = false;
2162
+ applyLayout();
2163
+ }
2164
+
2165
+ function inFocus(scope) {
2166
+ return !homeFocus || homeFocus.includes(scope);
2167
+ }
2168
+
2169
+ function treeHomeLink() {
2170
+ const home = `<span class="tree-home-crumb" onclick="showHome()">${HOME_ICON}Home</span>`;
2171
+ if (!homeFocus) return `<div class="tree-home-link">${home}</div>`;
2172
+ const { groups } = getTreeIndex();
2173
+ const label = homeFocus
2174
+ .filter((s) => groups[s])
2175
+ .map((s) => SCOPE_LABELS[s] || s)
2176
+ .join(' + ');
2177
+ return `<div class="tree-home-link">${home}<span class="tree-home-sep">›</span><span class="tree-home-focus">${esc(label)}</span><span class="tree-home-all" onclick="_clearHomeFocus()" title="Show every group">show all</span></div>`;
2178
+ }
2179
+
2180
+ function _clearHomeFocus() {
2181
+ homeFocus = null;
2182
+ renderTree();
2183
+ scrollToSelected();
2184
+ }
2185
+
2186
+ // scopes is a comma list; without it the clicked file's own group is the focus.
2187
+ function _openFromHome(id, scopes) {
2188
+ const item = stackData.find((s) => s.id === id);
2189
+ homeFocus = scopes ? scopes.split(',') : item ? [getItemScope(item)] : null;
2190
+ for (const s of homeFocus || []) setGroupCollapsed(s, false);
2191
+ selectedFileId = null;
2192
+ selectFile(id);
2193
+ }
2194
+
2195
+ function _openPath(path, scope) {
2196
+ leaveHome();
2197
+ healthViewOpen = false;
2198
+ selectedFileId = null;
2199
+ previewPath = { outside: true, id: null, scope, load: 'read-only', path, name: splitProjectPath(path).name };
2200
+ renderTree();
2201
+ renderPreview();
2202
+ }
2203
+
2204
+ function fmtChars(n) {
2205
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
2206
+ }
2207
+
2208
+ function rootOf(item, byId) {
2209
+ let cur = item;
2210
+ while (cur?.parentId && byId.has(cur.parentId)) cur = byId.get(cur.parentId);
2211
+ return cur;
2212
+ }
2213
+
2214
+ function hardImportsOf(rootId) {
2215
+ const kids = (getTreeIndex().childrenOf[rootId] || []).filter((s) => s.load === 'import');
2216
+ return kids.flatMap((k) => [k, ...hardImportsOf(k.id)]);
2217
+ }
2218
+
2219
+ function withImportsChars(item) {
2220
+ return [item, ...hardImportsOf(item.id)].reduce((n, s) => n + (s.chars || 0), 0);
2221
+ }
2222
+
2223
+ const HOME_NONE = '<li><span class="home-empty">None</span></li>';
2224
+
2225
+ function moreRow(n) {
2226
+ return `<li class="home-more"><span class="home-nm">+ ${n} more</span></li>`;
2227
+ }
2228
+
2229
+ function homeBudget() {
2230
+ const parts = { policy: 0, user: 0, rule: 0 };
2231
+ const byId = new Map(stackData.map((s) => [s.id, s]));
2232
+ for (const s of stackData) {
2233
+ if (!footprintIds.has(s.id)) continue;
2234
+ const root = rootOf(s, byId);
2235
+ if (root.scope === 'policy') parts.policy += s.chars || 0;
2236
+ else if (root.id === 'user-claude-md') parts.user += s.chars || 0;
2237
+ else if (root.scope === 'rule' && root.load === 'always') parts.rule += s.chars || 0;
2238
+ }
2239
+ parts.policy += homeData?.managed?.claudeMdChars || 0;
2240
+ const style = homeData?.outputStyle?.chars ?? null;
2241
+ return [
2242
+ { key: 'policy', label: 'Policy', chars: parts.policy },
2243
+ { key: 'user', label: 'CLAUDE.md + imports', chars: parts.user },
2244
+ { key: 'rule', label: 'Always-on rules', chars: parts.rule },
2245
+ { key: 'style', label: 'Output style', chars: style || 0, unknown: style == null },
2246
+ { key: 'skill', label: 'Skill descriptions', chars: summaryData?.skillDesc?.chars || 0 },
2247
+ { key: 'agent', label: 'Agent descriptions', chars: summaryData?.agentDesc?.chars || 0 },
2248
+ ];
2249
+ }
2250
+
2251
+ function homeCardClick({ open, focus, file, fileScope, health }) {
2252
+ if (open) return ` onclick="_openFromHome('${escAttrJs(open)}', '${escAttrJs(focus || '')}')"`;
2253
+ if (file) return ` onclick="_openPath('${escAttrJs(file)}', '${escAttrJs(fileScope)}')"`;
2254
+ if (health) return ' onclick="openHealthView()"';
2255
+ return '';
2256
+ }
2257
+
2258
+ function homeCard({ color, title, when, always, wide, full, body, ...click }) {
2259
+ const onclick = homeCardClick(click);
2260
+ return `<div class="home-card${full ? ' full' : wide ? ' wide' : ''}" style="--c:${color}"${onclick}${onclick ? ' tabindex="0" role="button"' : ''}>
2261
+ <div class="home-ch"><span class="home-title">${title}</span>${when ? `<span class="home-when${always ? ' always' : ''}">${esc(when)}</span>` : ''}</div>
2262
+ ${body}
2263
+ </div>`;
2264
+ }
2265
+
2266
+ function homeRow(s, right, extra = '', label = s.name) {
2267
+ return `<li onclick="event.stopPropagation();_openFromHome('${escAttrJs(s.id)}')"><span class="home-nm">${esc(label)}</span>${extra}<span class="home-r">${right}</span></li>`;
2268
+ }
2269
+
2270
+ function homeMiniRow(s, chars, max, color) {
2271
+ const pct = max ? Math.max(4, Math.round((chars / max) * 100)) : 0;
2272
+ return homeRow(
2273
+ s,
2274
+ esc(chars.toLocaleString()),
2275
+ `<span class="home-mini" style="--c:${color}"><span style="width:${pct}%"></span></span>`,
2276
+ );
2277
+ }
2278
+
2279
+ function homeTopRows(items, color, limit = 3) {
2280
+ if (!items.length) return HOME_NONE;
2281
+ const sorted = [...items].sort((a, b) => b.descChars - a.descChars);
2282
+ const max = sorted[0].descChars;
2283
+ let html = sorted
2284
+ .slice(0, limit)
2285
+ .map((s) => homeMiniRow(s, s.descChars, max, color))
2286
+ .join('');
2287
+ if (sorted.length > limit) html += moreRow(sorted.length - limit);
2288
+ return html;
2289
+ }
2290
+
2291
+ function homeStats(pairs) {
2292
+ return `<div class="home-stats">${pairs.map(([v, l]) => `<div><b>${esc(v)}</b><span>${esc(l)}</span></div>`).join('')}</div>`;
2293
+ }
2294
+
2295
+ function renderHomePolicy() {
2296
+ const policy = stackData.find((s) => s.scope === 'policy' && !s.parentId);
2297
+ const managed = homeData?.managed || {};
2298
+ const managedPath = homeData?.settingsPaths?.managed || '';
2299
+ const none = '<span class="home-pill mut">none</span>';
2300
+ const mdRow = policy
2301
+ ? homeRow(policy, esc(`${fmtChars(withImportsChars(policy))} chars`), '', 'CLAUDE.md')
2302
+ : `<li><span class="home-nm">CLAUDE.md</span><span class="home-r">${none}</span></li>`;
2303
+ const settingsName = splitProjectPath(managedPath).name || 'managed-settings.json';
2304
+ const settingsRow = managed.keys
2305
+ ? `<li onclick="event.stopPropagation();_openPath('${escAttrJs(managedPath)}', 'policy')" title="${esc(managedPath)}"><span class="home-nm">${esc(settingsName)}${managed.source === 'server' ? ' <span class="home-d">· from claude.ai</span>' : ''}</span><span class="home-r">${esc(managed.keys.join(', ') || 'empty')}</span></li>`
2306
+ : `<li title="${esc(managedPath)}"><span class="home-nm">managed settings</span><span class="home-r">${none}</span></li>`;
2307
+ const claudeMdRow =
2308
+ managed.claudeMdChars == null
2309
+ ? ''
2310
+ : `<li><span class="home-nm">claudeMd in settings</span><span class="home-r">${esc(`${fmtChars(managed.claudeMdChars)} chars`)}</span></li>`;
2311
+ return homeCard({
2312
+ color: 'var(--scope-policy)',
2313
+ title: `${LOCK_ICON}Managed policy`,
2314
+ when: 'always',
2315
+ always: true,
2316
+ open: policy?.id,
2317
+ file: managed.keys ? managedPath : null,
2318
+ fileScope: 'policy',
2319
+ body: `<ul class="home-rows">${mdRow}${settingsRow}${claudeMdRow}</ul>`,
2320
+ });
2321
+ }
2322
+
2323
+ function renderHomeUserMd() {
2324
+ const md = stackData.find((s) => s.id === 'user-claude-md');
2325
+ const card = { color: 'var(--scope-user)', title: 'User CLAUDE.md', when: 'always', always: true };
2326
+ if (!md) {
2327
+ return homeCard({
2328
+ ...card,
2329
+ body: `<div class="home-path">${esc(summaryData?.userClaudeMd || '')}</div><div class="home-empty">File does not exist</div>`,
2330
+ });
2331
+ }
2332
+ const imports = hardImportsOf(md.id);
2333
+ const tree = imports
2334
+ .map((s, i) => {
2335
+ const branch = i === imports.length - 1 ? '└─' : '├─';
2336
+ return `<div onclick="event.stopPropagation();_openFromHome('${escAttrJs(s.id)}')"><span class="home-d">${branch}</span> @${esc(s.name)} <span class="home-d">${esc(fmtChars(s.chars || 0))}</span></div>`;
2337
+ })
2338
+ .join('');
2339
+ return homeCard({
2340
+ ...card,
2341
+ open: md.id,
2342
+ body: `<div class="home-path" title="${esc(md.path)}">${esc(md.path)}</div>${homeStats([
2343
+ [md.lines, 'lines'],
2344
+ [fmtChars(withImportsChars(md)), 'chars'],
2345
+ [imports.length, 'imports'],
2346
+ ])}${imports.length ? `<div class="home-tree">${tree}</div>` : ''}`,
2347
+ });
2348
+ }
2349
+
2350
+ function renderHomeStyle() {
2351
+ const st = homeData?.outputStyle;
2352
+ if (!st) return '';
2353
+ const isDefault = st.from === 'default';
2354
+ const isManaged = st.from === 'managed';
2355
+ const fromLabel = isDefault ? 'not set · default' : `set in ${isManaged ? 'managed' : 'user'} settings`;
2356
+ const keep = st.keepCodingInstructions == null ? 'n/a' : String(st.keepCodingInstructions);
2357
+ const setIn = isManaged ? homeData.settingsPaths.managed : homeData.settingsPaths.user;
2358
+ const setInScope = isManaged ? 'policy' : 'user';
2359
+ const fromRow = isDefault
2360
+ ? `<li><span class="home-nm">${esc(fromLabel)}</span></li>`
2361
+ : `<li onclick="event.stopPropagation();_openPath('${escAttrJs(setIn)}', '${escAttrJs(setInScope)}')"><span class="home-nm" title="${esc(setIn)}">${esc(fromLabel)}</span></li>`;
2362
+ return homeCard({
2363
+ color: 'var(--scope-style)',
2364
+ title: 'Output style',
2365
+ when: 'always',
2366
+ always: true,
2367
+ file: st.path || (isDefault ? null : setIn),
2368
+ fileScope: st.path ? 'user' : setInScope,
2369
+ body: `${homeStats([[st.name, !st.path ? 'built-in · text not on disk' : `${fmtChars(st.chars || 0)} chars`]])}
2370
+ <ul class="home-rows">
2371
+ ${fromRow}
2372
+ <li><span class="home-nm">keep-coding-instructions</span><span class="home-r">${esc(keep)}</span></li>
2373
+ </ul>`,
2374
+ });
2375
+ }
2376
+
2377
+ function renderHomeRules() {
2378
+ const rules = stackData.filter((s) => s.scope === 'rule' && !s.parentId);
2379
+ const always = rules.filter((s) => s.load === 'always');
2380
+ const cond = rules.filter((s) => s.load !== 'always');
2381
+ const alwaysChars = always.reduce((n, s) => n + withImportsChars(s), 0);
2382
+ const globs = (s) => {
2383
+ const p = s.frontmatter?.paths;
2384
+ return Array.isArray(p) ? p.join(', ') : String(p || '');
2385
+ };
2386
+ const list = (items, right) => (items.length ? items.map((s) => homeRow(s, right(s))).join('') : HOME_NONE);
2387
+ return homeCard({
2388
+ color: 'var(--scope-rule)',
2389
+ title: 'User rules',
2390
+ when: `${claudeConfigDir}/rules`,
2391
+ wide: true,
2392
+ open: rules[0]?.id,
2393
+ body: `<div class="home-cols">
2394
+ <div><div class="home-colh">Always loaded · ${always.length}${always.length ? ` · ${esc(fmtChars(alwaysChars))}` : ''}</div>
2395
+ <ul class="home-rows">${list(always, (s) => esc(fmtChars(withImportsChars(s))))}</ul></div>
2396
+ <div><div class="home-colh">When a file matches · ${cond.length}</div>
2397
+ <ul class="home-rows">${list(cond, (s) => esc(globs(s)))}</ul></div>
2398
+ </div>`,
2399
+ });
2400
+ }
2401
+
2402
+ function renderHomeAgentMemory() {
2403
+ const idx = stackData.filter((s) => s.scope === 'agent-memory' && s.name === 'MEMORY.md' && !s.parentId);
2404
+ const rows = idx
2405
+ .map((s) => {
2406
+ const max = s.maxLines || 200;
2407
+ const pct = Math.min(100, Math.round((s.lines / max) * 100));
2408
+ const warn = pct >= 90 || s.bytes > (s.maxBytes || 25 * 1024);
2409
+ const bar = `<span class="home-mini" style="--c:var(--scope-agent-memory)"><span class="${warn ? 'warn' : ''}" style="width:${pct}%"></span></span>`;
2410
+ return homeRow(s, esc(`${s.lines}/${max}`), bar, s.agentName);
2411
+ })
2412
+ .join('');
2413
+ return homeCard({
2414
+ color: 'var(--scope-agent-memory)',
2415
+ title: 'Subagent memory',
2416
+ when: 'on agent start',
2417
+ open: idx[0]?.id,
2418
+ body: `<div class="home-path">${esc(claudeConfigDir)}/agent-memory/</div>${
2419
+ rows ? `<ul class="home-rows">${rows}</ul>` : '<div class="home-empty">No subagent keeps user memory</div>'
2420
+ }`,
2421
+ });
2422
+ }
2423
+
2424
+ function renderHomeListed() {
2425
+ const skills = stackData.filter((s) => s.scope === 'skill' && s.descChars != null);
2426
+ const agents = stackData.filter((s) => s.scope === 'agent' && s.descChars != null);
2427
+ const first = skills[0] || agents[0];
2428
+ return homeCard({
2429
+ color: 'var(--scope-skill)',
2430
+ title: 'User skills and agents',
2431
+ when: 'descriptions only',
2432
+ always: true,
2433
+ wide: true,
2434
+ open: first?.id,
2435
+ focus: 'skill,agent',
2436
+ body: `<div class="home-cols">
2437
+ <div><div class="home-colh">Skills · ${skills.length}${skills.length ? ` · ${esc(fmtChars(summaryData?.skillDesc?.chars || 0))}` : ''}</div>
2438
+ <ul class="home-rows">${homeTopRows(skills, 'var(--scope-skill)')}</ul></div>
2439
+ <div><div class="home-colh">Agents · ${agents.length}${agents.length ? ` · ${esc(fmtChars(summaryData?.agentDesc?.chars || 0))}` : ''}</div>
2440
+ <ul class="home-rows">${homeTopRows(agents, 'var(--scope-agent)')}</ul></div>
2441
+ </div>${
2442
+ hub.can('project.plugins')
2443
+ ? '<ul class="home-rows home-plugins"><li onclick="event.stopPropagation();_openPlugins()"><span class="home-nm">Plugins list their skills and agents too. Open Marketplace</span></li></ul>'
2444
+ : ''
2445
+ }`,
2446
+ });
2447
+ }
2448
+
2449
+ function _openPlugins() {
2450
+ hub.invoke('project.plugins');
2451
+ }
2452
+
2453
+ function renderHomeSettings() {
2454
+ const set = homeData?.settings;
2455
+ if (!set) return '';
2456
+ const show = (v) => {
2457
+ if (v == null) return '—';
2458
+ if (Array.isArray(v)) return v.length ? v.join(', ') : '[]';
2459
+ return String(v);
2460
+ };
2461
+ const rows = [
2462
+ ['autoMemoryEnabled', set.autoMemoryEnabled],
2463
+ ['autoMemoryDirectory', set.autoMemoryDirectory],
2464
+ ['claudeMdExcludes', set.claudeMdExcludes],
2465
+ ['cleanupPeriodDays', set.cleanupPeriodDays],
2466
+ ['DISABLE_AUTO_MEMORY', set.CLAUDE_CODE_DISABLE_AUTO_MEMORY],
2467
+ ]
2468
+ .map(
2469
+ ([k, s]) =>
2470
+ `<span class="home-k">${esc(k)}</span><span>${esc(show(s?.value))}<span class="home-from">${esc(s?.from || '')}</span></span>`,
2471
+ )
2472
+ .join('');
2473
+ return homeCard({
2474
+ color: 'var(--text-muted)',
2475
+ title: 'Memory settings',
2476
+ when: 'effective',
2477
+ file: homeData.settingsPaths.user,
2478
+ fileScope: 'user',
2479
+ body: `<div class="home-kv">${rows}</div>`,
2480
+ });
2481
+ }
2482
+
2483
+ const SEVERITY_PILL = { high: 'err', med: 'warn', low: 'mut' };
2484
+
2485
+ function renderHomeHealth() {
2486
+ const st = lastAnalysisSt;
2487
+ const run = st ? mergedRunView(st) : null;
2488
+ const latest = allAnalysisRuns(st)[0]?.run;
2489
+ const pending = (st?.pending || []).length;
2490
+ let rows;
2491
+ if (!run) {
2492
+ rows =
2493
+ '<li><span class="home-empty">No run yet. Analyze checks the user scope for conflicts and stale facts.</span></li>';
2494
+ } else {
2495
+ const findings = (run.result?.findings || [])
2496
+ .filter((f) => !isDismissed(f))
2497
+ .sort((a, b) => (SEV_ORDER[a.severity] ?? 3) - (SEV_ORDER[b.severity] ?? 3));
2498
+ rows = findings.length
2499
+ ? findings
2500
+ .slice(0, 4)
2501
+ .map(
2502
+ (f) =>
2503
+ `<li><span class="home-pill ${SEVERITY_PILL[f.severity] || 'mut'}">${esc(f.kind || f.severity)}</span><span class="home-nm">${esc(f.title)}</span></li>`,
2504
+ )
2505
+ .join('')
2506
+ : '<li><span class="home-pill ok">ok</span><span class="home-nm">No open findings</span></li>';
2507
+ if (findings.length > 4) rows += moreRow(findings.length - 4);
2508
+ }
2509
+ const when = pending ? 'running…' : latest ? `last run ${analysisTimeAgo(latest.ts)}` : 'never run';
2510
+ return homeCard({
2511
+ color: 'var(--accent)',
2512
+ title: `${SPARKLE_ICON}Health · user scope`,
2513
+ when,
2514
+ full: true,
2515
+ health: true,
2516
+ body: `<ul class="home-rows">${rows}</ul>`,
2517
+ });
2518
+ }
2519
+
2520
+ function renderHomeRecents() {
2521
+ const recent = getRecentProjects()
2522
+ .filter((p) => p !== USER_ROW)
2523
+ .slice(0, 6);
2524
+ const chips = recent
2525
+ .map((p) => {
2526
+ const { name } = splitProjectPath(p);
2527
+ return `<span class="home-chip" title="${esc(p)}" onclick="_homeSwitch('${escAttrJs(p)}')"><span class="home-dot"></span>${esc(name)}</span>`;
2528
+ })
2529
+ .join('');
2530
+ return `<div class="home-recents"><h2>Open a project</h2><div class="home-chips">${chips}<span class="home-chip home-chip-add" onclick="_homeAddPath()">+ Add path</span></div></div>`;
2531
+ }
2532
+
2533
+ function _homeSwitch(p) {
2534
+ document.getElementById('projectPathInput').value = p;
2535
+ submitProjectPicker();
2536
+ }
2537
+
2538
+ function _goUserHome() {
2539
+ if (projectData?.user) showHome();
2540
+ else _homeSwitch(USER_ROW);
2541
+ }
2542
+
2543
+ function _homeAddPath() {
2544
+ changeProject();
2545
+ setPickerMode(true);
2546
+ }
2547
+
2548
+ function renderHome() {
2549
+ const el = document.getElementById('homeView');
2550
+ if (!el || !summaryData) return;
2551
+ const parts = homeBudget();
2552
+ const known = parts.reduce((n, p) => n + p.chars, 0);
2553
+ const total = known || 1;
2554
+ const bar = parts
2555
+ .filter((p) => p.chars)
2556
+ .map(
2557
+ (p) =>
2558
+ `<span style="width:${(p.chars / total) * 100}%;background:var(--scope-${p.key})" title="${esc(p.label)}: ${p.chars.toLocaleString()} chars"></span>`,
2559
+ )
2560
+ .join('');
2561
+ const legend = parts
2562
+ .map((p) =>
2563
+ p.unknown
2564
+ ? `<span class="home-unk"><i style="background:var(--scope-${p.key})"></i>${esc(p.label)}: built-in, not counted</span>`
2565
+ : `<span${p.chars ? '' : ' class="home-zero"'}><i style="background:var(--scope-${p.key})"></i>${esc(p.label)} <b>${esc(fmtChars(p.chars))}</b></span>`,
2566
+ )
2567
+ .join('');
2568
+ el.innerHTML = `<div class="home">
2569
+ <h1>Your memory</h1>
2570
+ <div class="home-budget">
2571
+ <div class="home-budget-head"><span class="t">Loaded into every session</span><span class="n">${esc(known.toLocaleString())} <small>chars · from files on disk</small></span></div>
2572
+ <div class="home-bar">${bar}</div>
2573
+ <div class="home-legend">${legend}</div>
2574
+ </div>
2575
+ <div class="home-grid">
2576
+ ${renderHomePolicy()}
2577
+ ${renderHomeUserMd()}
2578
+ ${renderHomeStyle()}
2579
+ ${renderHomeListed()}
2580
+ ${renderHomeSettings()}
2581
+ ${renderHomeRules()}
2582
+ ${renderHomeAgentMemory()}
2583
+ ${renderHomeHealth()}
2584
+ </div>
2585
+ ${renderHomeRecents()}
2586
+ </div>`;
2587
+ }
2588
+
2589
+ // #endregion HOME
2590
+
2041
2591
  // #region DATA
2042
2592
 
2043
2593
  async function loadData() {
2044
2594
  try {
2045
- [stackData, summaryData] = await Promise.all([fetchJSON('/api/stack'), fetchJSON('/api/summary')]);
2595
+ [stackData, summaryData, homeData] = await Promise.all([
2596
+ fetchJSON('/api/stack'),
2597
+ fetchJSON('/api/summary'),
2598
+ projectData?.user ? fetchJSON('/api/home') : null,
2599
+ ]);
2046
2600
  invalidateTreeIndex();
2047
2601
  renderBudget(); // refreshes footprintIds, which renderTree's highlight reads
2048
2602
  renderTree();
@@ -2055,6 +2609,7 @@ async function loadData() {
2055
2609
  }
2056
2610
  if (selectedFileId) renderTree();
2057
2611
  renderPreview();
2612
+ applyLayout();
2058
2613
  } catch (err) {
2059
2614
  showToast(`Failed to load: ${err.message}`, 'error');
2060
2615
  }
@@ -2102,6 +2657,11 @@ document.addEventListener('keydown', (e) => {
2102
2657
  openHealthView();
2103
2658
  return;
2104
2659
  }
2660
+ if (e.key === 'Escape' && projectData?.user && !homeOpen) {
2661
+ e.preventDefault();
2662
+ showHome();
2663
+ return;
2664
+ }
2105
2665
  if (e.key === 't') toggleTheme();
2106
2666
  if (e.key === 'r') refreshData();
2107
2667
  if (e.key === '?') {
@@ -2112,6 +2672,14 @@ document.addEventListener('keydown', (e) => {
2112
2672
  e.preventDefault();
2113
2673
  changeProject();
2114
2674
  }
2675
+ if (homeOpen) {
2676
+ const card = e.target.closest?.('.home-card[onclick]');
2677
+ if (card && (e.key === 'Enter' || e.key === ' ')) {
2678
+ e.preventDefault();
2679
+ card.click();
2680
+ }
2681
+ return;
2682
+ }
2115
2683
  // Skip when modifiers are held (e.g. hub's Ctrl+Alt+Arrow app switching).
2116
2684
  const plain = !e.ctrlKey && !e.altKey && !e.metaKey;
2117
2685
  if (plain && (e.key === 'j' || e.key === 'ArrowDown')) {
@@ -2131,8 +2699,8 @@ document.addEventListener('keydown', (e) => {
2131
2699
  expandOrDescend();
2132
2700
  }
2133
2701
  if (e.key === 'Enter' && selectedFileId) renderPreview();
2134
- if (e.key === 'e' && selectedFileId) {
2135
- const s = stackData.find((x) => x.id === selectedFileId);
2702
+ if (e.key === 'e' && (selectedFileId || previewPath)) {
2703
+ const s = stackData.find((x) => x.id === selectedFileId) || previewPath;
2136
2704
  if (s) openInEditor(s.path);
2137
2705
  }
2138
2706
  });
@@ -2173,6 +2741,7 @@ hub.onThemes((themes) => {
2173
2741
  // Set synchronously when the hub pushes a project, so the DOMContentLoaded restore below can't
2174
2742
  // let a stale localStorage recent win the race against the hub's choice.
2175
2743
  let hubProjectPath = null;
2744
+ let hubProjectReplayed = false;
2176
2745
  // Shared with the DOMContentLoaded restore so a boot restore and a hub push never apply the same project twice.
2177
2746
  let lastAppliedProject = null;
2178
2747
 
@@ -2183,22 +2752,35 @@ let lastAppliedProject = null;
2183
2752
  if (lastAppliedProject === dirPath) return;
2184
2753
  lastAppliedProject = dirPath;
2185
2754
  try {
2186
- await putProject(dirPath);
2755
+ const info = await putProject(dirPath);
2187
2756
  // The hub does not persist its own scope, so a hub-pushed project must become the recent
2188
2757
  // head here: after a hard refresh the boot path PUTs getRecentProjects()[0], which would
2189
2758
  // otherwise clobber the server's hub-scoped project with the previous one.
2190
2759
  addRecentProject(dirPath);
2191
- await Promise.all([loadProject(), loadData()]);
2760
+ await loadProject(info);
2761
+ await loadData();
2192
2762
  } catch (err) {
2193
2763
  lastAppliedProject = null;
2194
2764
  throw err;
2195
2765
  }
2196
2766
  }
2197
2767
 
2198
- hub.subscribe('project.changed', (p) => {
2768
+ // The hub replays project.changed after its welcome and before the first hub:active; a live
2769
+ // change comes after it. Only a live change takes the app off a remembered user scope.
2770
+ let hubActiveSeen = false;
2771
+ hub.onActive(() => {
2772
+ hubActiveSeen = true;
2773
+ });
2774
+
2775
+ hub.subscribe('project.changed', async (p) => {
2199
2776
  const dirPath = typeof p?.project === 'string' && p.project ? p.project : null;
2200
2777
  if (!dirPath) return;
2201
2778
  hubProjectPath = dirPath;
2779
+ hubProjectReplayed = !hubActiveSeen;
2780
+ if (hubProjectReplayed && (await isUserScopeRemembered())) {
2781
+ if (hubProjectPath === dirPath) hubProjectPath = null;
2782
+ return;
2783
+ }
2202
2784
  applyProject(dirPath).catch((err) => console.warn('project.changed failed:', err.message));
2203
2785
  });
2204
2786
 
@@ -2211,6 +2793,11 @@ let lastAppliedProject = null;
2211
2793
 
2212
2794
  // #endregion HUB_INTEGRATION
2213
2795
 
2796
+ // The home's Marketplace link depends on hub.can, which only knows the hub's actions after welcome.
2797
+ hub.onStatus(() => {
2798
+ if (homeOpen) renderHome();
2799
+ });
2800
+
2214
2801
  // #region RESIZE
2215
2802
 
2216
2803
  function initResize() {
@@ -2270,7 +2857,10 @@ document.addEventListener('DOMContentLoaded', async () => {
2270
2857
  // A hub-pushed project outranks the localStorage recent: ClaudeHub.connect() is fire-and-forget at script
2271
2858
  // eval, so project.changed can land before or during this block.
2272
2859
  await projectInfoPromise;
2273
- if (!desiredProject) desiredProject = hubProjectPath || getRecentProjects()[0] || null;
2860
+ if (!desiredProject) {
2861
+ const hubPick = hubProjectReplayed && userScopeIsHead() ? null : hubProjectPath;
2862
+ desiredProject = hubPick || getRecentProjects()[0] || USER_ROW;
2863
+ }
2274
2864
  if (desiredProject) {
2275
2865
  // Set before the PUT: the hub's push can land while it is in flight.
2276
2866
  lastAppliedProject = desiredProject;
@@ -2301,12 +2891,13 @@ document.addEventListener('DOMContentLoaded', async () => {
2301
2891
  else showToast('Failed to connect to server', 'error');
2302
2892
  }
2303
2893
  }
2304
- if (projectData) addRecentProject(projectData.path);
2894
+ if (projectData) addRecentProject(projectKey(projectData));
2305
2895
  await loadData();
2306
2896
  // Restore file selection from hash
2307
2897
  const hash = decodeURIComponent(location.hash.slice(1));
2308
2898
  const hashItem = hash && stackData.find((s) => s.id === hash);
2309
2899
  if (hashItem) {
2900
+ leaveHome();
2310
2901
  selectedFileId = hash;
2311
2902
  expandGroupForItem(hashItem);
2312
2903
  expandItemAncestors(hashItem);