@westbayberry/dg 2.0.11 → 2.2.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.
Files changed (143) hide show
  1. package/README.md +216 -226
  2. package/dist/agents/claude-code.js +113 -0
  3. package/dist/agents/codex.js +65 -0
  4. package/dist/agents/copilot-cli.js +115 -0
  5. package/dist/agents/cursor.js +113 -0
  6. package/dist/agents/gemini.js +107 -0
  7. package/dist/agents/persistence.js +285 -0
  8. package/dist/agents/registry.js +127 -0
  9. package/dist/agents/types.js +1 -0
  10. package/dist/agents/windsurf.js +93 -0
  11. package/dist/api/analyze.js +6 -4
  12. package/dist/audit/detectors.js +0 -11
  13. package/dist/audit/events.js +5 -21
  14. package/dist/audit-ui/AuditApp.js +2 -0
  15. package/dist/audit-ui/components/AuditResultsView.js +55 -92
  16. package/dist/audit-ui/export.js +0 -4
  17. package/dist/audit-ui/format.js +0 -3
  18. package/dist/audit-ui/launch.js +10 -1
  19. package/dist/auth/device-login.js +4 -5
  20. package/dist/auth/login-app.js +7 -7
  21. package/dist/auth/store.js +8 -3
  22. package/dist/bin/dg.js +59 -52
  23. package/dist/commands/agents.js +231 -0
  24. package/dist/commands/audit.js +22 -3
  25. package/dist/commands/config.js +26 -10
  26. package/dist/commands/cooldown.js +389 -0
  27. package/dist/commands/decisions.js +169 -0
  28. package/dist/commands/doctor.js +1 -1
  29. package/dist/commands/help.js +1 -1
  30. package/dist/commands/licenses.js +10 -22
  31. package/dist/commands/logout.js +4 -11
  32. package/dist/commands/router.js +8 -4
  33. package/dist/commands/sbom.js +206 -0
  34. package/dist/commands/scan.js +3 -2
  35. package/dist/commands/service.js +32 -13
  36. package/dist/commands/setup.js +197 -27
  37. package/dist/commands/status.js +5 -2
  38. package/dist/commands/types.js +1 -0
  39. package/dist/commands/update.js +17 -8
  40. package/dist/commands/verify.js +8 -5
  41. package/dist/config/settings.js +154 -65
  42. package/dist/decisions/apply.js +128 -0
  43. package/dist/decisions/remember-prompt.js +92 -0
  44. package/dist/export-ui/ExportDialog.js +198 -0
  45. package/dist/install-ui/LiveInstall.js +2 -2
  46. package/dist/install-ui/block-render.js +21 -4
  47. package/dist/install-ui/prep-spinner.js +32 -0
  48. package/dist/install-ui/prompt.js +14 -0
  49. package/dist/launcher/agent-check.js +466 -0
  50. package/dist/launcher/agent-hook-exec.js +70 -0
  51. package/dist/launcher/agent-hook-io.js +31 -0
  52. package/dist/launcher/cargo-cache.js +40 -0
  53. package/dist/launcher/classify.js +17 -6
  54. package/dist/launcher/env.js +71 -24
  55. package/dist/launcher/install-preflight.js +167 -17
  56. package/dist/launcher/live-install.js +25 -5
  57. package/dist/launcher/output-redaction.js +7 -4
  58. package/dist/launcher/preflight-prompt.js +43 -3
  59. package/dist/launcher/run.js +100 -86
  60. package/dist/launcher/spawn-invocation.js +21 -0
  61. package/dist/policy/cooldown.js +117 -0
  62. package/dist/policy/evaluate.js +5 -21
  63. package/dist/policy/pypi-name.js +17 -0
  64. package/dist/presentation/mode.js +3 -2
  65. package/dist/presentation/package-page.js +9 -0
  66. package/dist/presentation/provenance.js +23 -0
  67. package/dist/presentation/theme.js +7 -7
  68. package/dist/project/dgfile.js +446 -0
  69. package/dist/proxy/auth.js +42 -0
  70. package/dist/proxy/ca.js +29 -9
  71. package/dist/proxy/cooldown-exemptions-file.js +33 -0
  72. package/dist/proxy/enforcement.js +57 -17
  73. package/dist/proxy/metadata-map.js +66 -4
  74. package/dist/proxy/preverified.js +55 -0
  75. package/dist/proxy/server.js +473 -45
  76. package/dist/proxy/worker.js +16 -1
  77. package/dist/publish-set/collect.js +1 -4
  78. package/dist/publish-set/npm.js +8 -5
  79. package/dist/publish-set/pack.js +9 -3
  80. package/dist/runtime/cli.js +0 -4
  81. package/dist/runtime/fatal.js +31 -0
  82. package/dist/runtime/first-run.js +12 -11
  83. package/dist/runtime/node-version.js +43 -6
  84. package/dist/runtime/nudges.js +35 -2
  85. package/dist/sbom/cyclonedx.js +211 -0
  86. package/dist/sbom-ui/SbomApp.js +158 -0
  87. package/dist/sbom-ui/components/SbomHeader.js +32 -0
  88. package/dist/sbom-ui/components/SbomList.js +52 -0
  89. package/dist/sbom-ui/inventory.js +128 -0
  90. package/dist/sbom-ui/launch.js +51 -0
  91. package/dist/sbom-ui/run.js +55 -0
  92. package/dist/sbom-ui/store.js +26 -0
  93. package/dist/scan/collect.js +10 -6
  94. package/dist/scan/command.js +51 -17
  95. package/dist/scan/discovery.js +11 -2
  96. package/dist/scan/render.js +63 -8
  97. package/dist/scan/scanner-report.js +42 -9
  98. package/dist/scan/staged.js +71 -11
  99. package/dist/scan-ui/LegacyApp.js +12 -16
  100. package/dist/scan-ui/alt-screen.js +5 -8
  101. package/dist/scan-ui/components/InteractiveResultsView.js +193 -123
  102. package/dist/scan-ui/components/ProgressBar.js +3 -14
  103. package/dist/scan-ui/components/ProjectSelector.js +1 -1
  104. package/dist/scan-ui/components/ScoreHeader.js +2 -3
  105. package/dist/scan-ui/components/SetupBanner.js +0 -6
  106. package/dist/scan-ui/format-helpers.js +61 -5
  107. package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
  108. package/dist/scan-ui/hooks/useScan.js +46 -4
  109. package/dist/scan-ui/launch.js +7 -4
  110. package/dist/scan-ui/shims.js +14 -4
  111. package/dist/scripts/detect.js +158 -0
  112. package/dist/scripts/gate.js +170 -0
  113. package/dist/service/state.js +27 -8
  114. package/dist/service/trust-refresh.js +92 -0
  115. package/dist/service/worker.js +23 -1
  116. package/dist/setup/activate-shell.js +28 -0
  117. package/dist/setup/git-hook.js +49 -4
  118. package/dist/setup/plan.js +98 -29
  119. package/dist/setup-ui/gate.js +39 -0
  120. package/dist/setup-ui/offer.js +42 -0
  121. package/dist/setup-ui/selector.js +27 -0
  122. package/dist/setup-ui/tasks.js +56 -0
  123. package/dist/setup-ui/wizard.js +225 -0
  124. package/dist/state/cooldown-held.js +66 -0
  125. package/dist/state/index.js +1 -0
  126. package/dist/state/locks.js +4 -2
  127. package/dist/state/store.js +2 -1
  128. package/dist/util/external-tool.js +25 -0
  129. package/dist/util/git.js +10 -3
  130. package/dist/util/json-file.js +24 -0
  131. package/dist/util/report-writer.js +57 -0
  132. package/dist/util/tty-prompt.js +13 -6
  133. package/dist/verify/local.js +240 -42
  134. package/dist/verify/package-check.js +86 -18
  135. package/dist/verify/preflight.js +242 -49
  136. package/dist/verify/render.js +15 -1
  137. package/npm-shrinkwrap.json +2383 -0
  138. package/package.json +14 -8
  139. package/NOTICE +0 -5
  140. package/dist/commands/completion.js +0 -116
  141. package/dist/commands/explain.js +0 -232
  142. package/dist/commands/unavailable.js +0 -11
  143. package/dist/telemetry/events.js +0 -40
@@ -2,14 +2,34 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
2
2
  import { useReducer, useMemo, useRef, useEffect, useState } from "react";
3
3
  import { Box, Text, useInput } from "ink";
4
4
  import chalk from "chalk";
5
- import { writeFileSync } from "node:fs";
6
- import { resolve as resolvePath } from "node:path";
7
- import { isLoggedIn } from "../shims.js";
8
- import { ScoreHeader, COMPACT_ROWS } from "./ScoreHeader.js";
5
+ import { ExportDialog, loginRequiredToast } from "../../export-ui/ExportDialog.js";
6
+ import { resolvePresentation } from "../../presentation/mode.js";
7
+ import { createTheme } from "../../presentation/theme.js";
8
+ import { packagePageUrl } from "../../presentation/package-page.js";
9
+ import { accountHeaderLine, isLoggedIn } from "../shims.js";
10
+ import { packageKey } from "../../decisions/apply.js";
11
+ import { ScoreHeader, COMPACT_ROWS, LOGO_MIN_COLS } from "./ScoreHeader.js";
12
+ import { renderLogo } from "../logo.js";
9
13
  import { useExpandAnimation } from "../hooks/useExpandAnimation.js";
10
14
  import { useTerminalSize } from "../hooks/useTerminalSize.js";
11
15
  import { clearScreen } from "../alt-screen.js";
12
16
  import { pad, truncate, groupPackages as sharedGroupPackages, formatUsage } from "../format-helpers.js";
17
+ import { provenanceLabel, provenanceDowngradeLine } from "../../presentation/provenance.js";
18
+ const ACK_PREVIEW_LIMIT = 3;
19
+ const EXPORT_SCOPE_LABELS = {
20
+ all: "All",
21
+ summary: "Summary",
22
+ packages: "Packages",
23
+ licenses: "Licenses",
24
+ findings: "Findings (warn+block)",
25
+ "current-license": "Current license"
26
+ };
27
+ const EXPORT_FORMAT_LABELS = {
28
+ json: "JSON",
29
+ csv: "CSV",
30
+ md: "Markdown",
31
+ txt: "Plain text"
32
+ };
13
33
  function groupPackages(packages) {
14
34
  return sharedGroupPackages(packages, "fingerprint");
15
35
  }
@@ -50,13 +70,39 @@ export function packageBadge(pkg) {
50
70
  return { label: "Unverified", color: chalk.yellow };
51
71
  return actionBadge(pkg.action);
52
72
  }
73
+ export function provenanceMarker(pkg) {
74
+ const prov = pkg.provenance;
75
+ if (!prov)
76
+ return " ";
77
+ if (prov.downgrade)
78
+ return chalk.yellow("◇ ");
79
+ if (prov.status === "attested")
80
+ return chalk.dim("◆ ");
81
+ return " ";
82
+ }
83
+ function packageDowngradeLine(pkg) {
84
+ return pkg.provenance ? provenanceDowngradeLine(pkg.version, pkg.provenance) : null;
85
+ }
53
86
  const EVIDENCE_LIMIT = 2;
54
87
  const BADGE_COL = "Unverified".length + 1;
55
- // Fixed lines outside the scrollable group area:
56
- // 5 ScoreHeader box | 2 Flagged box top | 2 scroll indicators
57
- // 1 Flagged box bottom | 4 Clean/Duration box | 1 help bar | 1 margin
58
- const FIXED_CHROME = 21;
59
- const FIXED_CHROME_COMPACT = 14;
88
+ // Mirrors the list-mode render row-for-row; drift shows up as blank rows or
89
+ // overflow on short terminals (counts pinned by resolveResultsLayout tests).
90
+ // Header text column: title + score + counts (+usage), full mode adds 2 spacer
91
+ // lines and the box is as tall as the logo when shown. Full chrome adds the
92
+ // flagged divider + heading and divider + summary + divider + help bar;
93
+ // compact drops the logo, spacers, and all three dividers.
94
+ export function resolveResultsLayout(input) {
95
+ const textRows = input.hasUsage ? 4 : 3;
96
+ const fullHeader = 2 + Math.max(input.logoRows, textRows + 2);
97
+ const compactHeader = 2 + textRows;
98
+ const shared = input.ackSectionLines + input.extraLines;
99
+ const fullChrome = fullHeader + (input.hasGroups ? 2 : 0) + 4 + shared;
100
+ const compactChrome = compactHeader + (input.hasGroups ? 1 : 0) + 2 + shared;
101
+ const compact = input.termRows < COMPACT_ROWS && input.listRows > input.termRows - fullChrome;
102
+ const maxList = input.termRows - (compact ? compactChrome : fullChrome);
103
+ const availableRows = Math.max(5, input.listRows <= maxList ? maxList : maxList - 2);
104
+ return { compact, availableRows };
105
+ }
60
106
  function firstPackage(group) {
61
107
  const rep = group.packages[0];
62
108
  if (!rep)
@@ -80,6 +126,8 @@ function findingsSummaryHeight(group) {
80
126
  let h = 0;
81
127
  if (rep.license)
82
128
  h += 1;
129
+ if (packageDowngradeLine(rep))
130
+ h += 1;
83
131
  if (isFree) {
84
132
  h += 1;
85
133
  }
@@ -131,6 +179,10 @@ function buildDetailLines(group, safeVersion, maxWidth) {
131
179
  const isFree = visibleFindings.length > 0 && !visibleFindings[0]?.title;
132
180
  const lines = [];
133
181
  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"));
182
+ const page = packagePageUrl(rep.ecosystem ?? "", rep.name);
183
+ if (page) {
184
+ lines.push(_jsx(Text, { dimColor: true, wrap: "truncate-end", children: `Full report ${page}` }, "page"));
185
+ }
134
186
  lines.push(_jsx(Text, { children: "" }, "verdict-gap"));
135
187
  if (group.packages.length > 3) {
136
188
  lines.push(_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["Affects: ", affectsLine(group)] }, "affects"));
@@ -140,6 +192,14 @@ function buildDetailLines(group, safeVersion, maxWidth) {
140
192
  lines.push(_jsxs(Text, { dimColor: true, children: ["Score: ", rep.score, "/100"] }, "score-info"));
141
193
  lines.push(_jsx(Text, { children: "" }, "score-gap"));
142
194
  }
195
+ if (rep.provenance) {
196
+ lines.push(_jsxs(Text, { dimColor: true, children: ["Provenance: ", provenanceLabel(rep.provenance)] }, "provenance"));
197
+ const downgrade = packageDowngradeLine(rep);
198
+ if (downgrade) {
199
+ lines.push(_jsx(Text, { wrap: "truncate-end", children: chalk.yellow(downgrade) }, "provenance-downgrade"));
200
+ }
201
+ lines.push(_jsx(Text, { children: "" }, "provenance-gap"));
202
+ }
143
203
  if (visibleFindings.length > 0) {
144
204
  for (let i = 0; i < visibleFindings.length; i++) {
145
205
  const f = visibleFindings[i];
@@ -194,17 +254,15 @@ function viewReducer(_state, action) {
194
254
  return { ..._state, expandedKey: action.expandedKey, expandLevel: action.expandLevel, viewport: action.viewport };
195
255
  }
196
256
  }
197
- export const InteractiveResultsView = ({ result, config: _config, durationMs, onExit, onBack, discoveredTotal, userStatus, scanUsage: scanUsageProp, initialView, }) => {
198
- // Prefer the server's uniform usage block ("used / limit packages this
199
- // month"); fall back to the legacy freeScansRemaining field, then to the
200
- // generic placeholder from bin.ts. usageNearLimit drives the yellow + nudge.
257
+ export const InteractiveResultsView = ({ result, config: _config, durationMs, onExit, onBack, discoveredTotal, initialView, decisions, }) => {
201
258
  const usageDisplay = result.usage ? formatUsage(result.usage) : null;
202
259
  const scanUsage = usageDisplay
203
260
  ? usageDisplay.text
204
261
  : result.freeScansRemaining !== undefined
205
262
  ? `${result.freeScansRemaining.toLocaleString()} packages left`
206
- : scanUsageProp;
263
+ : undefined;
207
264
  const usageNearLimit = usageDisplay?.nearLimit ?? false;
265
+ const headerStatus = useMemo(() => accountHeaderLine(result.usage?.tier), [result.usage]);
208
266
  // Bucket by the server `action`, never by score: the server can return
209
267
  // block/warn at score 0 (policy verdict / yanked / cooldown), and those must
210
268
  // surface as flagged, not hide in "clean".
@@ -212,7 +270,20 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
212
270
  const clean = useMemo(() => result.packages.filter((p) => (p.action ?? "pass") === "pass"), [result.packages]);
213
271
  const total = result.packages.length;
214
272
  const [searchQuery, setSearchQuery] = useState("");
215
- const allGroups = useMemo(() => groupPackages(flagged), [flagged]);
273
+ const ackByKey = useMemo(() => {
274
+ const map = new Map();
275
+ if (decisions) {
276
+ for (const [key, annotation] of Object.entries(decisions.packages)) {
277
+ if (annotation.acknowledged)
278
+ map.set(key, annotation.acknowledged);
279
+ }
280
+ }
281
+ return map;
282
+ }, [decisions]);
283
+ const activeFlagged = useMemo(() => flagged.filter((p) => !ackByKey.has(packageKey(p.name, p.version))), [flagged, ackByKey]);
284
+ const acked = useMemo(() => flagged.filter((p) => ackByKey.has(packageKey(p.name, p.version))), [flagged, ackByKey]);
285
+ const ackGroups = useMemo(() => groupPackages(acked), [acked]);
286
+ const allGroups = useMemo(() => groupPackages(activeFlagged), [activeFlagged]);
216
287
  const groups = useMemo(() => {
217
288
  if (!searchQuery)
218
289
  return allGroups;
@@ -223,13 +294,18 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
223
294
  if (members.length > 0)
224
295
  matched.push({ packages: members, key: g.key });
225
296
  }
297
+ for (const p of acked) {
298
+ if (p.name.toLowerCase().includes(q)) {
299
+ matched.push({ packages: [p], key: `ack|${p.name}@${p.version ?? ""}` });
300
+ }
301
+ }
226
302
  for (const p of clean) {
227
303
  if (p.name.toLowerCase().includes(q)) {
228
304
  matched.push({ packages: [p], key: `pass|${p.name}@${p.version ?? ""}` });
229
305
  }
230
306
  }
231
307
  return matched;
232
- }, [allGroups, clean, searchQuery]);
308
+ }, [allGroups, acked, clean, searchQuery]);
233
309
  const matchCount = useMemo(() => groups.reduce((n, g) => n + g.packages.length, 0), [groups]);
234
310
  const [view, dispatchView] = useReducer(viewReducer, {
235
311
  cursor: 0,
@@ -245,10 +321,6 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
245
321
  const [showHelp, setShowHelp] = useState(false);
246
322
  const showHelpRef = useRef(showHelp);
247
323
  showHelpRef.current = showHelp;
248
- // License-grouped overlay. Toggled with `l`. Shows packages grouped by
249
- // SPDX license id, sorted by count desc, color-coded by risk category.
250
- // Up/Down to select a row, Enter to drill into the packages for that
251
- // license, Esc to back out, l/Esc again to close the overlay.
252
324
  const [showLicenses, setShowLicenses] = useState(initialView === "licenses");
253
325
  const showLicensesRef = useRef(showLicenses);
254
326
  showLicensesRef.current = showLicenses;
@@ -259,20 +331,23 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
259
331
  const licenseDetailIdxRef = useRef(licenseDetailIdx);
260
332
  licenseDetailIdxRef.current = licenseDetailIdx;
261
333
  const [licenseDetailScroll, setLicenseDetailScroll] = useState(0);
262
- // Search inside the license drill-in: type-to-filter packages by name.
263
334
  const [licenseSearchMode, setLicenseSearchMode] = useState(false);
264
335
  const licenseSearchModeRef = useRef(licenseSearchMode);
265
336
  licenseSearchModeRef.current = licenseSearchMode;
266
337
  const [licenseSearchQuery, setLicenseSearchQuery] = useState("");
267
- // Export status (shown briefly in the footer after `e`).
268
338
  const [exportMsg, setExportMsg] = useState(null);
269
339
  const exportMsgRef = useRef(null);
270
- const showExportMsg = (s) => {
271
- setExportMsg(s);
340
+ const showExportMsg = (text, tone = "ok") => {
341
+ setExportMsg({ text, tone });
272
342
  if (exportMsgRef.current)
273
343
  clearTimeout(exportMsgRef.current);
274
344
  exportMsgRef.current = setTimeout(() => setExportMsg(null), 4000);
275
345
  };
346
+ const exportMsgText = exportMsg
347
+ ? exportMsg.tone === "nudge"
348
+ ? exportMsg.text
349
+ : (exportMsg.tone === "error" ? chalk.red : chalk.green)(exportMsg.text)
350
+ : null;
276
351
  useEffect(() => () => {
277
352
  if (exportMsgRef.current)
278
353
  clearTimeout(exportMsgRef.current);
@@ -280,17 +355,18 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
280
355
  const [exportMenu, setExportMenu] = useState(null);
281
356
  const exportMenuRef = useRef(exportMenu);
282
357
  exportMenuRef.current = exportMenu;
358
+ const [exportDialog, setExportDialog] = useState(null);
359
+ const exportDialogRef = useRef(exportDialog);
360
+ exportDialogRef.current = exportDialog;
361
+ const theme = useMemo(() => createTheme(resolvePresentation().color), []);
283
362
  const openExportMenu = (defaultScope = "all") => {
284
363
  if (!isLoggedIn()) {
285
- // Saved reports are a logged-in feature. Show the calm nudge instead
286
- // of letting the user navigate a menu they can't actually use.
287
- showExportMsg("Export requires `dg login` (free account)");
364
+ // Saved reports are a logged-in feature.
365
+ showExportMsg(loginRequiredToast(), "nudge");
288
366
  return;
289
367
  }
290
368
  setExportMenu({ scope: defaultScope, format: "json", activeRow: "scope" });
291
369
  };
292
- /** Build the JS object for a given export scope. The `currentLicenseIdx`
293
- * parameter scopes "current-license" to the drill-in user is viewing. */
294
370
  const buildExportPayload = (scope, currentLicenseIdx) => {
295
371
  const blocked = result.packages.filter((p) => p.action === "block");
296
372
  const warned = result.packages.filter((p) => p.action === "warn");
@@ -351,7 +427,6 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
351
427
  reasons: p.reasons,
352
428
  }));
353
429
  }
354
- // scope === "all"
355
430
  return {
356
431
  ...summary,
357
432
  packages: result.packages,
@@ -364,20 +439,15 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
364
439
  })),
365
440
  };
366
441
  };
367
- /** Quote a CSV cell — escape double-quotes by doubling, wrap in quotes if
368
- * the value contains a comma, quote, or newline. */
369
442
  const csvCell = (v) => {
370
443
  const s = v === null || v === undefined ? "" : String(v);
371
444
  return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
372
445
  };
373
- /** Convert a payload + scope to a serialized string in the chosen format
374
- * plus the file extension. */
375
446
  const formatExport = (payload, scope, format) => {
376
447
  if (format === "json") {
377
448
  return { body: JSON.stringify(payload, null, 2) + "\n", ext: "json" };
378
449
  }
379
450
  if (format === "csv") {
380
- // Tabular scopes only — flatten to header + rows.
381
451
  if (scope === "packages") {
382
452
  const rows = payload;
383
453
  const lines = ["name,version,score,license,riskCategory"];
@@ -429,10 +499,6 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
429
499
  }
430
500
  return { body: lines.join("\n") + "\n", ext: "csv" };
431
501
  }
432
- // Markdown — for `all`, render summary + packages table + licenses table
433
- // in one document so the file matches what the user sees in the TUI.
434
- // For `findings` with an empty list, surface that explicitly so the
435
- // file isn't just a title and nothing else.
436
502
  if (format === "md") {
437
503
  const lines = [`# Dependency Guardian — ${scope}`, ""];
438
504
  lines.push(`*Scanned at ${new Date().toISOString()}*`, "");
@@ -496,7 +562,6 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
496
562
  }
497
563
  return { body: lines.join("\n"), ext: "md" };
498
564
  }
499
- // format === "txt" — quick human-readable, mirrors the md structure
500
565
  const txt = [`Dependency Guardian — ${scope}`, "=".repeat(40), ""];
501
566
  const txtSummary = (s) => {
502
567
  txt.push(`Score: ${s.score} (${s.action})`);
@@ -559,33 +624,30 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
559
624
  }
560
625
  return { body: txt.join("\n") + "\n", ext: "txt" };
561
626
  };
562
- /** Run the export with the given scope/format. Writes
563
- * ./dg-scan-<ts>-<scope>.<ext> and shows a footer toast. Requires login —
564
- * saved reports are a logged-in feature. */
565
- const runExport = (scope, format, currentLicenseIdx) => {
566
- if (!isLoggedIn()) {
567
- showExportMsg("Export requires `dg login` (free account)");
627
+ const buildExportOption = (scope, format, currentLicenseIdx) => {
628
+ const payload = buildExportPayload(scope, currentLicenseIdx);
629
+ if (payload === null)
630
+ return null;
631
+ const { body, ext } = formatExport(payload, scope, format);
632
+ const scopeTag = scope === "current-license" && currentLicenseIdx !== null
633
+ ? `${(licenseGroups[currentLicenseIdx]?.spdx ?? "license").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 32)}`
634
+ : scope;
635
+ return {
636
+ label: `${EXPORT_SCOPE_LABELS[scope]} · ${EXPORT_FORMAT_LABELS[format]}`,
637
+ defaultName: scope === "all" ? `dg-scan.${ext}` : `dg-scan-${scopeTag}.${ext}`,
638
+ render: () => body
639
+ };
640
+ };
641
+ const handleExportDone = (result) => {
642
+ setExportDialog(null);
643
+ clearScreen();
644
+ if (result === null)
568
645
  return;
569
- }
570
- try {
571
- const ts = new Date().toISOString().replace(/[:T]/g, "-").replace(/\..*$/, "");
572
- const payload = buildExportPayload(scope, currentLicenseIdx);
573
- if (payload === null) {
574
- showExportMsg(`Nothing to export for scope "${scope}"`);
575
- return;
576
- }
577
- const { body, ext } = formatExport(payload, scope, format);
578
- const scopeTag = scope === "current-license" && currentLicenseIdx !== null
579
- ? `${(licenseGroups[currentLicenseIdx]?.spdx ?? "license").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 32)}`
580
- : scope;
581
- const filename = `dg-scan-${ts}-${scopeTag}.${ext}`;
582
- const path = resolvePath(process.cwd(), filename);
583
- writeFileSync(path, body, "utf-8");
584
- showExportMsg(`✓ Exported to ${filename}`);
585
- }
586
- catch (e) {
587
- showExportMsg(`Export failed: ${e.message}`);
588
- }
646
+ setExportMenu(null);
647
+ if ("path" in result)
648
+ showExportMsg(`✓ Exported to ${result.path}`);
649
+ else
650
+ showExportMsg(`Export failed: ${result.error}`, "error");
589
651
  };
590
652
  const licenseGroups = useMemo(() => {
591
653
  const buckets = new Map();
@@ -618,8 +680,10 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
618
680
  const searchModeRef = useRef(searchMode);
619
681
  searchModeRef.current = searchMode;
620
682
  const { rows: termRows, cols: termCols } = useTerminalSize();
621
- const compact = termRows < COMPACT_ROWS;
622
- const availableRows = Math.max(5, termRows - (compact ? FIXED_CHROME_COMPACT : FIXED_CHROME));
683
+ const subviewCompact = termRows < COMPACT_ROWS;
684
+ const ackSectionLines = acked.length > 0
685
+ ? 2 + Math.min(ackGroups.length, ACK_PREVIEW_LIMIT) + (ackGroups.length > ACK_PREVIEW_LIMIT ? 1 : 0)
686
+ : 0;
623
687
  const innerWidth = Math.max(40, termCols - 6);
624
688
  const detailGroup = useMemo(() => {
625
689
  if (!detailPane)
@@ -651,6 +715,17 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
651
715
  return 1 + animVisibleLines;
652
716
  return groupRowHeight(group, level);
653
717
  };
718
+ const listRows = groups.reduce((sum, group, idx) => sum + animatedGroupHeight(group, getLevel(idx)), 0);
719
+ const { compact, availableRows } = resolveResultsLayout({
720
+ termRows,
721
+ logoRows: termCols >= LOGO_MIN_COLS ? renderLogo(result.action).length : 0,
722
+ listRows,
723
+ ackSectionLines,
724
+ hasGroups: groups.length > 0,
725
+ hasUsage: Boolean(scanUsage),
726
+ extraLines: (discoveredTotal !== undefined && discoveredTotal > total ? 1 : 0) +
727
+ (searchQuery && groups.length === 0 ? 1 : 0),
728
+ });
654
729
  const visibleEnd = useMemo(() => {
655
730
  let consumed = 0;
656
731
  let end = view.viewport;
@@ -697,7 +772,6 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
697
772
  }
698
773
  return newStart;
699
774
  };
700
- // Re-clamp viewport when terminal is resized
701
775
  useEffect(() => {
702
776
  if (groups.length === 0)
703
777
  return;
@@ -706,7 +780,6 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
706
780
  const newVp = adjustViewport(cursor, expandedKey, expandLevel, clamped);
707
781
  dispatchView({ type: "MOVE", cursor, viewport: newVp });
708
782
  }, [availableRows]);
709
- // Clamp detail pane scroll when terminal resizes
710
783
  useEffect(() => {
711
784
  const dp = detailPaneRef.current;
712
785
  if (dp && detailLines.length > 0) {
@@ -722,10 +795,8 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
722
795
  }
723
796
  }, [detailPane, detailGroup]);
724
797
  useInput((input, key) => {
725
- // Export menu has the highest priority — once opened, all keys go here
726
- // until ⏎/Esc dismisses it. Arrow-driven: ↑↓ scrolls within the active
727
- // row, ←→ or Tab switches rows. No letter shortcuts (the user found
728
- // them clunky and they collide with `q` / other meaningful keys).
798
+ if (exportDialogRef.current)
799
+ return;
729
800
  if (exportMenuRef.current) {
730
801
  const menu = exportMenuRef.current;
731
802
  const SCOPES = ["all", "summary", "packages", "licenses", "findings"];
@@ -746,9 +817,14 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
746
817
  }
747
818
  if (key.return) {
748
819
  const m = exportMenuRef.current;
749
- setExportMenu(null);
820
+ const option = buildExportOption(m.scope, m.format, licenseDetailIdxRef.current);
750
821
  clearScreen();
751
- runExport(m.scope, m.format, licenseDetailIdxRef.current);
822
+ if (!option) {
823
+ setExportMenu(null);
824
+ showExportMsg(`Nothing to export for scope "${m.scope}"`);
825
+ return;
826
+ }
827
+ setExportDialog(option);
752
828
  return;
753
829
  }
754
830
  if (key.tab || key.leftArrow || key.rightArrow) {
@@ -787,9 +863,7 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
787
863
  return;
788
864
  }
789
865
  if (showLicensesRef.current) {
790
- // Drill-in mode: viewing the package list for one license.
791
866
  if (licenseDetailIdxRef.current !== null) {
792
- // Search-typing mode inside drill-in: capture text input.
793
867
  if (licenseSearchModeRef.current) {
794
868
  if (key.escape) {
795
869
  setLicenseSearchMode(false);
@@ -1013,32 +1087,23 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
1013
1087
  const visibleGroups = groups.slice(view.viewport, visibleEnd);
1014
1088
  const aboveCount = view.viewport;
1015
1089
  const belowCount = groups.length - visibleEnd;
1016
- const lcCol = 16; // fixed width for license column
1017
- const nameCol = Math.max(20, innerWidth - BADGE_COL - 14 - lcCol);
1090
+ const lcCol = 16;
1091
+ const nameCol = Math.max(20, innerWidth - BADGE_COL - 16 - lcCol);
1018
1092
  // Clamp cursor to valid range (groups may shrink via search filter)
1019
1093
  const clampedCursor = groups.length > 0 ? Math.min(view.cursor, groups.length - 1) : 0;
1020
- // ── Export menu overlay ──
1094
+ if (exportDialog) {
1095
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScoreHeader, { score: result.score, action: result.action, compact: subviewCompact, total: total, flagged: flagged.length, clean: clean.length, userStatus: headerStatus, scanUsage: scanUsage, usageNearLimit: usageNearLimit }), _jsx(ExportDialog, { options: [exportDialog], theme: theme, cwd: process.cwd(), onDone: handleExportDone })] }));
1096
+ }
1021
1097
  if (exportMenu) {
1022
- const SCOPES_RENDER = [
1023
- { value: "all", label: "All" },
1024
- { value: "summary", label: "Summary" },
1025
- { value: "packages", label: "Packages" },
1026
- { value: "licenses", label: "Licenses" },
1027
- { value: "findings", label: "Findings (warn+block)" },
1028
- ];
1098
+ const SCOPES_RENDER = ["all", "summary", "packages", "licenses", "findings"].map((value) => ({ value, label: EXPORT_SCOPE_LABELS[value] }));
1029
1099
  if (licenseDetailIdxRef.current !== null) {
1030
1100
  const focus = licenseGroups[licenseDetailIdxRef.current];
1031
1101
  SCOPES_RENDER.push({
1032
1102
  value: "current-license",
1033
- label: `Current license (${focus?.spdx ?? "—"})`,
1103
+ label: `${EXPORT_SCOPE_LABELS["current-license"]} (${focus?.spdx ?? "—"})`,
1034
1104
  });
1035
1105
  }
1036
- const FORMATS_RENDER = [
1037
- { value: "json", label: "JSON" },
1038
- { value: "csv", label: "CSV" },
1039
- { value: "md", label: "Markdown" },
1040
- { value: "txt", label: "Plain text" },
1041
- ];
1106
+ const FORMATS_RENDER = ["json", "csv", "md", "txt"].map((value) => ({ value, label: EXPORT_FORMAT_LABELS[value] }));
1042
1107
  const stackedLayout = termCols < 80;
1043
1108
  const colWidth = stackedLayout ? undefined : Math.max(24, Math.floor((termCols - 8) / 2));
1044
1109
  const renderColumn = (title, rows, current, isActive) => (_jsxs(Box, { flexDirection: "column", width: colWidth, flexShrink: 1, marginBottom: stackedLayout ? 1 : 0, children: [_jsxs(Text, { children: [isActive ? chalk.cyan("▌ ") : " ", isActive ? chalk.bold(title) : chalk.dim(title)] }), rows.map((r) => {
@@ -1049,14 +1114,12 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
1049
1114
  const text = selected ? chalk.bold(r.label) : r.label;
1050
1115
  return (_jsxs(Box, { children: [_jsx(Box, { width: 5, flexShrink: 0, children: _jsxs(Text, { children: [" ", bullet] }) }), _jsx(Box, { flexShrink: 1, children: _jsx(Text, { wrap: "truncate-end", children: text }) })] }, r.value));
1051
1116
  })] }));
1052
- 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, children: [_jsx(Text, { bold: true, children: "Export" }), _jsx(Text, { children: "" }), _jsxs(Box, { flexDirection: stackedLayout ? "column" : "row", children: [renderColumn("What", SCOPES_RENDER, exportMenu.scope, exportMenu.activeRow === "scope"), renderColumn("Format", FORMATS_RENDER, exportMenu.format, exportMenu.activeRow === "format")] })] }), _jsx(Text, { dimColor: true, children: chalk.dim("─".repeat(Math.max(20, termCols - 4))) }), _jsxs(Text, { children: [" ", chalk.bold.cyan("↑↓"), " ", chalk.dim("scroll"), " ", chalk.bold.cyan("←→/Tab"), " ", chalk.dim("switch row"), " ", chalk.bold.cyan("⏎"), " ", chalk.dim("export"), " ", chalk.bold.cyan("Esc"), " ", chalk.dim("cancel")] })] }));
1117
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScoreHeader, { score: result.score, action: result.action, compact: subviewCompact, total: total, flagged: flagged.length, clean: clean.length, userStatus: headerStatus, scanUsage: scanUsage, usageNearLimit: usageNearLimit }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingLeft: 2, paddingRight: 2, children: [_jsx(Text, { bold: true, children: "Export" }), _jsx(Text, { children: "" }), _jsxs(Box, { flexDirection: stackedLayout ? "column" : "row", children: [renderColumn("What", SCOPES_RENDER, exportMenu.scope, exportMenu.activeRow === "scope"), renderColumn("Format", FORMATS_RENDER, exportMenu.format, exportMenu.activeRow === "format")] })] }), _jsx(Text, { dimColor: true, children: chalk.dim("─".repeat(Math.max(20, termCols - 4))) }), _jsxs(Text, { children: [" ", chalk.bold.cyan("↑↓"), " ", chalk.dim("scroll"), " ", chalk.bold.cyan("←→/Tab"), " ", chalk.dim("switch row"), " ", chalk.bold.cyan("⏎"), " ", chalk.dim("export"), " ", chalk.bold.cyan("Esc"), " ", chalk.dim("cancel")] })] }));
1053
1118
  }
1054
- // ── Help overlay ──
1055
1119
  if (showHelp) {
1056
1120
  const isDetail = detailPane !== null;
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"] })] })] }));
1121
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScoreHeader, { score: result.score, action: result.action, compact: subviewCompact, total: total, flagged: flagged.length, clean: clean.length, userStatus: headerStatus, 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"] })] })] }));
1058
1122
  }
1059
- // ── License overlay ──
1060
1123
  if (showLicenses) {
1061
1124
  const lcColor = (risk) => {
1062
1125
  if (risk === "permissive")
@@ -1085,35 +1148,34 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
1085
1148
  const _above = top;
1086
1149
  const _below = filtered.length - bottom;
1087
1150
  const _slice = filtered.slice(top, bottom);
1088
- 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: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: color(g.spdx) }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", g.risk, " \u00B7 ", g.count, " package", g.count !== 1 ? "s" : "", q ? ` · ${filtered.length} match${filtered.length !== 1 ? "es" : ""}` : ""] })] }), licenseSearchMode || q ? (_jsx(Box, { children: _jsxs(Text, { children: [" ", chalk.bold.cyan("/"), " ", q || chalk.dim("(type to filter; Esc clears, Enter confirms)"), licenseSearchMode ? chalk.cyan("█") : ""] }) })) : (_jsx(Text, { children: "" })), _above > 0 && _jsx(Text, { dimColor: true, children: ` ↑ ${_above} more above` }), _slice.length === 0 && _jsx(Text, { dimColor: true, children: ` (no packages match "${q}")` }), _slice.map((p, i) => {
1151
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScoreHeader, { score: result.score, action: result.action, compact: subviewCompact, total: total, flagged: flagged.length, clean: clean.length, userStatus: headerStatus, scanUsage: scanUsage, usageNearLimit: usageNearLimit }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingLeft: 2, paddingRight: 2, width: "100%", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: color(g.spdx) }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", g.risk, " \u00B7 ", g.count, " package", g.count !== 1 ? "s" : "", q ? ` · ${filtered.length} match${filtered.length !== 1 ? "es" : ""}` : ""] })] }), licenseSearchMode || q ? (_jsx(Box, { children: _jsxs(Text, { children: [" ", chalk.bold.cyan("/"), " ", q || chalk.dim("(type to filter; Esc clears, Enter confirms)"), licenseSearchMode ? chalk.cyan("█") : ""] }) })) : (_jsx(Text, { children: "" })), _above > 0 && _jsx(Text, { dimColor: true, children: ` ↑ ${_above} more above` }), _slice.length === 0 && _jsx(Text, { dimColor: true, children: ` (no packages match "${q}")` }), _slice.map((p, i) => {
1089
1152
  const absIdx = top + i;
1090
1153
  const isSelected = absIdx === cursorIdx;
1091
1154
  const nameCol = Math.max(28, Math.floor(termCols * 0.55));
1092
1155
  if (isSelected) {
1093
- return (_jsxs(Text, { backgroundColor: "#1a1a2e", children: [chalk.cyan("▌"), " ", chalk.bold(pad(truncate(p.name, nameCol - 2), nameCol)), chalk.dim(pad(p.version, 16))] }, `${p.name}@${p.version}`));
1156
+ return (_jsxs(Text, { backgroundColor: "#1a1a2e", wrap: "truncate-end", children: [chalk.cyan("▌"), " ", chalk.bold(pad(truncate(p.name, nameCol - 2), nameCol)), chalk.dim(pad(truncate(p.version, 15), 16))] }, `${p.name}@${p.version}`));
1094
1157
  }
1095
- return (_jsxs(Text, { children: [" ", pad(truncate(p.name, nameCol - 2), nameCol), chalk.dim(pad(p.version, 16))] }, `${p.name}@${p.version}`));
1096
- }), _below > 0 && _jsx(Text, { dimColor: true, children: ` ↓ ${_below} more below` })] }), _jsx(Text, { dimColor: true, children: chalk.dim("─".repeat(Math.max(20, termCols - 4))) }), _jsxs(Text, { children: [" ", chalk.bold.cyan("↑↓"), " ", chalk.dim("scroll"), " ", chalk.bold.cyan("/"), " ", chalk.dim("search"), " ", chalk.bold.cyan("e"), " ", chalk.dim("export"), " ", chalk.bold.cyan("Esc"), " ", chalk.dim("back"), " ", chalk.bold.cyan("q"), " ", chalk.dim("quit"), exportMsg && _jsxs(_Fragment, { children: [" ", chalk.green(exportMsg)] })] })] }));
1158
+ return (_jsxs(Text, { wrap: "truncate-end", children: [" ", pad(truncate(p.name, nameCol - 2), nameCol), chalk.dim(pad(truncate(p.version, 15), 16))] }, `${p.name}@${p.version}`));
1159
+ }), _below > 0 && _jsx(Text, { dimColor: true, children: ` ↓ ${_below} more below` })] }), _jsx(Text, { dimColor: true, children: chalk.dim("─".repeat(Math.max(20, termCols - 4))) }), (() => {
1160
+ const sel = filtered[cursorIdx];
1161
+ const page = sel ? packagePageUrl(sel.ecosystem ?? "", sel.name) : null;
1162
+ return page ? _jsxs(Text, { wrap: "truncate-end", children: [" ", chalk.dim(`→ ${page}`)] }) : null;
1163
+ })(), _jsxs(Text, { children: [" ", chalk.bold.cyan("↑↓"), " ", chalk.dim("scroll"), " ", chalk.bold.cyan("/"), " ", chalk.dim("search"), " ", chalk.bold.cyan("e"), " ", chalk.dim("export"), " ", chalk.bold.cyan("Esc"), " ", chalk.dim("back"), " ", chalk.bold.cyan("q"), " ", chalk.dim("quit"), exportMsgText && _jsxs(_Fragment, { children: [" ", exportMsgText] })] })] }));
1097
1164
  }
1098
- // ── License list (default overlay view) ──
1099
1165
  // Column widths: pick from terminal so long SPDX strings (e.g.
1100
1166
  // "(BSD-2-Clause OR MIT OR Apache-2.0)") don't blow the layout.
1101
- const innerCols = Math.max(60, termCols - 6);
1167
+ const innerCols = Math.max(20, termCols - 6);
1102
1168
  const spdxCol = Math.min(40, Math.max(22, Math.floor(innerCols * 0.35)));
1103
1169
  const riskCol = 18;
1104
1170
  const countCol = 7;
1105
1171
  const barCol = Math.max(8, innerCols - spdxCol - riskCol - countCol - 4);
1106
1172
  const visibleRows = Math.max(5, termRows - 20);
1107
1173
  const cursor = Math.min(licenseCursor, licenseGroups.length - 1);
1108
- // Scrolling viewport: keep the cursor visible inside a window of
1109
- // `visibleRows` rows, clamped at the start/end. Replaces the prior
1110
- // "resize terminal to see all" dead end — ↑↓ now genuinely scrolls
1111
- // through the full list on any terminal height.
1112
1174
  const top = Math.max(0, Math.min(cursor - Math.floor((visibleRows - 1) / 2), licenseGroups.length - visibleRows));
1113
1175
  const bottom = Math.min(top + visibleRows, licenseGroups.length);
1114
1176
  const hiddenAbove = top;
1115
1177
  const hiddenBelow = licenseGroups.length - bottom;
1116
- 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("─".repeat(Math.max(20, termCols - 4))) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 2, paddingRight: 2, width: "100%", children: [_jsxs(Text, { bold: true, children: ["Licenses", " ", chalk.dim(`(${licenseGroups.length} unique across ${totalCount} packages)`)] }), _jsx(Text, { children: "" }), _jsxs(Box, { children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { children: " " }) }), _jsx(Box, { width: spdxCol, flexShrink: 1, children: _jsx(Text, { dimColor: true, children: "SPDX" }) }), _jsx(Box, { width: riskCol, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: "Risk" }) }), _jsx(Box, { width: countCol, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: "Count" }) }), _jsx(Box, { width: barCol, flexShrink: 1, children: _jsx(Text, { dimColor: true, children: "Share" }) })] }), hiddenAbove > 0 && (_jsx(Text, { dimColor: true, children: ` ↑ ${hiddenAbove} more above` })), licenseGroups.slice(top, bottom).map((g, i) => {
1178
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScoreHeader, { score: result.score, action: result.action, compact: subviewCompact, total: total, flagged: flagged.length, clean: clean.length, userStatus: headerStatus, scanUsage: scanUsage, usageNearLimit: usageNearLimit }), _jsx(Text, { dimColor: true, children: chalk.dim("─".repeat(Math.max(20, termCols - 4))) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 2, paddingRight: 2, width: "100%", children: [_jsxs(Text, { bold: true, children: ["Licenses", " ", chalk.dim(`(${licenseGroups.length} unique across ${totalCount} packages)`)] }), _jsx(Text, { children: "" }), _jsxs(Box, { children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { children: " " }) }), _jsx(Box, { width: spdxCol, flexShrink: 1, children: _jsx(Text, { dimColor: true, children: "SPDX" }) }), _jsx(Box, { width: riskCol, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: "Risk" }) }), _jsx(Box, { width: countCol, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: "Count" }) }), _jsx(Box, { width: barCol, flexShrink: 1, children: _jsx(Text, { dimColor: true, children: "Share" }) })] }), hiddenAbove > 0 && (_jsx(Text, { dimColor: true, children: ` ↑ ${hiddenAbove} more above` })), licenseGroups.slice(top, bottom).map((g, i) => {
1117
1179
  const absIdx = top + i;
1118
1180
  const color = lcColor(g.risk);
1119
1181
  const barLen = Math.max(1, Math.round((g.count / maxCount) * (barCol - 2)));
@@ -1122,12 +1184,11 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
1122
1184
  const spdxText = truncate(g.spdx, spdxCol - 1);
1123
1185
  const riskText = g.risk;
1124
1186
  if (isSelected) {
1125
- return (_jsxs(Text, { backgroundColor: "#1a1a2e", children: [chalk.cyan("▌"), " ", chalk.bold(color(pad(spdxText, spdxCol))), chalk.dim(pad(riskText, riskCol)), chalk.bold(pad(String(g.count), countCol)), color(bar)] }, `${g.risk}::${g.spdx}::${absIdx}`));
1187
+ return (_jsxs(Text, { backgroundColor: "#1a1a2e", wrap: "truncate-end", children: [chalk.cyan("▌"), " ", chalk.bold(color(pad(spdxText, spdxCol))), chalk.dim(pad(riskText, riskCol)), chalk.bold(pad(String(g.count), countCol)), color(bar)] }, `${g.risk}::${g.spdx}::${absIdx}`));
1126
1188
  }
1127
- return (_jsxs(Text, { children: [" ", color(pad(spdxText, spdxCol)), chalk.dim(pad(riskText, riskCol)), chalk.bold(pad(String(g.count), countCol)), color(bar)] }, `${g.risk}::${g.spdx}::${absIdx}`));
1128
- }), hiddenBelow > 0 && (_jsx(Text, { dimColor: true, children: ` ↓ ${hiddenBelow} more below` }))] }), _jsx(Text, { dimColor: true, children: chalk.dim("─".repeat(Math.max(20, termCols - 4))) }), _jsxs(Text, { children: [" ", chalk.bold.cyan("↑↓"), " ", chalk.dim("navigate"), " ", chalk.bold.cyan("⏎"), " ", chalk.dim("view packages"), " ", chalk.bold.cyan("e"), " ", chalk.dim("export"), " ", chalk.bold.cyan("Esc"), " ", chalk.dim("close"), " ", chalk.bold.cyan("q"), " ", chalk.dim("quit"), exportMsg && _jsxs(_Fragment, { children: [" ", chalk.green(exportMsg)] })] })] }));
1189
+ return (_jsxs(Text, { wrap: "truncate-end", children: [" ", color(pad(spdxText, spdxCol)), chalk.dim(pad(riskText, riskCol)), chalk.bold(pad(String(g.count), countCol)), color(bar)] }, `${g.risk}::${g.spdx}::${absIdx}`));
1190
+ }), hiddenBelow > 0 && (_jsx(Text, { dimColor: true, children: ` ↓ ${hiddenBelow} more below` }))] }), _jsx(Text, { dimColor: true, children: chalk.dim("─".repeat(Math.max(20, termCols - 4))) }), _jsxs(Text, { children: [" ", chalk.bold.cyan("↑↓"), " ", chalk.dim("navigate"), " ", chalk.bold.cyan("⏎"), " ", chalk.dim("view packages"), " ", chalk.bold.cyan("e"), " ", chalk.dim("export"), " ", chalk.bold.cyan("Esc"), " ", chalk.dim("close"), " ", chalk.bold.cyan("q"), " ", chalk.dim("quit"), exportMsgText && _jsxs(_Fragment, { children: [" ", exportMsgText] })] })] }));
1129
1191
  }
1130
- // ── Detail pane mode ──
1131
1192
  if (detailPane !== null) {
1132
1193
  const dpGroup = detailGroup;
1133
1194
  if (dpGroup) {
@@ -1137,11 +1198,10 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
1137
1198
  const dpAbove = dpScroll;
1138
1199
  const dpBelow = Math.max(0, detailLines.length - dpScroll - detailContentRows);
1139
1200
  const dpVisible = detailLines.slice(dpScroll, dpScroll + detailContentRows);
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")] })] }));
1201
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScoreHeader, { score: result.score, action: result.action, compact: subviewCompact, total: total, flagged: flagged.length, clean: clean.length, userStatus: headerStatus, 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")] })] }));
1141
1202
  }
1142
1203
  }
1143
- // ── List mode ──
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) => {
1204
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScoreHeader, { score: result.score, action: result.action, compact: compact, total: total, flagged: flagged.length, clean: clean.length, userStatus: headerStatus, 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) => {
1145
1205
  const globalIdx = view.viewport + visIdx;
1146
1206
  const isCursor = globalIdx === clampedCursor;
1147
1207
  const level = getLevel(globalIdx);
@@ -1156,8 +1216,18 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
1156
1216
  : (lcInfo.riskCategory === "no-license" || lcInfo.riskCategory === "unlicensed" || lcInfo.riskCategory === "network-copyleft") ? chalk.red
1157
1217
  : chalk.yellow;
1158
1218
  const arrow = level === "summary" ? "\u25BE" : "\u25B8"; // ▾ expanded, ▸ collapsed
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")] }))] }));
1219
+ 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)), provenanceMarker(rep), 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), provenanceMarker(rep), 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));
1220
+ }), 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}"` })), acked.length > 0 && !searchQuery && (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, width: "100%", children: [_jsx(Text, { bold: true, dimColor: true, children: `Acknowledged (${acked.length}) \u00b7 dg.json` }), ackGroups.slice(0, ACK_PREVIEW_LIMIT).map((group) => {
1221
+ const rep = firstPackage(group);
1222
+ const ack = ackByKey.get(packageKey(rep.name, rep.version));
1223
+ const who = ack?.by ?? "unknown";
1224
+ const when = ack?.at ? ` on ${ack.at.slice(0, 10)}` : "";
1225
+ return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", chalk.yellow("\u25b8"), " ", groupNames(group), " ", chalk.dim(`accepted by ${who}${when}`)] }, `ack-${group.key}`));
1226
+ }), ackGroups.length > ACK_PREVIEW_LIMIT && (_jsx(Text, { dimColor: true, children: ` +${ackGroups.length - ACK_PREVIEW_LIMIT} more \u2014 dg decisions` }))] })), !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"] })), acked.length > 0 && (_jsxs(Text, { wrap: "truncate-end", children: [chalk.yellow("\u26a0"), " ", chalk.yellow(String(acked.length)), " ", chalk.dim(`acknowledged warn${acked.length !== 1 ? "s" : ""} \u00b7 dg.json \u00b7 review with 'dg decisions'`)] })), _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))) }), (() => {
1227
+ const cur = groups.length > 0 ? groups[Math.min(view.cursor, groups.length - 1)] : undefined;
1228
+ const page = cur ? packagePageUrl(firstPackage(cur).ecosystem ?? "", firstPackage(cur).name) : null;
1229
+ return page ? _jsxs(Text, { wrap: "truncate-end", children: [" ", chalk.dim(`\u2192 ${page}`)] }) : null;
1230
+ })(), 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: [" ", exportMsgText] })) : 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")] }))] }));
1161
1231
  };
1162
1232
  const T = {
1163
1233
  branch: chalk.dim("\u251C\u2500\u2500"),
@@ -1193,21 +1263,22 @@ const FindingsSummary = ({ group, maxWidth, maxLines }) => {
1193
1263
  .sort((a, b) => b.severity - a.severity);
1194
1264
  const hasAffects = group.packages.length > 3;
1195
1265
  const allLines = [];
1196
- // License info
1197
1266
  const lcLine = licenseLine(rep);
1198
1267
  if (lcLine)
1199
1268
  allLines.push(lcLine);
1269
+ const downgrade = packageDowngradeLine(rep);
1270
+ if (downgrade) {
1271
+ allLines.push(_jsxs(Text, { wrap: "truncate-end", children: [T.branch, " ", chalk.yellow(downgrade)] }, "provenance-downgrade"));
1272
+ }
1200
1273
  // Render findings — API returns tier-gated data:
1201
1274
  // Free: { category, severity } — don't show raw IDs, just upgrade prompt
1202
1275
  // Pro: { category, severity, title } — show category + title
1203
1276
  // Team: { category, severity, title, evidence } — show everything
1204
1277
  const isFree = visibleFindings.length > 0 && !visibleFindings[0]?.title;
1205
1278
  if (isFree) {
1206
- // Free tier: don't show raw category IDs — just the upgrade prompt
1207
1279
  allLines.push(_jsxs(Text, { dimColor: true, children: [hasAffects ? T.branch : T.last, " ", chalk.yellow("\u2192"), " ", chalk.yellow("Upgrade to Pro"), " for finding details"] }, "upgrade"));
1208
1280
  }
1209
1281
  else {
1210
- // Paid tier: show findings with category + title
1211
1282
  for (let idx = 0; idx < visibleFindings.length; idx++) {
1212
1283
  const f = visibleFindings[idx];
1213
1284
  if (!f)
@@ -1221,7 +1292,6 @@ const FindingsSummary = ({ group, maxWidth, maxLines }) => {
1221
1292
  }
1222
1293
  }
1223
1294
  if (visibleFindings.length === 0 && rep.score > 0) {
1224
- // No findings at all (shouldn't happen after API change, but safety fallback)
1225
1295
  allLines.push(_jsxs(Text, { dimColor: true, children: [hasAffects ? T.branch : T.last, " Score: ", rep.score, "/100"] }, "score-only"));
1226
1296
  }
1227
1297
  if (visibleFindings.length === 0 && rep.score === 0) {