@westbayberry/dg 2.0.8 → 2.0.11

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.
Files changed (50) hide show
  1. package/README.md +17 -12
  2. package/dist/api/analyze.js +134 -34
  3. package/dist/audit-ui/export.js +3 -4
  4. package/dist/auth/device-login.js +13 -9
  5. package/dist/auth/store.js +43 -26
  6. package/dist/bin/dg.js +5 -0
  7. package/dist/commands/audit.js +14 -4
  8. package/dist/commands/config.js +3 -5
  9. package/dist/commands/doctor.js +3 -3
  10. package/dist/commands/explain.js +138 -6
  11. package/dist/commands/licenses.js +37 -24
  12. package/dist/commands/login.js +12 -3
  13. package/dist/commands/logout.js +15 -4
  14. package/dist/commands/scan.js +1 -1
  15. package/dist/commands/service.js +76 -24
  16. package/dist/commands/status.js +38 -4
  17. package/dist/commands/types.js +1 -0
  18. package/dist/config/settings.js +102 -22
  19. package/dist/launcher/install-preflight.js +81 -12
  20. package/dist/launcher/output-redaction.js +5 -3
  21. package/dist/launcher/preflight-prompt.js +31 -12
  22. package/dist/launcher/run.js +87 -8
  23. package/dist/proxy/ca.js +69 -29
  24. package/dist/proxy/enforcement.js +41 -3
  25. package/dist/proxy/worker.js +21 -9
  26. package/dist/runtime/first-run.js +33 -2
  27. package/dist/runtime/nudges.js +9 -2
  28. package/dist/scan/analyze-worker.js +18 -8
  29. package/dist/scan/collect.js +45 -32
  30. package/dist/scan/command.js +80 -40
  31. package/dist/scan/discovery.js +75 -7
  32. package/dist/scan/render.js +22 -6
  33. package/dist/scan/scanner-report.js +89 -12
  34. package/dist/scan/staged.js +69 -7
  35. package/dist/scan-ui/LegacyApp.js +10 -48
  36. package/dist/scan-ui/components/InteractiveResultsView.js +171 -111
  37. package/dist/scan-ui/components/ProjectSelector.js +3 -3
  38. package/dist/scan-ui/components/ScoreHeader.js +8 -4
  39. package/dist/scan-ui/hooks/useScan.js +74 -27
  40. package/dist/scan-ui/launch.js +21 -4
  41. package/dist/service/state.js +15 -4
  42. package/dist/service/trust-store.js +23 -2
  43. package/dist/setup/git-hook.js +28 -17
  44. package/dist/setup/plan.js +302 -18
  45. package/dist/state/cleanup-registry.js +65 -8
  46. package/dist/state/locks.js +95 -9
  47. package/dist/state/sessions.js +66 -2
  48. package/dist/verify/package-check.js +22 -3
  49. package/dist/verify/preflight.js +328 -170
  50. package/package.json +1 -1
@@ -5,7 +5,7 @@ import chalk from "chalk";
5
5
  import { writeFileSync } from "node:fs";
6
6
  import { resolve as resolvePath } from "node:path";
7
7
  import { isLoggedIn } from "../shims.js";
8
- import { ScoreHeader } from "./ScoreHeader.js";
8
+ import { ScoreHeader, COMPACT_ROWS } from "./ScoreHeader.js";
9
9
  import { useExpandAnimation } from "../hooks/useExpandAnimation.js";
10
10
  import { useTerminalSize } from "../hooks/useTerminalSize.js";
11
11
  import { clearScreen } from "../alt-screen.js";
@@ -51,77 +51,55 @@ export function packageBadge(pkg) {
51
51
  return actionBadge(pkg.action);
52
52
  }
53
53
  const EVIDENCE_LIMIT = 2;
54
+ const BADGE_COL = "Unverified".length + 1;
54
55
  // Fixed lines outside the scrollable group area:
55
56
  // 5 ScoreHeader box | 2 Flagged box top | 2 scroll indicators
56
57
  // 1 Flagged box bottom | 4 Clean/Duration box | 1 help bar | 1 margin
57
58
  const FIXED_CHROME = 21;
59
+ const FIXED_CHROME_COMPACT = 14;
58
60
  function firstPackage(group) {
59
61
  const rep = group.packages[0];
60
62
  if (!rep)
61
63
  throw new Error("package group cannot be empty");
62
64
  return rep;
63
65
  }
66
+ function statusSummaryLines(rep) {
67
+ if ((rep.action ?? "pass") === "pass")
68
+ return ["No findings"];
69
+ const reasons = rep.reasons.length > 0
70
+ ? [...rep.reasons]
71
+ : rep.findings.map((f) => f.title ?? "").filter((t) => t.length > 0);
72
+ if (reasons.length > 0)
73
+ return reasons;
74
+ return rep.action === "analysis_incomplete" ? ["Analysis incomplete"] : [];
75
+ }
64
76
  function findingsSummaryHeight(group) {
65
77
  const rep = firstPackage(group);
66
78
  const visibleFindings = rep.findings.filter((f) => f.severity > 1);
67
79
  const isFree = visibleFindings.length > 0 && !visibleFindings[0]?.title;
68
80
  let h = 0;
69
81
  if (rep.license)
70
- h += 1; // license info line
82
+ h += 1;
71
83
  if (isFree) {
72
- // Free tier: just the upgrade prompt line
73
84
  h += 1;
74
85
  }
75
86
  else if (visibleFindings.length > 0) {
76
- // Paid tier: one line per finding
77
87
  h += visibleFindings.length;
78
88
  }
79
89
  else if (rep.score > 0) {
80
- h += 1; // score-only fallback
81
- }
82
- if (group.packages.length > 3)
83
- h += 1;
84
- return h;
85
- }
86
- function findingsDetailHeight(group, safeVersions) {
87
- const rep = firstPackage(group);
88
- const visibleFindings = rep.findings
89
- .filter((f) => f.severity > 1)
90
- .sort((a, b) => b.severity - a.severity);
91
- let h = 0;
92
- if (rep.license)
93
- h += 1; // license info line
94
- if (group.packages.length > 3)
95
90
  h += 1;
96
- // Free tier: reasons + upgrade hint
97
- if (visibleFindings.length === 0 && rep.score > 0) {
98
- h += (rep.reasons ?? []).length;
99
- h += 1; // upgrade hint
100
91
  }
101
- const hasEvidence = visibleFindings.some((f) => f.evidence && f.evidence.length > 0);
102
- for (const finding of visibleFindings) {
103
- h += 1; // badge + id
104
- h += 1; // title
105
- const evidence = finding.evidence ?? [];
106
- h += Math.min(evidence.length, EVIDENCE_LIMIT);
107
- if (evidence.length > EVIDENCE_LIMIT)
108
- h += 1;
92
+ else {
93
+ h += statusSummaryLines(rep).length;
109
94
  }
110
- // Upgrade hint for pro tier (findings but no evidence)
111
- if (visibleFindings.length > 0 && !hasEvidence)
112
- h += 1;
113
- if (rep.recommendation)
114
- h += 1;
115
- if (safeVersions[rep.name])
95
+ if (group.packages.length > 3)
116
96
  h += 1;
117
97
  return h;
118
98
  }
119
- function groupRowHeight(group, level, safeVersions) {
99
+ function groupRowHeight(group, level) {
120
100
  if (level === null)
121
101
  return 1;
122
- if (level === "summary")
123
- return 1 + findingsSummaryHeight(group);
124
- return 1 + findingsDetailHeight(group, safeVersions);
102
+ return 1 + findingsSummaryHeight(group);
125
103
  }
126
104
  function nameVer(p) {
127
105
  return p.version ? `${p.name}@${p.version}` : p.name;
@@ -145,19 +123,23 @@ function affectsLine(group) {
145
123
  const DETAIL_PANE_CHROME = 20;
146
124
  function buildDetailLines(group, safeVersion, maxWidth) {
147
125
  const rep = firstPackage(group);
126
+ const badge = packageBadge(rep);
127
+ const analyzedAt = rep.analyzedAt;
148
128
  const visibleFindings = rep.findings
149
129
  .filter((f) => f.severity > 1)
150
130
  .sort((a, b) => b.severity - a.severity);
131
+ const isFree = visibleFindings.length > 0 && !visibleFindings[0]?.title;
151
132
  const lines = [];
133
+ lines.push(_jsxs(Text, { wrap: "truncate-end", children: [chalk.dim("Verdict"), " ", badge.color(badge.label), rep.version ? chalk.dim(` · v${rep.version}`) : "", analyzedAt ? chalk.dim(` · analyzed ${analyzedAt}`) : ""] }, "verdict"));
134
+ lines.push(_jsx(Text, { children: "" }, "verdict-gap"));
152
135
  if (group.packages.length > 3) {
153
- lines.push(_jsxs(Text, { dimColor: true, children: ["Affects: ", affectsLine(group)] }, "affects"));
136
+ lines.push(_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["Affects: ", affectsLine(group)] }, "affects"));
154
137
  lines.push(_jsx(Text, { children: "" }, "affects-gap"));
155
138
  }
156
139
  if (rep.score > 0) {
157
140
  lines.push(_jsxs(Text, { dimColor: true, children: ["Score: ", rep.score, "/100"] }, "score-info"));
158
141
  lines.push(_jsx(Text, { children: "" }, "score-gap"));
159
142
  }
160
- // Paid tier: show findings with category + severity
161
143
  if (visibleFindings.length > 0) {
162
144
  for (let i = 0; i < visibleFindings.length; i++) {
163
145
  const f = visibleFindings[i];
@@ -166,15 +148,36 @@ function buildDetailLines(group, safeVersion, maxWidth) {
166
148
  const sevLabel = SEVERITY_LABELS[f.severity] ?? "INFO";
167
149
  const sevColor = SEVERITY_COLORS[f.severity] ?? SEVERITY_COLORS[1] ?? chalk.dim;
168
150
  const connector = i === visibleFindings.length - 1 ? T.last : T.branch;
169
- lines.push(_jsxs(Text, { children: [connector, " ", sevColor(pad(sevLabel, 8)), chalk.dim(f.category ?? "")] }, `finding-${i}`));
151
+ lines.push(_jsxs(Text, { wrap: "truncate-end", children: [connector, " ", sevColor(pad(sevLabel, 5)), " ", chalk.dim(f.category ?? ""), f.title ? `: ${f.title}` : ""] }, `finding-${i}`));
152
+ const evidence = f.evidence ?? [];
153
+ for (let e = 0; e < Math.min(evidence.length, EVIDENCE_LIMIT); e++) {
154
+ lines.push(_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", T.pipe, " ", evidence[e]] }, `evidence-${i}-${e}`));
155
+ }
156
+ if (evidence.length > EVIDENCE_LIMIT) {
157
+ lines.push(_jsxs(Text, { dimColor: true, children: [" ", T.pipe, " + ", evidence.length - EVIDENCE_LIMIT, " more"] }, `evidence-more-${i}`));
158
+ }
159
+ }
160
+ if (isFree) {
161
+ lines.push(_jsxs(Text, { color: "yellow", children: [chalk.yellow("→"), " Upgrade to Pro for finding details"] }, "upgrade"));
170
162
  }
171
163
  lines.push(_jsx(Text, { children: "" }, "findings-gap"));
172
164
  }
173
165
  else if (rep.score > 0) {
174
- // Free tier: no findings returned — show upgrade prompt
175
166
  lines.push(_jsxs(Text, { color: "yellow", children: [chalk.yellow("\u2192"), " Upgrade to Pro to see risk categories"] }, "upgrade"));
176
167
  lines.push(_jsx(Text, { children: "" }, "upgrade-gap"));
177
168
  }
169
+ else {
170
+ const statusLines = statusSummaryLines(rep);
171
+ statusLines.forEach((line, i) => {
172
+ lines.push(_jsx(Text, { wrap: "truncate-end", children: rep.action === "analysis_incomplete"
173
+ ? chalk.yellow(line)
174
+ : (rep.action ?? "pass") === "pass"
175
+ ? chalk.green(`✓ ${line}`)
176
+ : chalk.dim(line) }, `status-${i}`));
177
+ });
178
+ if (statusLines.length > 0)
179
+ lines.push(_jsx(Text, { children: "" }, "status-gap"));
180
+ }
178
181
  if (rep.recommendation) {
179
182
  lines.push(_jsxs(Text, { children: [chalk.dim("Recommendation:"), " ", chalk.cyan(truncate(rep.recommendation, maxWidth - 18))] }, "recommendation"));
180
183
  }
@@ -188,9 +191,7 @@ function viewReducer(_state, action) {
188
191
  case "MOVE":
189
192
  return { ..._state, cursor: action.cursor, viewport: action.viewport };
190
193
  case "EXPAND":
191
- return { ..._state, expandedIndex: action.expandedIndex, expandLevel: action.expandLevel, viewport: action.viewport };
192
- case "MOVE_EXPAND":
193
- return { cursor: action.cursor, expandedIndex: action.expandedIndex, expandLevel: action.expandLevel, viewport: action.viewport };
194
+ return { ..._state, expandedKey: action.expandedKey, expandLevel: action.expandLevel, viewport: action.viewport };
194
195
  }
195
196
  }
196
197
  export const InteractiveResultsView = ({ result, config: _config, durationMs, onExit, onBack, discoveredTotal, userStatus, scanUsage: scanUsageProp, initialView, }) => {
@@ -212,17 +213,28 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
212
213
  const total = result.packages.length;
213
214
  const [searchQuery, setSearchQuery] = useState("");
214
215
  const allGroups = useMemo(() => groupPackages(flagged), [flagged]);
215
- const allGroupCount = allGroups.length;
216
216
  const groups = useMemo(() => {
217
217
  if (!searchQuery)
218
218
  return allGroups;
219
219
  const q = searchQuery.toLowerCase();
220
- return allGroups.filter(g => g.packages.some(p => p.name.toLowerCase().includes(q)));
221
- }, [allGroups, searchQuery]);
220
+ const matched = [];
221
+ for (const g of allGroups) {
222
+ const members = g.packages.filter((p) => p.name.toLowerCase().includes(q));
223
+ if (members.length > 0)
224
+ matched.push({ packages: members, key: g.key });
225
+ }
226
+ for (const p of clean) {
227
+ if (p.name.toLowerCase().includes(q)) {
228
+ matched.push({ packages: [p], key: `pass|${p.name}@${p.version ?? ""}` });
229
+ }
230
+ }
231
+ return matched;
232
+ }, [allGroups, clean, searchQuery]);
233
+ const matchCount = useMemo(() => groups.reduce((n, g) => n + g.packages.length, 0), [groups]);
222
234
  const [view, dispatchView] = useReducer(viewReducer, {
223
235
  cursor: 0,
224
236
  expandLevel: null,
225
- expandedIndex: null,
237
+ expandedKey: null,
226
238
  viewport: 0,
227
239
  });
228
240
  const viewRef = useRef(view);
@@ -261,6 +273,10 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
261
273
  clearTimeout(exportMsgRef.current);
262
274
  exportMsgRef.current = setTimeout(() => setExportMsg(null), 4000);
263
275
  };
276
+ useEffect(() => () => {
277
+ if (exportMsgRef.current)
278
+ clearTimeout(exportMsgRef.current);
279
+ }, []);
264
280
  const [exportMenu, setExportMenu] = useState(null);
265
281
  const exportMenuRef = useRef(exportMenu);
266
282
  exportMenuRef.current = exportMenu;
@@ -355,8 +371,7 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
355
371
  return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
356
372
  };
357
373
  /** Convert a payload + scope to a serialized string in the chosen format
358
- * plus the file extension. Some combinations are nonsensical (CSV of the
359
- * full scan tree); we degrade those to JSON with a comment. */
374
+ * plus the file extension. */
360
375
  const formatExport = (payload, scope, format) => {
361
376
  if (format === "json") {
362
377
  return { body: JSON.stringify(payload, null, 2) + "\n", ext: "json" };
@@ -397,8 +412,22 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
397
412
  }
398
413
  return { body: lines.join("\n") + "\n", ext: "csv" };
399
414
  }
400
- // CSV doesn't make sense for `all` or `summary` — fall back to JSON.
401
- return { body: JSON.stringify(payload, null, 2) + "\n", ext: "json" };
415
+ if (scope === "summary") {
416
+ const s = payload;
417
+ const lines = [
418
+ "score,action,packages_scanned,blocked,warned,clean,duration_ms",
419
+ [s.score, s.action, s.packagesScanned, s.blocked, s.warned, s.clean, s.durationMs].map(csvCell).join(","),
420
+ ];
421
+ return { body: lines.join("\n") + "\n", ext: "csv" };
422
+ }
423
+ const a = payload;
424
+ const lines = [
425
+ "scan_score,scan_action,name,version,score,action,license,riskCategory",
426
+ ];
427
+ for (const p of a.packages) {
428
+ lines.push([a.score, a.action, p.name, p.version, p.score, p.action, p.license?.spdx ?? p.license?.raw ?? null, p.license?.riskCategory ?? null].map(csvCell).join(","));
429
+ }
430
+ return { body: lines.join("\n") + "\n", ext: "csv" };
402
431
  }
403
432
  // Markdown — for `all`, render summary + packages table + licenses table
404
433
  // in one document so the file matches what the user sees in the TUI.
@@ -589,38 +618,38 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
589
618
  const searchModeRef = useRef(searchMode);
590
619
  searchModeRef.current = searchMode;
591
620
  const { rows: termRows, cols: termCols } = useTerminalSize();
592
- const availableRows = Math.max(5, termRows - FIXED_CHROME);
621
+ const compact = termRows < COMPACT_ROWS;
622
+ const availableRows = Math.max(5, termRows - (compact ? FIXED_CHROME_COMPACT : FIXED_CHROME));
593
623
  const innerWidth = Math.max(40, termCols - 6);
594
- const detailGroupIdx = detailPane?.groupIndex ?? -1;
624
+ const detailGroup = useMemo(() => {
625
+ if (!detailPane)
626
+ return null;
627
+ return groups.find((g) => g.key === detailPane.groupKey) ?? null;
628
+ }, [detailPane, groups]);
595
629
  const detailLines = useMemo(() => {
596
- if (detailGroupIdx < 0)
630
+ if (!detailGroup)
597
631
  return [];
598
- const group = groups[detailGroupIdx];
599
- if (!group)
600
- return [];
601
- return buildDetailLines(group, result.safeVersions[firstPackage(group).name], innerWidth);
602
- }, [detailGroupIdx, groups, result.safeVersions, innerWidth]);
632
+ return buildDetailLines(detailGroup, result.safeVersions[firstPackage(detailGroup).name], innerWidth);
633
+ }, [detailGroup, result.safeVersions, innerWidth]);
603
634
  const detailContentRows = Math.max(3, termRows - DETAIL_PANE_CHROME);
604
635
  const getLevel = (idx) => {
605
- return view.expandedIndex === idx ? view.expandLevel : null;
636
+ return view.expandedKey !== null && groups[idx]?.key === view.expandedKey ? view.expandLevel : null;
606
637
  };
607
638
  const expandTargetHeight = useMemo(() => {
608
- if (view.expandedIndex === null || view.expandLevel === null)
639
+ if (view.expandedKey === null || view.expandLevel === null)
609
640
  return 0;
610
- const group = groups[view.expandedIndex];
641
+ const group = groups.find((g) => g.key === view.expandedKey);
611
642
  if (!group)
612
643
  return 0;
613
- if (view.expandLevel === "summary")
614
- return findingsSummaryHeight(group);
615
- return findingsDetailHeight(group, result.safeVersions);
616
- }, [view.expandedIndex, view.expandLevel, groups, result.safeVersions]);
617
- const { visibleLines: animVisibleLines } = useExpandAnimation(expandTargetHeight, view.expandedIndex !== null);
618
- const animatedGroupHeight = (group, level, idx) => {
644
+ return findingsSummaryHeight(group);
645
+ }, [view.expandedKey, view.expandLevel, groups]);
646
+ const { visibleLines: animVisibleLines } = useExpandAnimation(expandTargetHeight, view.expandedKey !== null);
647
+ const animatedGroupHeight = (group, level) => {
619
648
  if (level === null)
620
649
  return 1;
621
- if (idx === view.expandedIndex)
650
+ if (group.key === view.expandedKey)
622
651
  return 1 + animVisibleLines;
623
- return groupRowHeight(group, level, result.safeVersions);
652
+ return groupRowHeight(group, level);
624
653
  };
625
654
  const visibleEnd = useMemo(() => {
626
655
  let consumed = 0;
@@ -630,7 +659,7 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
630
659
  const endGroup = groups[end];
631
660
  if (!endGroup)
632
661
  break;
633
- const h = animatedGroupHeight(endGroup, level, end);
662
+ const h = animatedGroupHeight(endGroup, level);
634
663
  if (consumed + h > availableRows)
635
664
  break;
636
665
  consumed += h;
@@ -639,17 +668,17 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
639
668
  if (end === view.viewport && groups.length > 0)
640
669
  end = view.viewport + 1;
641
670
  return end;
642
- }, [view.viewport, groups, view.expandedIndex, view.expandLevel, animVisibleLines, availableRows, result.safeVersions]);
643
- const adjustViewport = (cursor, expIdx, expLvl, currentStart) => {
671
+ }, [view.viewport, groups, view.expandedKey, view.expandLevel, animVisibleLines, availableRows]);
672
+ const adjustViewport = (cursor, expKey, expLvl, currentStart) => {
644
673
  if (cursor < currentStart)
645
674
  return cursor;
646
- const getLvl = (i) => (expIdx === i ? expLvl : null);
675
+ const getLvl = (i) => (expKey !== null && groups[i]?.key === expKey ? expLvl : null);
647
676
  let consumed = 0;
648
677
  for (let i = currentStart; i <= cursor && i < groups.length; i++) {
649
678
  const g = groups[i];
650
679
  if (!g)
651
680
  continue;
652
- consumed += groupRowHeight(g, getLvl(i), result.safeVersions);
681
+ consumed += groupRowHeight(g, getLvl(i));
653
682
  }
654
683
  if (consumed <= availableRows)
655
684
  return currentStart;
@@ -661,7 +690,7 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
661
690
  const g = groups[i];
662
691
  if (!g)
663
692
  continue;
664
- consumed += groupRowHeight(g, getLvl(i), result.safeVersions);
693
+ consumed += groupRowHeight(g, getLvl(i));
665
694
  }
666
695
  if (consumed <= availableRows)
667
696
  break;
@@ -672,9 +701,9 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
672
701
  useEffect(() => {
673
702
  if (groups.length === 0)
674
703
  return;
675
- const { cursor, expandedIndex, expandLevel, viewport } = viewRef.current;
704
+ const { cursor, expandedKey, expandLevel, viewport } = viewRef.current;
676
705
  const clamped = Math.min(viewport, Math.max(0, groups.length - 1));
677
- const newVp = adjustViewport(cursor, expandedIndex, expandLevel, clamped);
706
+ const newVp = adjustViewport(cursor, expandedKey, expandLevel, clamped);
678
707
  dispatchView({ type: "MOVE", cursor, viewport: newVp });
679
708
  }, [availableRows]);
680
709
  // Clamp detail pane scroll when terminal resizes
@@ -683,15 +712,15 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
683
712
  if (dp && detailLines.length > 0) {
684
713
  const maxScroll = Math.max(0, detailLines.length - detailContentRows);
685
714
  if (dp.scroll > maxScroll) {
686
- setDetailPane({ groupIndex: dp.groupIndex, scroll: maxScroll });
715
+ setDetailPane({ groupKey: dp.groupKey, scroll: maxScroll });
687
716
  }
688
717
  }
689
718
  }, [detailContentRows, detailLines.length]);
690
719
  useEffect(() => {
691
- if (detailPane !== null && !groups[detailPane.groupIndex]) {
720
+ if (detailPane !== null && !detailGroup) {
692
721
  setDetailPane(null);
693
722
  }
694
- }, [detailPane, groups]);
723
+ }, [detailPane, detailGroup]);
695
724
  useInput((input, key) => {
696
725
  // Export menu has the highest priority — once opened, all keys go here
697
726
  // until ⏎/Esc dismisses it. Arrow-driven: ↑↓ scrolls within the active
@@ -874,16 +903,19 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
874
903
  if (dp !== null) {
875
904
  const maxScroll = Math.max(0, detailLines.length - detailContentRows);
876
905
  if (key.upArrow || input === "k") {
877
- setDetailPane({ groupIndex: dp.groupIndex, scroll: Math.max(0, dp.scroll - 1) });
906
+ setDetailPane({ groupKey: dp.groupKey, scroll: Math.max(0, dp.scroll - 1) });
878
907
  }
879
908
  else if (key.downArrow || input === "j") {
880
- setDetailPane({ groupIndex: dp.groupIndex, scroll: Math.min(maxScroll, dp.scroll + 1) });
909
+ setDetailPane({ groupKey: dp.groupKey, scroll: Math.min(maxScroll, dp.scroll + 1) });
881
910
  }
882
911
  else if (input === "g") {
883
- setDetailPane({ groupIndex: dp.groupIndex, scroll: 0 });
912
+ setDetailPane({ groupKey: dp.groupKey, scroll: 0 });
884
913
  }
885
914
  else if (input === "G") {
886
- setDetailPane({ groupIndex: dp.groupIndex, scroll: maxScroll });
915
+ setDetailPane({ groupKey: dp.groupKey, scroll: maxScroll });
916
+ }
917
+ else if (input === "?") {
918
+ setShowHelp(true);
887
919
  }
888
920
  else if (key.escape) {
889
921
  setDetailPane(null);
@@ -906,8 +938,19 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
906
938
  openExportMenu("all");
907
939
  return;
908
940
  }
909
- if (key.escape && onBack) {
910
- onBack();
941
+ if (key.escape) {
942
+ const { expandedKey: openKey, viewport } = viewRef.current;
943
+ if (openKey !== null) {
944
+ dispatchView({ type: "EXPAND", expandedKey: null, expandLevel: null, viewport });
945
+ return;
946
+ }
947
+ if (searchQuery) {
948
+ setSearchQuery("");
949
+ dispatchView({ type: "MOVE", cursor: 0, viewport: 0 });
950
+ return;
951
+ }
952
+ if (onBack)
953
+ onBack();
911
954
  return;
912
955
  }
913
956
  if (input === "q") {
@@ -921,41 +964,47 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
921
964
  setSearchMode(true);
922
965
  return;
923
966
  }
924
- const { cursor, expandLevel: expLvl, expandedIndex: expIdx, viewport: vpStart } = viewRef.current;
967
+ const { cursor, expandLevel: expLvl, expandedKey: expKey, viewport: vpStart } = viewRef.current;
925
968
  if (key.upArrow || input === "k") {
926
969
  const next = Math.max(0, cursor - 1);
927
- const newVp = adjustViewport(next, expIdx, expLvl, vpStart < next ? vpStart : next);
970
+ const newVp = adjustViewport(next, expKey, expLvl, vpStart < next ? vpStart : next);
928
971
  dispatchView({ type: "MOVE", cursor: next, viewport: newVp });
929
972
  }
930
973
  else if (key.downArrow || input === "j") {
931
974
  const next = Math.min(groups.length - 1, cursor + 1);
932
- const newVp = adjustViewport(next, expIdx, expLvl, vpStart);
975
+ const newVp = adjustViewport(next, expKey, expLvl, vpStart);
933
976
  dispatchView({ type: "MOVE", cursor: next, viewport: newVp });
934
977
  }
935
978
  else if (input === "g") {
936
- const newVp = adjustViewport(0, expIdx, expLvl, 0);
979
+ const newVp = adjustViewport(0, expKey, expLvl, 0);
937
980
  dispatchView({ type: "MOVE", cursor: 0, viewport: newVp });
938
981
  }
939
982
  else if (input === "G") {
940
983
  const last = groups.length - 1;
941
- const newVp = adjustViewport(last, expIdx, expLvl, vpStart);
984
+ const newVp = adjustViewport(last, expKey, expLvl, vpStart);
942
985
  dispatchView({ type: "MOVE", cursor: last, viewport: newVp });
943
986
  }
944
987
  else if (key.pageDown) {
945
988
  const next = Math.min(groups.length - 1, cursor + availableRows);
946
- const newVp = adjustViewport(next, expIdx, expLvl, vpStart);
989
+ const newVp = adjustViewport(next, expKey, expLvl, vpStart);
947
990
  dispatchView({ type: "MOVE", cursor: next, viewport: newVp });
948
991
  }
949
992
  else if (key.pageUp) {
950
993
  const next = Math.max(0, cursor - availableRows);
951
- const newVp = adjustViewport(next, expIdx, expLvl, next);
994
+ const newVp = adjustViewport(next, expKey, expLvl, next);
952
995
  dispatchView({ type: "MOVE", cursor: next, viewport: newVp });
953
996
  }
954
997
  else if (key.return) {
955
- const newLevel = expIdx === cursor && expLvl === "summary" ? null : "summary";
956
- const newExpIdx = newLevel === null ? null : cursor;
957
- const newVp = adjustViewport(cursor, newExpIdx, newLevel, vpStart);
958
- dispatchView({ type: "EXPAND", expandedIndex: newExpIdx, expandLevel: newLevel, viewport: newVp });
998
+ const curGroup = groups[Math.min(cursor, groups.length - 1)];
999
+ if (!curGroup)
1000
+ return;
1001
+ if (expKey === curGroup.key && expLvl === "summary") {
1002
+ setDetailPane({ groupKey: curGroup.key, scroll: 0 });
1003
+ }
1004
+ else {
1005
+ const newVp = adjustViewport(cursor, curGroup.key, "summary", vpStart);
1006
+ dispatchView({ type: "EXPAND", expandedKey: curGroup.key, expandLevel: "summary", viewport: newVp });
1007
+ }
959
1008
  }
960
1009
  else if (input === "/") {
961
1010
  setSearchMode(true);
@@ -965,7 +1014,7 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
965
1014
  const aboveCount = view.viewport;
966
1015
  const belowCount = groups.length - visibleEnd;
967
1016
  const lcCol = 16; // fixed width for license column
968
- const nameCol = Math.max(20, innerWidth - 22 - lcCol);
1017
+ const nameCol = Math.max(20, innerWidth - BADGE_COL - 14 - lcCol);
969
1018
  // Clamp cursor to valid range (groups may shrink via search filter)
970
1019
  const clampedCursor = groups.length > 0 ? Math.min(view.cursor, groups.length - 1) : 0;
971
1020
  // ── Export menu overlay ──
@@ -1005,7 +1054,7 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
1005
1054
  // ── Help overlay ──
1006
1055
  if (showHelp) {
1007
1056
  const isDetail = detailPane !== null;
1008
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScoreHeader, { score: result.score, action: result.action, total: total, flagged: flagged.length, clean: clean.length, userStatus: userStatus, scanUsage: scanUsage, usageNearLimit: usageNearLimit }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingLeft: 2, paddingRight: 2, width: "100%", children: [_jsx(Text, { bold: true, children: "Keyboard Shortcuts" }), _jsx(Text, { children: "" }), _jsx(Text, { bold: true, children: " Navigation" }), _jsxs(Text, { children: [" ", chalk.cyan("\u2191 k"), " ", chalk.dim("Move up")] }), _jsxs(Text, { children: [" ", chalk.cyan("\u2193 j"), " ", chalk.dim("Move down")] }), _jsxs(Text, { children: [" ", chalk.cyan("g"), " ", chalk.dim("Jump to top")] }), _jsxs(Text, { children: [" ", chalk.cyan("G"), " ", chalk.dim("Jump to bottom")] }), !isDetail && _jsxs(Text, { children: [" ", chalk.cyan("PgUp"), " ", chalk.dim("Page up")] }), !isDetail && _jsxs(Text, { children: [" ", chalk.cyan("PgDn"), " ", chalk.dim("Page down")] }), _jsx(Text, { children: "" }), _jsx(Text, { bold: true, children: " Actions" }), !isDetail && _jsxs(Text, { children: [" ", chalk.cyan("\u23CE"), " ", chalk.dim("Expand findings")] }), isDetail && _jsxs(Text, { children: [" ", chalk.cyan("Esc"), " ", chalk.dim("Back to list")] }), !isDetail && _jsxs(Text, { children: [" ", chalk.cyan("/"), " ", chalk.dim("Search packages")] }), !isDetail && _jsxs(Text, { children: [" ", chalk.cyan("l"), " ", chalk.dim("License breakdown (browse + drill-in)")] }), !isDetail && _jsxs(Text, { children: [" ", chalk.cyan("e"), " ", chalk.dim("Export menu — pick scope (all / summary / packages / licenses / findings) and format (JSON / CSV / Markdown / text)")] }), !isDetail && onBack && _jsxs(Text, { children: [" ", chalk.cyan("Esc"), " ", chalk.dim("Back to project selector")] }), _jsxs(Text, { children: [" ", chalk.cyan("q"), " ", chalk.dim("Quit")] }), _jsx(Text, { children: "" }), _jsxs(Text, { dimColor: true, children: [" Press ", chalk.bold.cyan("?"), " or ", chalk.bold.cyan("Esc"), " to close"] })] })] }));
1057
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScoreHeader, { score: result.score, action: result.action, total: total, flagged: flagged.length, clean: clean.length, userStatus: userStatus, scanUsage: scanUsage, usageNearLimit: usageNearLimit }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingLeft: 2, paddingRight: 2, width: "100%", children: [_jsx(Text, { bold: true, children: "Keyboard Shortcuts" }), _jsx(Text, { children: "" }), _jsx(Text, { bold: true, children: " Navigation" }), _jsxs(Text, { children: [" ", chalk.cyan("\u2191 k"), " ", chalk.dim("Move up")] }), _jsxs(Text, { children: [" ", chalk.cyan("\u2193 j"), " ", chalk.dim("Move down")] }), _jsxs(Text, { children: [" ", chalk.cyan("g"), " ", chalk.dim("Jump to top")] }), _jsxs(Text, { children: [" ", chalk.cyan("G"), " ", chalk.dim("Jump to bottom")] }), !isDetail && _jsxs(Text, { children: [" ", chalk.cyan("PgUp"), " ", chalk.dim("Page up")] }), !isDetail && _jsxs(Text, { children: [" ", chalk.cyan("PgDn"), " ", chalk.dim("Page down")] }), _jsx(Text, { children: "" }), _jsx(Text, { bold: true, children: " Actions" }), !isDetail && _jsxs(Text, { children: [" ", chalk.cyan("\u23CE"), " ", chalk.dim("Expand findings (press again for full details)")] }), isDetail && _jsxs(Text, { children: [" ", chalk.cyan("Esc"), " ", chalk.dim("Back to list")] }), !isDetail && _jsxs(Text, { children: [" ", chalk.cyan("/"), " ", chalk.dim("Search all scanned packages (incl. passed)")] }), !isDetail && _jsxs(Text, { children: [" ", chalk.cyan("l"), " ", chalk.dim("License breakdown (browse + drill-in)")] }), !isDetail && _jsxs(Text, { children: [" ", chalk.cyan("e"), " ", chalk.dim("Export menu — pick scope (all / summary / packages / licenses / findings) and format (JSON / CSV / Markdown / text)")] }), !isDetail && _jsxs(Text, { children: [" ", chalk.cyan("Esc"), " ", chalk.dim(onBack ? "Collapse row / clear search / back to project selector" : "Collapse row / clear search")] }), _jsxs(Text, { children: [" ", chalk.cyan("q"), " ", chalk.dim("Quit")] }), _jsx(Text, { children: "" }), _jsxs(Text, { dimColor: true, children: [" Press ", chalk.bold.cyan("?"), " or ", chalk.bold.cyan("Esc"), " to close"] })] })] }));
1009
1058
  }
1010
1059
  // ── License overlay ──
1011
1060
  if (showLicenses) {
@@ -1080,7 +1129,7 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
1080
1129
  }
1081
1130
  // ── Detail pane mode ──
1082
1131
  if (detailPane !== null) {
1083
- const dpGroup = groups[detailPane.groupIndex];
1132
+ const dpGroup = detailGroup;
1084
1133
  if (dpGroup) {
1085
1134
  const dpRep = firstPackage(dpGroup);
1086
1135
  const { color: dpColor } = packageBadge(dpRep);
@@ -1088,11 +1137,11 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
1088
1137
  const dpAbove = dpScroll;
1089
1138
  const dpBelow = Math.max(0, detailLines.length - dpScroll - detailContentRows);
1090
1139
  const dpVisible = detailLines.slice(dpScroll, dpScroll + detailContentRows);
1091
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScoreHeader, { score: result.score, action: result.action, total: total, flagged: flagged.length, clean: clean.length, userStatus: userStatus, scanUsage: scanUsage, usageNearLimit: usageNearLimit }), _jsx(Text, { dimColor: true, children: chalk.dim("\u2500".repeat(Math.max(20, termCols - 4))) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, width: "100%", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { bold: true, children: [groupNames(dpGroup), dpRep.license ? chalk.dim(" \u00B7 ") + (dpRep.license.riskCategory === "permissive" ? chalk.green(dpRep.license.spdx ?? dpRep.license.raw ?? "") : dpRep.license.riskCategory === "no-license" || dpRep.license.riskCategory === "network-copyleft" ? chalk.red(dpRep.license.spdx ?? dpRep.license.raw ?? "No license") : chalk.yellow(dpRep.license.spdx ?? dpRep.license.raw ?? "")) : ""] }), _jsx(Text, { children: dpColor(`score ${dpRep.score}`) })] }), dpAbove > 0 && (_jsxs(Text, { dimColor: true, children: [chalk.cyan(" \u2191"), " ", dpAbove, " more above"] })), _jsx(Box, { flexDirection: "column", marginLeft: 2, children: dpVisible }), dpBelow > 0 && (_jsxs(Text, { dimColor: true, children: [chalk.cyan(" \u2193"), " ", dpBelow, " more below"] }))] }), _jsx(Text, { dimColor: true, children: chalk.dim("─".repeat(Math.max(20, termCols - 4))) }), _jsx(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, width: "100%", children: _jsxs(Box, { justifyContent: "space-between", children: [clean.length > 0 ? (_jsxs(Text, { children: [chalk.green("\u2713"), " ", chalk.green.bold(String(clean.length)), " ", chalk.dim(`package${clean.length !== 1 ? "s" : ""} passed`), " ", chalk.dim(`\u00b7 ${(durationMs / 1000).toFixed(1)}s`)] })) : (_jsxs(Text, { dimColor: true, children: [(durationMs / 1000).toFixed(1), "s"] })), result.freeScansRemaining !== undefined && (_jsx(Text, { dimColor: true, children: "Free tier \u00B7 dg login for higher scan limits" }))] }) }), _jsx(Text, { dimColor: true, children: chalk.dim("\u2500".repeat(Math.max(20, termCols - 4))) }), _jsxs(Text, { children: [" ", chalk.bold.cyan("\u2191\u2193"), " ", chalk.dim("scroll"), " ", chalk.bold.cyan("Esc"), " ", chalk.dim("back"), " ", chalk.bold.cyan("q"), " ", chalk.dim("quit")] })] }));
1140
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScoreHeader, { score: result.score, action: result.action, total: total, flagged: flagged.length, clean: clean.length, userStatus: userStatus, scanUsage: scanUsage, usageNearLimit: usageNearLimit }), _jsx(Text, { dimColor: true, children: chalk.dim("\u2500".repeat(Math.max(20, termCols - 4))) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, width: "100%", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [groupNames(dpGroup), dpRep.license ? chalk.dim(" \u00B7 ") + (dpRep.license.riskCategory === "permissive" ? chalk.green(dpRep.license.spdx ?? dpRep.license.raw ?? "") : dpRep.license.riskCategory === "no-license" || dpRep.license.riskCategory === "network-copyleft" ? chalk.red(dpRep.license.spdx ?? dpRep.license.raw ?? "No license") : chalk.yellow(dpRep.license.spdx ?? dpRep.license.raw ?? "")) : ""] }), _jsx(Text, { children: dpColor(`score ${dpRep.score}`) })] }), dpAbove > 0 && (_jsxs(Text, { dimColor: true, children: [chalk.cyan(" \u2191"), " ", dpAbove, " more above"] })), _jsx(Box, { flexDirection: "column", marginLeft: 2, children: dpVisible }), dpBelow > 0 && (_jsxs(Text, { dimColor: true, children: [chalk.cyan(" \u2193"), " ", dpBelow, " more below"] }))] }), _jsx(Text, { dimColor: true, children: chalk.dim("─".repeat(Math.max(20, termCols - 4))) }), _jsx(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, width: "100%", children: _jsxs(Box, { justifyContent: "space-between", children: [clean.length > 0 ? (_jsxs(Text, { wrap: "truncate-end", children: [chalk.green("\u2713"), " ", chalk.green.bold(String(clean.length)), " ", chalk.dim(`package${clean.length !== 1 ? "s" : ""} passed`), " ", chalk.dim(`\u00b7 ${(durationMs / 1000).toFixed(1)}s`)] })) : (_jsxs(Text, { dimColor: true, children: [(durationMs / 1000).toFixed(1), "s"] })), result.freeScansRemaining !== undefined && (_jsx(Text, { dimColor: true, children: "Free tier \u00B7 dg login for higher scan limits" }))] }) }), _jsx(Text, { dimColor: true, children: chalk.dim("\u2500".repeat(Math.max(20, termCols - 4))) }), _jsxs(Text, { children: [" ", chalk.bold.cyan("\u2191\u2193"), " ", chalk.dim("scroll"), " ", chalk.bold.cyan("Esc"), " ", chalk.dim("back"), " ", chalk.bold.cyan("q"), " ", chalk.dim("quit")] })] }));
1092
1141
  }
1093
1142
  }
1094
1143
  // ── List mode ──
1095
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScoreHeader, { score: result.score, action: result.action, total: total, flagged: flagged.length, clean: clean.length, userStatus: userStatus, scanUsage: scanUsage, usageNearLimit: usageNearLimit }), groups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: chalk.dim("─".repeat(Math.max(20, termCols - 4))) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, width: "100%", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: "Flagged Packages" }), _jsx(Text, { dimColor: true, children: searchQuery ? `${groups.length} of ${allGroupCount}` : `${clampedCursor + 1}/${groups.length}` })] }), aboveCount > 0 && (_jsxs(Text, { dimColor: true, children: [chalk.cyan(" \u2191"), " ", aboveCount, " more above"] })), visibleGroups.map((group, visIdx) => {
1144
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScoreHeader, { score: result.score, action: result.action, total: total, flagged: flagged.length, clean: clean.length, userStatus: userStatus, scanUsage: scanUsage, usageNearLimit: usageNearLimit }), groups.length > 0 && (_jsxs(_Fragment, { children: [!compact && _jsx(Text, { dimColor: true, children: chalk.dim("─".repeat(Math.max(20, termCols - 4))) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, width: "100%", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: searchQuery ? "Search Results" : "Flagged Packages" }), _jsx(Text, { dimColor: true, children: searchQuery ? `${matchCount} of ${total} packages` : `${clampedCursor + 1}/${groups.length}` })] }), aboveCount > 0 && (_jsxs(Text, { dimColor: true, children: [chalk.cyan(" \u2191"), " ", aboveCount, " more above"] })), visibleGroups.map((group, visIdx) => {
1096
1145
  const globalIdx = view.viewport + visIdx;
1097
1146
  const isCursor = globalIdx === clampedCursor;
1098
1147
  const level = getLevel(globalIdx);
@@ -1107,8 +1156,8 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
1107
1156
  : (lcInfo.riskCategory === "no-license" || lcInfo.riskCategory === "unlicensed" || lcInfo.riskCategory === "network-copyleft") ? chalk.red
1108
1157
  : chalk.yellow;
1109
1158
  const arrow = level === "summary" ? "\u25BE" : "\u25B8"; // ▾ expanded, ▸ collapsed
1110
- return (_jsxs(Box, { flexDirection: "column", children: [isCursor ? (_jsxs(Text, { backgroundColor: "#1a1a2e", children: [chalk.cyan("\u258C"), " ", chalk.cyan(arrow), " ", ` `, color(pad(label, 8)), chalk.bold(pad(truncate(names, nameCol - 2), nameCol)), lcColor(pad(lcStr, lcCol)), color(scoreStr.padStart(3)), " "] })) : (_jsxs(Text, { children: [` ${chalk.dim(arrow)} `, color(pad(label, 8)), pad(truncate(names, nameCol - 2), nameCol), lcColor(pad(lcStr, lcCol)), color(scoreStr.padStart(3))] })), level === "summary" && (_jsx(FindingsSummary, { group: group, maxWidth: innerWidth - 8, maxLines: globalIdx === view.expandedIndex ? animVisibleLines : undefined }))] }, group.key));
1111
- }), belowCount > 0 && (_jsxs(Text, { dimColor: true, children: [chalk.cyan(" \u2193"), " ", belowCount, " more below"] }))] })] })), searchQuery && groups.length === 0 && (_jsx(Text, { dimColor: true, children: ` No flagged packages match "${searchQuery}"` })), _jsx(Text, { dimColor: true, children: chalk.dim("\u2500".repeat(Math.max(20, termCols - 4))) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, width: "100%", children: [discoveredTotal !== undefined && discoveredTotal > total && (_jsxs(Text, { dimColor: true, children: ["Scanned ", total, " of ", discoveredTotal, " packages"] })), _jsxs(Box, { justifyContent: "space-between", children: [clean.length > 0 ? (_jsxs(Text, { children: [chalk.green("\u2713"), " ", chalk.green.bold(String(clean.length)), " ", chalk.dim(`package${clean.length !== 1 ? "s" : ""} passed`), " ", chalk.dim(`\u00b7 ${(durationMs / 1000).toFixed(1)}s`)] })) : (_jsxs(Text, { dimColor: true, children: [(durationMs / 1000).toFixed(1), "s"] })), result.freeScansRemaining !== undefined && (_jsx(Text, { dimColor: true, children: "Free tier \u00B7 dg login for higher scan limits" }))] })] }), _jsx(Text, { dimColor: true, children: chalk.dim("\u2500".repeat(Math.max(20, termCols - 4))) }), searchMode ? (_jsxs(Text, { children: [" ", chalk.bold.cyan("/"), " ", searchQuery, chalk.cyan("\u2588"), " ", chalk.dim("Esc clear")] })) : (_jsxs(Text, { children: [" ", allGroupCount > 0 && (_jsxs(_Fragment, { children: [chalk.bold.cyan("\u2191\u2193"), " ", chalk.dim("navigate"), " ", chalk.bold.cyan("\u23CE"), " ", chalk.dim("expand"), " ", chalk.bold.cyan("/"), " ", chalk.dim("search"), " "] })), chalk.bold.cyan("l"), " ", chalk.dim("licenses"), " ", chalk.bold.cyan("e"), " ", chalk.dim("export"), " ", onBack && _jsxs(_Fragment, { children: [chalk.bold.cyan("Esc"), " ", chalk.dim("back"), " "] }), chalk.bold.cyan("q"), " ", chalk.dim("quit"), " ", chalk.dim("\u00B7 Ctrl+C or q to exit"), exportMsg && _jsxs(_Fragment, { children: [" ", chalk.green(exportMsg)] })] }))] }));
1159
+ return (_jsxs(Box, { flexDirection: "column", children: [isCursor ? (_jsxs(Text, { backgroundColor: "#1a1a2e", wrap: "truncate-end", children: [chalk.cyan("\u258C"), " ", chalk.cyan(arrow), " ", ` `, color(pad(label, BADGE_COL)), chalk.bold(pad(truncate(names, nameCol - 2), nameCol)), lcColor(pad(lcStr, lcCol)), color(scoreStr.padStart(3)), " "] })) : (_jsxs(Text, { wrap: "truncate-end", children: [` ${chalk.dim(arrow)} `, color(pad(label, BADGE_COL)), pad(truncate(names, nameCol - 2), nameCol), lcColor(pad(lcStr, lcCol)), color(scoreStr.padStart(3))] })), level === "summary" && (_jsx(FindingsSummary, { group: group, maxWidth: innerWidth - 8, maxLines: group.key === view.expandedKey ? animVisibleLines : undefined }))] }, group.key));
1160
+ }), belowCount > 0 && (_jsxs(Text, { dimColor: true, children: [chalk.cyan(" \u2193"), " ", belowCount, " more below"] }))] })] })), searchQuery && groups.length === 0 && (_jsx(Text, { dimColor: true, children: ` No packages match "${searchQuery}"` })), !compact && _jsx(Text, { dimColor: true, children: chalk.dim("\u2500".repeat(Math.max(20, termCols - 4))) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, width: "100%", children: [discoveredTotal !== undefined && discoveredTotal > total && (_jsxs(Text, { dimColor: true, children: ["Scanned ", total, " of ", discoveredTotal, " packages"] })), _jsxs(Box, { justifyContent: "space-between", children: [clean.length > 0 ? (_jsxs(Text, { wrap: "truncate-end", children: [chalk.green("\u2713"), " ", chalk.green.bold(String(clean.length)), " ", chalk.dim(`package${clean.length !== 1 ? "s" : ""} passed`), " ", chalk.dim(`\u00b7 ${(durationMs / 1000).toFixed(1)}s`)] })) : (_jsxs(Text, { dimColor: true, children: [(durationMs / 1000).toFixed(1), "s"] })), result.freeScansRemaining !== undefined && (_jsx(Text, { dimColor: true, children: "Free tier \u00B7 dg login for higher scan limits" }))] })] }), !compact && _jsx(Text, { dimColor: true, children: chalk.dim("\u2500".repeat(Math.max(20, termCols - 4))) }), searchMode ? (_jsxs(Text, { wrap: "truncate-end", children: [" ", chalk.bold.cyan("/"), " ", searchQuery, chalk.cyan("\u2588"), " ", chalk.dim("Esc clear")] })) : exportMsg ? (_jsxs(Text, { wrap: "truncate-end", children: [" ", chalk.green(exportMsg)] })) : searchQuery ? (_jsxs(Text, { wrap: "truncate-end", children: [" ", chalk.bold.cyan("/"), " ", searchQuery, " ", chalk.dim(`${matchCount} of ${total} packages`), " ", chalk.bold.cyan("Esc"), " ", chalk.dim("clear"), " ", chalk.bold.cyan("q"), " ", chalk.dim("quit")] })) : (_jsxs(Text, { wrap: "truncate-end", children: [" ", groups.length > 0 && (_jsxs(_Fragment, { children: [chalk.bold.cyan("\u2191\u2193"), " ", chalk.dim("navigate"), " ", chalk.bold.cyan("\u23CE"), " ", chalk.dim("expand"), " "] })), total > 0 && (_jsxs(_Fragment, { children: [chalk.bold.cyan("/"), " ", chalk.dim("search"), " "] })), chalk.bold.cyan("l"), " ", chalk.dim("licenses"), " ", chalk.bold.cyan("e"), " ", chalk.dim("export"), " ", onBack && _jsxs(_Fragment, { children: [chalk.bold.cyan("Esc"), " ", chalk.dim("back"), " "] }), chalk.bold.cyan("q"), " ", chalk.dim("quit")] }))] }));
1112
1161
  };
1113
1162
  const T = {
1114
1163
  branch: chalk.dim("\u251C\u2500\u2500"),
@@ -1168,13 +1217,24 @@ const FindingsSummary = ({ group, maxWidth, maxLines }) => {
1168
1217
  const sevLabel = SEVERITY_LABELS[f.severity] ?? "INFO";
1169
1218
  const sevColor = SEVERITY_COLORS[f.severity] ?? SEVERITY_COLORS[1] ?? chalk.dim;
1170
1219
  const title = f.title ? `: ${f.title}` : "";
1171
- allLines.push(_jsxs(Text, { children: [connector, " ", sevColor(pad(sevLabel, 5)), " ", chalk.dim(f.category ?? ""), title] }, `finding-${idx}`));
1220
+ allLines.push(_jsxs(Text, { wrap: "truncate-end", children: [connector, " ", sevColor(pad(sevLabel, 5)), " ", chalk.dim(f.category ?? ""), title] }, `finding-${idx}`));
1172
1221
  }
1173
1222
  }
1174
1223
  if (visibleFindings.length === 0 && rep.score > 0) {
1175
1224
  // No findings at all (shouldn't happen after API change, but safety fallback)
1176
1225
  allLines.push(_jsxs(Text, { dimColor: true, children: [hasAffects ? T.branch : T.last, " Score: ", rep.score, "/100"] }, "score-only"));
1177
1226
  }
1227
+ if (visibleFindings.length === 0 && rep.score === 0) {
1228
+ const statusLines = statusSummaryLines(rep);
1229
+ statusLines.forEach((line, i) => {
1230
+ const isLast = !hasAffects && i === statusLines.length - 1;
1231
+ allLines.push(_jsxs(Text, { wrap: "truncate-end", children: [isLast ? T.last : T.branch, " ", rep.action === "analysis_incomplete"
1232
+ ? chalk.yellow(line)
1233
+ : (rep.action ?? "pass") === "pass"
1234
+ ? chalk.green(`✓ ${line}`)
1235
+ : chalk.dim(line)] }, `status-${i}`));
1236
+ });
1237
+ }
1178
1238
  if (hasAffects) {
1179
1239
  allLines.push(_jsxs(Text, { dimColor: true, children: [T.last, " ", truncate(affectsLine(group), maxWidth - 8)] }, "affects"));
1180
1240
  }
@@ -9,10 +9,10 @@ export const ProjectSelector = ({ projects, onConfirm, onCancel, userStatus }) =
9
9
  const [selected, setSelected] = useState(() => new Set(projects.map((_, i) => i)));
10
10
  const { cols: termCols } = useTerminalSize();
11
11
  useInput((input, key) => {
12
- if (key.upArrow) {
12
+ if (key.upArrow || input === "k") {
13
13
  setCursor((c) => Math.max(0, c - 1));
14
14
  }
15
- else if (key.downArrow) {
15
+ else if (key.downArrow || input === "j") {
16
16
  setCursor((c) => Math.min(projects.length - 1, c + 1));
17
17
  }
18
18
  else if (input === " ") {
@@ -37,7 +37,7 @@ export const ProjectSelector = ({ projects, onConfirm, onCancel, userStatus }) =
37
37
  if (picked.length > 0)
38
38
  onConfirm(picked);
39
39
  }
40
- else if (input === "q") {
40
+ else if (input === "q" || key.escape) {
41
41
  onCancel();
42
42
  }
43
43
  });