gh-glance 0.6.1 → 0.8.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 (4) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +144 -28
  3. package/index.mjs +1911 -184
  4. package/package.json +1 -1
package/index.mjs CHANGED
@@ -23,7 +23,17 @@
23
23
  process.env.NODE_ENV ??= "production";
24
24
 
25
25
  import { execFile, spawn } from "node:child_process";
26
- import { readFileSync, realpathSync } from "node:fs";
26
+ import {
27
+ chmodSync,
28
+ mkdirSync,
29
+ readFileSync,
30
+ realpathSync,
31
+ renameSync,
32
+ unlinkSync,
33
+ writeFileSync,
34
+ } from "node:fs";
35
+ import { homedir } from "node:os";
36
+ import { dirname, isAbsolute, join } from "node:path";
27
37
  import { fileURLToPath } from "node:url";
28
38
  import { promisify } from "node:util";
29
39
 
@@ -108,6 +118,8 @@ const ALERT_QUERY = `?state=open&per_page=${ALERT_PER_PAGE}&sort=created&directi
108
118
  // single pane. Only the tab you are looking at needs REFRESH_MS latency; the
109
119
  // other three exist to keep counts honest, which tolerates a minute.
110
120
  const BACKGROUND_EVERY = 12;
121
+ // What one fetch of each tab costs lives in REST_PER_FETCH / GRAPHQL_PER_FETCH,
122
+ // declared with ALERT_SOURCES below because the security figure derives from it.
111
123
 
112
124
  // A stalled `gh` used to wedge a tab permanently: the promise never settled, so
113
125
  // the in-flight guard was never cleared and that tab stopped fetching for the
@@ -793,6 +805,10 @@ async function fetchActions(limit, signal) {
793
805
  return {
794
806
  raw,
795
807
  limit,
808
+ // What this fetch actually cost, read from the one cost table rather than
809
+ // re-derived from the argv shape -- a second copy of the cost model is the
810
+ // drift this file warns about repeatedly.
811
+ restSpent: REST_PER_FETCH.actions,
796
812
  parse: () =>
797
813
  JSON.parse(raw).map((r) => ({
798
814
  databaseId: r.databaseId,
@@ -838,6 +854,9 @@ async function fetchIssues(signal) {
838
854
  return {
839
855
  raw,
840
856
  limit: LIST_LIMIT,
857
+ // Stated rather than omitted so the zero is visibly deliberate: SORT_RECENT's
858
+ // --search routes this through GraphQL, which is a separate budget.
859
+ restSpent: REST_PER_FETCH.issues,
841
860
  parse: () =>
842
861
  JSON.parse(raw).map((i) => ({
843
862
  number: i.number,
@@ -869,6 +888,8 @@ async function fetchPRs(signal) {
869
888
  return {
870
889
  raw,
871
890
  limit: LIST_LIMIT,
891
+ // Zero for the same reason as issues: --search makes this a GraphQL call.
892
+ restSpent: REST_PER_FETCH.prs,
872
893
  parse: () =>
873
894
  JSON.parse(raw).map((p) => ({
874
895
  number: p.number,
@@ -946,6 +967,148 @@ const ALERT_SOURCES = [
946
967
  },
947
968
  ];
948
969
 
970
+ // What one fetch of each tab costs, per budget. The single source of truth:
971
+ // `projectedHourlyCost` derives the hourly figure from it and the spend meter
972
+ // bills against it, so a corrected number cannot reach one and miss the other.
973
+ //
974
+ // actions is 2, not 1: measured 2026-08-10 with `GH_DEBUG=api`, `gh run list`
975
+ // issues GET /actions/runs *and* GET /actions/workflows. The 1 that stood here
976
+ // understated the default tab -- the tab every pane starts on, since
977
+ // initialTabIndex defaults to 0 -- by half.
978
+ //
979
+ // issues and prs are 0 REST because SORT_RECENT's --search routes both through
980
+ // GraphQL entirely (2 POSTs each, confirmed by the same measurement).
981
+ const REST_PER_FETCH = { actions: 2, issues: 0, prs: 0, security: ALERT_SOURCES.length };
982
+ const GRAPHQL_PER_FETCH = { actions: 0, issues: 2, prs: 2, security: 0 };
983
+
984
+ // Ceiling on adaptive widening. An adaptive interval needs one: a minute is
985
+ // where the pane stops being live in any useful sense, and past it the honest
986
+ // answer is fewer panes, not a slower one.
987
+ const MAX_ADAPTIVE_REFRESH_MS = 60_000;
988
+
989
+ // Target this fraction of what the token can afford, not all of it. The margin
990
+ // is for the user's own `gh` and `git` commands, which are the whole reason the
991
+ // pane yields at all -- see RATE_LIMIT_RETRY_MS.
992
+ const BUDGET_SAFETY = 0.8;
993
+
994
+ // How often to re-read the budget. `gh api rate_limit` does not count against
995
+ // the limit (verified, delta 0 -- see rateBudget) but it is still a subprocess,
996
+ // so once a minute rather than once a tick: the quantity it measures moves on
997
+ // the scale of minutes.
998
+ const BUDGET_PROBE_MS = 60_000;
999
+
1000
+ // Below this many of our own REST calls in a probe window, the share inference
1001
+ // is noise -- one call against a global delta of one reads as "we are the only
1002
+ // consumer" whether or not that is true. Under the threshold the loop declines
1003
+ // to infer a new share rather than guessing.
1004
+ const MIN_SAMPLE_CALLS = 5;
1005
+
1006
+ // Only move the applied interval when the target differs by more than this
1007
+ // factor either way, so a budget wobbling around a boundary cannot make the
1008
+ // poll rate flap. Same reasoning as TAB_LABEL_HYSTERESIS.
1009
+ const ADAPTIVE_HYSTERESIS = 1.25;
1010
+
1011
+ // Whether a probe window holds enough of our own calls for the ratio below to
1012
+ // mean anything. Named rather than inlined because the control law and the loop
1013
+ // that feeds it must agree on the answer: the law uses it to decide whether to
1014
+ // infer, and the loop uses it to decide whether the window may be closed.
1015
+ function sampleIsUsable(sample) {
1016
+ return Boolean(sample) && sample.myRestCalls >= MIN_SAMPLE_CALLS && sample.globalUsed > 0;
1017
+ }
1018
+
1019
+ // How many equivalent consumers are on this token, from one probe window.
1020
+ //
1021
+ // `lastShare` is what the previous measurable window said, and it is the answer
1022
+ // whenever this window is not measurable. Falling back to 1 instead would be the
1023
+ // controller starving its own input: a pane that has widened to 40s makes about
1024
+ // three REST calls per probe window, below MIN_SAMPLE_CALLS, so it would read
1025
+ // "no other consumers", snap back to the floor, spend fast enough to measure
1026
+ // again, and re-throttle -- a flap on a two-minute cycle at exactly the instance
1027
+ // counts the widening exists for. Holding the last measurement keeps the pane
1028
+ // where it is until a window is long enough to say otherwise.
1029
+ function inferShare(sample, lastShare = 1) {
1030
+ return sampleIsUsable(sample)
1031
+ ? Math.max(1, sample.globalUsed / sample.myRestCalls)
1032
+ : Math.max(1, lastShare);
1033
+ }
1034
+
1035
+ // One probe window's bookkeeping. Pure, so the flap it prevents is testable
1036
+ // without a timer: `prev` is `{ used, spent }` from the last window that could
1037
+ // be measured, or null before the first probe.
1038
+ //
1039
+ // The baseline only advances when the window was usable, so an unmeasurable
1040
+ // window is not thrown away -- it grows until it holds MIN_SAMPLE_CALLS of our
1041
+ // own calls, with both halves of the ratio still spanning the same stretch of
1042
+ // time. The cost is latency, not accuracy: a pane at the 60s cap needs two or
1043
+ // three probes rather than one to notice that the other panes have exited.
1044
+ function nextProbeWindow(prev, budget, spentTotal) {
1045
+ // A window reset makes `used` fall. The span before the reset cannot be
1046
+ // compared against the counter after it, so the window restarts here and this
1047
+ // cycle infers nothing -- the caller keeps the share it last measured, which
1048
+ // is the one direction that cannot spike the poll rate at the top of an hour.
1049
+ if (prev === null || budget.used < prev.used) {
1050
+ return { sample: null, next: { used: budget.used, spent: spentTotal } };
1051
+ }
1052
+ const sample = {
1053
+ globalUsed: budget.used - prev.used,
1054
+ myRestCalls: spentTotal - prev.spent,
1055
+ };
1056
+ return { sample, next: sampleIsUsable(sample) ? { used: budget.used, spent: spentTotal } : prev };
1057
+ }
1058
+
1059
+ // How wide the active-tab interval has to be for this instance to fit its share
1060
+ // of the token's remaining budget.
1061
+ //
1062
+ // The share is *inferred*, not configured: this instance knows exactly how many
1063
+ // REST calls it has spent since the last probe, and `rate_limit` reports how
1064
+ // many the token spent in total over the same window. The ratio is how many
1065
+ // equivalent consumers are on this token -- other panes, a `gh pr checks
1066
+ // --watch`, an agent shelling out to `gh`. That is why no IPC and no on-disk
1067
+ // registry is needed: the token's own counter is the shared channel, and it
1068
+ // already aggregates everything.
1069
+ //
1070
+ // Deliberately one-directional: the returned value is never below `floorMs`, so
1071
+ // --refresh and GH_GLANCE_REFRESH stay a floor the loop widens from and cannot
1072
+ // tighten past. A single instance on a fresh window computes a required interval
1073
+ // below the floor and therefore stays at exactly the configured refresh, which
1074
+ // is what keeps this from changing ordinary single-pane behaviour.
1075
+ //
1076
+ // MAX_ADAPTIVE_REFRESH_MS is wrapped in Math.max(floorMs, ...) at every exit, so
1077
+ // a user who configures a refresh wider than the cap is never silently sped up.
1078
+ //
1079
+ // `lastShare` defaults to 1, so a caller with no history behaves exactly as this
1080
+ // did before it existed: one instance, no adaptation.
1081
+ function adaptiveRefreshMs({ budget, sample, restPerTick, floorMs, nowMs, lastShare = 1 }) {
1082
+ if (!budget || !Number.isFinite(budget.remaining) || restPerTick <= 0) return floorMs;
1083
+ if (budget.remaining <= 0) return Math.max(floorMs, MAX_ADAPTIVE_REFRESH_MS);
1084
+
1085
+ const secondsToReset = Math.max(1, (budget.resetMs - nowMs) / 1000);
1086
+ const affordable = (budget.remaining / secondsToReset) * BUDGET_SAFETY;
1087
+ if (affordable <= 0) return Math.max(floorMs, MAX_ADAPTIVE_REFRESH_MS);
1088
+
1089
+ const mine = affordable / inferShare(sample, lastShare);
1090
+ const requiredMs = (restPerTick / mine) * 1000;
1091
+ return Math.min(Math.max(floorMs, requiredMs), Math.max(floorMs, MAX_ADAPTIVE_REFRESH_MS));
1092
+ }
1093
+
1094
+ // Whether a newly computed target is different enough to act on.
1095
+ function adaptiveChangeWorthApplying(appliedMs, targetMs) {
1096
+ if (appliedMs <= 0) return true;
1097
+ const ratio = targetMs / appliedMs;
1098
+ return ratio >= ADAPTIVE_HYSTERESIS || ratio <= 1 / ADAPTIVE_HYSTERESIS;
1099
+ }
1100
+
1101
+ // REST cost of one tick in this configuration: the active tab every tick, the
1102
+ // other three amortised over BACKGROUND_EVERY. Same table and same shape as
1103
+ // projectedHourlyCost, so the controller and the report cannot disagree.
1104
+ function restPerTick(activeKey) {
1105
+ let cost = REST_PER_FETCH[activeKey] ?? 0;
1106
+ for (const key of TAB_KEYS) {
1107
+ if (key !== activeKey) cost += (REST_PER_FETCH[key] ?? 0) / BACKGROUND_EVERY;
1108
+ }
1109
+ return cost;
1110
+ }
1111
+
949
1112
  function alertArgs(source) {
950
1113
  return ["api", apiPath(source.path), ...apiHostArgs(), "--jq", source.jq];
951
1114
  }
@@ -955,6 +1118,18 @@ function alertArgs(source) {
955
1118
  // mid-session is picked up within the hour.
956
1119
  const alertBackoff = new Map();
957
1120
 
1121
+ // REST calls this process has spent, ever. In-memory and per-process, in the
1122
+ // same spirit as alertBackoff above: lost on exit, and nothing depends on it
1123
+ // surviving. Only ever compared against itself between two budget probes, so it
1124
+ // needs no windowing and cannot overflow in any realistic session.
1125
+ //
1126
+ // Deliberately incomplete: openItem, preflight and resolveFailureContext also
1127
+ // spend, and are not counted, because they are occasional rather than periodic.
1128
+ // Under-reporting our own spend makes the inferred share *larger* and so
1129
+ // throttles slightly harder than strictly necessary -- the safe direction to err
1130
+ // in, which is why the imprecision is tolerated rather than chased.
1131
+ let restSpentTotal = 0;
1132
+
958
1133
  function backoffActive(key, now) {
959
1134
  const state = alertBackoff.get(key);
960
1135
  return Boolean(state) && now < state.until;
@@ -990,13 +1165,20 @@ async function fetchAlertSource(source, signal, now) {
990
1165
  // The verdict is replayed alongside the note. Replaying only the note left
991
1166
  // the tab unable to tell "Dependabot is switched off here" from "we cannot
992
1167
  // see Dependabot" for the whole length of a backoff window.
993
- return { raw: `backoff:${note}`, parse: () => ({ alerts: [], note, verdict, truncated: false }) };
1168
+ // Nothing was spawned, so nothing was billed -- which is the whole reason
1169
+ // the meter is fed by the fetchers rather than by counting ticks.
1170
+ return {
1171
+ raw: `backoff:${note}`,
1172
+ spent: 0,
1173
+ parse: () => ({ alerts: [], note, verdict, truncated: false }),
1174
+ };
994
1175
  }
995
1176
  try {
996
1177
  const raw = await runGh(alertArgs(source), { signal });
997
1178
  clearBackoff(source.key);
998
1179
  return {
999
1180
  raw,
1181
+ spent: 1,
1000
1182
  parse: () => {
1001
1183
  const rows = JSON.parse(raw).filter((a) => a.state === "open");
1002
1184
  return {
@@ -1021,6 +1203,9 @@ async function fetchAlertSource(source, signal, now) {
1021
1203
  }
1022
1204
  return {
1023
1205
  raw: `unavailable:${note}`,
1206
+ // A failed call still bills: the request reached GitHub and was counted
1207
+ // whether it returned data, `[]`, or a 403.
1208
+ spent: 1,
1024
1209
  parse: () => ({ alerts: [], note, verdict, truncated: false }),
1025
1210
  };
1026
1211
  }
@@ -1054,6 +1239,10 @@ async function fetchSecurity(signal) {
1054
1239
  // colliding on the same string.
1055
1240
  raw: parts.map((p) => p.raw).join("\0"),
1056
1241
  limit: ALERT_PER_PAGE,
1242
+ // Summed from what each source reported rather than assumed to be
1243
+ // ALERT_SOURCES.length: a source held off by backoff spawned nothing and
1244
+ // must not be billed for it.
1245
+ restSpent: parts.reduce((n, p) => n + p.spent, 0),
1057
1246
  parse: () => {
1058
1247
  const parsed = parts.map((p) => p.parse());
1059
1248
  const alerts = parsed.flatMap((p) => p.alerts);
@@ -1168,6 +1357,7 @@ const DOCTOR_ENV_PLAIN = [
1168
1357
  "NO_PROXY",
1169
1358
  "GH_GLANCE_ICONS",
1170
1359
  "GH_GLANCE_NO_ANIMATION",
1360
+ "GH_GLANCE_REFRESH",
1171
1361
  "NO_COLOR",
1172
1362
  "NODE_ENV",
1173
1363
  ];
@@ -1293,8 +1483,8 @@ function targetSource() {
1293
1483
 
1294
1484
  // How much of the hourly API budget is left, and roughly how fast this
1295
1485
  // configuration spends it. The steady-state cost is not small and was invisible:
1296
- // at the default 5s refresh with the Security tab open it is around 2,200 REST
1297
- // requests an hour -- about 44% of a personal token's 5,000 -- and `--refresh 2`
1486
+ // at the default 5s refresh with the Security tab open it is around 2,280 REST
1487
+ // requests an hour -- about 46% of a personal token's 5,000 -- and `--refresh 2`
1298
1488
  // projects past the limit outright, so it exhausts inside the hour, every hour.
1299
1489
  // The budget is shared with everything else the token does, so the first symptom
1300
1490
  // is usually "GitHub is broken" somewhere else entirely.
@@ -1305,7 +1495,10 @@ function targetSource() {
1305
1495
  // exactly why this reports the server's own numbers rather than asserting 5,000.
1306
1496
  async function rateBudget() {
1307
1497
  try {
1308
- const raw = await runGh(["api", "rate_limit"]);
1498
+ // Host-routed like every other `gh api` call: a budget is per token *per
1499
+ // server*, so on a GHES tenant the github.com numbers are not merely stale,
1500
+ // they belong to a different limit entirely.
1501
+ const raw = await runGh(["api", "rate_limit", ...apiHostArgs()]);
1309
1502
  const { resources } = JSON.parse(raw);
1310
1503
  // formatAge() is for timestamps in the past and returns "-" for a future one,
1311
1504
  // so the reset is rendered as a forward interval instead.
@@ -1321,24 +1514,52 @@ async function rateBudget() {
1321
1514
  }
1322
1515
  }
1323
1516
 
1517
+ // The same probe as rateBudget, but returning `core` as data rather than a
1518
+ // sentence, because the adaptive loop has to do arithmetic on it. rateBudget
1519
+ // keeps its own shape: it formats `graphql` too, which the controller does not
1520
+ // need, and it is on the --doctor path where a string is the product.
1521
+ //
1522
+ // Safe to run on a timer for the same reason it is safe on the diagnostic path:
1523
+ // `gh api rate_limit` is documented as not counting against the limit, and it
1524
+ // measures as free (verified: delta 0).
1525
+ async function readCoreBudget(signal) {
1526
+ try {
1527
+ // apiHostArgs() for the same reason the alert endpoints carry it: --repo
1528
+ // host/owner/name sets runtime.host without setting GH_HOST, so without the
1529
+ // flag this reads github.com's budget while the pane spends against the
1530
+ // tenant -- and then throttles, or fails to, against an unrelated number.
1531
+ const raw = await runGh(["api", "rate_limit", ...apiHostArgs()], { signal });
1532
+ const core = JSON.parse(raw)?.resources?.core;
1533
+ if (!core || !Number.isFinite(core.remaining)) return null;
1534
+ return {
1535
+ remaining: core.remaining,
1536
+ limit: core.limit,
1537
+ used: core.used,
1538
+ resetMs: core.reset * 1000,
1539
+ };
1540
+ } catch {
1541
+ // A probe failure must never be louder than the pane's own data: the loop
1542
+ // simply does not adapt this cycle and stays at whatever it last applied.
1543
+ return null;
1544
+ }
1545
+ }
1546
+
1324
1547
  // Requests per hour this configuration will spend once it settles, derived from
1325
1548
  // the same constants the poll loop uses rather than from a number written down
1326
1549
  // once and left to rot. The active tab refreshes every tick; the other three
1327
- // every BACKGROUND_EVERY ticks. Security is three REST calls per fetch, Actions
1328
- // one, and Issues/PRs go to GraphQL instead (two POSTs each, because --search
1329
- // routes through the search connection).
1550
+ // every BACKGROUND_EVERY ticks. The per-fetch prices come from REST_PER_FETCH
1551
+ // and GRAPHQL_PER_FETCH, which are also what the spend meter bills against --
1552
+ // two copies of them would diverge the first time one was fixed.
1330
1553
  function projectedHourlyCost(activeKey) {
1331
1554
  const perHour = 3_600_000 / runtime.refreshMs;
1332
- const restCalls = { actions: 1, issues: 0, prs: 0, security: ALERT_SOURCES.length };
1333
- const graphqlCalls = { actions: 0, issues: 2, prs: 2, security: 0 };
1334
1555
  let rest = 0;
1335
1556
  let graphql = 0;
1336
1557
  // TAB_KEYS rather than TABS: this runs on the --doctor path, which deliberately
1337
1558
  // returns before the render tree is reached, and TABS is declared down there.
1338
1559
  for (const key of TAB_KEYS) {
1339
1560
  const ticks = key === activeKey ? perHour : perHour / BACKGROUND_EVERY;
1340
- rest += ticks * restCalls[key];
1341
- graphql += ticks * graphqlCalls[key];
1561
+ rest += ticks * REST_PER_FETCH[key];
1562
+ graphql += ticks * GRAPHQL_PER_FETCH[key];
1342
1563
  }
1343
1564
  return { rest: Math.round(rest), graphql: Math.round(graphql) };
1344
1565
  }
@@ -1495,6 +1716,11 @@ function parseArgs(argv) {
1495
1716
  doctor: false,
1496
1717
  repo: null,
1497
1718
  refresh: null,
1719
+ // Which surface supplied `refresh`, so validateArgs can name it in the
1720
+ // bounds messages. argv never sets it -- the entry block does, when it
1721
+ // falls back to GH_GLANCE_REFRESH -- but it is declared here so the shape
1722
+ // parseArgs returns stays stated in one place.
1723
+ refreshSource: null,
1498
1724
  tab: null,
1499
1725
  verbose: false,
1500
1726
  };
@@ -1537,13 +1763,16 @@ function validateArgs(opts, tabKeys) {
1537
1763
 
1538
1764
  let refreshMs = null;
1539
1765
  if (opts.refresh !== null) {
1766
+ // Named so the message points at whichever surface supplied the value: the
1767
+ // interval can now come from GH_GLANCE_REFRESH as well as --refresh.
1768
+ const refreshLabel = opts.refreshSource ?? "--refresh";
1540
1769
  const seconds = Number(opts.refresh);
1541
1770
  if (!Number.isFinite(seconds) || !Number.isInteger(seconds)) {
1542
- throw new Error(`--refresh must be a whole number of seconds, got: ${opts.refresh}`);
1771
+ throw new Error(`${refreshLabel} must be a whole number of seconds, got: ${opts.refresh}`);
1543
1772
  }
1544
1773
  if (seconds < MIN_REFRESH_SECONDS || seconds > MAX_REFRESH_SECONDS) {
1545
1774
  throw new Error(
1546
- `--refresh must be between ${MIN_REFRESH_SECONDS} and ${MAX_REFRESH_SECONDS} seconds, got: ${seconds}`,
1775
+ `${refreshLabel} must be between ${MIN_REFRESH_SECONDS} and ${MAX_REFRESH_SECONDS} seconds, got: ${seconds}`,
1547
1776
  );
1548
1777
  }
1549
1778
  refreshMs = seconds * 1000;
@@ -1581,8 +1810,9 @@ const KEY_TABLE = [
1581
1810
  ["PgUp / PgDn", "Move a page at a time"],
1582
1811
  ["Enter", "Open the selected item or accept a prompt"],
1583
1812
  ["r", "Refresh the current tab now"],
1813
+ ["w", "Adjust table column widths"],
1584
1814
  ["?", "Show the keys (any key closes it)"],
1585
- ["q / Esc / Ctrl+C", "Quit"],
1815
+ ["q / Esc / Ctrl+C", "Quit (Esc leaves width mode)"],
1586
1816
  ];
1587
1817
  const KEY_COL = Math.max(...KEY_TABLE.map(([k]) => k.length)) + 3;
1588
1818
  const keyTableLines = () => KEY_TABLE.map(([k, d]) => `${k.padEnd(KEY_COL)}${d}`);
@@ -1643,6 +1873,9 @@ Environment:
1643
1873
  GH_HOST=host GitHub Enterprise or EMU host to send every call to.
1644
1874
  A host-qualified GH_REPO does not route \`gh api\`;
1645
1875
  this and --repo host/owner/name both do.
1876
+ GH_GLANCE_REFRESH=<seconds>
1877
+ Active-tab poll interval, ${MIN_REFRESH_SECONDS}-${MAX_REFRESH_SECONDS}
1878
+ (--refresh takes precedence)
1646
1879
  GH_GLANCE_ICONS=unicode Plain-unicode status icons (no Nerd Font needed)
1647
1880
  GH_GLANCE_NO_ANIMATION=1 Freeze the spinner (no motion)
1648
1881
  NO_COLOR=1 Disable colour (status stays readable)
@@ -1660,7 +1893,20 @@ Environment:
1660
1893
  if (IS_MAIN) {
1661
1894
  let opts;
1662
1895
  try {
1663
- opts = validateArgs(parseArgs(process.argv.slice(2)), TAB_KEYS);
1896
+ const argvOpts = parseArgs(process.argv.slice(2));
1897
+ // The flag wins, which is the precedence GH_REPO already advertises -- but
1898
+ // not its mechanism: GH_REPO is read at each use site, because `gh` honours
1899
+ // it natively and a repo slug needs no validation. An interval does, so it
1900
+ // is resolved once, here, and substituted before validation -- an
1901
+ // out-of-range GH_GLANCE_REFRESH is then refused by the same two messages
1902
+ // an out-of-range --refresh gets, rather than by a second copy of the
1903
+ // bounds. It still reaches runtime through the one write site below rather
1904
+ // than being assigned beside it.
1905
+ if (argvOpts.refresh === null && process.env.GH_GLANCE_REFRESH) {
1906
+ argvOpts.refresh = process.env.GH_GLANCE_REFRESH;
1907
+ argvOpts.refreshSource = "GH_GLANCE_REFRESH";
1908
+ }
1909
+ opts = validateArgs(argvOpts, TAB_KEYS);
1664
1910
  } catch (err) {
1665
1911
  // Exit 2 for every argv problem, unchanged from when the only possible
1666
1912
  // problem was an unrecognised flag. CI asserts this code.
@@ -1734,10 +1980,10 @@ if (IS_MAIN) {
1734
1980
  }
1735
1981
 
1736
1982
  const ReactModule = await import("react");
1737
- const { render, Box, Text, useStdout, useInput, useStdin, useApp } = await import("ink");
1983
+ const { render, measureElement, Box, Text, useStdout, useInput, useStdin, useApp } = await import("ink");
1738
1984
 
1739
1985
  const React = ReactModule.default;
1740
- const { useState, useEffect, useRef } = ReactModule;
1986
+ const { useState, useEffect, useMemo, useRef, useCallback } = ReactModule;
1741
1987
  const e = React.createElement;
1742
1988
 
1743
1989
  // The NODE_ENV escape hatch above is genuinely useful -- React's warnings are
@@ -1840,10 +2086,10 @@ const ERROR_TEXT = "red";
1840
2086
  // column is otherwise a private-use codepoint that announces as nothing --
1841
2087
  // leaving every row with no status at all. It is derived from the same lookup
1842
2088
  // that picks the glyph, so the two cannot drift apart.
1843
- function Column({ width, grow, children, bold, color, dim, wrap, label }) {
2089
+ function Column({ width, grow, children, bold, color, dim, wrap, label, marginRight = 1 }) {
1844
2090
  return e(
1845
2091
  Box,
1846
- { width, flexGrow: grow ? 1 : 0, flexShrink: grow ? 1 : 0, marginRight: 1 },
2092
+ { width, flexGrow: grow ? 1 : 0, flexShrink: grow ? 1 : 0, marginRight },
1847
2093
  e(
1848
2094
  Text,
1849
2095
  { bold, color, dimColor: dim, wrap: wrap ?? "truncate-end", "aria-label": label },
@@ -1852,10 +2098,35 @@ function Column({ width, grow, children, bold, color, dim, wrap, label }) {
1852
2098
  );
1853
2099
  }
1854
2100
 
1855
- function HeaderCells({ cells }) {
2101
+ // Each descriptor still owns one gutter cell, but an adjustable column owns
2102
+ // the edge that faces the flexible TITLE/SUMMARY reservoir. Fixed columns after
2103
+ // the grow cell therefore use the preceding descriptor's trailing gutter;
2104
+ // fixed columns before it use their own. Keeping this mapping pure lets the
2105
+ // pointer phase share the exact same geometry as the visible grips.
2106
+ function headerGutterKeyAt(cells, index, growIndex) {
2107
+ if (!Array.isArray(cells) || !Number.isSafeInteger(index) || index < 0 || index >= cells.length) {
2108
+ return null;
2109
+ }
2110
+ if (growIndex < 0) return null;
2111
+ if (index < growIndex) {
2112
+ return isAdjustableWidthColumn(cells[index]) ? cells[index].key : null;
2113
+ }
2114
+ return isAdjustableWidthColumn(cells[index + 1]) ? cells[index + 1].key : null;
2115
+ }
2116
+
2117
+ function headerGutterKey(cells, index) {
2118
+ const growIndex = Array.isArray(cells)
2119
+ ? cells.findIndex((column) => column.props?.grow)
2120
+ : -1;
2121
+ return headerGutterKeyAt(cells, index, growIndex);
2122
+ }
2123
+
2124
+ function HeaderCells({ cells, selectedWidthKey = null, headerRef = null }) {
2125
+ const growIndex = cells.findIndex((column) => column.props?.grow);
1856
2126
  return e(
1857
2127
  Box,
1858
2128
  {
2129
+ ref: headerRef,
1859
2130
  flexDirection: "row",
1860
2131
  borderStyle: "single",
1861
2132
  borderTop: false,
@@ -1863,7 +2134,198 @@ function HeaderCells({ cells }) {
1863
2134
  borderRight: false,
1864
2135
  borderColor: BORDER_COLOR,
1865
2136
  },
1866
- ...cells.map((c, i) => e(Column, { key: i, ...c.props, bold: true, dim: true }, c.label)),
2137
+ ...cells.flatMap((c, index) => {
2138
+ const gripKey = headerGutterKeyAt(cells, index, growIndex);
2139
+ const selected = gripKey !== null && gripKey === selectedWidthKey;
2140
+ return [
2141
+ e(
2142
+ Column,
2143
+ { key: `${c.key}:cell`, ...c.props, bold: true, dim: true, marginRight: 0 },
2144
+ c.label,
2145
+ ),
2146
+ e(
2147
+ Text,
2148
+ {
2149
+ key: `${c.key}:gutter`,
2150
+ color: selected ? TITLE_COLOR : BORDER_COLOR,
2151
+ bold: selected,
2152
+ dimColor: gripKey !== null && !selected,
2153
+ "aria-hidden": true,
2154
+ },
2155
+ gripKey === null ? " " : "│",
2156
+ ),
2157
+ ];
2158
+ }),
2159
+ );
2160
+ }
2161
+
2162
+ const MemoHeaderCells = React.memo(HeaderCells);
2163
+
2164
+ function parseSgrMouse(input) {
2165
+ if (typeof input !== "string") return null;
2166
+ const match = /^\[<(\d+);(\d+);(\d+)([Mm])$/.exec(input);
2167
+ if (!match) return null;
2168
+
2169
+ const code = Number(match[1]);
2170
+ const encodedX = Number(match[2]);
2171
+ const encodedY = Number(match[3]);
2172
+ if (
2173
+ !Number.isSafeInteger(code) ||
2174
+ !Number.isSafeInteger(encodedX) ||
2175
+ !Number.isSafeInteger(encodedY) ||
2176
+ encodedX < 1 ||
2177
+ encodedY < 1 ||
2178
+ (code !== 0 && code !== 32)
2179
+ ) {
2180
+ return null;
2181
+ }
2182
+
2183
+ return {
2184
+ x: encodedX - 1,
2185
+ y: encodedY - 1,
2186
+ action: match[4] === "m" ? "release" : code === 32 ? "drag" : "press",
2187
+ };
2188
+ }
2189
+
2190
+ function dividerHandles({ header, metrics }) {
2191
+ if (
2192
+ !Array.isArray(header) ||
2193
+ !metrics ||
2194
+ !Number.isSafeInteger(metrics.x) ||
2195
+ !Number.isSafeInteger(metrics.y) ||
2196
+ !Number.isSafeInteger(metrics.width) ||
2197
+ !Number.isSafeInteger(metrics.height) ||
2198
+ metrics.x < 0 ||
2199
+ metrics.y < 0 ||
2200
+ metrics.width < 1 ||
2201
+ metrics.height < 1
2202
+ ) {
2203
+ return [];
2204
+ }
2205
+
2206
+ const growIndexes = header
2207
+ .map((column, index) => column.props?.grow ? index : -1)
2208
+ .filter((index) => index >= 0);
2209
+ if (growIndexes.length !== 1) return [];
2210
+ const growIndex = growIndexes[0];
2211
+ const fixedWidth = header.reduce((sum, column, index) => {
2212
+ if (index === growIndex) return sum;
2213
+ return Number.isSafeInteger(column.props?.width) && column.props.width >= 0
2214
+ ? sum + column.props.width
2215
+ : Number.NaN;
2216
+ }, 0);
2217
+ const growWidth = metrics.width - header.length - fixedWidth;
2218
+ const yEnd = metrics.y + metrics.height;
2219
+ if (!Number.isSafeInteger(growWidth) || growWidth < 0 || !Number.isSafeInteger(yEnd)) return [];
2220
+
2221
+ const handles = [];
2222
+ let x = metrics.x;
2223
+ for (let index = 0; index < header.length; index += 1) {
2224
+ const contentWidth = index === growIndex ? growWidth : header[index].props.width;
2225
+ const gutterX = x + contentWidth;
2226
+ const key = headerGutterKeyAt(header, index, growIndex);
2227
+ if (key !== null) {
2228
+ const ownerIndex = index < growIndex ? index : index + 1;
2229
+ const owner = header[ownerIndex];
2230
+ if (!isAdjustableWidthColumn(owner) || owner.key !== key) return [];
2231
+ handles.push({
2232
+ key,
2233
+ x: gutterX,
2234
+ yStart: metrics.y,
2235
+ yEnd,
2236
+ width: owner.props.width,
2237
+ direction: ownerIndex < growIndex ? 1 : -1,
2238
+ });
2239
+ }
2240
+ x = gutterX + 1;
2241
+ }
2242
+ return handles;
2243
+ }
2244
+
2245
+ function hitDivider(handles, point, tolerance = 1) {
2246
+ if (
2247
+ !Array.isArray(handles) ||
2248
+ !point ||
2249
+ !Number.isSafeInteger(point.x) ||
2250
+ !Number.isSafeInteger(point.y) ||
2251
+ !Number.isSafeInteger(tolerance) ||
2252
+ tolerance < 0
2253
+ ) {
2254
+ return null;
2255
+ }
2256
+
2257
+ let nearest = null;
2258
+ let nearestDistance = Number.POSITIVE_INFINITY;
2259
+ for (const handle of handles) {
2260
+ if (
2261
+ !Number.isSafeInteger(handle?.x) ||
2262
+ !Number.isSafeInteger(handle.yStart) ||
2263
+ !Number.isSafeInteger(handle.yEnd) ||
2264
+ point.y < handle.yStart ||
2265
+ point.y >= handle.yEnd
2266
+ ) {
2267
+ continue;
2268
+ }
2269
+ const distance = Math.abs(point.x - handle.x);
2270
+ if (
2271
+ distance <= tolerance &&
2272
+ (distance < nearestDistance ||
2273
+ (distance === nearestDistance && (nearest === null || handle.x < nearest.x)))
2274
+ ) {
2275
+ nearest = handle;
2276
+ nearestDistance = distance;
2277
+ }
2278
+ }
2279
+ return nearest;
2280
+ }
2281
+
2282
+ function beginDividerDrag({ event, handles, tabKey, tolerance = 1 }) {
2283
+ if (event?.action !== "press" || typeof tabKey !== "string" || tabKey.length === 0) {
2284
+ return null;
2285
+ }
2286
+ const handle = hitDivider(handles, event, tolerance);
2287
+ if (!handle) return null;
2288
+ return {
2289
+ tabKey,
2290
+ key: handle.key,
2291
+ startX: event.x,
2292
+ startWidth: handle.width,
2293
+ direction: handle.direction,
2294
+ };
2295
+ }
2296
+
2297
+ function draggedWidth({
2298
+ drag,
2299
+ event,
2300
+ tabKey,
2301
+ fullHeaderVisible,
2302
+ layoutValid = true,
2303
+ }) {
2304
+ if (
2305
+ !drag ||
2306
+ event?.action !== "drag" ||
2307
+ drag.tabKey !== tabKey ||
2308
+ fullHeaderVisible !== true ||
2309
+ layoutValid !== true ||
2310
+ !Number.isSafeInteger(drag.startX) ||
2311
+ !Number.isSafeInteger(drag.startWidth) ||
2312
+ (drag.direction !== 1 && drag.direction !== -1) ||
2313
+ !Number.isSafeInteger(event.x)
2314
+ ) {
2315
+ return null;
2316
+ }
2317
+ const nextWidth = drag.startWidth + drag.direction * (event.x - drag.startX);
2318
+ return Number.isSafeInteger(nextWidth) ? { key: drag.key, nextWidth } : null;
2319
+ }
2320
+
2321
+ function sameElementMetrics(left, right) {
2322
+ return (
2323
+ left != null &&
2324
+ right != null &&
2325
+ left.x === right.x &&
2326
+ left.y === right.y &&
2327
+ left.width === right.width &&
2328
+ left.height === right.height
1867
2329
  );
1868
2330
  }
1869
2331
 
@@ -1986,23 +2448,23 @@ function runStatusIcon(run, spin) {
1986
2448
  }
1987
2449
 
1988
2450
  const ACTIONS_HEADER = [
1989
- { label: "", props: { width: 3 } },
1990
- { label: "TITLE", props: { grow: true } },
1991
- { label: "WORKFLOW", props: { width: 10 } },
1992
- { label: "BRANCH", props: { width: 14 } },
1993
- { label: "TIME", props: { width: 7 } },
1994
- { label: "UPDATED", props: { width: 8 } },
2451
+ { key: "status", label: "", props: { width: 3 }, adjustable: false },
2452
+ { key: "title", label: "TITLE", props: { grow: true }, adjustable: false },
2453
+ { key: "workflow", label: "WORKFLOW", props: { width: 10 }, adjustable: true, minWidth: 5 },
2454
+ { key: "branch", label: "BRANCH", props: { width: 14 }, adjustable: true, minWidth: 6 },
2455
+ { key: "time", label: "TIME", props: { width: 7 }, adjustable: true, minWidth: 5 },
2456
+ { key: "updated", label: "UPDATED", props: { width: 8 }, adjustable: true, minWidth: 6 },
1995
2457
  ];
1996
2458
 
1997
2459
  // Dropped in order of least value first: BRANCH and WORKFLOW are inferable
1998
2460
  // from the title far more often than the status icon or the age are.
1999
2461
  const ACTIONS_HEADER_COMPACT = [
2000
- { label: "", props: { width: 3 } },
2001
- { label: "TITLE", props: { grow: true } },
2002
- { label: "UPDATED", props: { width: 8 } },
2462
+ { key: "status", label: "", props: { width: 3 } },
2463
+ { key: "title", label: "TITLE", props: { grow: true } },
2464
+ { key: "updated", label: "UPDATED", props: { width: 8 } },
2003
2465
  ];
2004
2466
 
2005
- function ActionsRow({ item, now, spin, compact, cursor }) {
2467
+ function ActionsRow({ item, now, spin, compact, cursor, columns }) {
2006
2468
  const { icon, color, label } = runStatusIcon(item, spin);
2007
2469
  const started = new Date(item.startedAt);
2008
2470
  const finished = item.status === "completed" ? new Date(item.updatedAt) : now;
@@ -2010,52 +2472,52 @@ function ActionsRow({ item, now, spin, compact, cursor }) {
2010
2472
  return e(
2011
2473
  Box,
2012
2474
  { flexDirection: "row" },
2013
- e(Column, { width: 3, color, label }, `${cursor ? ">" : " "}${icon}`),
2014
- e(Column, { grow: true }, item.displayTitle),
2015
- e(Column, { width: 8, dim: true }, formatAge(new Date(item.updatedAt), now)),
2475
+ e(Column, { ...columnProps(columns, "status"), color, label }, `${cursor ? ">" : " "}${icon}`),
2476
+ e(Column, columnProps(columns, "title"), item.displayTitle),
2477
+ e(Column, { ...columnProps(columns, "updated"), dim: true }, formatAge(new Date(item.updatedAt), now)),
2016
2478
  );
2017
2479
  }
2018
2480
  return e(
2019
2481
  Box,
2020
2482
  { flexDirection: "row" },
2021
- e(Column, { width: 3, color, label }, `${cursor ? ">" : " "}${icon}`),
2022
- e(Column, { grow: true }, item.displayTitle),
2483
+ e(Column, { ...columnProps(columns, "status"), color, label }, `${cursor ? ">" : " "}${icon}`),
2484
+ e(Column, columnProps(columns, "title"), item.displayTitle),
2023
2485
  // The run number is the actionable half and used to be the first thing
2024
2486
  // truncation ate, since it sat at the tail of a 10-column cell.
2025
- e(Column, { width: 10, color: IDENTIFIER }, `#${item.number} ${item.workflowName}`),
2026
- e(Column, { width: 14, color: REF, wrap: "truncate-middle" }, item.headBranch),
2027
- e(Column, { width: 7 }, formatDuration(finished - started)),
2028
- e(Column, { width: 8, dim: true }, formatAge(new Date(item.updatedAt), now)),
2487
+ e(Column, { ...columnProps(columns, "workflow"), color: IDENTIFIER }, `#${item.number} ${item.workflowName}`),
2488
+ e(Column, { ...columnProps(columns, "branch"), color: REF, wrap: "truncate-middle" }, item.headBranch),
2489
+ e(Column, columnProps(columns, "time"), formatDuration(finished - started)),
2490
+ e(Column, { ...columnProps(columns, "updated"), dim: true }, formatAge(new Date(item.updatedAt), now)),
2029
2491
  );
2030
2492
  }
2031
2493
 
2032
2494
  // ---------- Issues tab ----------
2033
2495
 
2034
2496
  const ISSUES_HEADER = [
2035
- { label: "", props: { width: 3 } },
2036
- { label: "TITLE", props: { grow: true } },
2037
- { label: "AUTHOR", props: { width: 12 } },
2038
- { label: "LABEL", props: { width: 14 } },
2039
- { label: "UPDATED", props: { width: 8 } },
2497
+ { key: "status", label: "", props: { width: 3 }, adjustable: false },
2498
+ { key: "title", label: "TITLE", props: { grow: true }, adjustable: false },
2499
+ { key: "author", label: "AUTHOR", props: { width: 12 }, adjustable: true, minWidth: 6 },
2500
+ { key: "label", label: "LABEL", props: { width: 14 }, adjustable: true, minWidth: 6 },
2501
+ { key: "updated", label: "UPDATED", props: { width: 8 }, adjustable: true, minWidth: 6 },
2040
2502
  ];
2041
2503
 
2042
2504
  const ISSUES_HEADER_COMPACT = [
2043
- { label: "", props: { width: 3 } },
2044
- { label: "TITLE", props: { grow: true } },
2045
- { label: "UPDATED", props: { width: 8 } },
2505
+ { key: "status", label: "", props: { width: 3 } },
2506
+ { key: "title", label: "TITLE", props: { grow: true } },
2507
+ { key: "updated", label: "UPDATED", props: { width: 8 } },
2046
2508
  ];
2047
2509
 
2048
- function IssueRow({ item, now, compact, cursor }) {
2510
+ function IssueRow({ item, now, compact, cursor, columns }) {
2049
2511
  const cells = [
2050
- e(Column, { key: "i", width: 3, color: OK, label: "open issue" }, `${cursor ? ">" : " "}${OCT.issueOpened}`),
2051
- e(Column, { key: "t", grow: true }, `#${item.number} ${item.title}`),
2512
+ e(Column, { key: "status", ...columnProps(columns, "status"), color: OK, label: "open issue" }, `${cursor ? ">" : " "}${OCT.issueOpened}`),
2513
+ e(Column, { key: "title", ...columnProps(columns, "title") }, `#${item.number} ${item.title}`),
2052
2514
  ];
2053
2515
  if (!compact) {
2054
- cells.push(e(Column, { key: "a", width: 12, color: IDENTIFIER }, item.author));
2055
- cells.push(e(Column, { key: "l", width: 14, color: REF }, item.label));
2516
+ cells.push(e(Column, { key: "author", ...columnProps(columns, "author"), color: IDENTIFIER }, item.author));
2517
+ cells.push(e(Column, { key: "label", ...columnProps(columns, "label"), color: REF }, item.label));
2056
2518
  }
2057
2519
  cells.push(
2058
- e(Column, { key: "u", width: 8, dim: true }, formatAge(new Date(item.updatedAt), now)),
2520
+ e(Column, { key: "updated", ...columnProps(columns, "updated"), dim: true }, formatAge(new Date(item.updatedAt), now)),
2059
2521
  );
2060
2522
  return e(Box, { flexDirection: "row" }, ...cells);
2061
2523
  }
@@ -2063,18 +2525,18 @@ function IssueRow({ item, now, compact, cursor }) {
2063
2525
  // ---------- Pull requests tab ----------
2064
2526
 
2065
2527
  const PRS_HEADER = [
2066
- { label: "", props: { width: 3 } },
2067
- { label: "TITLE", props: { grow: true } },
2068
- { label: "AUTHOR", props: { width: 12 } },
2069
- { label: "BRANCH", props: { width: 14 } },
2070
- { label: "REVIEW", props: { width: 10 } },
2071
- { label: "UPDATED", props: { width: 8 } },
2528
+ { key: "status", label: "", props: { width: 3 }, adjustable: false },
2529
+ { key: "title", label: "TITLE", props: { grow: true }, adjustable: false },
2530
+ { key: "author", label: "AUTHOR", props: { width: 12 }, adjustable: true, minWidth: 6 },
2531
+ { key: "branch", label: "BRANCH", props: { width: 14 }, adjustable: true, minWidth: 6 },
2532
+ { key: "review", label: "REVIEW", props: { width: 10 }, adjustable: true, minWidth: 7 },
2533
+ { key: "updated", label: "UPDATED", props: { width: 8 }, adjustable: true, minWidth: 6 },
2072
2534
  ];
2073
2535
 
2074
2536
  const PRS_HEADER_COMPACT = [
2075
- { label: "", props: { width: 3 } },
2076
- { label: "TITLE", props: { grow: true } },
2077
- { label: "REVIEW", props: { width: 10 } },
2537
+ { key: "status", label: "", props: { width: 3 } },
2538
+ { key: "title", label: "TITLE", props: { grow: true } },
2539
+ { key: "review", label: "REVIEW", props: { width: 10 } },
2078
2540
  ];
2079
2541
 
2080
2542
  const REVIEW_LABEL = {
@@ -2084,25 +2546,25 @@ const REVIEW_LABEL = {
2084
2546
  };
2085
2547
  const REVIEW_NONE = { label: "", color: INERT };
2086
2548
 
2087
- function PRRow({ item, now, compact, cursor }) {
2549
+ function PRRow({ item, now, compact, cursor, columns }) {
2088
2550
  const prIcon = item.isDraft
2089
2551
  ? { icon: OCT.pullRequestDraft, color: INERT, label: "draft pull request" }
2090
2552
  : { icon: OCT.pullRequest, color: OK, label: "open pull request" };
2091
2553
  const review = pick(REVIEW_LABEL, item.reviewDecision, REVIEW_NONE);
2092
2554
  const cells = [
2093
- e(Column, { key: "i", width: 3, color: prIcon.color, label: prIcon.label }, `${cursor ? ">" : " "}${prIcon.icon}`),
2094
- e(Column, { key: "t", grow: true }, `#${item.number} ${item.title}`),
2555
+ e(Column, { key: "status", ...columnProps(columns, "status"), color: prIcon.color, label: prIcon.label }, `${cursor ? ">" : " "}${prIcon.icon}`),
2556
+ e(Column, { key: "title", ...columnProps(columns, "title") }, `#${item.number} ${item.title}`),
2095
2557
  ];
2096
2558
  if (!compact) {
2097
- cells.push(e(Column, { key: "a", width: 12, color: IDENTIFIER }, item.author));
2559
+ cells.push(e(Column, { key: "author", ...columnProps(columns, "author"), color: IDENTIFIER }, item.author));
2098
2560
  cells.push(
2099
- e(Column, { key: "b", width: 14, color: REF, wrap: "truncate-middle" }, item.headRefName),
2561
+ e(Column, { key: "branch", ...columnProps(columns, "branch"), color: REF, wrap: "truncate-middle" }, item.headRefName),
2100
2562
  );
2101
2563
  }
2102
- cells.push(e(Column, { key: "r", width: 10, color: review.color }, review.label));
2564
+ cells.push(e(Column, { key: "review", ...columnProps(columns, "review"), color: review.color }, review.label));
2103
2565
  if (!compact) {
2104
2566
  cells.push(
2105
- e(Column, { key: "u", width: 8, dim: true }, formatAge(new Date(item.updatedAt), now)),
2567
+ e(Column, { key: "updated", ...columnProps(columns, "updated"), dim: true }, formatAge(new Date(item.updatedAt), now)),
2106
2568
  );
2107
2569
  }
2108
2570
  return e(Box, { flexDirection: "row" }, ...cells);
@@ -2116,19 +2578,19 @@ function PRRow({ item, now, compact, cursor }) {
2116
2578
  // text column states it outright; the width comes out of PACKAGE / FILE, whose
2117
2579
  // contents were already being truncated.
2118
2580
  const SECURITY_HEADER = [
2119
- { label: "", props: { width: 3 } },
2120
- { label: "SEV", props: { width: 4 } },
2121
- { label: "PACKAGE / FILE", props: { width: 16 } },
2122
- { label: "SUMMARY", props: { grow: true } },
2123
- { label: "AGE", props: { width: 8 } },
2581
+ { key: "status", label: "", props: { width: 3 }, adjustable: false },
2582
+ { key: "severity", label: "SEV", props: { width: 4 }, adjustable: false },
2583
+ { key: "package", label: "PACKAGE / FILE", props: { width: 16 }, adjustable: true, minWidth: 6 },
2584
+ { key: "summary", label: "SUMMARY", props: { grow: true }, adjustable: false },
2585
+ { key: "age", label: "AGE", props: { width: 8 }, adjustable: true, minWidth: 6 },
2124
2586
  ];
2125
2587
 
2126
2588
  // SEV is the last thing to drop on this tab: it is the whole point of the pane
2127
2589
  // and the only non-colour severity channel.
2128
2590
  const SECURITY_HEADER_COMPACT = [
2129
- { label: "", props: { width: 3 } },
2130
- { label: "SEV", props: { width: 4 } },
2131
- { label: "SUMMARY", props: { grow: true } },
2591
+ { key: "status", label: "", props: { width: 3 } },
2592
+ { key: "severity", label: "SEV", props: { width: 4 } },
2593
+ { key: "summary", label: "SUMMARY", props: { grow: true } },
2132
2594
  ];
2133
2595
 
2134
2596
  const SEVERITY_STYLE = {
@@ -2141,19 +2603,19 @@ const SEVERITY_STYLE = {
2141
2603
  };
2142
2604
  const SEVERITY_UNKNOWN = SEVERITY_STYLE.unknown;
2143
2605
 
2144
- function SecurityRow({ item, now, compact, cursor }) {
2606
+ function SecurityRow({ item, now, compact, cursor, columns }) {
2145
2607
  const sev = pick(SEVERITY_STYLE, item.severity, SEVERITY_UNKNOWN);
2146
2608
  const cells = [
2147
- e(Column, { key: "i", width: 3, color: sev.color, label: `${sev.short} severity` }, `${cursor ? ">" : " "}${OCT.shield}`),
2148
- e(Column, { key: "s", width: 4, color: sev.color }, sev.short),
2609
+ e(Column, { key: "status", ...columnProps(columns, "status"), color: sev.color, label: `${sev.short} severity` }, `${cursor ? ">" : " "}${OCT.shield}`),
2610
+ e(Column, { key: "severity", ...columnProps(columns, "severity"), color: sev.color }, sev.short),
2149
2611
  ];
2150
2612
  if (!compact) {
2151
- cells.push(e(Column, { key: "p", width: 16, color: IDENTIFIER }, item.detail || item.kind));
2613
+ cells.push(e(Column, { key: "package", ...columnProps(columns, "package"), color: IDENTIFIER }, item.detail || item.kind));
2152
2614
  }
2153
- cells.push(e(Column, { key: "t", grow: true }, item.title));
2615
+ cells.push(e(Column, { key: "summary", ...columnProps(columns, "summary") }, item.title));
2154
2616
  if (!compact) {
2155
2617
  cells.push(
2156
- e(Column, { key: "a", width: 8, dim: true }, formatAge(new Date(item.createdAt), now)),
2618
+ e(Column, { key: "age", ...columnProps(columns, "age"), dim: true }, formatAge(new Date(item.createdAt), now)),
2157
2619
  );
2158
2620
  }
2159
2621
  return e(Box, { flexDirection: "row" }, ...cells);
@@ -2214,6 +2676,181 @@ const TABS = [
2214
2676
  },
2215
2677
  ];
2216
2678
 
2679
+ function tabForKey(key) {
2680
+ return TABS.find((tab) => tab.key === key);
2681
+ }
2682
+
2683
+ const EMPTY_WIDTH_OVERRIDES = Object.freeze({});
2684
+
2685
+ function columnProps(columns, key) {
2686
+ const column = columns.find((candidate) => candidate.key === key);
2687
+ if (!column) throw new Error(`Unknown column: ${key}`);
2688
+ return column.props;
2689
+ }
2690
+
2691
+ function isAdjustableWidthColumn(column) {
2692
+ return (
2693
+ column?.adjustable &&
2694
+ Number.isSafeInteger(column.props.width) &&
2695
+ Number.isSafeInteger(column.minWidth)
2696
+ );
2697
+ }
2698
+
2699
+ function adjustableWidthKeys(tab) {
2700
+ if (!tab || !Array.isArray(tab.header)) return [];
2701
+ return tab.header.filter(isAdjustableWidthColumn).map((column) => column.key);
2702
+ }
2703
+
2704
+ function selectWidthKey(tab, rememberedKey = null) {
2705
+ const keys = adjustableWidthKeys(tab);
2706
+ return keys.includes(rememberedKey) ? rememberedKey : (keys[0] ?? null);
2707
+ }
2708
+
2709
+ function cycleWidthKey(tab, selectedKey, direction) {
2710
+ if (!Number.isSafeInteger(direction) || direction === 0) return selectWidthKey(tab, selectedKey);
2711
+ const keys = adjustableWidthKeys(tab);
2712
+ if (keys.length === 0) return null;
2713
+ const current = keys.indexOf(selectedKey);
2714
+ if (current < 0) return direction > 0 ? keys[0] : keys[keys.length - 1];
2715
+ return keys[(current + Math.sign(direction) + keys.length) % keys.length];
2716
+ }
2717
+
2718
+ function resolveHeader(base, overrides = EMPTY_WIDTH_OVERRIDES) {
2719
+ let changed = false;
2720
+ const resolved = base.map((column) => {
2721
+ if (
2722
+ !isAdjustableWidthColumn(column) ||
2723
+ overrides == null ||
2724
+ !Object.hasOwn(overrides, column.key) ||
2725
+ !Number.isSafeInteger(overrides[column.key])
2726
+ ) {
2727
+ return column;
2728
+ }
2729
+
2730
+ const width = Math.max(column.minWidth, overrides[column.key]);
2731
+ if (width === column.props.width) return column;
2732
+ changed = true;
2733
+ return { ...column, props: { ...column.props, width } };
2734
+ });
2735
+ return changed ? resolved : base;
2736
+ }
2737
+
2738
+ function fitHeaderToFrame(preferred, defaults, frameCols) {
2739
+ const preferredFloor = minimumWidthFor(preferred);
2740
+ if (preferredFloor <= frameCols) return preferred;
2741
+ if (minimumWidthFor(defaults) > frameCols) return null;
2742
+
2743
+ let remaining = preferredFloor - frameCols;
2744
+ return preferred.map((column) => {
2745
+ if (remaining <= 0 || !Number.isSafeInteger(column.props.width)) return column;
2746
+ const defaultColumn = defaults.find((candidate) => candidate.key === column.key);
2747
+ const defaultWidth = defaultColumn?.props.width;
2748
+ if (!Number.isSafeInteger(defaultWidth) || column.props.width <= defaultWidth) return column;
2749
+
2750
+ const shrinkBy = Math.min(remaining, column.props.width - defaultWidth);
2751
+ remaining -= shrinkBy;
2752
+ return { ...column, props: { ...column.props, width: column.props.width - shrinkBy } };
2753
+ });
2754
+ }
2755
+
2756
+ function effectiveHeaderFor(tab, tabOverrides, frameCols) {
2757
+ return fitHeaderToFrame(
2758
+ resolveHeader(tab.header, tabOverrides),
2759
+ tab.header,
2760
+ frameCols,
2761
+ );
2762
+ }
2763
+
2764
+ function adjustWidth({ header, key, delta, frameCols }) {
2765
+ if (!Number.isSafeInteger(delta) || delta === 0 || !Number.isSafeInteger(frameCols)) return header;
2766
+ const index = header.findIndex((column) => column.key === key);
2767
+ if (index < 0) return header;
2768
+
2769
+ const column = header[index];
2770
+ if (!isAdjustableWidthColumn(column)) return header;
2771
+
2772
+ const available = Math.max(0, frameCols - minimumWidthFor(header));
2773
+ const maximum = column.props.width + available;
2774
+ const width = Math.min(maximum, Math.max(column.minWidth, column.props.width + delta));
2775
+ if (width === column.props.width) return header;
2776
+
2777
+ const adjusted = [...header];
2778
+ adjusted[index] = { ...column, props: { ...column.props, width } };
2779
+ return adjusted;
2780
+ }
2781
+
2782
+ function removeWidthOverride(overrides, tabKey, key) {
2783
+ if (!isRecord(overrides)) return overrides;
2784
+ const tabOverrides = overrides[tabKey];
2785
+ if (!isRecord(tabOverrides) || !Object.hasOwn(tabOverrides, key)) return overrides;
2786
+
2787
+ const nextTab = { ...tabOverrides };
2788
+ delete nextTab[key];
2789
+ if (Object.keys(nextTab).length > 0) return { ...overrides, [tabKey]: nextTab };
2790
+
2791
+ const next = { ...overrides };
2792
+ delete next[tabKey];
2793
+ return next;
2794
+ }
2795
+
2796
+ // One immutable preference reducer for keyboard deltas and the pointer phase's
2797
+ // absolute drag snapshots. The optional geometry arguments are the live fitted
2798
+ // header and frame budget; omitting them gives unit callers semantic-minimum
2799
+ // clamping without inventing a terminal width.
2800
+ function updateWidthPreference({
2801
+ overrides,
2802
+ tab,
2803
+ key,
2804
+ nextWidth,
2805
+ effectiveHeader,
2806
+ frameCols,
2807
+ }) {
2808
+ if (!isRecord(overrides) || !tab || !Array.isArray(tab.header) || !Number.isSafeInteger(nextWidth)) {
2809
+ return overrides;
2810
+ }
2811
+ const defaultColumn = tab.header.find((column) => column.key === key);
2812
+ if (!isAdjustableWidthColumn(defaultColumn)) return overrides;
2813
+
2814
+ const tabOverrides = isRecord(overrides[tab.key]) ? overrides[tab.key] : EMPTY_WIDTH_OVERRIDES;
2815
+ const currentHeader = Array.isArray(effectiveHeader)
2816
+ ? effectiveHeader
2817
+ : resolveHeader(tab.header, tabOverrides);
2818
+ const currentColumn = currentHeader.find((column) => column.key === key);
2819
+ if (!isAdjustableWidthColumn(currentColumn)) return overrides;
2820
+
2821
+ const delta = nextWidth - currentColumn.props.width;
2822
+ const liveFrameCols = Number.isSafeInteger(frameCols)
2823
+ ? frameCols
2824
+ : minimumWidthFor(currentHeader) + Math.max(0, delta);
2825
+ const adjusted = adjustWidth({ header: currentHeader, key, delta, frameCols: liveFrameCols });
2826
+ if (adjusted === currentHeader) return overrides;
2827
+ const width = adjusted.find((column) => column.key === key).props.width;
2828
+ const hasOverride = Object.hasOwn(tabOverrides, key);
2829
+
2830
+ if (width === defaultColumn.props.width) {
2831
+ return removeWidthOverride(overrides, tab.key, key);
2832
+ }
2833
+
2834
+ if (hasOverride && tabOverrides[key] === width) return overrides;
2835
+ return { ...overrides, [tab.key]: { ...tabOverrides, [key]: width } };
2836
+ }
2837
+
2838
+ function resetWidthPreference(overrides, tabKey, key) {
2839
+ if (!isRecord(overrides)) return overrides;
2840
+ const tab = tabForKey(tabKey);
2841
+ const column = tab?.header.find((candidate) => candidate.key === key);
2842
+ if (!isAdjustableWidthColumn(column)) return overrides;
2843
+ return removeWidthOverride(overrides, tabKey, key);
2844
+ }
2845
+
2846
+ function resetTabWidthPreferences(overrides, tabKey) {
2847
+ if (!isRecord(overrides) || !Object.hasOwn(overrides, tabKey)) return overrides;
2848
+ if (!tabForKey(tabKey)) return overrides;
2849
+ const next = { ...overrides };
2850
+ delete next[tabKey];
2851
+ return next;
2852
+ }
2853
+
2217
2854
  // The narrowest width each tab's table can render without its fixed columns
2218
2855
  // overflowing the frame. Derived from the header descriptors rather than
2219
2856
  // hard-coded, so adding or resizing a column cannot silently invalidate it.
@@ -2226,6 +2863,448 @@ function minimumWidthFor(header) {
2226
2863
  // + 2 border verticals, + 2 paddingX, + at least 4 columns for TITLE.
2227
2864
  return fixed + 8;
2228
2865
  }
2866
+
2867
+ // ---------- Width preferences ----------
2868
+
2869
+ const WIDTH_PREFERENCES_VERSION = 1;
2870
+ let widthPreferenceTempSequence = 0;
2871
+
2872
+ function widthPreferencesPath({
2873
+ env = process.env,
2874
+ platform = process.platform,
2875
+ home = homedir(),
2876
+ } = {}) {
2877
+ const xdgRoot = env?.XDG_CONFIG_HOME;
2878
+ const root =
2879
+ typeof xdgRoot === "string" && xdgRoot.length > 0 && isAbsolute(xdgRoot)
2880
+ ? xdgRoot
2881
+ : platform === "darwin"
2882
+ ? join(home, "Library", "Application Support")
2883
+ : join(home, ".config");
2884
+ return join(root, "gh-glance", "preferences.json");
2885
+ }
2886
+
2887
+ function isRecord(value) {
2888
+ return value !== null && typeof value === "object" && !Array.isArray(value);
2889
+ }
2890
+
2891
+ function normalizeWidthOverrides(overrides, tabs = TABS, { omitDefaults = false } = {}) {
2892
+ if (!isRecord(overrides)) return {};
2893
+
2894
+ const normalized = {};
2895
+ for (const tab of tabs) {
2896
+ if (!Object.hasOwn(overrides, tab.key) || !isRecord(overrides[tab.key])) continue;
2897
+
2898
+ const source = overrides[tab.key];
2899
+ const tabOverrides = {};
2900
+ for (const column of tab.header) {
2901
+ if (!isAdjustableWidthColumn(column) || !Object.hasOwn(source, column.key)) continue;
2902
+ const width = source[column.key];
2903
+ if (!Number.isSafeInteger(width) || width < column.minWidth) continue;
2904
+ if (omitDefaults && width === column.props.width) continue;
2905
+ tabOverrides[column.key] = width;
2906
+ }
2907
+ if (Object.keys(tabOverrides).length > 0) normalized[tab.key] = tabOverrides;
2908
+ }
2909
+ return normalized;
2910
+ }
2911
+
2912
+ function decodeWidthPreferences(raw, tabs = TABS) {
2913
+ let document;
2914
+ try {
2915
+ document = JSON.parse(raw);
2916
+ } catch (error) {
2917
+ return { preferences: {}, error };
2918
+ }
2919
+
2920
+ if (
2921
+ !isRecord(document) ||
2922
+ !Object.hasOwn(document, "version") ||
2923
+ document.version !== WIDTH_PREFERENCES_VERSION ||
2924
+ !Object.hasOwn(document, "tabs") ||
2925
+ !isRecord(document.tabs)
2926
+ ) {
2927
+ return { preferences: {}, error: new Error("Unsupported width preferences document") };
2928
+ }
2929
+
2930
+ return { preferences: normalizeWidthOverrides(document.tabs, tabs), error: null };
2931
+ }
2932
+
2933
+ function parseWidthPreferences(raw, tabs = TABS) {
2934
+ return decodeWidthPreferences(raw, tabs).preferences;
2935
+ }
2936
+
2937
+ function serializeWidthPreferences(overrides, tabs = TABS) {
2938
+ const document = {
2939
+ version: WIDTH_PREFERENCES_VERSION,
2940
+ tabs: normalizeWidthOverrides(overrides, tabs, { omitDefaults: true }),
2941
+ };
2942
+ return `${JSON.stringify(document, null, 2)}\n`;
2943
+ }
2944
+
2945
+ function loadWidthPreferences(path, tabs = TABS) {
2946
+ let raw;
2947
+ try {
2948
+ raw = readFileSync(path, "utf8");
2949
+ } catch (error) {
2950
+ return error?.code === "ENOENT"
2951
+ ? { preferences: {}, error: null }
2952
+ : { preferences: {}, error };
2953
+ }
2954
+ return decodeWidthPreferences(raw, tabs);
2955
+ }
2956
+
2957
+ function saveWidthPreferences(path, overrides, tabs = TABS) {
2958
+ let tempPath = null;
2959
+ try {
2960
+ const payload = serializeWidthPreferences(overrides, tabs);
2961
+ const parent = dirname(path);
2962
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
2963
+ widthPreferenceTempSequence += 1;
2964
+ tempPath = `${path}.${process.pid}.${Date.now()}.${widthPreferenceTempSequence}.tmp`;
2965
+ writeFileSync(tempPath, payload, {
2966
+ encoding: "utf8",
2967
+ mode: 0o600,
2968
+ flag: "wx",
2969
+ });
2970
+ renameSync(tempPath, path);
2971
+ return { ok: true };
2972
+ } catch (error) {
2973
+ if (tempPath !== null) {
2974
+ try {
2975
+ unlinkSync(tempPath);
2976
+ } catch {
2977
+ // The write may have failed before creating it. Cleanup is best effort
2978
+ // and deliberately targets only this operation's exact temporary file.
2979
+ }
2980
+ }
2981
+ return { ok: false, error };
2982
+ }
2983
+ }
2984
+
2985
+ function createCoalescedWriter({ write, delay = 200, onResult = () => {} }) {
2986
+ let latest;
2987
+ let generation = 0;
2988
+ let persistedGeneration = 0;
2989
+ let timer = null;
2990
+ let flushPromise = null;
2991
+
2992
+ function report(result) {
2993
+ try {
2994
+ onResult(result);
2995
+ } catch {
2996
+ // Persistence reporting is advisory and must not reach Ink's render path.
2997
+ }
2998
+ return result;
2999
+ }
3000
+
3001
+ function failed(error) {
3002
+ return { ok: false, error };
3003
+ }
3004
+
3005
+ async function writeGeneration(value) {
3006
+ let pending;
3007
+ try {
3008
+ // Keep this call before the first await. The production writer is
3009
+ // synchronous, so an unmount flush completes the filesystem replacement
3010
+ // before React hands terminal teardown back to its caller.
3011
+ pending = write(value);
3012
+ } catch (error) {
3013
+ return failed(error);
3014
+ }
3015
+
3016
+ try {
3017
+ const result = await pending;
3018
+ return result?.ok === false ? result : (result ?? { ok: true });
3019
+ } catch (error) {
3020
+ return failed(error);
3021
+ }
3022
+ }
3023
+
3024
+ async function drain() {
3025
+ let result = { ok: true, written: false };
3026
+ while (persistedGeneration < generation) {
3027
+ const attemptedGeneration = generation;
3028
+ const value = latest;
3029
+ result = await writeGeneration(value);
3030
+
3031
+ const isLatest = attemptedGeneration === generation;
3032
+ if (result.ok !== false) {
3033
+ persistedGeneration = Math.max(persistedGeneration, attemptedGeneration);
3034
+ }
3035
+
3036
+ // The loop serializes a superseding write behind this completion, so its
3037
+ // result owns the warning state. A stale completion is deliberately not
3038
+ // reported, while async replacements can neither race on disk nor report
3039
+ // out of order.
3040
+ if (!isLatest) continue;
3041
+ report(result);
3042
+ // Leave the latest generation dirty after failure. A later flush/dispose
3043
+ // retries it; concurrent callers still share this completed attempt.
3044
+ if (result.ok === false) return result;
3045
+ }
3046
+ return result;
3047
+ }
3048
+
3049
+ function flush() {
3050
+ if (timer !== null) {
3051
+ clearTimeout(timer);
3052
+ timer = null;
3053
+ }
3054
+ if (flushPromise !== null) return flushPromise;
3055
+ if (persistedGeneration >= generation) {
3056
+ return Promise.resolve({ ok: true, written: false });
3057
+ }
3058
+
3059
+ const pending = drain().finally(() => {
3060
+ if (flushPromise === pending) flushPromise = null;
3061
+ });
3062
+ flushPromise = pending;
3063
+ return pending;
3064
+ }
3065
+
3066
+ function schedule(value) {
3067
+ latest = value;
3068
+ generation += 1;
3069
+ if (timer === null) {
3070
+ timer = setTimeout(() => {
3071
+ timer = null;
3072
+ void flush();
3073
+ }, delay);
3074
+ timer.unref?.();
3075
+ } else {
3076
+ timer.refresh();
3077
+ }
3078
+ }
3079
+
3080
+ return {
3081
+ schedule,
3082
+ flush,
3083
+ dispose: flush,
3084
+ };
3085
+ }
3086
+
3087
+ const createWidthPreferenceWriter = createCoalescedWriter;
3088
+
3089
+ // ---------- Last-known-good dashboard cache ----------
3090
+
3091
+ const DASHBOARD_CACHE_VERSION = 1;
3092
+ const MAX_DASHBOARD_CACHE_TARGETS = 5;
3093
+ const MAX_DASHBOARD_CACHE_ROWS_PER_TAB = 60;
3094
+ let dashboardCacheTempSequence = 0;
3095
+
3096
+ function dashboardCachePath(options = {}) {
3097
+ return join(dirname(widthPreferencesPath(options)), "dashboard-cache.json");
3098
+ }
3099
+
3100
+ // Explicit targets are stable across working directories. In inferred mode the
3101
+ // directory is the only repository identity available without making another
3102
+ // GitHub call -- exactly the call that cannot succeed when this cache is needed.
3103
+ // JSON encoding avoids delimiter ambiguity in host, repo, and path strings.
3104
+ function dashboardCacheTarget({ repo = null, ghRepo = null, host = null, cwd = process.cwd() } = {}) {
3105
+ const target = repo || ghRepo;
3106
+ return target
3107
+ ? JSON.stringify({ kind: "repo", host: String(host ?? ""), repo: String(target) })
3108
+ : JSON.stringify({ kind: "cwd", host: String(host ?? ""), cwd: String(cwd) });
3109
+ }
3110
+
3111
+ function cacheTimestamp(value) {
3112
+ return Number.isFinite(value) && value >= 0 ? value : null;
3113
+ }
3114
+
3115
+ function normalizeCachedItem(tabKey, item) {
3116
+ if (!isRecord(item)) return null;
3117
+ if (tabKey === "actions") {
3118
+ if (!Number.isSafeInteger(item.databaseId)) return null;
3119
+ return {
3120
+ databaseId: item.databaseId,
3121
+ displayTitle: safe(item.displayTitle),
3122
+ workflowName: safe(item.workflowName),
3123
+ number: Number.isSafeInteger(item.number) ? item.number : null,
3124
+ headBranch: safe(item.headBranch),
3125
+ status: safe(item.status),
3126
+ conclusion: item.conclusion == null ? null : safe(item.conclusion),
3127
+ startedAt: safe(item.startedAt),
3128
+ updatedAt: safe(item.updatedAt),
3129
+ };
3130
+ }
3131
+ if (tabKey === "issues") {
3132
+ if (!Number.isSafeInteger(item.number)) return null;
3133
+ return {
3134
+ number: item.number,
3135
+ title: safe(item.title),
3136
+ author: safe(item.author),
3137
+ label: safe(item.label),
3138
+ updatedAt: safe(item.updatedAt),
3139
+ };
3140
+ }
3141
+ if (tabKey === "prs") {
3142
+ if (!Number.isSafeInteger(item.number)) return null;
3143
+ return {
3144
+ number: item.number,
3145
+ title: safe(item.title),
3146
+ author: safe(item.author),
3147
+ headRefName: safe(item.headRefName),
3148
+ isDraft: Boolean(item.isDraft),
3149
+ reviewDecision: safe(item.reviewDecision),
3150
+ updatedAt: safe(item.updatedAt),
3151
+ };
3152
+ }
3153
+ if (tabKey === "security") {
3154
+ const id = safe(item.id);
3155
+ if (!id) return null;
3156
+ return {
3157
+ id,
3158
+ kind: safe(item.kind),
3159
+ severity: safe(item.severity),
3160
+ title: safe(item.title),
3161
+ detail: safe(item.detail),
3162
+ createdAt: safe(item.createdAt),
3163
+ };
3164
+ }
3165
+ return null;
3166
+ }
3167
+
3168
+ function normalizeDashboardCacheEntry(entry) {
3169
+ if (!isRecord(entry) || !isRecord(entry.tabs)) return null;
3170
+ const tabs = {};
3171
+ for (const tabKey of TAB_KEYS) {
3172
+ const tab = entry.tabs[tabKey];
3173
+ if (!isRecord(tab) || !Array.isArray(tab.data)) continue;
3174
+ const lastOk = cacheTimestamp(tab.lastOk);
3175
+ if (
3176
+ lastOk === null ||
3177
+ !isRecord(tab.meta) ||
3178
+ cacheTimestamp(tab.meta.at) === null ||
3179
+ typeof tab.meta.truncated !== "boolean"
3180
+ ) {
3181
+ continue;
3182
+ }
3183
+ const normalizedData = tab.data.map((item) => normalizeCachedItem(tabKey, item)).filter(Boolean);
3184
+ // A malformed row means this tab was not written by the current schema.
3185
+ // Reject the tab rather than turning a corrupt non-empty payload into a
3186
+ // confident empty state.
3187
+ if (normalizedData.length !== tab.data.length) continue;
3188
+ const data = normalizedData.slice(0, MAX_DASHBOARD_CACHE_ROWS_PER_TAB);
3189
+ const meta = {
3190
+ at: tab.meta.at,
3191
+ truncated: tab.meta.truncated || normalizedData.length > MAX_DASHBOARD_CACHE_ROWS_PER_TAB,
3192
+ };
3193
+ tabs[tabKey] = { data, meta, lastOk };
3194
+ }
3195
+ if (Object.keys(tabs).length === 0) return null;
3196
+ const latestTab = Math.max(...Object.values(tabs).map((tab) => tab.lastOk));
3197
+ return {
3198
+ tabs,
3199
+ securityNotes: Array.isArray(entry.securityNotes)
3200
+ ? entry.securityNotes
3201
+ .filter((note) => typeof note === "string")
3202
+ .slice(0, ALERT_SOURCES.length)
3203
+ .map((note) => safe(note))
3204
+ : [],
3205
+ securityBlind: typeof entry.securityBlind === "boolean" ? entry.securityBlind : false,
3206
+ updatedAt: cacheTimestamp(entry.updatedAt) ?? latestTab,
3207
+ };
3208
+ }
3209
+
3210
+ function normalizeDashboardCache(cache) {
3211
+ if (!isRecord(cache)) return {};
3212
+ return limitDashboardCache(
3213
+ Object.fromEntries(
3214
+ Object.entries(cache)
3215
+ .map(([target, entry]) => [target, normalizeDashboardCacheEntry(entry)])
3216
+ .filter(([target, entry]) => target.length > 0 && entry !== null),
3217
+ ),
3218
+ );
3219
+ }
3220
+
3221
+ function limitDashboardCache(cache) {
3222
+ return Object.fromEntries(
3223
+ Object.entries(cache)
3224
+ .sort((left, right) => right[1].updatedAt - left[1].updatedAt)
3225
+ .slice(0, MAX_DASHBOARD_CACHE_TARGETS),
3226
+ );
3227
+ }
3228
+
3229
+ function mergeDashboardCacheEntry(cache, target, entry) {
3230
+ const normalized = normalizeDashboardCacheEntry(entry);
3231
+ return normalized === null ? cache : limitDashboardCache({ ...cache, [target]: normalized });
3232
+ }
3233
+
3234
+ function decodeDashboardCache(raw) {
3235
+ let document;
3236
+ try {
3237
+ document = JSON.parse(raw);
3238
+ } catch (error) {
3239
+ return { cache: {}, error };
3240
+ }
3241
+ if (
3242
+ !isRecord(document) ||
3243
+ document.version !== DASHBOARD_CACHE_VERSION ||
3244
+ !isRecord(document.targets)
3245
+ ) {
3246
+ return { cache: {}, error: new Error("Unsupported dashboard cache document") };
3247
+ }
3248
+ return { cache: normalizeDashboardCache(document.targets), error: null };
3249
+ }
3250
+
3251
+ function serializeDashboardCache(cache, { normalized = false } = {}) {
3252
+ return `${JSON.stringify(
3253
+ { version: DASHBOARD_CACHE_VERSION, targets: normalized ? cache : normalizeDashboardCache(cache) },
3254
+ null,
3255
+ 2,
3256
+ )}\n`;
3257
+ }
3258
+
3259
+ function loadDashboardCache(path, target) {
3260
+ let raw;
3261
+ try {
3262
+ raw = readFileSync(path, "utf8");
3263
+ } catch (error) {
3264
+ return error?.code === "ENOENT"
3265
+ ? { cache: {}, entry: null, error: null }
3266
+ : { cache: {}, entry: null, error };
3267
+ }
3268
+ const decoded = decodeDashboardCache(raw);
3269
+ return {
3270
+ cache: decoded.cache,
3271
+ entry: pick(decoded.cache, target, null),
3272
+ error: decoded.error,
3273
+ };
3274
+ }
3275
+
3276
+ function saveDashboardCache(path, cache, { normalized = false } = {}) {
3277
+ let tempPath = null;
3278
+ try {
3279
+ const parent = dirname(path);
3280
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
3281
+ if (process.platform !== "win32") chmodSync(parent, 0o700);
3282
+ dashboardCacheTempSequence += 1;
3283
+ tempPath = `${path}.${process.pid}.${Date.now()}.${dashboardCacheTempSequence}.tmp`;
3284
+ writeFileSync(tempPath, serializeDashboardCache(cache, { normalized }), {
3285
+ encoding: "utf8",
3286
+ mode: 0o600,
3287
+ flag: "wx",
3288
+ });
3289
+ renameSync(tempPath, path);
3290
+ return { ok: true };
3291
+ } catch (error) {
3292
+ if (tempPath !== null) {
3293
+ try {
3294
+ unlinkSync(tempPath);
3295
+ } catch {
3296
+ // The write may have failed before creating this exact temporary file.
3297
+ }
3298
+ }
3299
+ return { ok: false, error };
3300
+ }
3301
+ }
3302
+
3303
+ function nextSecurityRaw(previousRaw, raw, blind) {
3304
+ if (blind === true) return null;
3305
+ return previousRaw === raw ? previousRaw : raw;
3306
+ }
3307
+
2229
3308
  // Below this the full column set cannot render without its fixed columns
2230
3309
  // overflowing the frame -- rows hard-wrap and ink switches to clearing and
2231
3310
  // repainting the whole screen every frame. Worse, in the band just above the
@@ -2314,6 +3393,7 @@ const KEY_HINTS = [
2314
3393
  { label: "Move", keys: "↑↓" },
2315
3394
  { label: "Open", keys: "Ent" },
2316
3395
  { label: "Refresh", keys: "r" },
3396
+ { label: "Width", keys: "w" },
2317
3397
  { label: "Quit", keys: "q" },
2318
3398
  ];
2319
3399
 
@@ -2340,6 +3420,26 @@ const REMOTE_SETUP_NONINTERACTIVE_LINES = [
2340
3420
  // finishes. Wide enough for the spinner, a space and "Fetching".
2341
3421
  const FETCHING_WIDTH = 12;
2342
3422
 
3423
+ function widthStatusText({ label, width, cols, saveError = false }) {
3424
+ const budget = Number.isSafeInteger(cols) ? Math.max(0, cols) : 0;
3425
+ const safeLabel = String(label ?? "").replace(/[^\x20-\x7e]/g, "?");
3426
+ const safeWidth = Number.isSafeInteger(width) ? String(width) : "?";
3427
+ const variants = saveError
3428
+ ? [
3429
+ `Width: ${safeLabel} ${safeWidth} Widths not saved`,
3430
+ `${safeLabel} ${safeWidth} Widths not saved`,
3431
+ "Widths not saved",
3432
+ ]
3433
+ : [
3434
+ `Width: ${safeLabel} ${safeWidth} Tab select <- -> resize r reset Esc done`,
3435
+ `Width: ${safeLabel} ${safeWidth} <- -> resize r reset Esc done`,
3436
+ `${safeLabel} ${safeWidth} <- -> r reset Esc done`,
3437
+ `${safeLabel} ${safeWidth} <- -> r Esc`,
3438
+ ];
3439
+ const fitting = variants.find((variant) => variant.length <= budget);
3440
+ return fitting ?? variants[variants.length - 1].slice(0, budget);
3441
+ }
3442
+
2343
3443
  // Compact drops the labels and keeps the keys, separated by a space:
2344
3444
  // "↑↓ Ent r q". No constant for its width -- nothing branches on it, and the
2345
3445
  // keys are short enough that it fits anywhere the frame itself does.
@@ -2356,7 +3456,37 @@ const FETCHING_WIDTH = 12;
2356
3456
  // finding at a glance, so they get the accent colour and the words describing
2357
3457
  // them stay dim. The accent is the panel-title cyan rather than the amber used
2358
3458
  // for in-progress status, so amber means exactly one thing across the product.
2359
- function StatusBar({ fetching, spin, stale, interactive, cols, remoteSetup = false }) {
3459
+ function StatusBar({
3460
+ fetching,
3461
+ spin,
3462
+ stale,
3463
+ throttle,
3464
+ interactive,
3465
+ cols,
3466
+ remoteSetup = false,
3467
+ widthMode = false,
3468
+ widthColumn = null,
3469
+ widthSaveError = null,
3470
+ }) {
3471
+ // Width mode owns the whole bar, so the throttle badge is deliberately not
3472
+ // shown here: this state is transient and explicitly entered, and the widened
3473
+ // interval is still reported the moment the user leaves it.
3474
+ if (widthMode && widthColumn) {
3475
+ return e(
3476
+ Box,
3477
+ { flexDirection: "row" },
3478
+ e(
3479
+ Text,
3480
+ { color: widthSaveError ? ATTENTION : undefined, wrap: "truncate-end" },
3481
+ widthStatusText({
3482
+ label: widthColumn.label,
3483
+ width: widthColumn.props.width,
3484
+ cols,
3485
+ saveError: Boolean(widthSaveError),
3486
+ }),
3487
+ ),
3488
+ );
3489
+ }
2360
3490
  // Without raw mode none of the key handlers run, so advertising them would be
2361
3491
  // telling the user something untrue about what the app can do. Ctrl+C still
2362
3492
  // works there, because the tty delivers a real SIGINT.
@@ -2393,6 +3523,12 @@ function StatusBar({ fetching, spin, stale, interactive, cols, remoteSetup = fal
2393
3523
  stale
2394
3524
  ? e(Box, { marginRight: 1, flexShrink: 0 }, e(Text, { color: ATTENTION }, stale))
2395
3525
  : null,
3526
+ // Same free-slot treatment as `stale` above. Dim rather than ATTENTION: a
3527
+ // widened interval is the throttle working, not a failure, and colouring it
3528
+ // like a problem would send the user looking for one.
3529
+ throttle
3530
+ ? e(Box, { marginRight: 1, flexShrink: 0 }, e(Text, { dimColor: true }, throttle))
3531
+ : null,
2396
3532
  ...hints
2397
3533
  .flatMap((hint, i) => [
2398
3534
  i > 0 &&
@@ -2462,21 +3598,75 @@ function useTerminalSize(stdout) {
2462
3598
  function App({ onCreateRemote = () => {} } = {}) {
2463
3599
  const { stdout } = useStdout();
2464
3600
  const { isRawModeSupported } = useStdin();
2465
- const { exit } = useApp();
3601
+ const { exit, suspendTerminal } = useApp();
2466
3602
  const [activeIndex, setActiveIndex] = useState(runtime.initialTabIndex);
3603
+ const [preferencePath] = useState(() => widthPreferencesPath());
3604
+ const [widthOverrides, setWidthOverrides] = useState(
3605
+ () => loadWidthPreferences(preferencePath).preferences,
3606
+ );
3607
+ const widthOverridesRef = useRef(widthOverrides);
3608
+ widthOverridesRef.current = widthOverrides;
3609
+ const [widthSaveError, setWidthSaveError] = useState(null);
3610
+ const widthPreferencesMountedRef = useRef(true);
3611
+ const [widthPreferenceWriter] = useState(() =>
3612
+ createWidthPreferenceWriter({
3613
+ write: (overrides) => saveWidthPreferences(preferencePath, overrides),
3614
+ onResult: (result) => {
3615
+ if (!widthPreferencesMountedRef.current) return;
3616
+ setWidthSaveError(
3617
+ result?.ok === false
3618
+ ? (result.error ?? new Error("Width preferences could not be saved"))
3619
+ : null,
3620
+ );
3621
+ },
3622
+ }),
3623
+ );
3624
+ useEffect(() => {
3625
+ widthPreferencesMountedRef.current = true;
3626
+ return () => {
3627
+ // saveWidthPreferences() itself runs synchronously inside dispose(), but
3628
+ // writer result reporting settles through a promise. Mark unmounted first
3629
+ // so that later callback cannot enqueue state on a departing App.
3630
+ widthPreferencesMountedRef.current = false;
3631
+ void widthPreferenceWriter.dispose();
3632
+ };
3633
+ }, [widthPreferenceWriter]);
3634
+ const [cachePath] = useState(() => dashboardCachePath());
3635
+ const [cacheTarget] = useState(() =>
3636
+ dashboardCacheTarget({
3637
+ repo: runtime.repo,
3638
+ ghRepo: process.env.GH_REPO,
3639
+ host: runtime.host ?? process.env.GH_HOST,
3640
+ cwd: process.cwd(),
3641
+ }),
3642
+ );
3643
+ const [loadedCache] = useState(() => loadDashboardCache(cachePath, cacheTarget));
3644
+ const dashboardCacheRef = useRef(loadedCache.cache);
3645
+ const dashboardCacheTargetRef = useRef(cacheTarget);
3646
+ const [dashboardCacheWriter] = useState(() =>
3647
+ createCoalescedWriter({
3648
+ write: (cache) => saveDashboardCache(cachePath, cache, { normalized: true }),
3649
+ }),
3650
+ );
3651
+ useEffect(() => () => {
3652
+ // Production writes start synchronously, so a pending latest snapshot is
3653
+ // not lost on quit. The cache is bounded; ignoring the advisory result keeps
3654
+ // a filesystem error from replacing terminal teardown.
3655
+ void dashboardCacheWriter.dispose();
3656
+ }, [dashboardCacheWriter]);
3657
+ const cachedEntry = loadedCache.entry;
2467
3658
  // `null` means "never resolved" -- distinct from `[]`, which means "resolved
2468
3659
  // and genuinely empty". The tab bar and the body render those differently.
2469
- const [data, setData] = useState({ actions: null, issues: null, prs: null, security: null });
2470
- const [meta, setMeta] = useState({
2471
- actions: null,
2472
- issues: null,
2473
- prs: null,
2474
- security: null,
2475
- });
2476
- const [securityNotes, setSecurityNotes] = useState([]);
3660
+ const [data, setData] = useState(() =>
3661
+ Object.fromEntries(TABS.map((candidate) => [candidate.key, cachedEntry?.tabs[candidate.key]?.data ?? null])),
3662
+ );
3663
+ const [meta, setMeta] = useState(() =>
3664
+ Object.fromEntries(TABS.map((candidate) => [candidate.key, cachedEntry?.tabs[candidate.key]?.meta ?? null])),
3665
+ );
3666
+ const [securityNotes, setSecurityNotes] = useState(() => cachedEntry?.securityNotes ?? []);
2477
3667
  // Whether the Security tab is currently unable to see its endpoints, as
2478
3668
  // opposed to seeing that they are switched off. Drives the count marker.
2479
- const [securityBlind, setSecurityBlind] = useState(false);
3669
+ const [securityBlind, setSecurityBlind] = useState(() => cachedEntry?.securityBlind ?? false);
2480
3670
  // The `?` overlay. Renders only on a keypress, so consecutive idle frames are
2481
3671
  // still byte-identical and the redraw suppression is untouched.
2482
3672
  const [showHelp, setShowHelp] = useState(false);
@@ -2487,6 +3677,9 @@ function App({ onCreateRemote = () => {} } = {}) {
2487
3677
  const [errors, setErrors] = useState({ actions: null, issues: null, prs: null, security: null });
2488
3678
  const [failureContext, setFailureContext] = useState(null);
2489
3679
  const [loading, setLoading] = useState({ actions: true, issues: true, prs: true, security: true });
3680
+ // The widened poll interval in ms while the adaptive throttle is holding one,
3681
+ // or null at the configured refresh. Only ever set from inside the poll effect.
3682
+ const [throttleMs, setThrottleMs] = useState(null);
2490
3683
  const [now, setNow] = useState(new Date());
2491
3684
  const { rows, cols, useShortLabels } = useTerminalSize(stdout);
2492
3685
  const [frame, setFrame] = useState(0);
@@ -2495,6 +3688,8 @@ function App({ onCreateRemote = () => {} } = {}) {
2495
3688
  // renders byte-identical frames and ink still writes nothing.
2496
3689
  const [selected, setSelected] = useState({});
2497
3690
  const [offset, setOffset] = useState({});
3691
+ const [widthMode, setWidthMode] = useState(false);
3692
+ const [selectedWidthKeyByTab, setSelectedWidthKeyByTab] = useState({});
2498
3693
 
2499
3694
  const tab = TABS[activeIndex];
2500
3695
  const tabError = errors[tab.key];
@@ -2559,12 +3754,18 @@ function App({ onCreateRemote = () => {} } = {}) {
2559
3754
 
2560
3755
  const inFlightRef = useRef({});
2561
3756
  const rawRef = useRef({});
2562
- const cleanRef = useRef({});
2563
3757
  // Last *successful* poll per tab, wall-clock. Wall-clock on purpose: a laptop
2564
3758
  // sleeping is exactly the gap this is meant to report, and a monotonic clock
2565
3759
  // does not advance across suspend. Never written on the failure path, or a
2566
3760
  // persistently failing tab would report itself fresh forever.
2567
- const lastOkRef = useRef({});
3761
+ const lastOkRef = useRef(
3762
+ Object.fromEntries(
3763
+ TABS.flatMap((candidate) => {
3764
+ const lastOk = cachedEntry?.tabs[candidate.key]?.lastOk;
3765
+ return lastOk == null ? [] : [[candidate.key, lastOk]];
3766
+ }),
3767
+ ),
3768
+ );
2568
3769
  const fetchTabRef = useRef(null);
2569
3770
  const contextCoordinatorRef = useRef(null);
2570
3771
 
@@ -2590,6 +3791,185 @@ function App({ onCreateRemote = () => {} } = {}) {
2590
3791
  showHelpRef.current = showHelp;
2591
3792
  const remoteSetupRef = useRef(false);
2592
3793
  remoteSetupRef.current = remoteSetup;
3794
+ const headerRef = useRef(null);
3795
+ const dragRef = useRef(null);
3796
+ const resizeRef = useRef({
3797
+ active: false,
3798
+ tabKey: "actions",
3799
+ selectedKey: null,
3800
+ effectiveHeader: null,
3801
+ frameCols: 0,
3802
+ compact: true,
3803
+ fullHeaderVisible: false,
3804
+ });
3805
+
3806
+ useEffect(() => {
3807
+ if (!interactive) return;
3808
+ enableMouseReporting();
3809
+ return () => {
3810
+ dragRef.current = null;
3811
+ disableMouseReporting();
3812
+ };
3813
+ }, [interactive]);
3814
+
3815
+ function applyWidthOverrides(next) {
3816
+ const current = widthOverridesRef.current;
3817
+ if (next === current) return false;
3818
+ widthOverridesRef.current = next;
3819
+ setWidthOverrides(next);
3820
+ // Schedule in the input turn, not a post-render effect. Reset, mode exit and
3821
+ // quit all flush immediately, and must not race ahead of React committing the
3822
+ // state that contains the latest width.
3823
+ widthPreferenceWriter.schedule(next);
3824
+
3825
+ const geometry = resizeRef.current;
3826
+ const activeTab = tabForKey(geometry.tabKey);
3827
+ if (activeTab) {
3828
+ const nextTabOverrides = pick(next, activeTab.key, EMPTY_WIDTH_OVERRIDES) ??
3829
+ EMPTY_WIDTH_OVERRIDES;
3830
+ const effective = effectiveHeaderFor(activeTab, nextTabOverrides, geometry.frameCols);
3831
+ resizeRef.current = {
3832
+ ...geometry,
3833
+ effectiveHeader: effective,
3834
+ compact: effective == null,
3835
+ fullHeaderVisible: geometry.fullHeaderVisible && effective != null,
3836
+ };
3837
+ }
3838
+ return true;
3839
+ }
3840
+
3841
+ function flushWidthPreferences() {
3842
+ void widthPreferenceWriter.flush();
3843
+ }
3844
+
3845
+ function leaveWidthMode() {
3846
+ dragRef.current = null;
3847
+ resizeRef.current = { ...resizeRef.current, active: false };
3848
+ setWidthMode(false);
3849
+ flushWidthPreferences();
3850
+ }
3851
+
3852
+ function rememberWidthSelection(key) {
3853
+ if (key === null) return;
3854
+ const tabKey = resizeRef.current.tabKey;
3855
+ resizeRef.current = { ...resizeRef.current, selectedKey: key };
3856
+ setSelectedWidthKeyByTab((current) =>
3857
+ current[tabKey] === key ? current : { ...current, [tabKey]: key },
3858
+ );
3859
+ }
3860
+
3861
+ function enterWidthMode(requestedKey = null) {
3862
+ const geometry = resizeRef.current;
3863
+ if (!geometry.fullHeaderVisible || geometry.compact) return false;
3864
+ const activeTab = tabForKey(geometry.tabKey);
3865
+ const selectedKey = selectWidthKey(activeTab, requestedKey ?? geometry.selectedKey);
3866
+ if (selectedKey === null) return false;
3867
+ rememberWidthSelection(selectedKey);
3868
+ resizeRef.current = { ...resizeRef.current, active: true, selectedKey };
3869
+ setWidthMode(true);
3870
+ return true;
3871
+ }
3872
+
3873
+ function resizeSelectedWidth(delta) {
3874
+ const geometry = resizeRef.current;
3875
+ if (!geometry.active || !geometry.fullHeaderVisible || geometry.compact) return;
3876
+ const activeTab = tabForKey(geometry.tabKey);
3877
+ const column = geometry.effectiveHeader?.find(
3878
+ (candidate) => candidate.key === geometry.selectedKey,
3879
+ );
3880
+ if (!activeTab || !isAdjustableWidthColumn(column)) return;
3881
+ const next = updateWidthPreference({
3882
+ overrides: widthOverridesRef.current,
3883
+ tab: activeTab,
3884
+ key: geometry.selectedKey,
3885
+ nextWidth: column.props.width + delta,
3886
+ effectiveHeader: geometry.effectiveHeader,
3887
+ frameCols: geometry.frameCols,
3888
+ });
3889
+ applyWidthOverrides(next);
3890
+ }
3891
+
3892
+ function resetSelectedWidth() {
3893
+ dragRef.current = null;
3894
+ const { tabKey, selectedKey } = resizeRef.current;
3895
+ applyWidthOverrides(resetWidthPreference(widthOverridesRef.current, tabKey, selectedKey));
3896
+ flushWidthPreferences();
3897
+ }
3898
+
3899
+ function resetActiveTabWidths() {
3900
+ dragRef.current = null;
3901
+ const { tabKey } = resizeRef.current;
3902
+ applyWidthOverrides(resetTabWidthPreferences(widthOverridesRef.current, tabKey));
3903
+ flushWidthPreferences();
3904
+ }
3905
+
3906
+ const cancelWidthDrag = useCallback(() => {
3907
+ if (dragRef.current === null) return false;
3908
+ dragRef.current = null;
3909
+ void widthPreferenceWriter.flush();
3910
+ return true;
3911
+ }, [widthPreferenceWriter]);
3912
+
3913
+ const measuredHeader = useCallback(() => {
3914
+ return headerRef.current ? measureElement(headerRef.current) : null;
3915
+ }, []);
3916
+
3917
+ function handleSgrMouse(event) {
3918
+ if (event.action === "release") {
3919
+ dragRef.current = null;
3920
+ flushWidthPreferences();
3921
+ return;
3922
+ }
3923
+
3924
+ const geometry = resizeRef.current;
3925
+ if (event.action === "press") {
3926
+ if (!interactive || !geometry.fullHeaderVisible || geometry.compact) return;
3927
+ const metrics = measuredHeader();
3928
+ if (!metrics) return;
3929
+ const drag = beginDividerDrag({
3930
+ event,
3931
+ handles: dividerHandles({ header: geometry.effectiveHeader, metrics }),
3932
+ tabKey: geometry.tabKey,
3933
+ });
3934
+ if (!drag || !enterWidthMode(drag.key)) return;
3935
+ dragRef.current = { ...drag, metrics };
3936
+ return;
3937
+ }
3938
+
3939
+ const drag = dragRef.current;
3940
+ if (!drag) return;
3941
+ const metrics = measuredHeader();
3942
+ const layoutValid = sameElementMetrics(drag.metrics, metrics);
3943
+ const proposal = draggedWidth({
3944
+ drag,
3945
+ event,
3946
+ tabKey: geometry.tabKey,
3947
+ fullHeaderVisible: geometry.fullHeaderVisible,
3948
+ layoutValid,
3949
+ });
3950
+ if (!proposal) {
3951
+ if (!layoutValid || drag.tabKey !== geometry.tabKey || !geometry.fullHeaderVisible) {
3952
+ cancelWidthDrag();
3953
+ }
3954
+ return;
3955
+ }
3956
+
3957
+ const activeTab = tabForKey(geometry.tabKey);
3958
+ if (!activeTab) {
3959
+ cancelWidthDrag();
3960
+ return;
3961
+ }
3962
+ applyWidthOverrides(
3963
+ updateWidthPreference({
3964
+ overrides: widthOverridesRef.current,
3965
+ tab: activeTab,
3966
+ key: proposal.key,
3967
+ nextWidth: proposal.nextWidth,
3968
+ effectiveHeader: geometry.effectiveHeader,
3969
+ frameCols: geometry.frameCols,
3970
+ }),
3971
+ );
3972
+ }
2593
3973
 
2594
3974
  function moveSelection(delta) {
2595
3975
  const { items, key: currentKey, bodyRows: rows_, tabKey, offset: offsetRaw } = navRef.current;
@@ -2665,7 +4045,38 @@ function App({ onCreateRemote = () => {} } = {}) {
2665
4045
 
2666
4046
  useInput(
2667
4047
  (input, key) => {
2668
- if (input === "q" || key.escape) {
4048
+ // Ink delivers a complete unrecognised CSI token with its leading Escape
4049
+ // removed. Consume the whole SGR namespace here, including unsupported
4050
+ // buttons/modifiers, so no report can fall through to keyboard bindings.
4051
+ if (input.startsWith("[<")) {
4052
+ const mouse = parseSgrMouse(input);
4053
+ if (mouse) handleSgrMouse(mouse);
4054
+ return;
4055
+ }
4056
+ if (input === "q" || (input === "c" && key.ctrl)) {
4057
+ flushWidthPreferences();
4058
+ exit();
4059
+ } else if (resizeRef.current.active) {
4060
+ if (input === "w" || key.return || key.escape) {
4061
+ leaveWidthMode();
4062
+ } else if (key.tab) {
4063
+ const activeTab = tabForKey(resizeRef.current.tabKey);
4064
+ rememberWidthSelection(
4065
+ cycleWidthKey(activeTab, resizeRef.current.selectedKey, key.shift ? -1 : 1),
4066
+ );
4067
+ } else if (key.leftArrow) {
4068
+ resizeSelectedWidth(key.shift ? -5 : -1);
4069
+ } else if (key.rightArrow) {
4070
+ resizeSelectedWidth(key.shift ? 5 : 1);
4071
+ } else if (input === "r") {
4072
+ resetSelectedWidth();
4073
+ } else if (input === "R") {
4074
+ resetActiveTabWidths();
4075
+ }
4076
+ // Width mode owns every other key. In particular, digits, arrows, Tab,
4077
+ // Enter and r must never fall through to their ordinary meanings.
4078
+ } else if (key.escape) {
4079
+ flushWidthPreferences();
2669
4080
  exit();
2670
4081
  } else if (showHelpRef.current) {
2671
4082
  // Any key dismisses -- except quit, handled above, which must never be
@@ -2675,6 +4086,8 @@ function App({ onCreateRemote = () => {} } = {}) {
2675
4086
  setShowHelp(false);
2676
4087
  } else if (input === "?") {
2677
4088
  setShowHelp(true);
4089
+ } else if (input === "w") {
4090
+ enterWidthMode();
2678
4091
  } else if (key.downArrow || input === "j") {
2679
4092
  moveSelection(1);
2680
4093
  } else if (key.upArrow || input === "k") {
@@ -2684,7 +4097,7 @@ function App({ onCreateRemote = () => {} } = {}) {
2684
4097
  } else if (key.pageUp) {
2685
4098
  moveSelection(-pageStep);
2686
4099
  } else if (key.return) {
2687
- if (remoteSetupRef.current) onCreateRemote();
4100
+ if (remoteSetupRef.current) onCreateRemote(suspendTerminal);
2688
4101
  else openSelected();
2689
4102
  } else if (input === "r") {
2690
4103
  // Goes through the same per-tab in-flight guard as the poll loop, so
@@ -2719,10 +4132,17 @@ function App({ onCreateRemote = () => {} } = {}) {
2719
4132
  // -- the behaviour the per-tab guard and the fetch-time limit read were
2720
4133
  // written to prevent. Do not "fix" this by adding them.
2721
4134
  //
2722
- // exhaustive-deps is satisfied as written -- every captured value is either a
2723
- // ref or a setState function, both of which the rule treats as stable -- so
2724
- // this needs no suppression, and if one ever becomes necessary that is the
2725
- // signal that a real dependency crept in.
4135
+ // exhaustive-deps is satisfied as written -- every captured value is a ref,
4136
+ // a setState function, or the stable cache writer created once above. If a
4137
+ // changing dependency ever becomes necessary, that is the signal that the
4138
+ // mount-only contract has been broken.
4139
+ //
4140
+ // The adaptive throttle re-arms the timer, which is not a contradiction of the
4141
+ // above: `rearm` swaps the interval from *inside* this effect, so the effect
4142
+ // still runs exactly once and no closure, ref or AbortController is ever
4143
+ // recreated. What is created once is the effect; what changes is only the
4144
+ // delay `tick` is scheduled at. Do not move the re-arm into a dependency
4145
+ // array -- that is precisely the teardown this comment exists to prevent.
2726
4146
  useEffect(() => {
2727
4147
  let cancelled = false;
2728
4148
  let ticks = 0;
@@ -2746,6 +4166,32 @@ function App({ onCreateRemote = () => {} } = {}) {
2746
4166
  });
2747
4167
  contextCoordinatorRef.current = coordinator;
2748
4168
 
4169
+ function cacheSuccessfulTab(key, tabData, tabMeta, lastOk, security = {}) {
4170
+ const target = dashboardCacheTargetRef.current;
4171
+ const currentCache = dashboardCacheRef.current;
4172
+ const currentEntry = pick(currentCache, target, null) ?? {
4173
+ tabs: {},
4174
+ securityNotes: [],
4175
+ securityBlind: false,
4176
+ updatedAt: lastOk,
4177
+ };
4178
+ const nextEntry = {
4179
+ ...currentEntry,
4180
+ tabs: {
4181
+ ...currentEntry.tabs,
4182
+ [key]: { data: tabData, meta: tabMeta, lastOk },
4183
+ },
4184
+ securityNotes:
4185
+ key === "security" ? (security.notes ?? []) : currentEntry.securityNotes,
4186
+ securityBlind:
4187
+ key === "security" ? Boolean(security.blind) : currentEntry.securityBlind,
4188
+ updatedAt: lastOk,
4189
+ };
4190
+ const nextCache = mergeDashboardCacheEntry(currentCache, target, nextEntry);
4191
+ dashboardCacheRef.current = nextCache;
4192
+ dashboardCacheWriter.schedule(nextCache);
4193
+ }
4194
+
2749
4195
  // Each tab commits its own result the moment it lands instead of waiting on
2750
4196
  // a Promise.allSettled barrier. Actions is by far the slowest fetch, so
2751
4197
  // barrelling everything together meant the three fast tabs sat invisible
@@ -2759,6 +4205,10 @@ function App({ onCreateRemote = () => {} } = {}) {
2759
4205
  setLoading((l) => (l[key] ? l : { ...l, [key]: true }));
2760
4206
  return run()
2761
4207
  .then((result) => {
4208
+ // Billed before the `cancelled` early return: the requests were made
4209
+ // and counted by GitHub whether or not this process still wants the
4210
+ // answer.
4211
+ restSpentTotal += result?.restSpent ?? 0;
2762
4212
  if (cancelled) return;
2763
4213
  const { raw, parse, limit } = result;
2764
4214
  // Identical payload: skip the parse *and* the state update. Returning
@@ -2780,36 +4230,58 @@ function App({ onCreateRemote = () => {} } = {}) {
2780
4230
  // completely healthy. A ref rather than state because writing state here
2781
4231
  // would allocate a new object every tick and permanently defeat the
2782
4232
  // React bail-out this early return exists to preserve.
2783
- lastOkRef.current[key] = Date.now();
2784
- // Clear on the first success or a single failure latches the ladder.
2785
- clearBackoff(`tab:${key}`);
2786
- if (rawRef.current[key] === raw && cleanRef.current[key]) {
4233
+ const completedAt = Date.now();
4234
+ if (rawRef.current[key] === raw) {
4235
+ lastOkRef.current[key] = completedAt;
4236
+ // Clear on the first success or a single failure latches the ladder.
4237
+ clearBackoff(`tab:${key}`);
2787
4238
  setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
2788
4239
  return;
2789
4240
  }
2790
4241
  const value = parse();
2791
- rawRef.current[key] = raw;
2792
- cleanRef.current[key] = true;
4242
+ // Security fetches resolve each source independently so their notes
4243
+ // remain visible. A blind result is still a failed observation: it
4244
+ // must not replace known alerts with a false empty state or advance
4245
+ // freshness. Do not retain its raw value either, so the next source
4246
+ // retry is parsed instead of taking the identical-payload fast path.
4247
+ if (key === "security" && value.blind) {
4248
+ rawRef.current[key] = nextSecurityRaw(rawRef.current[key], raw, true);
4249
+ setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
4250
+ setSecurityNotes(value.notes ?? []);
4251
+ setSecurityBlind(true);
4252
+ return;
4253
+ }
4254
+ const tabData = value.alerts ?? value;
4255
+ const tabMeta = {
4256
+ at: completedAt,
4257
+ // Compare against the limit this payload was actually fetched
4258
+ // with. Comparing against the live pane-height ref made the "n+"
4259
+ // marker lie for one cycle after every resize.
4260
+ truncated: value.truncated ?? tabData.length >= limit,
4261
+ };
4262
+ lastOkRef.current[key] = completedAt;
4263
+ clearBackoff(`tab:${key}`);
4264
+ rawRef.current[key] =
4265
+ key === "security" ? nextSecurityRaw(rawRef.current[key], raw, false) : raw;
2793
4266
  setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
2794
- setData((d) => ({ ...d, [key]: value.alerts ?? value }));
2795
- setMeta((m) => ({
2796
- ...m,
2797
- [key]: {
2798
- at: Date.now(),
2799
- // Compare against the limit this payload was actually fetched
2800
- // with. Comparing against the live pane-height ref made the "n+"
2801
- // marker lie for one cycle after every resize.
2802
- truncated: value.truncated ?? (value.alerts ?? value).length >= limit,
2803
- },
2804
- }));
4267
+ setData((d) => ({ ...d, [key]: tabData }));
4268
+ setMeta((m) => ({ ...m, [key]: tabMeta }));
2805
4269
  if (value.notes) setSecurityNotes(value.notes);
2806
4270
  if (key === "security") {
2807
4271
  setSecurityBlind((b) => (b === Boolean(value.blind) ? b : Boolean(value.blind)));
2808
4272
  }
4273
+ cacheSuccessfulTab(key, tabData, tabMeta, completedAt, {
4274
+ notes: value.notes,
4275
+ blind: value.blind,
4276
+ });
2809
4277
  })
2810
4278
  .catch((err) => {
4279
+ // A rejected fetch carries no result, so its own spend is unknowable
4280
+ // here; bill the tab's table cost instead. A request that failed after
4281
+ // reaching GitHub still counted, and over-billing by an aborted call
4282
+ // errs toward throttling harder, which is the safe direction.
4283
+ restSpentTotal += REST_PER_FETCH[key] ?? 0;
2811
4284
  if (cancelled || err?.name === "AbortError") return;
2812
- cleanRef.current[key] = false;
2813
4285
  // Preserve both the verdict and the bounded raw error in state. The
2814
4286
  // renderer translates recognized verdicts at draw time, which lets a
2815
4287
  // later repository/account context refine the one-line remedy without
@@ -2838,7 +4310,7 @@ function App({ onCreateRemote = () => {} } = {}) {
2838
4310
  // exactly the moment the pane looks broken and they reach for it.
2839
4311
  function fetchTab(key, { force = false } = {}) {
2840
4312
  const signal = controller.signal;
2841
- const descriptor = TABS.find((t) => t.key === key);
4313
+ const descriptor = tabForKey(key);
2842
4314
  if (!force && backoffActive(`tab:${key}`, performance.now())) return Promise.resolve();
2843
4315
  if (force) clearBackoff(`tab:${key}`);
2844
4316
  // Every tab carries its own fetcher on the registry, so adding a tab is a
@@ -2854,6 +4326,10 @@ function App({ onCreateRemote = () => {} } = {}) {
2854
4326
  // so stop spawning subprocesses once any endpoint proves there is no
2855
4327
  // remote. The interval may still wake, but this branch is otherwise idle.
2856
4328
  if (remoteSetupRef.current) return;
4329
+ // Kicked off, never awaited: the budget probe must not delay the fetches
4330
+ // this tick is for. `probeInFlight` keeps a slow probe from stampeding.
4331
+ const nowMs = Date.now();
4332
+ if (!probeInFlight && nowMs - lastProbeAt >= BUDGET_PROBE_MS) void probeBudget(nowMs);
2857
4333
  // Only the tab you're looking at needs to keep up with REFRESH_MS. The
2858
4334
  // other three exist to keep the tab-bar counts honest, which tolerates
2859
4335
  // being a minute behind -- so they refresh every BACKGROUND_EVERY ticks
@@ -2874,11 +4350,64 @@ function App({ onCreateRemote = () => {} } = {}) {
2874
4350
  );
2875
4351
  }
2876
4352
 
4353
+ // The adaptive throttle. `appliedMs` is what the timer is currently set to;
4354
+ // runtime.refreshMs stays the floor it may widen from and never tighten past.
4355
+ let appliedMs = runtime.refreshMs;
4356
+ let intervalId = null;
4357
+ // 0 rather than `now`, so the first tick probes immediately: a pane started
4358
+ // into an already-drained budget must back off at once rather than spend a
4359
+ // minute at full rate first.
4360
+ let lastProbeAt = 0;
4361
+ let probeInFlight = false;
4362
+ // The open probe window, and the last share it was able to measure. Both are
4363
+ // carried by nextProbeWindow/inferShare rather than reset every probe -- see
4364
+ // those functions for why a throttled pane must not re-measure from scratch.
4365
+ let probeWindow = null;
4366
+ let lastShare = 1;
4367
+
4368
+ function rearm(ms) {
4369
+ appliedMs = ms;
4370
+ if (intervalId) clearInterval(intervalId);
4371
+ intervalId = setInterval(tick, ms);
4372
+ }
4373
+
4374
+ async function probeBudget(nowMs) {
4375
+ probeInFlight = true;
4376
+ lastProbeAt = nowMs; // set before awaiting, so ticks cannot stampede
4377
+ try {
4378
+ // `controller` is this effect's own AbortController, so a probe still in
4379
+ // flight at unmount is killed along with everything else.
4380
+ const budget = await readCoreBudget(controller.signal);
4381
+ if (cancelled || !budget) return;
4382
+ const { sample, next } = nextProbeWindow(probeWindow, budget, restSpentTotal);
4383
+ probeWindow = next;
4384
+ // Updated before the law reads it. inferShare is idempotent, so passing
4385
+ // the already-updated value cannot change the answer -- it just keeps
4386
+ // one variable holding the current share rather than two.
4387
+ lastShare = inferShare(sample, lastShare);
4388
+
4389
+ const target = adaptiveRefreshMs({
4390
+ budget,
4391
+ sample,
4392
+ lastShare,
4393
+ floorMs: runtime.refreshMs,
4394
+ nowMs: Date.now(),
4395
+ restPerTick: restPerTick(TABS[activeIndexRef.current].key),
4396
+ });
4397
+ if (adaptiveChangeWorthApplying(appliedMs, target)) {
4398
+ rearm(target);
4399
+ setThrottleMs(target > runtime.refreshMs ? target : null);
4400
+ }
4401
+ } finally {
4402
+ probeInFlight = false;
4403
+ }
4404
+ }
4405
+
2877
4406
  tick();
2878
- const id = setInterval(tick, runtime.refreshMs);
4407
+ rearm(runtime.refreshMs);
2879
4408
  return () => {
2880
4409
  cancelled = true;
2881
- clearInterval(id);
4410
+ clearInterval(intervalId);
2882
4411
  if (contextCoordinatorRef.current === coordinator) contextCoordinatorRef.current = null;
2883
4412
  // `cancelled` stops state updates from a promise that already resolved;
2884
4413
  // the signal stops the subprocess itself, so quitting doesn't orphan up to
@@ -2886,7 +4415,7 @@ function App({ onCreateRemote = () => {} } = {}) {
2886
4415
  // needed.
2887
4416
  controller.abort();
2888
4417
  };
2889
- }, []);
4418
+ }, [dashboardCacheWriter]);
2890
4419
 
2891
4420
  // Background tabs can be up to BACKGROUND_EVERY ticks stale, so the tab you
2892
4421
  // switch to refreshes straight away rather than showing old data until its
@@ -3062,8 +4591,14 @@ function App({ onCreateRemote = () => {} } = {}) {
3062
4591
  // the cost -- different tabs showing different column counts at the same width
3063
4592
  // -- is not something you can actually see. MIN_TABLE_WIDTH stays as the
3064
4593
  // exported worst case, which is what the tests pin.
3065
- const compact = frameCols < minimumWidthFor(tab.header);
3066
- const header = compact ? tab.compactHeader : tab.header;
4594
+ const tabOverrides = pick(widthOverridesRef.current, tab.key, EMPTY_WIDTH_OVERRIDES) ??
4595
+ EMPTY_WIDTH_OVERRIDES;
4596
+ const effectiveHeader = useMemo(
4597
+ () => effectiveHeaderFor(tab, tabOverrides, frameCols),
4598
+ [tab, tabOverrides, frameCols],
4599
+ );
4600
+ const compact = effectiveHeader == null;
4601
+ const header = compact ? tab.compactHeader : effectiveHeader;
3067
4602
 
3068
4603
  // Below the compact set's own floor even the fixed columns overflow, which
3069
4604
  // hard-wraps every row and drives ink into clearing and repainting the whole
@@ -3072,10 +4607,64 @@ function App({ onCreateRemote = () => {} } = {}) {
3072
4607
  // sits after usableSize(), which substitutes DEFAULT_COLS for a 0 reported
3073
4608
  // mid-resize, so a transient zero cannot be mistaken for a genuinely tiny pane.
3074
4609
  const tooNarrow = frameCols < MIN_COMPACT_WIDTH;
4610
+ const fullHeaderVisible = !compact && !showHelp && !tooNarrow && !remoteSetup;
4611
+ const selectedWidthKey = selectedWidthKeyByTab[tab.key] ?? null;
4612
+ const selectedWidthColumn = effectiveHeader?.find(
4613
+ (column) => column.key === selectedWidthKey,
4614
+ ) ?? null;
4615
+ resizeRef.current = {
4616
+ active: widthMode && fullHeaderVisible && selectedWidthColumn !== null,
4617
+ tabKey: tab.key,
4618
+ selectedKey: selectedWidthKey,
4619
+ effectiveHeader,
4620
+ frameCols,
4621
+ compact,
4622
+ fullHeaderVisible,
4623
+ };
4624
+
4625
+ // A resize, help/setup transition, or replacement layout can remove the full
4626
+ // header without a keypress. Stop owning input immediately through resizeRef,
4627
+ // then settle the visible mode state and durable write in the effect.
4628
+ useEffect(() => {
4629
+ let flushed = false;
4630
+ const drag = dragRef.current;
4631
+ if (drag) {
4632
+ const metrics = measuredHeader();
4633
+ if (
4634
+ drag.tabKey !== tab.key ||
4635
+ !fullHeaderVisible ||
4636
+ !sameElementMetrics(drag.metrics, metrics)
4637
+ ) {
4638
+ flushed = cancelWidthDrag();
4639
+ }
4640
+ }
4641
+ if (!widthMode || fullHeaderVisible) return;
4642
+ setWidthMode(false);
4643
+ if (!flushed) void widthPreferenceWriter.flush();
4644
+ }, [
4645
+ widthMode,
4646
+ fullHeaderVisible,
4647
+ tab.key,
4648
+ frameCols,
4649
+ extraLines,
4650
+ cancelWidthDrag,
4651
+ measuredHeader,
4652
+ widthPreferenceWriter,
4653
+ ]);
4654
+
4655
+ // Keep one physical row below Ink's output, the vertical counterpart to the
4656
+ // spare column in `frameCols`. Incremental rendering assumes its cursor still
4657
+ // sits at the bottom of the previous frame; if dynamic content occupies the
4658
+ // terminal's last row, a terminal scroll can invalidate that assumption and
4659
+ // leave old status lines behind. A non-fullscreen frame gets a trailing
4660
+ // newline from Ink, so the cursor parks on the unused guard row while the
4661
+ // status bar remains one row above the scroll edge.
4662
+ const liveRows = Math.min(rows, usableSize(stdout?.rows, rows));
4663
+ const frameRows = Math.max(1, liveRows - 1);
3075
4664
 
3076
4665
  return e(
3077
4666
  Box,
3078
- { flexDirection: "column", width: frameCols, height: Math.min(rows, usableSize(stdout?.rows, rows)) },
4667
+ { flexDirection: "column", width: frameCols, height: frameRows },
3079
4668
  e(TabBar, {
3080
4669
  activeIndex,
3081
4670
  counts,
@@ -3148,7 +4737,14 @@ function App({ onCreateRemote = () => {} } = {}) {
3148
4737
  line,
3149
4738
  ),
3150
4739
  ),
3151
- !showHelp && !tooNarrow && !remoteSetup && e(HeaderCells, { cells: header }),
4740
+ !showHelp &&
4741
+ !tooNarrow &&
4742
+ !remoteSetup &&
4743
+ e(MemoHeaderCells, {
4744
+ cells: header,
4745
+ selectedWidthKey: resizeRef.current.active ? selectedWidthKey : null,
4746
+ headerRef,
4747
+ }),
3152
4748
  ...(tooNarrow || showHelp || remoteSetup ? [] : visibleItems).map((item) => {
3153
4749
  const key = itemKey(item);
3154
4750
  return e(
@@ -3158,6 +4754,7 @@ function App({ onCreateRemote = () => {} } = {}) {
3158
4754
  item,
3159
4755
  now,
3160
4756
  compact,
4757
+ columns: header,
3161
4758
  cursor: key === selectedKey,
3162
4759
  // Only an *executing* Actions row reads this. It used to go to every
3163
4760
  // visible row on the tab, so a prop that changes several times a
@@ -3208,9 +4805,13 @@ function App({ onCreateRemote = () => {} } = {}) {
3208
4805
  fetching: Object.values(loading).some(Boolean),
3209
4806
  spin: showSpinner ? spin : null,
3210
4807
  stale: staleLabel,
4808
+ throttle: throttleMs ? `throttled ${Math.round(throttleMs / 1000)}s` : null,
3211
4809
  interactive,
3212
4810
  cols: frameCols,
3213
4811
  remoteSetup,
4812
+ widthMode: resizeRef.current.active,
4813
+ widthColumn: selectedWidthColumn,
4814
+ widthSaveError,
3214
4815
  }),
3215
4816
  );
3216
4817
  }
@@ -3224,16 +4825,58 @@ function enterAlternateScreen() {
3224
4825
  process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H");
3225
4826
  }
3226
4827
 
3227
- // Idempotent by construction: writing the exit sequence while already on the
3228
- // primary buffer is a no-op, which matters because this runs both from the
3229
- // crash handlers and from the `exit` listener. `?25h` is here because ink only
3230
- // restores cursor visibility through its own unmount path, which an explicit
3231
- // process.exit() skips.
3232
- let screenRestored = false;
4828
+ function createTerminalLifecycle(write) {
4829
+ if (typeof write !== "function") throw new TypeError("write must be a function");
4830
+ let mouseReportingEnabled = false;
4831
+ let screenRestored = false;
4832
+
4833
+ function enableMouseReporting() {
4834
+ if (mouseReportingEnabled || screenRestored) return false;
4835
+ write("\x1b[?1002h\x1b[?1006h");
4836
+ mouseReportingEnabled = true;
4837
+ return true;
4838
+ }
4839
+
4840
+ function disableMouseReporting() {
4841
+ if (!mouseReportingEnabled) return false;
4842
+ write("\x1b[?1002l\x1b[?1006l");
4843
+ mouseReportingEnabled = false;
4844
+ return true;
4845
+ }
4846
+
4847
+ function restoreScreen() {
4848
+ if (screenRestored) return false;
4849
+ disableMouseReporting();
4850
+ screenRestored = true;
4851
+ // Ink restores the cursor only through its own unmount path, which an
4852
+ // explicit process.exit() skips.
4853
+ write("\x1b[?25h\x1b[?1049l");
4854
+ return true;
4855
+ }
4856
+
4857
+ return {
4858
+ enableMouseReporting,
4859
+ disableMouseReporting,
4860
+ restoreScreen,
4861
+ isMouseReportingEnabled: () => mouseReportingEnabled,
4862
+ };
4863
+ }
4864
+
4865
+ const terminalLifecycle = createTerminalLifecycle((output) => process.stdout.write(output));
4866
+
4867
+ function enableMouseReporting() {
4868
+ return terminalLifecycle.enableMouseReporting();
4869
+ }
4870
+
4871
+ function disableMouseReporting() {
4872
+ return terminalLifecycle.disableMouseReporting();
4873
+ }
4874
+
4875
+ // Idempotent because both the lifecycle controller and this process-level
4876
+ // wrapper share one restored state. Mouse modes are always disabled before the
4877
+ // alternate buffer is released, including when this is only the exit backstop.
3233
4878
  function restoreScreen() {
3234
- if (screenRestored) return;
3235
- screenRestored = true;
3236
- process.stdout.write("\x1b[?25h\x1b[?1049l");
4879
+ return terminalLifecycle.restoreScreen();
3237
4880
  }
3238
4881
 
3239
4882
  // A crash used to be indistinguishable from a clean quit. Ink catches render
@@ -3242,18 +4885,18 @@ function restoreScreen() {
3242
4885
  // simply vanished and any wrapper saw success. Restore the primary buffer
3243
4886
  // *first*, then write, or the fix reproduces the problem it is fixing.
3244
4887
  //
3245
- // Aborting in-flight `gh` children is the poll effect's job on every other exit
3246
- // path, but its cleanup only runs through ink's unmount -- which an explicit
3247
- // process.exit() skips. Without this, a crash orphaned up to six subprocesses
3248
- // for the length of GH_TIMEOUT_MS. The abort runs *after* restoreScreen() and is
3249
- // wrapped, because an exception thrown here would replace the stack trace this
3250
- // handler exists to print with an unrelated one.
4888
+ // Unmount before restore so Ink's final repaint stays in the alternate buffer.
4889
+ // A crash before app assignment, or another failure during unmount, can skip the
4890
+ // poll effect's cleanup, so abortLiveRequests() remains an explicit backstop.
4891
+ // It runs *after* restoreScreen() and is wrapped because an exception there must
4892
+ // not replace the stack trace this handler exists to print.
3251
4893
  //
3252
4894
  // Both messages go through redact(): a stack can carry a URL with inline
3253
4895
  // credentials, and this output is what a user pastes into a bug report -- the
3254
4896
  // same reasoning --doctor already applies to its own report.
3255
- function installCrashHandlers() {
4897
+ function installCrashHandlers(unmountApp) {
3256
4898
  const fail = (label) => (err) => {
4899
+ unmountApp();
3257
4900
  restoreScreen();
3258
4901
  try {
3259
4902
  abortLiveRequests();
@@ -3269,8 +4912,16 @@ function installCrashHandlers() {
3269
4912
  }
3270
4913
 
3271
4914
  if (IS_MAIN) {
4915
+ let app;
4916
+ const unmountApp = () => {
4917
+ try {
4918
+ app?.unmount();
4919
+ } catch {
4920
+ // Teardown is best effort. Every caller still restores the terminal.
4921
+ }
4922
+ };
3272
4923
  disarmDevBuildLeak();
3273
- installCrashHandlers();
4924
+ installCrashHandlers(unmountApp);
3274
4925
  enterAlternateScreen();
3275
4926
  process.on("exit", restoreScreen);
3276
4927
 
@@ -3284,40 +4935,71 @@ if (IS_MAIN) {
3284
4935
  // root box is the terminal height. Windows is already documented as untested
3285
4936
  // (README Limitations), and the pty harness covers the two platforms that are
3286
4937
  // supported, so the flag is verified where it is claimed to work.
3287
- let app;
3288
4938
  let remoteSetupStarted = false;
3289
- const createRemote = () => {
4939
+ const createRemote = (suspendTerminal) => {
3290
4940
  if (remoteSetupStarted) return;
3291
4941
  remoteSetupStarted = true;
3292
- try {
3293
- app?.unmount();
3294
- } catch {
3295
- // The interactive gh flow matters more than a best-effort final repaint.
3296
- }
3297
- restoreScreen();
3298
- abortLiveRequests();
3299
-
3300
- // Plain `gh repo create` is the interactive form. Supplying --source here
3301
- // would switch gh to non-interactive mode and require gh-glance to choose a
3302
- // visibility on the user's behalf, which this consent boundary must never
3303
- // do. The prompt tells the user which interactive path matches this folder.
3304
- const child = spawn("gh", ["repo", "create"], {
3305
- stdio: "inherit",
3306
- env: process.env,
3307
- });
3308
- setupChild = child;
3309
- child.once("error", (err) => {
3310
- if (setupChild === child) setupChild = null;
3311
- console.error(`gh-glance: could not start repository setup: ${redact(shortErr(err))}`);
3312
- process.exitCode = 1;
3313
- });
3314
- child.once("exit", (code, signal) => {
3315
- if (setupChild === child) setupChild = null;
3316
- process.exitCode = signal ? 1 : (code ?? 1);
3317
- if (code === 0) {
3318
- console.log("gh repo create finished. Run gh-glance again when this folder has a remote.");
3319
- }
3320
- });
4942
+
4943
+ // Release Ink's raw-mode/parser state before the child inherits stdin.
4944
+ // Unmounting alone can leave an active readable dispatch racing the first
4945
+ // bytes typed for gh, so one immediate boundary lets that dispatch return;
4946
+ // the explicit stream cleanup below then prevents any future parent reads.
4947
+ void suspendTerminal()
4948
+ .then(async () => {
4949
+ await new Promise((resolve) => setImmediate(resolve));
4950
+ unmountApp();
4951
+ // This is a permanent handoff, so the parent must not retain any stream
4952
+ // consumer that can race the child for terminal bytes. Pausing/removing
4953
+ // Node listeners does not close fd 0; the spawned process still inherits
4954
+ // the same canonical TTY directly from the operating system.
4955
+ process.stdin.pause();
4956
+ process.stdin.removeAllListeners("readable");
4957
+ process.stdin.removeAllListeners("data");
4958
+ restoreScreen();
4959
+ abortLiveRequests();
4960
+
4961
+ await new Promise((resolve) => {
4962
+ // Plain `gh repo create` is the interactive form. Supplying --source
4963
+ // here would switch gh to non-interactive mode and require gh-glance
4964
+ // to choose a visibility on the user's behalf, which this consent
4965
+ // boundary must never do. The prompt tells the user which interactive
4966
+ // path matches this folder.
4967
+ const child = spawn("gh", ["repo", "create"], {
4968
+ stdio: "inherit",
4969
+ env: process.env,
4970
+ });
4971
+ setupChild = child;
4972
+ child.once("error", (err) => {
4973
+ if (setupChild === child) setupChild = null;
4974
+ console.error(
4975
+ `gh-glance: could not start repository setup: ${redact(shortErr(err))}`,
4976
+ );
4977
+ process.exitCode = 1;
4978
+ resolve();
4979
+ });
4980
+ child.once("exit", (code, signal) => {
4981
+ if (setupChild === child) setupChild = null;
4982
+ process.exitCode = signal ? 1 : (code ?? 1);
4983
+ if (code === 0) {
4984
+ console.log(
4985
+ "gh repo create finished. Run gh-glance again when this folder has a remote.",
4986
+ );
4987
+ }
4988
+ resolve();
4989
+ });
4990
+ });
4991
+ })
4992
+ .catch((err) => {
4993
+ unmountApp();
4994
+ restoreScreen();
4995
+ try {
4996
+ abortLiveRequests();
4997
+ } catch {
4998
+ // Preserve the handoff error below.
4999
+ }
5000
+ console.error(`gh-glance: could not hand off the terminal: ${redact(shortErr(err))}`);
5001
+ process.exitCode = 1;
5002
+ });
3321
5003
  };
3322
5004
  app = render(e(App, { onCreateRemote: createRemote }), { incrementalRendering: true });
3323
5005
 
@@ -3347,12 +5029,7 @@ if (IS_MAIN) {
3347
5029
  if (signalExitStarted) return;
3348
5030
  signalExitStarted = true;
3349
5031
  const finish = () => {
3350
- try {
3351
- app.unmount();
3352
- } catch {
3353
- // Teardown is best effort. restoreScreen below, and the `exit` listener
3354
- // above, both still run, so the terminal is handed back either way.
3355
- }
5032
+ unmountApp();
3356
5033
  restoreScreen();
3357
5034
  process.exit(code);
3358
5035
  };
@@ -3387,6 +5064,22 @@ export {
3387
5064
  MIN_REFRESH_SECONDS,
3388
5065
  MAX_REFRESH_SECONDS,
3389
5066
  TAB_KEYS,
5067
+ ALERT_SOURCES,
5068
+ REST_PER_FETCH,
5069
+ GRAPHQL_PER_FETCH,
5070
+ projectedHourlyCost,
5071
+ REFRESH_MS,
5072
+ BACKGROUND_EVERY,
5073
+ adaptiveRefreshMs,
5074
+ adaptiveChangeWorthApplying,
5075
+ sampleIsUsable,
5076
+ inferShare,
5077
+ nextProbeWindow,
5078
+ restPerTick,
5079
+ MAX_ADAPTIVE_REFRESH_MS,
5080
+ BUDGET_SAFETY,
5081
+ MIN_SAMPLE_CALLS,
5082
+ ADAPTIVE_HYSTERESIS,
3390
5083
  safe,
3391
5084
  shortErr,
3392
5085
  isUnavailable,
@@ -3411,17 +5104,51 @@ export {
3411
5104
  usableSize,
3412
5105
  severityRank,
3413
5106
  pick,
5107
+ columnProps,
5108
+ adjustableWidthKeys,
5109
+ selectWidthKey,
5110
+ cycleWidthKey,
5111
+ resolveHeader,
5112
+ fitHeaderToFrame,
5113
+ adjustWidth,
5114
+ updateWidthPreference,
5115
+ resetWidthPreference,
5116
+ resetTabWidthPreferences,
3414
5117
  minimumWidthFor,
5118
+ WIDTH_PREFERENCES_VERSION,
5119
+ widthPreferencesPath,
5120
+ parseWidthPreferences,
5121
+ serializeWidthPreferences,
5122
+ loadWidthPreferences,
5123
+ saveWidthPreferences,
5124
+ createWidthPreferenceWriter,
5125
+ DASHBOARD_CACHE_VERSION,
5126
+ dashboardCachePath,
5127
+ dashboardCacheTarget,
5128
+ serializeDashboardCache,
5129
+ loadDashboardCache,
5130
+ saveDashboardCache,
5131
+ nextSecurityRaw,
3415
5132
  runStatusIcon,
3416
5133
  RUN_STATUS_ICON,
3417
5134
  SEVERITY_STYLE,
3418
5135
  REVIEW_LABEL,
3419
5136
  MIN_TABLE_WIDTH,
3420
5137
  MIN_COMPACT_WIDTH,
5138
+ TABS,
3421
5139
  OCT_NERD,
3422
5140
  OCT_UNICODE,
3423
5141
  KEY_TABLE,
3424
5142
  KEY_HINTS,
5143
+ widthStatusText,
5144
+ headerGutterKey,
5145
+ HeaderCells,
5146
+ parseSgrMouse,
5147
+ dividerHandles,
5148
+ hitDivider,
5149
+ beginDividerDrag,
5150
+ draggedWidth,
5151
+ createTerminalLifecycle,
3425
5152
  REMOTE_SETUP_HINTS,
3426
5153
  REMOTE_SETUP_LINES,
3427
5154
  REMOTE_SETUP_NONINTERACTIVE_LINES,