gh-glance 0.7.0 → 0.9.1

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 (4) hide show
  1. package/CHANGELOG.md +95 -1
  2. package/README.md +97 -47
  3. package/index.mjs +1069 -185
  4. package/package.json +3 -1
package/index.mjs CHANGED
@@ -23,11 +23,16 @@
23
23
  process.env.NODE_ENV ??= "production";
24
24
 
25
25
  import { execFile, spawn } from "node:child_process";
26
+ import { createHash } from "node:crypto";
26
27
  import {
28
+ chmodSync,
29
+ closeSync,
27
30
  mkdirSync,
31
+ openSync,
28
32
  readFileSync,
29
33
  realpathSync,
30
34
  renameSync,
35
+ statSync,
31
36
  unlinkSync,
32
37
  writeFileSync,
33
38
  } from "node:fs";
@@ -96,18 +101,16 @@ const MAX_RUN_LIMIT = 60;
96
101
  // stay generous. They are also far cheaper, being bounded by what's open.
97
102
  const LIST_LIMIT = 150;
98
103
 
99
- // Alert endpoints are filtered server-side to open items and capped at one
100
- // page. The previous --paginate walked the repo's entire alert history --
101
- // mostly closed alerts -- and then discarded them client-side.
104
+ // Alert endpoints are filtered server-side to open items and every lane is
105
+ // capped at one page. The previous --paginate walked the repo's entire alert
106
+ // history -- mostly closed alerts -- and then discarded them client-side.
102
107
  const ALERT_PER_PAGE = 100;
103
- // Ordering is pinned rather than left to each endpoint's default. Severity
104
- // sorting happens client-side over whatever this returns, so on a repo with more
105
- // than a page of open alerts the *page boundary* decides what can be ranked at
106
- // all -- a critical alert sitting 150th by some endpoint's arbitrary default was
107
- // simply never fetched, and the pane showed "100+" with a screen of moderates.
108
- // Newest-first at least makes the cut deterministic and explainable, and it
109
- // survives GitHub changing a default underneath us. All three endpoints accept
110
- // these parameters.
108
+ // Newest-first makes the base cut deterministic. When it fills, bounded
109
+ // critical/high lanes for the sources that support severity filtering recover
110
+ // priority rows beyond that cut; a full lane remains explicitly incomplete.
111
+ // Secret scanning has no severity filter and every row is critical, so its one
112
+ // newest lane is the honest bounded shape. All three endpoints accept the base
113
+ // parameters below.
111
114
  const ALERT_QUERY = `?state=open&per_page=${ALERT_PER_PAGE}&sort=created&direction=desc`;
112
115
 
113
116
  // Inactive tabs only feed the tab-bar counts, so they refresh every Nth tick
@@ -441,6 +444,13 @@ const VERDICT_REMEDY = {
441
444
  "Repository not found or inaccessible to the active `gh` account -- check `gh auth status` and the repository target",
442
445
  };
443
446
 
447
+ const NARROW_VERDICT_REMEDY = {
448
+ "auth-problem": "Run: gh auth status (login required)",
449
+ "rate-limited": "Wait: GitHub rate limit; retrying",
450
+ unavailable: "Run: gh-glance --doctor (repo unavailable)",
451
+ "no-remote": "Run: gh-glance --repo owner/name",
452
+ };
453
+
444
454
  function toTabError(err) {
445
455
  return { kind: "fetch", verdict: classify(err), raw: shortErr(err) };
446
456
  }
@@ -503,6 +513,16 @@ function formatTabError(error, failureContext = null) {
503
513
  return pick(VERDICT_REMEDY, error.verdict, null) ?? error.raw;
504
514
  }
505
515
 
516
+ function formatTabErrorForWidth(error, failureContext = null, width = MAX_ERR_LENGTH) {
517
+ const budget = Number.isSafeInteger(width) ? Math.max(0, width) : MAX_ERR_LENGTH;
518
+ const full = formatTabError(error, failureContext) ?? "";
519
+ const candidate =
520
+ budget < 60 && error?.kind === "fetch"
521
+ ? (pick(NARROW_VERDICT_REMEDY, error.verdict, null) ?? `Run: gh-glance --doctor (${full})`)
522
+ : full;
523
+ return candidate.length <= budget ? candidate : `${candidate.slice(0, Math.max(0, budget - 1))}…`;
524
+ }
525
+
506
526
  // Some pty wrappers (and a terminal mid-resize) report a size of 0 or
507
527
  // undefined. Taking that literally collapses the table to a single row (or
508
528
  // draws a zero-width border), so fall back to sane defaults until real
@@ -808,6 +828,7 @@ async function fetchActions(limit, signal) {
808
828
  // re-derived from the argv shape -- a second copy of the cost model is the
809
829
  // drift this file warns about repeatedly.
810
830
  restSpent: REST_PER_FETCH.actions,
831
+ graphqlSpent: GRAPHQL_PER_FETCH.actions,
811
832
  parse: () =>
812
833
  JSON.parse(raw).map((r) => ({
813
834
  databaseId: r.databaseId,
@@ -856,6 +877,7 @@ async function fetchIssues(signal) {
856
877
  // Stated rather than omitted so the zero is visibly deliberate: SORT_RECENT's
857
878
  // --search routes this through GraphQL, which is a separate budget.
858
879
  restSpent: REST_PER_FETCH.issues,
880
+ graphqlSpent: GRAPHQL_PER_FETCH.issues,
859
881
  parse: () =>
860
882
  JSON.parse(raw).map((i) => ({
861
883
  number: i.number,
@@ -889,6 +911,7 @@ async function fetchPRs(signal) {
889
911
  limit: LIST_LIMIT,
890
912
  // Zero for the same reason as issues: --search makes this a GraphQL call.
891
913
  restSpent: REST_PER_FETCH.prs,
914
+ graphqlSpent: GRAPHQL_PER_FETCH.prs,
892
915
  parse: () =>
893
916
  JSON.parse(raw).map((p) => ({
894
917
  number: p.number,
@@ -921,6 +944,7 @@ const ALERT_SOURCES = [
921
944
  key: "dependabot",
922
945
  name: "Dependabot alerts",
923
946
  path: `repos/{owner}/{repo}/dependabot/alerts${ALERT_QUERY}`,
947
+ priorityQueries: ["severity=critical,high"],
924
948
  jq: "[.[] | {number, state, created_at, severity: .security_advisory.severity, title: .security_advisory.summary, detail: .dependency.package.name}]",
925
949
  unavailable: "Dependabot alerts: unavailable (not enabled for this repository)",
926
950
  map: (a) => ({
@@ -936,6 +960,7 @@ const ALERT_SOURCES = [
936
960
  key: "codeScanning",
937
961
  name: "Code scanning",
938
962
  path: `repos/{owner}/{repo}/code-scanning/alerts${ALERT_QUERY}`,
963
+ priorityQueries: ["severity=critical", "severity=high"],
939
964
  jq: "[.[] | {number, state, created_at, severity: (.rule.security_severity_level // .rule.severity), title: (.rule.description // .rule.name), detail: .most_recent_instance.location.path}]",
940
965
  unavailable: "Code scanning: not enabled (needs GitHub Advanced Security)",
941
966
  map: (a) => ({
@@ -951,6 +976,7 @@ const ALERT_SOURCES = [
951
976
  key: "secretScanning",
952
977
  name: "Secret scanning",
953
978
  path: `repos/{owner}/{repo}/secret-scanning/alerts${ALERT_QUERY}`,
979
+ priorityQueries: [],
954
980
  jq: "[.[] | {number, state, created_at, title: (.secret_type_display_name // .secret_type)}]",
955
981
  unavailable: "Secret scanning: not enabled for this repository",
956
982
  map: (a) => ({
@@ -977,7 +1003,11 @@ const ALERT_SOURCES = [
977
1003
  //
978
1004
  // issues and prs are 0 REST because SORT_RECENT's --search routes both through
979
1005
  // GraphQL entirely (2 POSTs each, confirmed by the same measurement).
980
- const REST_PER_FETCH = { actions: 2, issues: 0, prs: 0, security: ALERT_SOURCES.length };
1006
+ const SECURITY_REQUESTS_PER_FETCH = ALERT_SOURCES.reduce(
1007
+ (total, source) => total + 1 + source.priorityQueries.length,
1008
+ 0,
1009
+ );
1010
+ const REST_PER_FETCH = { actions: 2, issues: 0, prs: 0, security: SECURITY_REQUESTS_PER_FETCH };
981
1011
  const GRAPHQL_PER_FETCH = { actions: 0, issues: 2, prs: 2, security: 0 };
982
1012
 
983
1013
  // Ceiling on adaptive widening. An adaptive interval needs one: a minute is
@@ -1012,7 +1042,7 @@ const ADAPTIVE_HYSTERESIS = 1.25;
1012
1042
  // that feeds it must agree on the answer: the law uses it to decide whether to
1013
1043
  // infer, and the loop uses it to decide whether the window may be closed.
1014
1044
  function sampleIsUsable(sample) {
1015
- return Boolean(sample) && sample.myRestCalls >= MIN_SAMPLE_CALLS && sample.globalUsed > 0;
1045
+ return Boolean(sample) && sample.myCalls >= MIN_SAMPLE_CALLS && sample.globalUsed > 0;
1016
1046
  }
1017
1047
 
1018
1048
  // How many equivalent consumers are on this token, from one probe window.
@@ -1027,7 +1057,7 @@ function sampleIsUsable(sample) {
1027
1057
  // where it is until a window is long enough to say otherwise.
1028
1058
  function inferShare(sample, lastShare = 1) {
1029
1059
  return sampleIsUsable(sample)
1030
- ? Math.max(1, sample.globalUsed / sample.myRestCalls)
1060
+ ? Math.max(1, sample.globalUsed / sample.myCalls)
1031
1061
  : Math.max(1, lastShare);
1032
1062
  }
1033
1063
 
@@ -1050,7 +1080,7 @@ function nextProbeWindow(prev, budget, spentTotal) {
1050
1080
  }
1051
1081
  const sample = {
1052
1082
  globalUsed: budget.used - prev.used,
1053
- myRestCalls: spentTotal - prev.spent,
1083
+ myCalls: spentTotal - prev.spent,
1054
1084
  };
1055
1085
  return { sample, next: sampleIsUsable(sample) ? { used: budget.used, spent: spentTotal } : prev };
1056
1086
  }
@@ -1101,15 +1131,67 @@ function adaptiveChangeWorthApplying(appliedMs, targetMs) {
1101
1131
  // other three amortised over BACKGROUND_EVERY. Same table and same shape as
1102
1132
  // projectedHourlyCost, so the controller and the report cannot disagree.
1103
1133
  function restPerTick(activeKey) {
1104
- let cost = REST_PER_FETCH[activeKey] ?? 0;
1134
+ return resourcePerTick(REST_PER_FETCH, activeKey);
1135
+ }
1136
+
1137
+ function resourcePerTick(table, activeKey) {
1138
+ let cost = table[activeKey] ?? 0;
1105
1139
  for (const key of TAB_KEYS) {
1106
- if (key !== activeKey) cost += (REST_PER_FETCH[key] ?? 0) / BACKGROUND_EVERY;
1140
+ if (key !== activeKey) cost += (table[key] ?? 0) / BACKGROUND_EVERY;
1107
1141
  }
1108
1142
  return cost;
1109
1143
  }
1110
1144
 
1111
- function alertArgs(source) {
1112
- return ["api", apiPath(source.path), ...apiHostArgs(), "--jq", source.jq];
1145
+ function nextBudgetTargets({ budgets, samples, shares, previous, activeKey, floorMs, nowMs }) {
1146
+ const targets = { core: floorMs, graphql: floorMs, ...previous };
1147
+ for (const [resource, table] of [
1148
+ ["core", REST_PER_FETCH],
1149
+ ["graphql", GRAPHQL_PER_FETCH],
1150
+ ]) {
1151
+ const callsPerTick = resourcePerTick(table, activeKey);
1152
+ if (callsPerTick <= 0 || !budgets?.[resource]) continue;
1153
+ targets[resource] = adaptiveRefreshMs({
1154
+ budget: budgets[resource],
1155
+ sample: samples?.[resource] ?? null,
1156
+ lastShare: shares?.[resource] ?? 1,
1157
+ floorMs,
1158
+ nowMs,
1159
+ restPerTick: callsPerTick,
1160
+ });
1161
+ }
1162
+ return { targets, targetMs: Math.max(floorMs, ...Object.values(targets)) };
1163
+ }
1164
+
1165
+ function alertArgs(source, path = source.path) {
1166
+ return ["api", apiPath(path), ...apiHostArgs(), "--jq", source.jq];
1167
+ }
1168
+
1169
+ function alertRequestArgs(source) {
1170
+ if (!source || typeof source.path !== "string" || !Array.isArray(source.priorityQueries)) {
1171
+ return [];
1172
+ }
1173
+ return [
1174
+ alertArgs(source),
1175
+ ...source.priorityQueries.map((query) => alertArgs(source, `${source.path}&${query}`)),
1176
+ ];
1177
+ }
1178
+
1179
+ function shouldFetchAlertPriorityLanes(openCount) {
1180
+ return openCount >= ALERT_PER_PAGE;
1181
+ }
1182
+
1183
+ function mergeAlertRows(groups) {
1184
+ const seen = new Set();
1185
+ const merged = [];
1186
+ for (const rows of groups) {
1187
+ for (const row of Array.isArray(rows) ? rows : []) {
1188
+ const key = row?.number ?? row?.id;
1189
+ if (key == null || seen.has(key)) continue;
1190
+ seen.add(key);
1191
+ merged.push(row);
1192
+ }
1193
+ }
1194
+ return merged;
1113
1195
  }
1114
1196
 
1115
1197
  // Per-source backoff. Keyed by source so one unavailable endpoint cannot slow
@@ -1117,17 +1199,17 @@ function alertArgs(source) {
1117
1199
  // mid-session is picked up within the hour.
1118
1200
  const alertBackoff = new Map();
1119
1201
 
1120
- // REST calls this process has spent, ever. In-memory and per-process, in the
1121
- // same spirit as alertBackoff above: lost on exit, and nothing depends on it
1122
- // surviving. Only ever compared against itself between two budget probes, so it
1123
- // needs no windowing and cannot overflow in any realistic session.
1202
+ // REST and GraphQL calls this process has spent, ever. In-memory and per-process,
1203
+ // in the same spirit as alertBackoff above: lost on exit, and nothing depends on
1204
+ // it surviving. Each counter is compared only against its matching resource
1205
+ // between two budget probes, so neither needs persistence or an extra window.
1124
1206
  //
1125
1207
  // Deliberately incomplete: openItem, preflight and resolveFailureContext also
1126
1208
  // spend, and are not counted, because they are occasional rather than periodic.
1127
1209
  // Under-reporting our own spend makes the inferred share *larger* and so
1128
1210
  // throttles slightly harder than strictly necessary -- the safe direction to err
1129
1211
  // in, which is why the imprecision is tolerated rather than chased.
1130
- let restSpentTotal = 0;
1212
+ const spentTotal = { core: 0, graphql: 0 };
1131
1213
 
1132
1214
  function backoffActive(key, now) {
1133
1215
  const state = alertBackoff.get(key);
@@ -1158,6 +1240,13 @@ function clearBackoff(key) {
1158
1240
  alertBackoff.delete(key);
1159
1241
  }
1160
1242
 
1243
+ function forcedBackoffKeys(key) {
1244
+ return [
1245
+ `tab:${key}`,
1246
+ ...(key === "security" ? ALERT_SOURCES.map((source) => source.key) : []),
1247
+ ];
1248
+ }
1249
+
1161
1250
  async function fetchAlertSource(source, signal, now) {
1162
1251
  if (backoffActive(source.key, now)) {
1163
1252
  const { note, verdict } = alertBackoff.get(source.key);
@@ -1172,19 +1261,30 @@ async function fetchAlertSource(source, signal, now) {
1172
1261
  parse: () => ({ alerts: [], note, verdict, truncated: false }),
1173
1262
  };
1174
1263
  }
1264
+ let spent = 0;
1175
1265
  try {
1176
- const raw = await runGh(alertArgs(source), { signal });
1266
+ const requests = alertRequestArgs(source);
1267
+ const payloads = [];
1268
+ const groups = [];
1269
+ for (const [index, args] of requests.entries()) {
1270
+ if (index > 0 && !shouldFetchAlertPriorityLanes(groups[0]?.length ?? 0)) break;
1271
+ spent += 1;
1272
+ const payload = await runGh(args, { signal });
1273
+ payloads.push(payload);
1274
+ groups.push(JSON.parse(payload).filter((alert) => alert.state === "open"));
1275
+ }
1276
+ const raw = payloads.join("\0");
1177
1277
  clearBackoff(source.key);
1178
1278
  return {
1179
1279
  raw,
1180
- spent: 1,
1280
+ spent,
1181
1281
  parse: () => {
1182
- const rows = JSON.parse(raw).filter((a) => a.state === "open");
1282
+ const rows = mergeAlertRows(groups);
1183
1283
  return {
1184
1284
  alerts: rows.map(source.map),
1185
1285
  note: null,
1186
1286
  verdict: "ok",
1187
- truncated: rows.length >= ALERT_PER_PAGE,
1287
+ truncated: groups.some((group) => group.length >= ALERT_PER_PAGE),
1188
1288
  };
1189
1289
  },
1190
1290
  };
@@ -1204,7 +1304,7 @@ async function fetchAlertSource(source, signal, now) {
1204
1304
  raw: `unavailable:${note}`,
1205
1305
  // A failed call still bills: the request reached GitHub and was counted
1206
1306
  // whether it returned data, `[]`, or a 403.
1207
- spent: 1,
1307
+ spent,
1208
1308
  parse: () => ({ alerts: [], note, verdict, truncated: false }),
1209
1309
  };
1210
1310
  }
@@ -1242,6 +1342,7 @@ async function fetchSecurity(signal) {
1242
1342
  // ALERT_SOURCES.length: a source held off by backoff spawned nothing and
1243
1343
  // must not be billed for it.
1244
1344
  restSpent: parts.reduce((n, p) => n + p.spent, 0),
1345
+ graphqlSpent: 0,
1245
1346
  parse: () => {
1246
1347
  const parsed = parts.map((p) => p.parse());
1247
1348
  const alerts = parsed.flatMap((p) => p.alerts);
@@ -1278,6 +1379,19 @@ function itemKey(item) {
1278
1379
  return item?.id ?? item?.databaseId ?? item?.number ?? null;
1279
1380
  }
1280
1381
 
1382
+ function reconcileSelectionViewport({ items, key, offset = 0, rows = 1 }) {
1383
+ const list = Array.isArray(items) ? items : [];
1384
+ const rowCount = Math.max(1, Number.isSafeInteger(rows) ? rows : 1);
1385
+ const maxOffset = Math.max(0, list.length - rowCount);
1386
+ let nextOffset = Math.min(Math.max(0, Number.isSafeInteger(offset) ? offset : 0), maxOffset);
1387
+ if (key == null) return { key: null, offset: nextOffset };
1388
+ const index = list.findIndex((item) => itemKey(item) === key);
1389
+ if (index < 0) return { key: null, offset: nextOffset };
1390
+ if (index < nextOffset) nextOffset = index;
1391
+ else if (index >= nextOffset + rowCount) nextOffset = index - rowCount + 1;
1392
+ return { key, offset: nextOffset };
1393
+ }
1394
+
1281
1395
  // `gh <kind> view --web` for the tabs that have a per-item command. The Security
1282
1396
  // tab is absent on purpose: alerts have no `gh` view subcommand, so there is
1283
1397
  // nothing honest to open.
@@ -1300,6 +1414,30 @@ async function openInBrowser(tabKey, item, signal) {
1300
1414
  await runGh([kind, "view", ...repoArgs(), String(id), "--web"], { signal });
1301
1415
  }
1302
1416
 
1417
+ function createOpenRequestRegistry() {
1418
+ const requests = new Map();
1419
+ return {
1420
+ start(key, run) {
1421
+ if (requests.has(key)) return null;
1422
+ const controller = new AbortController();
1423
+ requests.set(key, { controller, promise: null });
1424
+ let started;
1425
+ try {
1426
+ started = run({ signal: controller.signal });
1427
+ } catch (error) {
1428
+ started = Promise.reject(error);
1429
+ }
1430
+ const promise = Promise.resolve(started).finally(() => requests.delete(key));
1431
+ requests.set(key, { controller, promise });
1432
+ return promise;
1433
+ },
1434
+ abortAll() {
1435
+ for (const { controller } of requests.values()) controller.abort();
1436
+ },
1437
+ size: () => requests.size,
1438
+ };
1439
+ }
1440
+
1303
1441
  // ---------- Startup preflight ----------
1304
1442
 
1305
1443
  // Three failures are guaranteed on a fresh machine or a terminal that happens
@@ -1394,6 +1532,20 @@ function doctorEnvNames() {
1394
1532
  return [...named, ...extra];
1395
1533
  }
1396
1534
 
1535
+ function summarizeDoctorEnv(name, value, { home = homedir() } = {}) {
1536
+ if (!value) return NOT_SET;
1537
+ if (name === "NO_PROXY") {
1538
+ const entries = String(value).split(",").map((entry) => entry.trim()).filter(Boolean).length;
1539
+ return `set (${entries} ${entries === 1 ? "entry" : "entries"})`;
1540
+ }
1541
+ if (name === "GH_CONFIG_DIR") {
1542
+ const text = String(value);
1543
+ const prefix = `${home}/`;
1544
+ return text === home ? "~" : text.startsWith(prefix) ? `~/${text.slice(prefix.length)}` : `…/${text.split(/[\\/]/).filter(Boolean).at(-1) ?? "gh"}`;
1545
+ }
1546
+ return String(value);
1547
+ }
1548
+
1397
1549
  // Presence-only is the DEFAULT; printing a value is the opt-in, and the opt-in
1398
1550
  // list is curated above. This was the other way round -- print unless the *name*
1399
1551
  // ended in _TOKEN/_SECRET/_PASSWORD/_KEY -- which meant the discovery loop above
@@ -1407,7 +1559,7 @@ function envValue(name) {
1407
1559
  const value = process.env[name];
1408
1560
  if (!value) return NOT_SET;
1409
1561
  if (DOCTOR_ENV_PROXY.includes(name)) return proxySummary(value);
1410
- if (DOCTOR_ENV_PLAIN.includes(name)) return value;
1562
+ if (DOCTOR_ENV_PLAIN.includes(name)) return summarizeDoctorEnv(name, value);
1411
1563
  return "set";
1412
1564
  }
1413
1565
 
@@ -1482,8 +1634,9 @@ function targetSource() {
1482
1634
 
1483
1635
  // How much of the hourly API budget is left, and roughly how fast this
1484
1636
  // configuration spends it. The steady-state cost is not small and was invisible:
1485
- // at the default 5s refresh with the Security tab open it is around 2,280 REST
1486
- // requests an hour -- about 46% of a personal token's 5,000 -- and `--refresh 2`
1637
+ // at the default 5s refresh with the Security tab open the safe projection is
1638
+ // around 4,440 REST requests an hour -- about 89% of a personal token's 5,000 --
1639
+ // because a full newest page activates bounded priority lanes. `--refresh 2`
1487
1640
  // projects past the limit outright, so it exhausts inside the hour, every hour.
1488
1641
  // The budget is shared with everything else the token does, so the first symptom
1489
1642
  // is usually "GitHub is broken" somewhere else entirely.
@@ -1513,28 +1666,43 @@ async function rateBudget() {
1513
1666
  }
1514
1667
  }
1515
1668
 
1516
- // The same probe as rateBudget, but returning `core` as data rather than a
1517
- // sentence, because the adaptive loop has to do arithmetic on it. rateBudget
1518
- // keeps its own shape: it formats `graphql` too, which the controller does not
1519
- // need, and it is on the --doctor path where a string is the product.
1669
+ // The same probe as rateBudget, but returning both resources as data rather than
1670
+ // sentences, because the adaptive loop has to do arithmetic on each budget.
1671
+ // rateBudget keeps its display shape for the --doctor path, where strings are
1672
+ // the product.
1520
1673
  //
1521
1674
  // Safe to run on a timer for the same reason it is safe on the diagnostic path:
1522
1675
  // `gh api rate_limit` is documented as not counting against the limit, and it
1523
1676
  // measures as free (verified: delta 0).
1524
- async function readCoreBudget(signal) {
1677
+ function normalizeRateBudget(resource) {
1678
+ if (
1679
+ !resource ||
1680
+ !Number.isFinite(resource.remaining) ||
1681
+ !Number.isFinite(resource.limit) ||
1682
+ !Number.isFinite(resource.used) ||
1683
+ !Number.isFinite(resource.reset)
1684
+ ) {
1685
+ return null;
1686
+ }
1687
+ return {
1688
+ remaining: resource.remaining,
1689
+ limit: resource.limit,
1690
+ used: resource.used,
1691
+ resetMs: resource.reset * 1000,
1692
+ };
1693
+ }
1694
+
1695
+ async function readRateBudgets(signal) {
1525
1696
  try {
1526
1697
  // apiHostArgs() for the same reason the alert endpoints carry it: --repo
1527
1698
  // host/owner/name sets runtime.host without setting GH_HOST, so without the
1528
1699
  // flag this reads github.com's budget while the pane spends against the
1529
1700
  // tenant -- and then throttles, or fails to, against an unrelated number.
1530
1701
  const raw = await runGh(["api", "rate_limit", ...apiHostArgs()], { signal });
1531
- const core = JSON.parse(raw)?.resources?.core;
1532
- if (!core || !Number.isFinite(core.remaining)) return null;
1702
+ const resources = JSON.parse(raw)?.resources;
1533
1703
  return {
1534
- remaining: core.remaining,
1535
- limit: core.limit,
1536
- used: core.used,
1537
- resetMs: core.reset * 1000,
1704
+ core: normalizeRateBudget(resources?.core),
1705
+ graphql: normalizeRateBudget(resources?.graphql),
1538
1706
  };
1539
1707
  } catch {
1540
1708
  // A probe failure must never be louder than the pane's own data: the loop
@@ -1551,16 +1719,10 @@ async function readCoreBudget(signal) {
1551
1719
  // two copies of them would diverge the first time one was fixed.
1552
1720
  function projectedHourlyCost(activeKey) {
1553
1721
  const perHour = 3_600_000 / runtime.refreshMs;
1554
- let rest = 0;
1555
- let graphql = 0;
1556
- // TAB_KEYS rather than TABS: this runs on the --doctor path, which deliberately
1557
- // returns before the render tree is reached, and TABS is declared down there.
1558
- for (const key of TAB_KEYS) {
1559
- const ticks = key === activeKey ? perHour : perHour / BACKGROUND_EVERY;
1560
- rest += ticks * REST_PER_FETCH[key];
1561
- graphql += ticks * GRAPHQL_PER_FETCH[key];
1562
- }
1563
- return { rest: Math.round(rest), graphql: Math.round(graphql) };
1722
+ return {
1723
+ rest: Math.round(resourcePerTick(REST_PER_FETCH, activeKey) * perHour),
1724
+ graphql: Math.round(resourcePerTick(GRAPHQL_PER_FETCH, activeKey) * perHour),
1725
+ };
1564
1726
  }
1565
1727
 
1566
1728
  async function runDoctor() {
@@ -1573,11 +1735,16 @@ async function runDoctor() {
1573
1735
  ["Actions (run list)", actionsArgs(MIN_RUN_LIMIT)],
1574
1736
  ["Issues (issue list)", issuesArgs()],
1575
1737
  ["Pull requests (pr list)", prsArgs()],
1576
- ...ALERT_SOURCES.map((source) => [source.name, alertArgs(source)]),
1738
+ ...ALERT_SOURCES.flatMap((source) =>
1739
+ alertRequestArgs(source).map((args, index) => [
1740
+ index === 0 ? source.name : `${source.name} (priority ${index})`,
1741
+ args,
1742
+ ]),
1743
+ ),
1577
1744
  ];
1578
1745
 
1579
1746
  // allSettled, and every probe already resolves rather than rejects, so one
1580
- // slow endpoint cannot suppress the other six. runGh's own GH_TIMEOUT_MS
1747
+ // slow endpoint cannot suppress the other probes. runGh's own GH_TIMEOUT_MS
1581
1748
  // bounds each one.
1582
1749
  const [ghVersion, authStatus, remote, budget, results] = await Promise.all([
1583
1750
  captureGh(["--version"]),
@@ -1816,6 +1983,17 @@ const KEY_TABLE = [
1816
1983
  const KEY_COL = Math.max(...KEY_TABLE.map(([k]) => k.length)) + 3;
1817
1984
  const keyTableLines = () => KEY_TABLE.map(([k, d]) => `${k.padEnd(KEY_COL)}${d}`);
1818
1985
 
1986
+ function helpLines(maxRows) {
1987
+ const rows = Math.max(1, Number.isSafeInteger(maxRows) ? maxRows : 1);
1988
+ const all = keyTableLines();
1989
+ if (all.length <= rows) return all;
1990
+ if (rows === 1) return [`… ${all.length} keys: gh-glance --help`];
1991
+ const priority = [9, 6, 5, 3, 0, 7, 8, 1, 2, 4]
1992
+ .slice(0, rows - 1)
1993
+ .map((index) => all[index]);
1994
+ return [...priority, `… ${all.length - priority.length} more: gh-glance --help`];
1995
+ }
1996
+
1819
1997
  const HELP = `gh-glance ${version} -- a live-refreshing GitHub dashboard for a narrow terminal pane.
1820
1998
 
1821
1999
  Usage:
@@ -2097,6 +2275,10 @@ function Column({ width, grow, children, bold, color, dim, wrap, label, marginRi
2097
2275
  );
2098
2276
  }
2099
2277
 
2278
+ function selectionLabel(label, selected) {
2279
+ return selected ? `selected, ${label}` : label;
2280
+ }
2281
+
2100
2282
  // Each descriptor still owns one gutter cell, but an adjustable column owns
2101
2283
  // the edge that faces the flexible TITLE/SUMMARY reservoir. Fixed columns after
2102
2284
  // the grow cell therefore use the preceding descriptor's trailing gutter;
@@ -2328,6 +2510,10 @@ function sameElementMetrics(left, right) {
2328
2510
  );
2329
2511
  }
2330
2512
 
2513
+ function shouldEnableMouseReporting({ interactive, widthMode }) {
2514
+ return Boolean(interactive && widthMode);
2515
+ }
2516
+
2331
2517
  // ---------- Panel frame ----------
2332
2518
 
2333
2519
  // Ink can draw a border but not label one, so the horizontal edges are plain
@@ -2471,7 +2657,7 @@ function ActionsRow({ item, now, spin, compact, cursor, columns }) {
2471
2657
  return e(
2472
2658
  Box,
2473
2659
  { flexDirection: "row" },
2474
- e(Column, { ...columnProps(columns, "status"), color, label }, `${cursor ? ">" : " "}${icon}`),
2660
+ e(Column, { ...columnProps(columns, "status"), color, label: selectionLabel(label, cursor) }, `${cursor ? ">" : " "}${icon}`),
2475
2661
  e(Column, columnProps(columns, "title"), item.displayTitle),
2476
2662
  e(Column, { ...columnProps(columns, "updated"), dim: true }, formatAge(new Date(item.updatedAt), now)),
2477
2663
  );
@@ -2479,7 +2665,7 @@ function ActionsRow({ item, now, spin, compact, cursor, columns }) {
2479
2665
  return e(
2480
2666
  Box,
2481
2667
  { flexDirection: "row" },
2482
- e(Column, { ...columnProps(columns, "status"), color, label }, `${cursor ? ">" : " "}${icon}`),
2668
+ e(Column, { ...columnProps(columns, "status"), color, label: selectionLabel(label, cursor) }, `${cursor ? ">" : " "}${icon}`),
2483
2669
  e(Column, columnProps(columns, "title"), item.displayTitle),
2484
2670
  // The run number is the actionable half and used to be the first thing
2485
2671
  // truncation ate, since it sat at the tail of a 10-column cell.
@@ -2508,7 +2694,7 @@ const ISSUES_HEADER_COMPACT = [
2508
2694
 
2509
2695
  function IssueRow({ item, now, compact, cursor, columns }) {
2510
2696
  const cells = [
2511
- e(Column, { key: "status", ...columnProps(columns, "status"), color: OK, label: "open issue" }, `${cursor ? ">" : " "}${OCT.issueOpened}`),
2697
+ e(Column, { key: "status", ...columnProps(columns, "status"), color: OK, label: selectionLabel("open issue", cursor) }, `${cursor ? ">" : " "}${OCT.issueOpened}`),
2512
2698
  e(Column, { key: "title", ...columnProps(columns, "title") }, `#${item.number} ${item.title}`),
2513
2699
  ];
2514
2700
  if (!compact) {
@@ -2551,7 +2737,7 @@ function PRRow({ item, now, compact, cursor, columns }) {
2551
2737
  : { icon: OCT.pullRequest, color: OK, label: "open pull request" };
2552
2738
  const review = pick(REVIEW_LABEL, item.reviewDecision, REVIEW_NONE);
2553
2739
  const cells = [
2554
- e(Column, { key: "status", ...columnProps(columns, "status"), color: prIcon.color, label: prIcon.label }, `${cursor ? ">" : " "}${prIcon.icon}`),
2740
+ e(Column, { key: "status", ...columnProps(columns, "status"), color: prIcon.color, label: selectionLabel(prIcon.label, cursor) }, `${cursor ? ">" : " "}${prIcon.icon}`),
2555
2741
  e(Column, { key: "title", ...columnProps(columns, "title") }, `#${item.number} ${item.title}`),
2556
2742
  ];
2557
2743
  if (!compact) {
@@ -2605,7 +2791,7 @@ const SEVERITY_UNKNOWN = SEVERITY_STYLE.unknown;
2605
2791
  function SecurityRow({ item, now, compact, cursor, columns }) {
2606
2792
  const sev = pick(SEVERITY_STYLE, item.severity, SEVERITY_UNKNOWN);
2607
2793
  const cells = [
2608
- e(Column, { key: "status", ...columnProps(columns, "status"), color: sev.color, label: `${sev.short} severity` }, `${cursor ? ">" : " "}${OCT.shield}`),
2794
+ e(Column, { key: "status", ...columnProps(columns, "status"), color: sev.color, label: selectionLabel(`${sev.short} severity`, cursor) }, `${cursor ? ">" : " "}${OCT.shield}`),
2609
2795
  e(Column, { key: "severity", ...columnProps(columns, "severity"), color: sev.color }, sev.short),
2610
2796
  ];
2611
2797
  if (!compact) {
@@ -2887,6 +3073,116 @@ function isRecord(value) {
2887
3073
  return value !== null && typeof value === "object" && !Array.isArray(value);
2888
3074
  }
2889
3075
 
3076
+ const persistenceWaitCell = new Int32Array(new SharedArrayBuffer(4));
3077
+ const PERSISTENCE_LOCK_WAIT_MS = 250;
3078
+ const PERSISTENCE_STALE_LOCK_MS = 5000;
3079
+
3080
+ function withPersistenceLock(path, operation) {
3081
+ const lockPath = `${path}.lock`;
3082
+ const deadline = Date.now() + PERSISTENCE_LOCK_WAIT_MS;
3083
+ let descriptor = null;
3084
+ while (descriptor === null) {
3085
+ try {
3086
+ descriptor = openSync(lockPath, "wx", 0o600);
3087
+ } catch (error) {
3088
+ if (error?.code !== "EEXIST") return { ok: false, error };
3089
+ try {
3090
+ if (Date.now() - statSync(lockPath).mtimeMs > PERSISTENCE_STALE_LOCK_MS) {
3091
+ unlinkSync(lockPath);
3092
+ continue;
3093
+ }
3094
+ } catch {
3095
+ continue;
3096
+ }
3097
+ if (Date.now() >= deadline) return { ok: false, busy: true, error };
3098
+ Atomics.wait(persistenceWaitCell, 0, 0, 10);
3099
+ }
3100
+ }
3101
+ try {
3102
+ return operation();
3103
+ } finally {
3104
+ try {
3105
+ closeSync(descriptor);
3106
+ } finally {
3107
+ try {
3108
+ unlinkSync(lockPath);
3109
+ } catch {
3110
+ // The lock is advisory; failure to remove it is recovered as stale.
3111
+ }
3112
+ }
3113
+ }
3114
+ }
3115
+
3116
+ function samePersistedValue(left, right) {
3117
+ return JSON.stringify(left) === JSON.stringify(right);
3118
+ }
3119
+
3120
+ function mergeWidthPreferenceSnapshots(base, disk, next) {
3121
+ const merged = isRecord(disk) ? structuredClone(disk) : {};
3122
+ const base_ = isRecord(base) ? base : {};
3123
+ const next_ = isRecord(next) ? next : {};
3124
+ const tabKeys = new Set([...Object.keys(base_), ...Object.keys(next_)]);
3125
+ for (const tabKey of tabKeys) {
3126
+ const baseTab = isRecord(base_[tabKey]) ? base_[tabKey] : {};
3127
+ const nextTab = isRecord(next_[tabKey]) ? next_[tabKey] : {};
3128
+ const columnKeys = new Set([...Object.keys(baseTab), ...Object.keys(nextTab)]);
3129
+ for (const key of columnKeys) {
3130
+ if (samePersistedValue(baseTab[key], nextTab[key])) continue;
3131
+ if (!Object.hasOwn(nextTab, key)) {
3132
+ if (isRecord(merged[tabKey])) delete merged[tabKey][key];
3133
+ } else {
3134
+ merged[tabKey] = isRecord(merged[tabKey]) ? merged[tabKey] : {};
3135
+ merged[tabKey][key] = nextTab[key];
3136
+ }
3137
+ }
3138
+ if (isRecord(merged[tabKey]) && Object.keys(merged[tabKey]).length === 0) delete merged[tabKey];
3139
+ }
3140
+ return merged;
3141
+ }
3142
+
3143
+ function mergeDashboardCacheSnapshots(base, disk, next) {
3144
+ const merged = isRecord(disk) ? structuredClone(disk) : {};
3145
+ const base_ = isRecord(base) ? base : {};
3146
+ const next_ = isRecord(next) ? next : {};
3147
+ for (const target of new Set([...Object.keys(base_), ...Object.keys(next_)])) {
3148
+ if (samePersistedValue(base_[target], next_[target])) continue;
3149
+ if (!Object.hasOwn(next_, target)) {
3150
+ delete merged[target];
3151
+ continue;
3152
+ }
3153
+
3154
+ const baseEntry = isRecord(base_[target]) ? base_[target] : {};
3155
+ const diskEntry = isRecord(merged[target]) ? merged[target] : {};
3156
+ const nextEntry = isRecord(next_[target]) ? next_[target] : {};
3157
+ const mergedEntry = structuredClone(diskEntry);
3158
+ const baseTabs = isRecord(baseEntry.tabs) ? baseEntry.tabs : {};
3159
+ const nextTabs = isRecord(nextEntry.tabs) ? nextEntry.tabs : {};
3160
+ const mergedTabs = isRecord(mergedEntry.tabs) ? mergedEntry.tabs : {};
3161
+ for (const tabKey of new Set([...Object.keys(baseTabs), ...Object.keys(nextTabs)])) {
3162
+ if (samePersistedValue(baseTabs[tabKey], nextTabs[tabKey])) continue;
3163
+ if (Object.hasOwn(nextTabs, tabKey)) mergedTabs[tabKey] = nextTabs[tabKey];
3164
+ else delete mergedTabs[tabKey];
3165
+ }
3166
+ if (Object.keys(mergedTabs).length > 0) mergedEntry.tabs = mergedTabs;
3167
+ else delete mergedEntry.tabs;
3168
+
3169
+ for (const field of ["securityNotes", "securityBlind", "updatedAt"]) {
3170
+ if (samePersistedValue(baseEntry[field], nextEntry[field])) continue;
3171
+ if (Object.hasOwn(nextEntry, field)) mergedEntry[field] = nextEntry[field];
3172
+ else delete mergedEntry[field];
3173
+ }
3174
+ merged[target] = mergedEntry;
3175
+ }
3176
+ return merged;
3177
+ }
3178
+
3179
+ function adoptPersistedSnapshot(result, persistedRef, liveRef) {
3180
+ if (result?.ok !== true || !isRecord(result.persisted)) return false;
3181
+ persistedRef.current = result.persisted;
3182
+ liveRef.current = result.persisted;
3183
+ return true;
3184
+ }
3185
+
2890
3186
  function normalizeWidthOverrides(overrides, tabs = TABS, { omitDefaults = false } = {}) {
2891
3187
  if (!isRecord(overrides)) return {};
2892
3188
 
@@ -2953,36 +3249,45 @@ function loadWidthPreferences(path, tabs = TABS) {
2953
3249
  return decodeWidthPreferences(raw, tabs);
2954
3250
  }
2955
3251
 
2956
- function saveWidthPreferences(path, overrides, tabs = TABS) {
2957
- let tempPath = null;
3252
+ function saveWidthPreferences(path, overrides, tabs = TABS, { base = null } = {}) {
2958
3253
  try {
2959
- const payload = serializeWidthPreferences(overrides, tabs);
2960
3254
  const parent = dirname(path);
2961
3255
  mkdirSync(parent, { recursive: true, mode: 0o700 });
2962
- widthPreferenceTempSequence += 1;
2963
- tempPath = `${path}.${process.pid}.${Date.now()}.${widthPreferenceTempSequence}.tmp`;
2964
- writeFileSync(tempPath, payload, {
2965
- encoding: "utf8",
2966
- mode: 0o600,
2967
- flag: "wx",
2968
- });
2969
- renameSync(tempPath, path);
2970
- return { ok: true };
2971
- } catch (error) {
2972
- if (tempPath !== null) {
3256
+ return withPersistenceLock(path, () => {
3257
+ let tempPath = null;
2973
3258
  try {
2974
- unlinkSync(tempPath);
2975
- } catch {
2976
- // The write may have failed before creating it. Cleanup is best effort
2977
- // and deliberately targets only this operation's exact temporary file.
3259
+ const disk = base === null ? {} : loadWidthPreferences(path, tabs).preferences;
3260
+ const merged = base === null
3261
+ ? overrides
3262
+ : mergeWidthPreferenceSnapshots(base, disk, overrides);
3263
+ const payload = serializeWidthPreferences(merged, tabs);
3264
+ widthPreferenceTempSequence += 1;
3265
+ tempPath = `${path}.${process.pid}.${Date.now()}.${widthPreferenceTempSequence}.tmp`;
3266
+ writeFileSync(tempPath, payload, {
3267
+ encoding: "utf8",
3268
+ mode: 0o600,
3269
+ flag: "wx",
3270
+ });
3271
+ renameSync(tempPath, path);
3272
+ return { ok: true, persisted: normalizeWidthOverrides(merged, tabs) };
3273
+ } catch (error) {
3274
+ if (tempPath !== null) {
3275
+ try {
3276
+ unlinkSync(tempPath);
3277
+ } catch {
3278
+ // Cleanup targets only this operation's exact temporary file.
3279
+ }
3280
+ }
3281
+ return { ok: false, error };
2978
3282
  }
2979
- }
3283
+ });
3284
+ } catch (error) {
2980
3285
  return { ok: false, error };
2981
3286
  }
2982
3287
  }
2983
3288
 
2984
- function createWidthPreferenceWriter({ write, delay = 200, onResult = () => {} }) {
2985
- let latest = EMPTY_WIDTH_OVERRIDES;
3289
+ function createCoalescedWriter({ write, delay = 200, onResult = () => {} }) {
3290
+ let latest;
2986
3291
  let generation = 0;
2987
3292
  let persistedGeneration = 0;
2988
3293
  let timer = null;
@@ -3083,6 +3388,336 @@ function createWidthPreferenceWriter({ write, delay = 200, onResult = () => {} }
3083
3388
  };
3084
3389
  }
3085
3390
 
3391
+ const createWidthPreferenceWriter = createCoalescedWriter;
3392
+
3393
+ // ---------- Last-known-good dashboard cache ----------
3394
+
3395
+ const DASHBOARD_CACHE_VERSION = 2;
3396
+ const MAX_DASHBOARD_CACHE_TARGETS = 5;
3397
+ const MAX_DASHBOARD_CACHE_ROWS_PER_TAB = 60;
3398
+ let dashboardCacheTempSequence = 0;
3399
+
3400
+ function dashboardCachePath(options = {}) {
3401
+ return join(dirname(widthPreferencesPath(options)), "dashboard-cache.json");
3402
+ }
3403
+
3404
+ function effectiveGhConfigDir({ env = process.env, platform = process.platform, home = homedir() } = {}) {
3405
+ if (env?.GH_CONFIG_DIR) return String(env.GH_CONFIG_DIR);
3406
+ if (env?.XDG_CONFIG_HOME && isAbsolute(env.XDG_CONFIG_HOME)) return join(env.XDG_CONFIG_HOME, "gh");
3407
+ if (platform === "win32" && env?.APPDATA) return join(env.APPDATA, "GitHub CLI");
3408
+ return join(home, ".config", "gh");
3409
+ }
3410
+
3411
+ function authCacheIdentity({
3412
+ env = process.env,
3413
+ platform = process.platform,
3414
+ home = homedir(),
3415
+ stat,
3416
+ } = {}) {
3417
+ const configDir = effectiveGhConfigDir({ env, platform, home });
3418
+ let configStat = stat;
3419
+ if (configStat === undefined) {
3420
+ try {
3421
+ configStat = statSync(join(configDir, "hosts.yml"));
3422
+ } catch {
3423
+ configStat = null;
3424
+ }
3425
+ }
3426
+ const tokenDigests = Object.fromEntries(
3427
+ ["GH_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_TOKEN", "GITHUB_ENTERPRISE_TOKEN"]
3428
+ .filter((name) => env?.[name])
3429
+ .map((name) => [
3430
+ name,
3431
+ createHash("sha256").update(String(env[name])).digest("hex"),
3432
+ ]),
3433
+ );
3434
+ const payload = {
3435
+ configDir,
3436
+ configStat: configStat
3437
+ ? {
3438
+ dev: Number(configStat.dev),
3439
+ ino: Number(configStat.ino),
3440
+ size: Number(configStat.size),
3441
+ mtimeMs: Math.trunc(Number(configStat.mtimeMs)),
3442
+ }
3443
+ : null,
3444
+ tokenDigests,
3445
+ };
3446
+ return createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 24);
3447
+ }
3448
+
3449
+ // Explicit targets are stable across working directories. In inferred mode the
3450
+ // directory is the only repository identity available without making another
3451
+ // GitHub call -- exactly the call that cannot succeed when this cache is needed.
3452
+ // JSON encoding avoids delimiter ambiguity in host, repo, and path strings.
3453
+ function dashboardCacheTarget({
3454
+ repo = null,
3455
+ ghRepo = null,
3456
+ host = null,
3457
+ cwd = process.cwd(),
3458
+ account = authCacheIdentity(),
3459
+ } = {}) {
3460
+ const target = repo || ghRepo;
3461
+ return target
3462
+ ? JSON.stringify({ kind: "repo", host: String(host ?? ""), repo: String(target), account })
3463
+ : JSON.stringify({ kind: "cwd", host: String(host ?? ""), cwd: String(cwd), account });
3464
+ }
3465
+
3466
+ function cacheTimestamp(value) {
3467
+ return Number.isFinite(value) && value >= 0 ? value : null;
3468
+ }
3469
+
3470
+ function normalizeCachedItem(tabKey, item) {
3471
+ if (!isRecord(item)) return null;
3472
+ if (tabKey === "actions") {
3473
+ if (!Number.isSafeInteger(item.databaseId)) return null;
3474
+ return {
3475
+ databaseId: item.databaseId,
3476
+ displayTitle: safe(item.displayTitle),
3477
+ workflowName: safe(item.workflowName),
3478
+ number: Number.isSafeInteger(item.number) ? item.number : null,
3479
+ headBranch: safe(item.headBranch),
3480
+ status: safe(item.status),
3481
+ conclusion: item.conclusion == null ? null : safe(item.conclusion),
3482
+ startedAt: safe(item.startedAt),
3483
+ updatedAt: safe(item.updatedAt),
3484
+ };
3485
+ }
3486
+ if (tabKey === "issues") {
3487
+ if (!Number.isSafeInteger(item.number)) return null;
3488
+ return {
3489
+ number: item.number,
3490
+ title: safe(item.title),
3491
+ author: safe(item.author),
3492
+ label: safe(item.label),
3493
+ updatedAt: safe(item.updatedAt),
3494
+ };
3495
+ }
3496
+ if (tabKey === "prs") {
3497
+ if (!Number.isSafeInteger(item.number)) return null;
3498
+ return {
3499
+ number: item.number,
3500
+ title: safe(item.title),
3501
+ author: safe(item.author),
3502
+ headRefName: safe(item.headRefName),
3503
+ isDraft: Boolean(item.isDraft),
3504
+ reviewDecision: safe(item.reviewDecision),
3505
+ updatedAt: safe(item.updatedAt),
3506
+ };
3507
+ }
3508
+ if (tabKey === "security") {
3509
+ const id = safe(item.id);
3510
+ if (!id) return null;
3511
+ return {
3512
+ id,
3513
+ kind: safe(item.kind),
3514
+ severity: safe(item.severity),
3515
+ title: safe(item.title),
3516
+ detail: safe(item.detail),
3517
+ createdAt: safe(item.createdAt),
3518
+ };
3519
+ }
3520
+ return null;
3521
+ }
3522
+
3523
+ function normalizeDashboardCacheEntry(entry) {
3524
+ if (!isRecord(entry) || !isRecord(entry.tabs)) return null;
3525
+ const tabs = {};
3526
+ for (const tabKey of TAB_KEYS) {
3527
+ const tab = entry.tabs[tabKey];
3528
+ if (!isRecord(tab) || !Array.isArray(tab.data)) continue;
3529
+ const lastOk = cacheTimestamp(tab.lastOk);
3530
+ if (
3531
+ lastOk === null ||
3532
+ !isRecord(tab.meta) ||
3533
+ cacheTimestamp(tab.meta.at) === null ||
3534
+ typeof tab.meta.truncated !== "boolean"
3535
+ ) {
3536
+ continue;
3537
+ }
3538
+ const normalizedData = tab.data.map((item) => normalizeCachedItem(tabKey, item)).filter(Boolean);
3539
+ // A malformed row means this tab was not written by the current schema.
3540
+ // Reject the tab rather than turning a corrupt non-empty payload into a
3541
+ // confident empty state.
3542
+ if (normalizedData.length !== tab.data.length) continue;
3543
+ const data = normalizedData.slice(0, MAX_DASHBOARD_CACHE_ROWS_PER_TAB);
3544
+ const meta = {
3545
+ at: tab.meta.at,
3546
+ truncated: tab.meta.truncated || normalizedData.length > MAX_DASHBOARD_CACHE_ROWS_PER_TAB,
3547
+ };
3548
+ tabs[tabKey] = { data, meta, lastOk };
3549
+ }
3550
+ if (Object.keys(tabs).length === 0) return null;
3551
+ const latestTab = Math.max(...Object.values(tabs).map((tab) => tab.lastOk));
3552
+ return {
3553
+ tabs,
3554
+ securityNotes: Array.isArray(entry.securityNotes)
3555
+ ? entry.securityNotes
3556
+ .filter((note) => typeof note === "string")
3557
+ .slice(0, ALERT_SOURCES.length)
3558
+ .map((note) => safe(note))
3559
+ : [],
3560
+ securityBlind: typeof entry.securityBlind === "boolean" ? entry.securityBlind : false,
3561
+ updatedAt: cacheTimestamp(entry.updatedAt) ?? latestTab,
3562
+ };
3563
+ }
3564
+
3565
+ function normalizeDashboardCache(cache) {
3566
+ if (!isRecord(cache)) return {};
3567
+ return limitDashboardCache(
3568
+ Object.fromEntries(
3569
+ Object.entries(cache)
3570
+ .map(([target, entry]) => [target, normalizeDashboardCacheEntry(entry)])
3571
+ .filter(([target, entry]) => target.length > 0 && entry !== null),
3572
+ ),
3573
+ );
3574
+ }
3575
+
3576
+ function limitDashboardCache(cache) {
3577
+ return Object.fromEntries(
3578
+ Object.entries(cache)
3579
+ .sort((left, right) => right[1].updatedAt - left[1].updatedAt)
3580
+ .slice(0, MAX_DASHBOARD_CACHE_TARGETS),
3581
+ );
3582
+ }
3583
+
3584
+ function mergeDashboardCacheEntry(cache, target, entry) {
3585
+ const normalized = normalizeDashboardCacheEntry(entry);
3586
+ return normalized === null ? cache : limitDashboardCache({ ...cache, [target]: normalized });
3587
+ }
3588
+
3589
+ function decodeDashboardCache(raw) {
3590
+ let document;
3591
+ try {
3592
+ document = JSON.parse(raw);
3593
+ } catch (error) {
3594
+ return { cache: {}, error };
3595
+ }
3596
+ if (
3597
+ !isRecord(document) ||
3598
+ document.version !== DASHBOARD_CACHE_VERSION ||
3599
+ !isRecord(document.targets)
3600
+ ) {
3601
+ return { cache: {}, error: new Error("Unsupported dashboard cache document") };
3602
+ }
3603
+ return { cache: normalizeDashboardCache(document.targets), error: null };
3604
+ }
3605
+
3606
+ function serializeDashboardCache(cache, { normalized = false } = {}) {
3607
+ return `${JSON.stringify(
3608
+ { version: DASHBOARD_CACHE_VERSION, targets: normalized ? cache : normalizeDashboardCache(cache) },
3609
+ null,
3610
+ 2,
3611
+ )}\n`;
3612
+ }
3613
+
3614
+ function loadDashboardCache(path, target) {
3615
+ let raw;
3616
+ try {
3617
+ raw = readFileSync(path, "utf8");
3618
+ } catch (error) {
3619
+ return error?.code === "ENOENT"
3620
+ ? { cache: {}, entry: null, error: null }
3621
+ : { cache: {}, entry: null, error };
3622
+ }
3623
+ const decoded = decodeDashboardCache(raw);
3624
+ return {
3625
+ cache: decoded.cache,
3626
+ entry: pick(decoded.cache, target, null),
3627
+ error: decoded.error,
3628
+ };
3629
+ }
3630
+
3631
+ function saveDashboardCache(path, cache, { normalized = false, base = null } = {}) {
3632
+ try {
3633
+ const parent = dirname(path);
3634
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
3635
+ if (process.platform !== "win32") chmodSync(parent, 0o700);
3636
+ return withPersistenceLock(path, () => {
3637
+ let tempPath = null;
3638
+ try {
3639
+ const disk = base === null ? {} : loadDashboardCache(path, "").cache;
3640
+ const merged = base === null
3641
+ ? cache
3642
+ : limitDashboardCache(mergeDashboardCacheSnapshots(base, disk, cache));
3643
+ dashboardCacheTempSequence += 1;
3644
+ tempPath = `${path}.${process.pid}.${Date.now()}.${dashboardCacheTempSequence}.tmp`;
3645
+ writeFileSync(tempPath, serializeDashboardCache(merged, { normalized }), {
3646
+ encoding: "utf8",
3647
+ mode: 0o600,
3648
+ flag: "wx",
3649
+ });
3650
+ renameSync(tempPath, path);
3651
+ return { ok: true, persisted: normalizeDashboardCache(merged) };
3652
+ } catch (error) {
3653
+ if (tempPath !== null) {
3654
+ try {
3655
+ unlinkSync(tempPath);
3656
+ } catch {
3657
+ // Cleanup targets only this operation's exact temporary file.
3658
+ }
3659
+ }
3660
+ return { ok: false, error };
3661
+ }
3662
+ });
3663
+ } catch (error) {
3664
+ return { ok: false, error };
3665
+ }
3666
+ }
3667
+
3668
+ function nextSecurityRaw(previousRaw, raw, blind) {
3669
+ if (blind === true) return null;
3670
+ return previousRaw === raw ? previousRaw : raw;
3671
+ }
3672
+
3673
+ const CACHE_FRESHNESS_CHECKPOINT_MS = 60_000;
3674
+ const SECURITY_UNCHANGED_POLL_MS = 60_000;
3675
+
3676
+ function shouldCheckpointFreshness({ persistedAt, completedAt }) {
3677
+ return !Number.isFinite(persistedAt) || completedAt - persistedAt >= CACHE_FRESHNESS_CHECKPOINT_MS;
3678
+ }
3679
+
3680
+ function securityPollDelay({ unchangedPolls, floorMs, force = false }) {
3681
+ if (force) return 0;
3682
+ return unchangedPolls > 0 ? Math.max(floorMs, SECURITY_UNCHANGED_POLL_MS) : floorMs;
3683
+ }
3684
+
3685
+ function shouldShowFetchLoading({ hasData, force }) {
3686
+ return !hasData || force === true;
3687
+ }
3688
+
3689
+ function pollTickKeys({ ticks, activeKey }) {
3690
+ return {
3691
+ due: ticks % BACKGROUND_EVERY === 0 ? [...TAB_KEYS] : [activeKey],
3692
+ nextTicks: ticks + 1,
3693
+ };
3694
+ }
3695
+
3696
+ function pollResultTransition({ key, previousRaw, raw, parse, limit, completedAt }) {
3697
+ if (previousRaw === raw) return { kind: "unchanged", completedAt };
3698
+ const value = parse();
3699
+ if (key === "security" && value?.blind) {
3700
+ return {
3701
+ kind: "blind",
3702
+ nextRaw: nextSecurityRaw(previousRaw, raw, true),
3703
+ notes: value.notes ?? [],
3704
+ blind: true,
3705
+ };
3706
+ }
3707
+ const data = value?.alerts ?? value;
3708
+ return {
3709
+ kind: "changed",
3710
+ nextRaw: key === "security" ? nextSecurityRaw(previousRaw, raw, false) : raw,
3711
+ data,
3712
+ meta: {
3713
+ at: completedAt,
3714
+ truncated: value?.truncated ?? data.length >= limit,
3715
+ },
3716
+ notes: value?.notes,
3717
+ blind: Boolean(value?.blind),
3718
+ };
3719
+ }
3720
+
3086
3721
  // Below this the full column set cannot render without its fixed columns
3087
3722
  // overflowing the frame -- rows hard-wrap and ink switches to clearing and
3088
3723
  // repainting the whole screen every frame. Worse, in the band just above the
@@ -3101,6 +3736,11 @@ const MIN_COMPACT_WIDTH = Math.max(...TABS.map((t) => minimumWidthFor(t.compactH
3101
3736
  const TAB_LABEL_FULL_WIDTH = 78;
3102
3737
  const TAB_LABEL_HYSTERESIS = 4;
3103
3738
 
3739
+ function tabFailureSuffix({ count, failed, brokenCI }) {
3740
+ if (count == null) return failed ? " x" : "";
3741
+ return ` (${count}${brokenCI ? "!" : ""}${failed ? "x" : ""})`;
3742
+ }
3743
+
3104
3744
  function TabBar({ activeIndex, counts, brokenCI, firstLoad, failed, spin, useShort }) {
3105
3745
  return e(
3106
3746
  Box,
@@ -3114,8 +3754,12 @@ function TabBar({ activeIndex, counts, brokenCI, firstLoad, failed, spin, useSho
3114
3754
  count == null
3115
3755
  ? firstLoad[tab.key]
3116
3756
  ? ` ${spin}`
3117
- : ""
3118
- : ` (${count}${brokenCI[tab.key] ? "!" : ""})`;
3757
+ : tabFailureSuffix({ count, failed: failed[tab.key], brokenCI: false })
3758
+ : tabFailureSuffix({
3759
+ count,
3760
+ failed: failed[tab.key],
3761
+ brokenCI: brokenCI[tab.key],
3762
+ });
3119
3763
  const name = useShort ? tab.short : tab.label;
3120
3764
  // Bold and inverse are both stripped at chalk level 0 (NO_COLOR, or a
3121
3765
  // dumb terminal), which left no indication at all of which tab was
@@ -3175,6 +3819,23 @@ const KEY_HINTS = [
3175
3819
  { label: "Quit", keys: "q" },
3176
3820
  ];
3177
3821
 
3822
+ function activeKeyHints({
3823
+ interactive,
3824
+ remoteSetup = false,
3825
+ canMove = false,
3826
+ canOpen = false,
3827
+ canResize = false,
3828
+ }) {
3829
+ if (!interactive) return [{ label: "Quit", keys: "^C" }];
3830
+ if (remoteSetup) return REMOTE_SETUP_HINTS;
3831
+ return KEY_HINTS.filter((hint) => {
3832
+ if (hint.label === "Move") return canMove;
3833
+ if (hint.label === "Open") return canOpen;
3834
+ if (hint.label === "Width") return canResize;
3835
+ return true;
3836
+ });
3837
+ }
3838
+
3178
3839
  const REMOTE_SETUP_HINTS = [
3179
3840
  { label: "Create remote", keys: "Ent" },
3180
3841
  { label: "Quit", keys: "q" },
@@ -3245,6 +3906,9 @@ function StatusBar({
3245
3906
  widthMode = false,
3246
3907
  widthColumn = null,
3247
3908
  widthSaveError = null,
3909
+ canMove = false,
3910
+ canOpen = false,
3911
+ canResize = false,
3248
3912
  }) {
3249
3913
  // Width mode owns the whole bar, so the throttle badge is deliberately not
3250
3914
  // shown here: this state is transient and explicitly entered, and the widened
@@ -3268,11 +3932,7 @@ function StatusBar({
3268
3932
  // Without raw mode none of the key handlers run, so advertising them would be
3269
3933
  // telling the user something untrue about what the app can do. Ctrl+C still
3270
3934
  // works there, because the tty delivers a real SIGINT.
3271
- const hints = interactive
3272
- ? remoteSetup
3273
- ? REMOTE_SETUP_HINTS
3274
- : KEY_HINTS
3275
- : [{ label: "Quit", keys: "^C" }];
3935
+ const hints = activeKeyHints({ interactive, remoteSetup, canMove, canOpen, canResize });
3276
3936
  const hintsFullWidth =
3277
3937
  hints.reduce((sum, hint) => sum + hint.label.length + 2 + [...hint.keys].length, 0) +
3278
3938
  (hints.length - 1) * 3;
@@ -3379,18 +4039,29 @@ function App({ onCreateRemote = () => {} } = {}) {
3379
4039
  const { exit, suspendTerminal } = useApp();
3380
4040
  const [activeIndex, setActiveIndex] = useState(runtime.initialTabIndex);
3381
4041
  const [preferencePath] = useState(() => widthPreferencesPath());
3382
- const [widthOverrides, setWidthOverrides] = useState(
3383
- () => loadWidthPreferences(preferencePath).preferences,
3384
- );
4042
+ const [loadedWidthPreferences] = useState(() => loadWidthPreferences(preferencePath));
4043
+ const [widthOverrides, setWidthOverrides] = useState(() => loadedWidthPreferences.preferences);
3385
4044
  const widthOverridesRef = useRef(widthOverrides);
3386
4045
  widthOverridesRef.current = widthOverrides;
4046
+ const widthPersistedRef = useRef(loadedWidthPreferences.preferences);
3387
4047
  const [widthSaveError, setWidthSaveError] = useState(null);
3388
4048
  const widthPreferencesMountedRef = useRef(true);
3389
4049
  const [widthPreferenceWriter] = useState(() =>
3390
4050
  createWidthPreferenceWriter({
3391
- write: (overrides) => saveWidthPreferences(preferencePath, overrides),
4051
+ write: (overrides) => {
4052
+ const result = saveWidthPreferences(preferencePath, overrides, TABS, {
4053
+ base: widthPersistedRef.current,
4054
+ });
4055
+ adoptPersistedSnapshot(result, widthPersistedRef, widthOverridesRef);
4056
+ return result;
4057
+ },
3392
4058
  onResult: (result) => {
3393
4059
  if (!widthPreferencesMountedRef.current) return;
4060
+ if (result?.ok === true) {
4061
+ setWidthOverrides((current) =>
4062
+ samePersistedValue(current, result.persisted) ? current : result.persisted,
4063
+ );
4064
+ }
3394
4065
  setWidthSaveError(
3395
4066
  result?.ok === false
3396
4067
  ? (result.error ?? new Error("Width preferences could not be saved"))
@@ -3409,19 +4080,58 @@ function App({ onCreateRemote = () => {} } = {}) {
3409
4080
  void widthPreferenceWriter.dispose();
3410
4081
  };
3411
4082
  }, [widthPreferenceWriter]);
4083
+ const [cachePath] = useState(() => dashboardCachePath());
4084
+ const [cacheTarget] = useState(() =>
4085
+ dashboardCacheTarget({
4086
+ repo: runtime.repo,
4087
+ ghRepo: process.env.GH_REPO,
4088
+ host: runtime.host ?? process.env.GH_HOST,
4089
+ cwd: process.cwd(),
4090
+ }),
4091
+ );
4092
+ const [loadedCache] = useState(() => loadDashboardCache(cachePath, cacheTarget));
4093
+ const dashboardCacheRef = useRef(loadedCache.cache);
4094
+ const dashboardCachePersistedRef = useRef(loadedCache.cache);
4095
+ const dashboardCacheTargetRef = useRef(cacheTarget);
4096
+ const [dashboardCacheWriter] = useState(() =>
4097
+ createCoalescedWriter({
4098
+ write: (cache) => {
4099
+ const result = saveDashboardCache(cachePath, cache, {
4100
+ normalized: true,
4101
+ base: dashboardCachePersistedRef.current,
4102
+ });
4103
+ adoptPersistedSnapshot(result, dashboardCachePersistedRef, dashboardCacheRef);
4104
+ return result;
4105
+ },
4106
+ }),
4107
+ );
4108
+ useEffect(() => () => {
4109
+ // Production writes start synchronously, so a pending latest snapshot is
4110
+ // not lost on quit. The cache is bounded; ignoring the advisory result keeps
4111
+ // a filesystem error from replacing terminal teardown.
4112
+ void dashboardCacheWriter.dispose();
4113
+ }, [dashboardCacheWriter]);
4114
+ const cachedEntry = loadedCache.entry;
3412
4115
  // `null` means "never resolved" -- distinct from `[]`, which means "resolved
3413
4116
  // and genuinely empty". The tab bar and the body render those differently.
3414
- const [data, setData] = useState({ actions: null, issues: null, prs: null, security: null });
3415
- const [meta, setMeta] = useState({
3416
- actions: null,
3417
- issues: null,
3418
- prs: null,
3419
- security: null,
3420
- });
3421
- const [securityNotes, setSecurityNotes] = useState([]);
4117
+ const [data, setData] = useState(() =>
4118
+ Object.fromEntries(TABS.map((candidate) => [candidate.key, cachedEntry?.tabs[candidate.key]?.data ?? null])),
4119
+ );
4120
+ const dataRef = useRef(data);
4121
+ dataRef.current = data;
4122
+ const [meta, setMeta] = useState(() =>
4123
+ Object.fromEntries(TABS.map((candidate) => [candidate.key, cachedEntry?.tabs[candidate.key]?.meta ?? null])),
4124
+ );
4125
+ const metaRef = useRef(meta);
4126
+ metaRef.current = meta;
4127
+ const [securityNotes, setSecurityNotes] = useState(() => cachedEntry?.securityNotes ?? []);
4128
+ const securityNotesRef = useRef(securityNotes);
4129
+ securityNotesRef.current = securityNotes;
3422
4130
  // Whether the Security tab is currently unable to see its endpoints, as
3423
4131
  // opposed to seeing that they are switched off. Drives the count marker.
3424
- const [securityBlind, setSecurityBlind] = useState(false);
4132
+ const [securityBlind, setSecurityBlind] = useState(() => cachedEntry?.securityBlind ?? false);
4133
+ const securityBlindRef = useRef(securityBlind);
4134
+ securityBlindRef.current = securityBlind;
3425
4135
  // The `?` overlay. Renders only on a keypress, so consecutive idle frames are
3426
4136
  // still byte-identical and the redraw suppression is untouched.
3427
4137
  const [showHelp, setShowHelp] = useState(false);
@@ -3509,12 +4219,18 @@ function App({ onCreateRemote = () => {} } = {}) {
3509
4219
 
3510
4220
  const inFlightRef = useRef({});
3511
4221
  const rawRef = useRef({});
3512
- const cleanRef = useRef({});
3513
4222
  // Last *successful* poll per tab, wall-clock. Wall-clock on purpose: a laptop
3514
4223
  // sleeping is exactly the gap this is meant to report, and a monotonic clock
3515
4224
  // does not advance across suspend. Never written on the failure path, or a
3516
4225
  // persistently failing tab would report itself fresh forever.
3517
- const lastOkRef = useRef({});
4226
+ const lastOkRef = useRef(
4227
+ Object.fromEntries(
4228
+ TABS.flatMap((candidate) => {
4229
+ const lastOk = cachedEntry?.tabs[candidate.key]?.lastOk;
4230
+ return lastOk == null ? [] : [[candidate.key, lastOk]];
4231
+ }),
4232
+ ),
4233
+ );
3518
4234
  const fetchTabRef = useRef(null);
3519
4235
  const contextCoordinatorRef = useRef(null);
3520
4236
 
@@ -3553,13 +4269,16 @@ function App({ onCreateRemote = () => {} } = {}) {
3553
4269
  });
3554
4270
 
3555
4271
  useEffect(() => {
3556
- if (!interactive) return;
4272
+ if (!shouldEnableMouseReporting({ interactive, widthMode })) {
4273
+ disableMouseReporting();
4274
+ return;
4275
+ }
3557
4276
  enableMouseReporting();
3558
4277
  return () => {
3559
4278
  dragRef.current = null;
3560
4279
  disableMouseReporting();
3561
4280
  };
3562
- }, [interactive]);
4281
+ }, [interactive, widthMode]);
3563
4282
 
3564
4283
  function applyWidthOverrides(next) {
3565
4284
  const current = widthOverridesRef.current;
@@ -3672,7 +4391,7 @@ function App({ onCreateRemote = () => {} } = {}) {
3672
4391
 
3673
4392
  const geometry = resizeRef.current;
3674
4393
  if (event.action === "press") {
3675
- if (!interactive || !geometry.fullHeaderVisible || geometry.compact) return;
4394
+ if (!interactive || !geometry.active || !geometry.fullHeaderVisible || geometry.compact) return;
3676
4395
  const metrics = measuredHeader();
3677
4396
  if (!metrics) return;
3678
4397
  const drag = beginDividerDrag({
@@ -3771,24 +4490,22 @@ function App({ onCreateRemote = () => {} } = {}) {
3771
4490
  // rather than by tab so that moving to a different row and opening it
3772
4491
  // immediately still works -- selection does not move on Enter, so a second
3773
4492
  // press within the window is always a duplicate of the first.
3774
- const openingRef = useRef({});
4493
+ const [openRequests] = useState(() => createOpenRequestRegistry());
4494
+ useEffect(() => () => openRequests.abortAll(), [openRequests]);
3775
4495
 
3776
4496
  function openSelected() {
3777
4497
  const { items, key: currentKey, tabKey } = navRef.current;
3778
4498
  const item = items.find((i) => itemKey(i) === currentKey);
3779
4499
  if (!item) return;
3780
4500
  const guard = `${tabKey}:${itemKey(item)}`;
3781
- if (openingRef.current[guard]) return;
3782
- openingRef.current[guard] = true;
3783
4501
  // Fire and forget: a browser launch must not block the render loop, and a
3784
4502
  // failure surfaces through the tab's normal error line rather than as an
3785
4503
  // unhandled rejection.
3786
- openInBrowser(tabKey, item)
3787
- .catch((err) => {
4504
+ openRequests
4505
+ .start(guard, ({ signal }) => openInBrowser(tabKey, item, signal))
4506
+ ?.catch((err) => {
4507
+ if (err?.name === "AbortError") return;
3788
4508
  setErrors((x) => ({ ...x, [tabKey]: textTabError(err) }));
3789
- })
3790
- .finally(() => {
3791
- delete openingRef.current[guard];
3792
4509
  });
3793
4510
  }
3794
4511
 
@@ -3803,6 +4520,7 @@ function App({ onCreateRemote = () => {} } = {}) {
3803
4520
  return;
3804
4521
  }
3805
4522
  if (input === "q" || (input === "c" && key.ctrl)) {
4523
+ openRequests.abortAll();
3806
4524
  flushWidthPreferences();
3807
4525
  exit();
3808
4526
  } else if (resizeRef.current.active) {
@@ -3825,6 +4543,7 @@ function App({ onCreateRemote = () => {} } = {}) {
3825
4543
  // Width mode owns every other key. In particular, digits, arrows, Tab,
3826
4544
  // Enter and r must never fall through to their ordinary meanings.
3827
4545
  } else if (key.escape) {
4546
+ openRequests.abortAll();
3828
4547
  flushWidthPreferences();
3829
4548
  exit();
3830
4549
  } else if (showHelpRef.current) {
@@ -3881,10 +4600,10 @@ function App({ onCreateRemote = () => {} } = {}) {
3881
4600
  // -- the behaviour the per-tab guard and the fetch-time limit read were
3882
4601
  // written to prevent. Do not "fix" this by adding them.
3883
4602
  //
3884
- // exhaustive-deps is satisfied as written -- every captured value is either a
3885
- // ref or a setState function, both of which the rule treats as stable -- so
3886
- // this needs no suppression, and if one ever becomes necessary that is the
3887
- // signal that a real dependency crept in.
4603
+ // exhaustive-deps is satisfied as written -- every captured value is a ref,
4604
+ // a setState function, or the stable cache writer created once above. If a
4605
+ // changing dependency ever becomes necessary, that is the signal that the
4606
+ // mount-only contract has been broken.
3888
4607
  //
3889
4608
  // The adaptive throttle re-arms the timer, which is not a contradiction of the
3890
4609
  // above: `rearm` swaps the interval from *inside* this effect, so the effect
@@ -3915,23 +4634,60 @@ function App({ onCreateRemote = () => {} } = {}) {
3915
4634
  });
3916
4635
  contextCoordinatorRef.current = coordinator;
3917
4636
 
4637
+ function cacheSuccessfulTab(key, tabData, tabMeta, lastOk, security = {}) {
4638
+ const target = dashboardCacheTargetRef.current;
4639
+ const currentCache = dashboardCacheRef.current;
4640
+ const currentEntry = pick(currentCache, target, null) ?? {
4641
+ tabs: {},
4642
+ securityNotes: [],
4643
+ securityBlind: false,
4644
+ updatedAt: lastOk,
4645
+ };
4646
+ const nextEntry = {
4647
+ ...currentEntry,
4648
+ tabs: {
4649
+ ...currentEntry.tabs,
4650
+ [key]: { data: tabData, meta: tabMeta, lastOk },
4651
+ },
4652
+ securityNotes:
4653
+ key === "security" ? (security.notes ?? []) : currentEntry.securityNotes,
4654
+ securityBlind:
4655
+ key === "security" ? Boolean(security.blind) : currentEntry.securityBlind,
4656
+ updatedAt: lastOk,
4657
+ };
4658
+ const nextCache = mergeDashboardCacheEntry(currentCache, target, nextEntry);
4659
+ dashboardCacheRef.current = nextCache;
4660
+ dashboardCacheWriter.schedule(nextCache);
4661
+ }
4662
+
3918
4663
  // Each tab commits its own result the moment it lands instead of waiting on
3919
4664
  // a Promise.allSettled barrier. Actions is by far the slowest fetch, so
3920
4665
  // barrelling everything together meant the three fast tabs sat invisible
3921
4666
  // behind it and nothing at all appeared until the slowest call returned.
3922
- function commit(key, run) {
4667
+ let securityUnchangedPolls = 0;
4668
+ let securityNextPollAt = 0;
4669
+
4670
+ function commit(key, run, { force = false } = {}) {
3923
4671
  // Per-tab rather than one flag for the whole tick, so switching tabs can
3924
4672
  // refresh the tab you just landed on without waiting on an unrelated
3925
4673
  // background fetch -- and so a slow repo can't stack refreshes.
3926
4674
  if (inFlightRef.current[key]) return Promise.resolve();
3927
4675
  inFlightRef.current[key] = true;
3928
- setLoading((l) => (l[key] ? l : { ...l, [key]: true }));
4676
+ if (force) {
4677
+ for (const backoffKey of forcedBackoffKeys(key)) clearBackoff(backoffKey);
4678
+ }
4679
+ const visibleLoading = shouldShowFetchLoading({
4680
+ hasData: dataRef.current[key] !== null,
4681
+ force,
4682
+ });
4683
+ if (visibleLoading) setLoading((l) => (l[key] ? l : { ...l, [key]: true }));
3929
4684
  return run()
3930
4685
  .then((result) => {
3931
4686
  // Billed before the `cancelled` early return: the requests were made
3932
4687
  // and counted by GitHub whether or not this process still wants the
3933
4688
  // answer.
3934
- restSpentTotal += result?.restSpent ?? 0;
4689
+ spentTotal.core += result?.restSpent ?? 0;
4690
+ spentTotal.graphql += result?.graphqlSpent ?? 0;
3935
4691
  if (cancelled) return;
3936
4692
  const { raw, parse, limit } = result;
3937
4693
  // Identical payload: skip the parse *and* the state update. Returning
@@ -3953,41 +4709,88 @@ function App({ onCreateRemote = () => {} } = {}) {
3953
4709
  // completely healthy. A ref rather than state because writing state here
3954
4710
  // would allocate a new object every tick and permanently defeat the
3955
4711
  // React bail-out this early return exists to preserve.
3956
- lastOkRef.current[key] = Date.now();
3957
- // Clear on the first success or a single failure latches the ladder.
3958
- clearBackoff(`tab:${key}`);
3959
- if (rawRef.current[key] === raw && cleanRef.current[key]) {
4712
+ const completedAt = Date.now();
4713
+ const transition = pollResultTransition({
4714
+ key,
4715
+ previousRaw: rawRef.current[key],
4716
+ raw,
4717
+ parse,
4718
+ limit,
4719
+ completedAt,
4720
+ });
4721
+ if (key === "security") {
4722
+ securityUnchangedPolls = transition.kind === "unchanged" ? securityUnchangedPolls + 1 : 0;
4723
+ securityNextPollAt =
4724
+ performance.now() +
4725
+ securityPollDelay({
4726
+ unchangedPolls: securityUnchangedPolls,
4727
+ floorMs: runtime.refreshMs,
4728
+ force,
4729
+ });
4730
+ }
4731
+ if (transition.kind === "unchanged") {
4732
+ lastOkRef.current[key] = completedAt;
4733
+ // Clear on the first success or a single failure latches the ladder.
4734
+ clearBackoff(`tab:${key}`);
3960
4735
  setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
4736
+ const cachedTab = pick(
4737
+ pick(dashboardCacheRef.current, dashboardCacheTargetRef.current, null)?.tabs ?? {},
4738
+ key,
4739
+ null,
4740
+ );
4741
+ if (
4742
+ cachedTab &&
4743
+ shouldCheckpointFreshness({ persistedAt: cachedTab.lastOk, completedAt })
4744
+ ) {
4745
+ cacheSuccessfulTab(
4746
+ key,
4747
+ dataRef.current[key],
4748
+ metaRef.current[key],
4749
+ completedAt,
4750
+ key === "security"
4751
+ ? { notes: securityNotesRef.current, blind: securityBlindRef.current }
4752
+ : {},
4753
+ );
4754
+ }
3961
4755
  return;
3962
4756
  }
3963
- const value = parse();
3964
- rawRef.current[key] = raw;
3965
- cleanRef.current[key] = true;
4757
+ // Security fetches resolve each source independently so their notes
4758
+ // remain visible. A blind result is still a failed observation: it
4759
+ // must not replace known alerts with a false empty state or advance
4760
+ // freshness. Do not retain its raw value either, so the next source
4761
+ // retry is parsed instead of taking the identical-payload fast path.
4762
+ if (transition.kind === "blind") {
4763
+ rawRef.current[key] = transition.nextRaw;
4764
+ setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
4765
+ setSecurityNotes(transition.notes);
4766
+ setSecurityBlind(true);
4767
+ return;
4768
+ }
4769
+ const tabData = transition.data;
4770
+ const tabMeta = transition.meta;
4771
+ lastOkRef.current[key] = completedAt;
4772
+ clearBackoff(`tab:${key}`);
4773
+ rawRef.current[key] = transition.nextRaw;
3966
4774
  setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
3967
- setData((d) => ({ ...d, [key]: value.alerts ?? value }));
3968
- setMeta((m) => ({
3969
- ...m,
3970
- [key]: {
3971
- at: Date.now(),
3972
- // Compare against the limit this payload was actually fetched
3973
- // with. Comparing against the live pane-height ref made the "n+"
3974
- // marker lie for one cycle after every resize.
3975
- truncated: value.truncated ?? (value.alerts ?? value).length >= limit,
3976
- },
3977
- }));
3978
- if (value.notes) setSecurityNotes(value.notes);
4775
+ setData((d) => ({ ...d, [key]: tabData }));
4776
+ setMeta((m) => ({ ...m, [key]: tabMeta }));
4777
+ if (transition.notes) setSecurityNotes(transition.notes);
3979
4778
  if (key === "security") {
3980
- setSecurityBlind((b) => (b === Boolean(value.blind) ? b : Boolean(value.blind)));
4779
+ setSecurityBlind((b) => (b === transition.blind ? b : transition.blind));
3981
4780
  }
4781
+ cacheSuccessfulTab(key, tabData, tabMeta, completedAt, {
4782
+ notes: transition.notes,
4783
+ blind: transition.blind,
4784
+ });
3982
4785
  })
3983
4786
  .catch((err) => {
3984
4787
  // A rejected fetch carries no result, so its own spend is unknowable
3985
4788
  // here; bill the tab's table cost instead. A request that failed after
3986
4789
  // reaching GitHub still counted, and over-billing by an aborted call
3987
4790
  // errs toward throttling harder, which is the safe direction.
3988
- restSpentTotal += REST_PER_FETCH[key] ?? 0;
4791
+ spentTotal.core += REST_PER_FETCH[key] ?? 0;
4792
+ spentTotal.graphql += GRAPHQL_PER_FETCH[key] ?? 0;
3989
4793
  if (cancelled || err?.name === "AbortError") return;
3990
- cleanRef.current[key] = false;
3991
4794
  // Preserve both the verdict and the bounded raw error in state. The
3992
4795
  // renderer translates recognized verdicts at draw time, which lets a
3993
4796
  // later repository/account context refine the one-line remedy without
@@ -4006,7 +4809,9 @@ function App({ onCreateRemote = () => {} } = {}) {
4006
4809
  })
4007
4810
  .finally(() => {
4008
4811
  inFlightRef.current[key] = false;
4009
- if (!cancelled) setLoading((l) => (l[key] ? { ...l, [key]: false } : l));
4812
+ if (!cancelled) {
4813
+ setLoading((l) => (l[key] ? { ...l, [key]: false } : l));
4814
+ }
4010
4815
  });
4011
4816
  }
4012
4817
 
@@ -4017,12 +4822,17 @@ function App({ onCreateRemote = () => {} } = {}) {
4017
4822
  function fetchTab(key, { force = false } = {}) {
4018
4823
  const signal = controller.signal;
4019
4824
  const descriptor = tabForKey(key);
4020
- if (!force && backoffActive(`tab:${key}`, performance.now())) return Promise.resolve();
4021
- if (force) clearBackoff(`tab:${key}`);
4825
+ const now_ = performance.now();
4826
+ if (!force && backoffActive(`tab:${key}`, now_)) return Promise.resolve();
4827
+ if (!force && key === "security" && now_ < securityNextPollAt) return Promise.resolve();
4022
4828
  // Every tab carries its own fetcher on the registry, so adding a tab is a
4023
4829
  // TABS entry plus a fetcher rather than an entry plus an edit to a chain
4024
4830
  // in a different part of the file.
4025
- return commit(key, () => descriptor.fetch({ signal, runLimit: runLimitRef.current }));
4831
+ return commit(
4832
+ key,
4833
+ () => descriptor.fetch({ signal, runLimit: runLimitRef.current }),
4834
+ { force },
4835
+ );
4026
4836
  }
4027
4837
  fetchTabRef.current = fetchTab;
4028
4838
 
@@ -4041,8 +4851,9 @@ function App({ onCreateRemote = () => {} } = {}) {
4041
4851
  // being a minute behind -- so they refresh every BACKGROUND_EVERY ticks
4042
4852
  // instead, which is what keeps steady-state API usage off the rate limit.
4043
4853
  const active = TABS[activeIndexRef.current].key;
4044
- const due = ticks % BACKGROUND_EVERY === 0 ? TABS.map((t) => t.key) : [active];
4045
- ticks += 1;
4854
+ const planned = pollTickKeys({ ticks, activeKey: active });
4855
+ const due = planned.due;
4856
+ ticks = planned.nextTicks;
4046
4857
  // Mapped explicitly rather than passing fetchTab by reference: Array.map
4047
4858
  // hands the callback an index as its second argument, which would land in
4048
4859
  // the options bag and make every background tab look force-refreshed.
@@ -4068,8 +4879,9 @@ function App({ onCreateRemote = () => {} } = {}) {
4068
4879
  // The open probe window, and the last share it was able to measure. Both are
4069
4880
  // carried by nextProbeWindow/inferShare rather than reset every probe -- see
4070
4881
  // those functions for why a throttled pane must not re-measure from scratch.
4071
- let probeWindow = null;
4072
- let lastShare = 1;
4882
+ const probeWindows = { core: null, graphql: null };
4883
+ const lastShares = { core: 1, graphql: 1 };
4884
+ let resourceTargets = { core: runtime.refreshMs, graphql: runtime.refreshMs };
4073
4885
 
4074
4886
  function rearm(ms) {
4075
4887
  appliedMs = ms;
@@ -4083,23 +4895,28 @@ function App({ onCreateRemote = () => {} } = {}) {
4083
4895
  try {
4084
4896
  // `controller` is this effect's own AbortController, so a probe still in
4085
4897
  // flight at unmount is killed along with everything else.
4086
- const budget = await readCoreBudget(controller.signal);
4087
- if (cancelled || !budget) return;
4088
- const { sample, next } = nextProbeWindow(probeWindow, budget, restSpentTotal);
4089
- probeWindow = next;
4090
- // Updated before the law reads it. inferShare is idempotent, so passing
4091
- // the already-updated value cannot change the answer -- it just keeps
4092
- // one variable holding the current share rather than two.
4093
- lastShare = inferShare(sample, lastShare);
4094
-
4095
- const target = adaptiveRefreshMs({
4096
- budget,
4097
- sample,
4098
- lastShare,
4898
+ const budgets = await readRateBudgets(controller.signal);
4899
+ if (cancelled || !budgets) return;
4900
+ const samples = { core: null, graphql: null };
4901
+ for (const resource of ["core", "graphql"]) {
4902
+ const budget = budgets[resource];
4903
+ if (!budget) continue;
4904
+ const step = nextProbeWindow(probeWindows[resource], budget, spentTotal[resource]);
4905
+ probeWindows[resource] = step.next;
4906
+ samples[resource] = step.sample;
4907
+ lastShares[resource] = inferShare(step.sample, lastShares[resource]);
4908
+ }
4909
+ const nextTargets = nextBudgetTargets({
4910
+ budgets,
4911
+ samples,
4912
+ shares: lastShares,
4913
+ previous: resourceTargets,
4914
+ activeKey: TABS[activeIndexRef.current].key,
4099
4915
  floorMs: runtime.refreshMs,
4100
4916
  nowMs: Date.now(),
4101
- restPerTick: restPerTick(TABS[activeIndexRef.current].key),
4102
4917
  });
4918
+ resourceTargets = nextTargets.targets;
4919
+ const target = nextTargets.targetMs;
4103
4920
  if (adaptiveChangeWorthApplying(appliedMs, target)) {
4104
4921
  rearm(target);
4105
4922
  setThrottleMs(target > runtime.refreshMs ? target : null);
@@ -4121,7 +4938,7 @@ function App({ onCreateRemote = () => {} } = {}) {
4121
4938
  // needed.
4122
4939
  controller.abort();
4123
4940
  };
4124
- }, []);
4941
+ }, [dashboardCacheWriter]);
4125
4942
 
4126
4943
  // Background tabs can be up to BACKGROUND_EVERY ticks stale, so the tab you
4127
4944
  // switch to refreshes straight away rather than showing old data until its
@@ -4131,13 +4948,12 @@ function App({ onCreateRemote = () => {} } = {}) {
4131
4948
  fetchTabRef.current?.(TABS[activeIndex].key);
4132
4949
  }, [activeIndex]);
4133
4950
 
4134
- // Animate only when something on screen is genuinely moving. `loading` flips
4135
- // true then false on *every* tick, including one that changed nothing, so
4136
- // driving the spinner from it meant the 100ms interval restarted every 5
4137
- // seconds forever -- roughly 44MB of terminal writes and 27 CPU-seconds per
4138
- // idle hour, and the exact opposite of the redraw suppression above. The
4139
- // first load genuinely is worth animating, because an empty pane with no
4140
- // motion reads as broken; after that, only a run actually executing is.
4951
+ // Animate only when something on screen is genuinely moving. Automatic polls
4952
+ // with settled data deliberately leave `loading` false; otherwise this timer
4953
+ // would restart every five seconds on an unchanged repository and undo the
4954
+ // redraw suppression above. The first load genuinely is worth animating,
4955
+ // because an empty pane with no motion reads as broken; after that, only a run
4956
+ // actually executing is.
4141
4957
  // "Never resolved" and "never *succeeded*" are different states, and conflating
4142
4958
  // them pinned the spinner on forever. setData is only ever called on success,
4143
4959
  // so a tab whose fetch keeps failing -- offline laptop, VPN down, expired auth,
@@ -4177,7 +4993,7 @@ function App({ onCreateRemote = () => {} } = {}) {
4177
4993
  }, [anyFirstLoad, iconHintDue]);
4178
4994
 
4179
4995
  const items = data[tab.key];
4180
- const displayError = formatTabError(tabError, failureContext);
4996
+ const displayError = formatTabErrorForWidth(tabError, failureContext, Math.max(1, cols - 5));
4181
4997
  const spin = SPINNER[frame % SPINNER.length];
4182
4998
 
4183
4999
  const counts = Object.fromEntries(
@@ -4185,9 +5001,9 @@ function App({ onCreateRemote = () => {} } = {}) {
4185
5001
  const list = data[t.key];
4186
5002
  if (list == null) return [t.key, null];
4187
5003
  // Every tab can be truncated, not just Actions: issues and PRs cap at
4188
- // LIST_LIMIT and alerts at one page of 100. Reporting those as exact made
4189
- // the count stop moving through a genuine change on any repo big enough
4190
- // for this tool to matter.
5004
+ // LIST_LIMIT and each alert lane caps at 100. Reporting a filled newest or
5005
+ // priority lane as exact would hide that relevant rows can remain beyond
5006
+ // the bounded fetch.
4191
5007
  const suffix = meta[t.key]?.truncated ? "+" : "";
4192
5008
  // A blind Security tab reports "?" rather than a number it cannot stand
4193
5009
  // behind. Zero alerts and zero visibility look identical otherwise.
@@ -4237,11 +5053,18 @@ function App({ onCreateRemote = () => {} } = {}) {
4237
5053
 
4238
5054
  const allItems = items ?? [];
4239
5055
  const tabOffsetRaw = offset[tab.key] ?? 0;
5056
+ const selectedKey = selected[tab.key] ?? null;
4240
5057
  // Re-clamped on every render rather than only on resize: the payload can
4241
5058
  // shrink under us between ticks, and a stale offset would render an empty
4242
5059
  // body while the count in the frame said otherwise.
4243
5060
  const maxOffset = Math.max(0, allItems.length - bodyRows);
4244
- const tabOffset = Math.min(tabOffsetRaw, maxOffset);
5061
+ const reconciledSelection = reconcileSelectionViewport({
5062
+ items: allItems,
5063
+ key: selectedKey,
5064
+ offset: Math.min(tabOffsetRaw, maxOffset),
5065
+ rows: bodyRows,
5066
+ });
5067
+ const tabOffset = reconciledSelection.offset;
4245
5068
  const visibleItems = allItems.slice(tabOffset, tabOffset + bodyRows);
4246
5069
 
4247
5070
  // Matched by key, never by position. If the selected item is gone -- closed,
@@ -4249,7 +5072,22 @@ function App({ onCreateRemote = () => {} } = {}) {
4249
5072
  // highlighted, which is the honest state; the next arrow key selects from the
4250
5073
  // top again. Resolving to a neighbouring index instead would silently move
4251
5074
  // the cursor onto an unrelated row.
4252
- const selectedKey = selected[tab.key] ?? null;
5075
+ useEffect(() => {
5076
+ if (reconciledSelection.key !== selectedKey) {
5077
+ setSelected((current) => {
5078
+ if (reconciledSelection.key !== null) {
5079
+ return { ...current, [tab.key]: reconciledSelection.key };
5080
+ }
5081
+ if (!Object.hasOwn(current, tab.key)) return current;
5082
+ const next = { ...current };
5083
+ delete next[tab.key];
5084
+ return next;
5085
+ });
5086
+ }
5087
+ if (reconciledSelection.offset !== tabOffsetRaw) {
5088
+ setOffset((current) => ({ ...current, [tab.key]: reconciledSelection.offset }));
5089
+ }
5090
+ }, [reconciledSelection.key, reconciledSelection.offset, selectedKey, tabOffsetRaw, tab.key]);
4253
5091
  // Bottom-right of the frame, lazygit style: how much of the tab you can
4254
5092
  // currently see out of how much there is.
4255
5093
  const countLabel =
@@ -4358,9 +5196,19 @@ function App({ onCreateRemote = () => {} } = {}) {
4358
5196
  widthPreferenceWriter,
4359
5197
  ]);
4360
5198
 
5199
+ // Keep one physical row below Ink's output, the vertical counterpart to the
5200
+ // spare column in `frameCols`. Incremental rendering assumes its cursor still
5201
+ // sits at the bottom of the previous frame; if dynamic content occupies the
5202
+ // terminal's last row, a terminal scroll can invalidate that assumption and
5203
+ // leave old status lines behind. A non-fullscreen frame gets a trailing
5204
+ // newline from Ink, so the cursor parks on the unused guard row while the
5205
+ // status bar remains one row above the scroll edge.
5206
+ const liveRows = Math.min(rows, usableSize(stdout?.rows, rows));
5207
+ const frameRows = Math.max(1, liveRows - 1);
5208
+
4361
5209
  return e(
4362
5210
  Box,
4363
- { flexDirection: "column", width: frameCols, height: Math.min(rows, usableSize(stdout?.rows, rows)) },
5211
+ { flexDirection: "column", width: frameCols, height: frameRows },
4364
5212
  e(TabBar, {
4365
5213
  activeIndex,
4366
5214
  counts,
@@ -4398,8 +5246,7 @@ function App({ onCreateRemote = () => {} } = {}) {
4398
5246
  // truncate-end so it degrades rather than wraps in a narrow pane. Every
4399
5247
  // glyph in KEY_TABLE is plain ASCII, which is what keeps it safe here.
4400
5248
  showHelp &&
4401
- keyTableLines()
4402
- .slice(0, bodyRows)
5249
+ helpLines(bodyRows)
4403
5250
  .map((line, i) => e(Text, { key: `help${i}`, wrap: "truncate-end" }, line)),
4404
5251
  !showHelp &&
4405
5252
  tooNarrow &&
@@ -4445,7 +5292,7 @@ function App({ onCreateRemote = () => {} } = {}) {
4445
5292
  const key = itemKey(item);
4446
5293
  return e(
4447
5294
  RowBoundary,
4448
- { key, resetKey: key },
5295
+ { key, resetKey: item },
4449
5296
  e(tab.Row, {
4450
5297
  item,
4451
5298
  now,
@@ -4465,8 +5312,8 @@ function App({ onCreateRemote = () => {} } = {}) {
4465
5312
  // Distinguishes "still fetching" from "resolved and empty" in the body,
4466
5313
  // which is the difference between a dashboard that looks hung and one
4467
5314
  // that looks correct. Driven by "never resolved" rather than by the
4468
- // loading flag, which toggles on every tick and made a settled-empty tab
4469
- // swap its message every five seconds. Suppressed entirely when a tab has
5315
+ // loading flag, which also covers a manual refresh and made a
5316
+ // settled-empty tab swap its message. Suppressed entirely when a tab has
4470
5317
  // an error and has never resolved: the error line directly above already
4471
5318
  // says what happened, and "no runs" underneath it reads as a fact about the
4472
5319
  // repository rather than the absence of an answer.
@@ -4508,6 +5355,12 @@ function App({ onCreateRemote = () => {} } = {}) {
4508
5355
  widthMode: resizeRef.current.active,
4509
5356
  widthColumn: selectedWidthColumn,
4510
5357
  widthSaveError,
5358
+ canMove: allItems.length > 0,
5359
+ canOpen:
5360
+ selectedKey !== null &&
5361
+ Object.hasOwn(OPENABLE, tab.key) &&
5362
+ allItems.some((item) => itemKey(item) === selectedKey),
5363
+ canResize: fullHeaderVisible,
4511
5364
  }),
4512
5365
  );
4513
5366
  }
@@ -4767,6 +5620,7 @@ export {
4767
5620
  REFRESH_MS,
4768
5621
  BACKGROUND_EVERY,
4769
5622
  adaptiveRefreshMs,
5623
+ nextBudgetTargets,
4770
5624
  adaptiveChangeWorthApplying,
4771
5625
  sampleIsUsable,
4772
5626
  inferShare,
@@ -4791,6 +5645,7 @@ export {
4791
5645
  failureTargetHost,
4792
5646
  unavailableRemedy,
4793
5647
  createFailureContextCoordinator,
5648
+ createOpenRequestRegistry,
4794
5649
  AUTH_RETRY_MS,
4795
5650
  BACKOFF_STEPS_MS,
4796
5651
  redact,
@@ -4818,6 +5673,27 @@ export {
4818
5673
  loadWidthPreferences,
4819
5674
  saveWidthPreferences,
4820
5675
  createWidthPreferenceWriter,
5676
+ mergeWidthPreferenceSnapshots,
5677
+ adoptPersistedSnapshot,
5678
+ DASHBOARD_CACHE_VERSION,
5679
+ dashboardCachePath,
5680
+ dashboardCacheTarget,
5681
+ authCacheIdentity,
5682
+ serializeDashboardCache,
5683
+ loadDashboardCache,
5684
+ saveDashboardCache,
5685
+ mergeDashboardCacheSnapshots,
5686
+ nextSecurityRaw,
5687
+ shouldCheckpointFreshness,
5688
+ securityPollDelay,
5689
+ shouldShowFetchLoading,
5690
+ pollTickKeys,
5691
+ pollResultTransition,
5692
+ forcedBackoffKeys,
5693
+ alertRequestArgs,
5694
+ shouldFetchAlertPriorityLanes,
5695
+ mergeAlertRows,
5696
+ reconcileSelectionViewport,
4821
5697
  runStatusIcon,
4822
5698
  RUN_STATUS_ICON,
4823
5699
  SEVERITY_STYLE,
@@ -4829,6 +5705,12 @@ export {
4829
5705
  OCT_UNICODE,
4830
5706
  KEY_TABLE,
4831
5707
  KEY_HINTS,
5708
+ activeKeyHints,
5709
+ tabFailureSuffix,
5710
+ formatTabErrorForWidth,
5711
+ selectionLabel,
5712
+ helpLines,
5713
+ summarizeDoctorEnv,
4832
5714
  widthStatusText,
4833
5715
  headerGutterKey,
4834
5716
  HeaderCells,
@@ -4837,7 +5719,9 @@ export {
4837
5719
  hitDivider,
4838
5720
  beginDividerDrag,
4839
5721
  draggedWidth,
5722
+ shouldEnableMouseReporting,
4840
5723
  createTerminalLifecycle,
5724
+ RowBoundary,
4841
5725
  REMOTE_SETUP_HINTS,
4842
5726
  REMOTE_SETUP_LINES,
4843
5727
  REMOTE_SETUP_NONINTERACTIVE_LINES,