gh-glance 0.6.1 → 0.7.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 +49 -0
  2. package/README.md +106 -26
  3. package/index.mjs +1565 -149
  4. package/package.json +1 -1
package/index.mjs CHANGED
@@ -23,7 +23,16 @@
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
+ mkdirSync,
28
+ readFileSync,
29
+ realpathSync,
30
+ renameSync,
31
+ unlinkSync,
32
+ writeFileSync,
33
+ } from "node:fs";
34
+ import { homedir } from "node:os";
35
+ import { dirname, isAbsolute, join } from "node:path";
27
36
  import { fileURLToPath } from "node:url";
28
37
  import { promisify } from "node:util";
29
38
 
@@ -108,6 +117,8 @@ const ALERT_QUERY = `?state=open&per_page=${ALERT_PER_PAGE}&sort=created&directi
108
117
  // single pane. Only the tab you are looking at needs REFRESH_MS latency; the
109
118
  // other three exist to keep counts honest, which tolerates a minute.
110
119
  const BACKGROUND_EVERY = 12;
120
+ // What one fetch of each tab costs lives in REST_PER_FETCH / GRAPHQL_PER_FETCH,
121
+ // declared with ALERT_SOURCES below because the security figure derives from it.
111
122
 
112
123
  // A stalled `gh` used to wedge a tab permanently: the promise never settled, so
113
124
  // the in-flight guard was never cleared and that tab stopped fetching for the
@@ -793,6 +804,10 @@ async function fetchActions(limit, signal) {
793
804
  return {
794
805
  raw,
795
806
  limit,
807
+ // What this fetch actually cost, read from the one cost table rather than
808
+ // re-derived from the argv shape -- a second copy of the cost model is the
809
+ // drift this file warns about repeatedly.
810
+ restSpent: REST_PER_FETCH.actions,
796
811
  parse: () =>
797
812
  JSON.parse(raw).map((r) => ({
798
813
  databaseId: r.databaseId,
@@ -838,6 +853,9 @@ async function fetchIssues(signal) {
838
853
  return {
839
854
  raw,
840
855
  limit: LIST_LIMIT,
856
+ // Stated rather than omitted so the zero is visibly deliberate: SORT_RECENT's
857
+ // --search routes this through GraphQL, which is a separate budget.
858
+ restSpent: REST_PER_FETCH.issues,
841
859
  parse: () =>
842
860
  JSON.parse(raw).map((i) => ({
843
861
  number: i.number,
@@ -869,6 +887,8 @@ async function fetchPRs(signal) {
869
887
  return {
870
888
  raw,
871
889
  limit: LIST_LIMIT,
890
+ // Zero for the same reason as issues: --search makes this a GraphQL call.
891
+ restSpent: REST_PER_FETCH.prs,
872
892
  parse: () =>
873
893
  JSON.parse(raw).map((p) => ({
874
894
  number: p.number,
@@ -946,6 +966,148 @@ const ALERT_SOURCES = [
946
966
  },
947
967
  ];
948
968
 
969
+ // What one fetch of each tab costs, per budget. The single source of truth:
970
+ // `projectedHourlyCost` derives the hourly figure from it and the spend meter
971
+ // bills against it, so a corrected number cannot reach one and miss the other.
972
+ //
973
+ // actions is 2, not 1: measured 2026-08-10 with `GH_DEBUG=api`, `gh run list`
974
+ // issues GET /actions/runs *and* GET /actions/workflows. The 1 that stood here
975
+ // understated the default tab -- the tab every pane starts on, since
976
+ // initialTabIndex defaults to 0 -- by half.
977
+ //
978
+ // issues and prs are 0 REST because SORT_RECENT's --search routes both through
979
+ // GraphQL entirely (2 POSTs each, confirmed by the same measurement).
980
+ const REST_PER_FETCH = { actions: 2, issues: 0, prs: 0, security: ALERT_SOURCES.length };
981
+ const GRAPHQL_PER_FETCH = { actions: 0, issues: 2, prs: 2, security: 0 };
982
+
983
+ // Ceiling on adaptive widening. An adaptive interval needs one: a minute is
984
+ // where the pane stops being live in any useful sense, and past it the honest
985
+ // answer is fewer panes, not a slower one.
986
+ const MAX_ADAPTIVE_REFRESH_MS = 60_000;
987
+
988
+ // Target this fraction of what the token can afford, not all of it. The margin
989
+ // is for the user's own `gh` and `git` commands, which are the whole reason the
990
+ // pane yields at all -- see RATE_LIMIT_RETRY_MS.
991
+ const BUDGET_SAFETY = 0.8;
992
+
993
+ // How often to re-read the budget. `gh api rate_limit` does not count against
994
+ // the limit (verified, delta 0 -- see rateBudget) but it is still a subprocess,
995
+ // so once a minute rather than once a tick: the quantity it measures moves on
996
+ // the scale of minutes.
997
+ const BUDGET_PROBE_MS = 60_000;
998
+
999
+ // Below this many of our own REST calls in a probe window, the share inference
1000
+ // is noise -- one call against a global delta of one reads as "we are the only
1001
+ // consumer" whether or not that is true. Under the threshold the loop declines
1002
+ // to infer a new share rather than guessing.
1003
+ const MIN_SAMPLE_CALLS = 5;
1004
+
1005
+ // Only move the applied interval when the target differs by more than this
1006
+ // factor either way, so a budget wobbling around a boundary cannot make the
1007
+ // poll rate flap. Same reasoning as TAB_LABEL_HYSTERESIS.
1008
+ const ADAPTIVE_HYSTERESIS = 1.25;
1009
+
1010
+ // Whether a probe window holds enough of our own calls for the ratio below to
1011
+ // mean anything. Named rather than inlined because the control law and the loop
1012
+ // that feeds it must agree on the answer: the law uses it to decide whether to
1013
+ // infer, and the loop uses it to decide whether the window may be closed.
1014
+ function sampleIsUsable(sample) {
1015
+ return Boolean(sample) && sample.myRestCalls >= MIN_SAMPLE_CALLS && sample.globalUsed > 0;
1016
+ }
1017
+
1018
+ // How many equivalent consumers are on this token, from one probe window.
1019
+ //
1020
+ // `lastShare` is what the previous measurable window said, and it is the answer
1021
+ // whenever this window is not measurable. Falling back to 1 instead would be the
1022
+ // controller starving its own input: a pane that has widened to 40s makes about
1023
+ // three REST calls per probe window, below MIN_SAMPLE_CALLS, so it would read
1024
+ // "no other consumers", snap back to the floor, spend fast enough to measure
1025
+ // again, and re-throttle -- a flap on a two-minute cycle at exactly the instance
1026
+ // counts the widening exists for. Holding the last measurement keeps the pane
1027
+ // where it is until a window is long enough to say otherwise.
1028
+ function inferShare(sample, lastShare = 1) {
1029
+ return sampleIsUsable(sample)
1030
+ ? Math.max(1, sample.globalUsed / sample.myRestCalls)
1031
+ : Math.max(1, lastShare);
1032
+ }
1033
+
1034
+ // One probe window's bookkeeping. Pure, so the flap it prevents is testable
1035
+ // without a timer: `prev` is `{ used, spent }` from the last window that could
1036
+ // be measured, or null before the first probe.
1037
+ //
1038
+ // The baseline only advances when the window was usable, so an unmeasurable
1039
+ // window is not thrown away -- it grows until it holds MIN_SAMPLE_CALLS of our
1040
+ // own calls, with both halves of the ratio still spanning the same stretch of
1041
+ // time. The cost is latency, not accuracy: a pane at the 60s cap needs two or
1042
+ // three probes rather than one to notice that the other panes have exited.
1043
+ function nextProbeWindow(prev, budget, spentTotal) {
1044
+ // A window reset makes `used` fall. The span before the reset cannot be
1045
+ // compared against the counter after it, so the window restarts here and this
1046
+ // cycle infers nothing -- the caller keeps the share it last measured, which
1047
+ // is the one direction that cannot spike the poll rate at the top of an hour.
1048
+ if (prev === null || budget.used < prev.used) {
1049
+ return { sample: null, next: { used: budget.used, spent: spentTotal } };
1050
+ }
1051
+ const sample = {
1052
+ globalUsed: budget.used - prev.used,
1053
+ myRestCalls: spentTotal - prev.spent,
1054
+ };
1055
+ return { sample, next: sampleIsUsable(sample) ? { used: budget.used, spent: spentTotal } : prev };
1056
+ }
1057
+
1058
+ // How wide the active-tab interval has to be for this instance to fit its share
1059
+ // of the token's remaining budget.
1060
+ //
1061
+ // The share is *inferred*, not configured: this instance knows exactly how many
1062
+ // REST calls it has spent since the last probe, and `rate_limit` reports how
1063
+ // many the token spent in total over the same window. The ratio is how many
1064
+ // equivalent consumers are on this token -- other panes, a `gh pr checks
1065
+ // --watch`, an agent shelling out to `gh`. That is why no IPC and no on-disk
1066
+ // registry is needed: the token's own counter is the shared channel, and it
1067
+ // already aggregates everything.
1068
+ //
1069
+ // Deliberately one-directional: the returned value is never below `floorMs`, so
1070
+ // --refresh and GH_GLANCE_REFRESH stay a floor the loop widens from and cannot
1071
+ // tighten past. A single instance on a fresh window computes a required interval
1072
+ // below the floor and therefore stays at exactly the configured refresh, which
1073
+ // is what keeps this from changing ordinary single-pane behaviour.
1074
+ //
1075
+ // MAX_ADAPTIVE_REFRESH_MS is wrapped in Math.max(floorMs, ...) at every exit, so
1076
+ // a user who configures a refresh wider than the cap is never silently sped up.
1077
+ //
1078
+ // `lastShare` defaults to 1, so a caller with no history behaves exactly as this
1079
+ // did before it existed: one instance, no adaptation.
1080
+ function adaptiveRefreshMs({ budget, sample, restPerTick, floorMs, nowMs, lastShare = 1 }) {
1081
+ if (!budget || !Number.isFinite(budget.remaining) || restPerTick <= 0) return floorMs;
1082
+ if (budget.remaining <= 0) return Math.max(floorMs, MAX_ADAPTIVE_REFRESH_MS);
1083
+
1084
+ const secondsToReset = Math.max(1, (budget.resetMs - nowMs) / 1000);
1085
+ const affordable = (budget.remaining / secondsToReset) * BUDGET_SAFETY;
1086
+ if (affordable <= 0) return Math.max(floorMs, MAX_ADAPTIVE_REFRESH_MS);
1087
+
1088
+ const mine = affordable / inferShare(sample, lastShare);
1089
+ const requiredMs = (restPerTick / mine) * 1000;
1090
+ return Math.min(Math.max(floorMs, requiredMs), Math.max(floorMs, MAX_ADAPTIVE_REFRESH_MS));
1091
+ }
1092
+
1093
+ // Whether a newly computed target is different enough to act on.
1094
+ function adaptiveChangeWorthApplying(appliedMs, targetMs) {
1095
+ if (appliedMs <= 0) return true;
1096
+ const ratio = targetMs / appliedMs;
1097
+ return ratio >= ADAPTIVE_HYSTERESIS || ratio <= 1 / ADAPTIVE_HYSTERESIS;
1098
+ }
1099
+
1100
+ // REST cost of one tick in this configuration: the active tab every tick, the
1101
+ // other three amortised over BACKGROUND_EVERY. Same table and same shape as
1102
+ // projectedHourlyCost, so the controller and the report cannot disagree.
1103
+ function restPerTick(activeKey) {
1104
+ let cost = REST_PER_FETCH[activeKey] ?? 0;
1105
+ for (const key of TAB_KEYS) {
1106
+ if (key !== activeKey) cost += (REST_PER_FETCH[key] ?? 0) / BACKGROUND_EVERY;
1107
+ }
1108
+ return cost;
1109
+ }
1110
+
949
1111
  function alertArgs(source) {
950
1112
  return ["api", apiPath(source.path), ...apiHostArgs(), "--jq", source.jq];
951
1113
  }
@@ -955,6 +1117,18 @@ function alertArgs(source) {
955
1117
  // mid-session is picked up within the hour.
956
1118
  const alertBackoff = new Map();
957
1119
 
1120
+ // REST calls this process has spent, ever. In-memory and per-process, in the
1121
+ // same spirit as alertBackoff above: lost on exit, and nothing depends on it
1122
+ // surviving. Only ever compared against itself between two budget probes, so it
1123
+ // needs no windowing and cannot overflow in any realistic session.
1124
+ //
1125
+ // Deliberately incomplete: openItem, preflight and resolveFailureContext also
1126
+ // spend, and are not counted, because they are occasional rather than periodic.
1127
+ // Under-reporting our own spend makes the inferred share *larger* and so
1128
+ // throttles slightly harder than strictly necessary -- the safe direction to err
1129
+ // in, which is why the imprecision is tolerated rather than chased.
1130
+ let restSpentTotal = 0;
1131
+
958
1132
  function backoffActive(key, now) {
959
1133
  const state = alertBackoff.get(key);
960
1134
  return Boolean(state) && now < state.until;
@@ -990,13 +1164,20 @@ async function fetchAlertSource(source, signal, now) {
990
1164
  // The verdict is replayed alongside the note. Replaying only the note left
991
1165
  // the tab unable to tell "Dependabot is switched off here" from "we cannot
992
1166
  // see Dependabot" for the whole length of a backoff window.
993
- return { raw: `backoff:${note}`, parse: () => ({ alerts: [], note, verdict, truncated: false }) };
1167
+ // Nothing was spawned, so nothing was billed -- which is the whole reason
1168
+ // the meter is fed by the fetchers rather than by counting ticks.
1169
+ return {
1170
+ raw: `backoff:${note}`,
1171
+ spent: 0,
1172
+ parse: () => ({ alerts: [], note, verdict, truncated: false }),
1173
+ };
994
1174
  }
995
1175
  try {
996
1176
  const raw = await runGh(alertArgs(source), { signal });
997
1177
  clearBackoff(source.key);
998
1178
  return {
999
1179
  raw,
1180
+ spent: 1,
1000
1181
  parse: () => {
1001
1182
  const rows = JSON.parse(raw).filter((a) => a.state === "open");
1002
1183
  return {
@@ -1021,6 +1202,9 @@ async function fetchAlertSource(source, signal, now) {
1021
1202
  }
1022
1203
  return {
1023
1204
  raw: `unavailable:${note}`,
1205
+ // A failed call still bills: the request reached GitHub and was counted
1206
+ // whether it returned data, `[]`, or a 403.
1207
+ spent: 1,
1024
1208
  parse: () => ({ alerts: [], note, verdict, truncated: false }),
1025
1209
  };
1026
1210
  }
@@ -1054,6 +1238,10 @@ async function fetchSecurity(signal) {
1054
1238
  // colliding on the same string.
1055
1239
  raw: parts.map((p) => p.raw).join("\0"),
1056
1240
  limit: ALERT_PER_PAGE,
1241
+ // Summed from what each source reported rather than assumed to be
1242
+ // ALERT_SOURCES.length: a source held off by backoff spawned nothing and
1243
+ // must not be billed for it.
1244
+ restSpent: parts.reduce((n, p) => n + p.spent, 0),
1057
1245
  parse: () => {
1058
1246
  const parsed = parts.map((p) => p.parse());
1059
1247
  const alerts = parsed.flatMap((p) => p.alerts);
@@ -1168,6 +1356,7 @@ const DOCTOR_ENV_PLAIN = [
1168
1356
  "NO_PROXY",
1169
1357
  "GH_GLANCE_ICONS",
1170
1358
  "GH_GLANCE_NO_ANIMATION",
1359
+ "GH_GLANCE_REFRESH",
1171
1360
  "NO_COLOR",
1172
1361
  "NODE_ENV",
1173
1362
  ];
@@ -1293,8 +1482,8 @@ function targetSource() {
1293
1482
 
1294
1483
  // How much of the hourly API budget is left, and roughly how fast this
1295
1484
  // 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`
1485
+ // at the default 5s refresh with the Security tab open it is around 2,280 REST
1486
+ // requests an hour -- about 46% of a personal token's 5,000 -- and `--refresh 2`
1298
1487
  // projects past the limit outright, so it exhausts inside the hour, every hour.
1299
1488
  // The budget is shared with everything else the token does, so the first symptom
1300
1489
  // is usually "GitHub is broken" somewhere else entirely.
@@ -1305,7 +1494,10 @@ function targetSource() {
1305
1494
  // exactly why this reports the server's own numbers rather than asserting 5,000.
1306
1495
  async function rateBudget() {
1307
1496
  try {
1308
- const raw = await runGh(["api", "rate_limit"]);
1497
+ // Host-routed like every other `gh api` call: a budget is per token *per
1498
+ // server*, so on a GHES tenant the github.com numbers are not merely stale,
1499
+ // they belong to a different limit entirely.
1500
+ const raw = await runGh(["api", "rate_limit", ...apiHostArgs()]);
1309
1501
  const { resources } = JSON.parse(raw);
1310
1502
  // formatAge() is for timestamps in the past and returns "-" for a future one,
1311
1503
  // so the reset is rendered as a forward interval instead.
@@ -1321,24 +1513,52 @@ async function rateBudget() {
1321
1513
  }
1322
1514
  }
1323
1515
 
1516
+ // The same probe as rateBudget, but returning `core` as data rather than a
1517
+ // sentence, because the adaptive loop has to do arithmetic on it. rateBudget
1518
+ // keeps its own shape: it formats `graphql` too, which the controller does not
1519
+ // need, and it is on the --doctor path where a string is the product.
1520
+ //
1521
+ // Safe to run on a timer for the same reason it is safe on the diagnostic path:
1522
+ // `gh api rate_limit` is documented as not counting against the limit, and it
1523
+ // measures as free (verified: delta 0).
1524
+ async function readCoreBudget(signal) {
1525
+ try {
1526
+ // apiHostArgs() for the same reason the alert endpoints carry it: --repo
1527
+ // host/owner/name sets runtime.host without setting GH_HOST, so without the
1528
+ // flag this reads github.com's budget while the pane spends against the
1529
+ // tenant -- and then throttles, or fails to, against an unrelated number.
1530
+ const raw = await runGh(["api", "rate_limit", ...apiHostArgs()], { signal });
1531
+ const core = JSON.parse(raw)?.resources?.core;
1532
+ if (!core || !Number.isFinite(core.remaining)) return null;
1533
+ return {
1534
+ remaining: core.remaining,
1535
+ limit: core.limit,
1536
+ used: core.used,
1537
+ resetMs: core.reset * 1000,
1538
+ };
1539
+ } catch {
1540
+ // A probe failure must never be louder than the pane's own data: the loop
1541
+ // simply does not adapt this cycle and stays at whatever it last applied.
1542
+ return null;
1543
+ }
1544
+ }
1545
+
1324
1546
  // Requests per hour this configuration will spend once it settles, derived from
1325
1547
  // the same constants the poll loop uses rather than from a number written down
1326
1548
  // 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).
1549
+ // every BACKGROUND_EVERY ticks. The per-fetch prices come from REST_PER_FETCH
1550
+ // and GRAPHQL_PER_FETCH, which are also what the spend meter bills against --
1551
+ // two copies of them would diverge the first time one was fixed.
1330
1552
  function projectedHourlyCost(activeKey) {
1331
1553
  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
1554
  let rest = 0;
1335
1555
  let graphql = 0;
1336
1556
  // TAB_KEYS rather than TABS: this runs on the --doctor path, which deliberately
1337
1557
  // returns before the render tree is reached, and TABS is declared down there.
1338
1558
  for (const key of TAB_KEYS) {
1339
1559
  const ticks = key === activeKey ? perHour : perHour / BACKGROUND_EVERY;
1340
- rest += ticks * restCalls[key];
1341
- graphql += ticks * graphqlCalls[key];
1560
+ rest += ticks * REST_PER_FETCH[key];
1561
+ graphql += ticks * GRAPHQL_PER_FETCH[key];
1342
1562
  }
1343
1563
  return { rest: Math.round(rest), graphql: Math.round(graphql) };
1344
1564
  }
@@ -1495,6 +1715,11 @@ function parseArgs(argv) {
1495
1715
  doctor: false,
1496
1716
  repo: null,
1497
1717
  refresh: null,
1718
+ // Which surface supplied `refresh`, so validateArgs can name it in the
1719
+ // bounds messages. argv never sets it -- the entry block does, when it
1720
+ // falls back to GH_GLANCE_REFRESH -- but it is declared here so the shape
1721
+ // parseArgs returns stays stated in one place.
1722
+ refreshSource: null,
1498
1723
  tab: null,
1499
1724
  verbose: false,
1500
1725
  };
@@ -1537,13 +1762,16 @@ function validateArgs(opts, tabKeys) {
1537
1762
 
1538
1763
  let refreshMs = null;
1539
1764
  if (opts.refresh !== null) {
1765
+ // Named so the message points at whichever surface supplied the value: the
1766
+ // interval can now come from GH_GLANCE_REFRESH as well as --refresh.
1767
+ const refreshLabel = opts.refreshSource ?? "--refresh";
1540
1768
  const seconds = Number(opts.refresh);
1541
1769
  if (!Number.isFinite(seconds) || !Number.isInteger(seconds)) {
1542
- throw new Error(`--refresh must be a whole number of seconds, got: ${opts.refresh}`);
1770
+ throw new Error(`${refreshLabel} must be a whole number of seconds, got: ${opts.refresh}`);
1543
1771
  }
1544
1772
  if (seconds < MIN_REFRESH_SECONDS || seconds > MAX_REFRESH_SECONDS) {
1545
1773
  throw new Error(
1546
- `--refresh must be between ${MIN_REFRESH_SECONDS} and ${MAX_REFRESH_SECONDS} seconds, got: ${seconds}`,
1774
+ `${refreshLabel} must be between ${MIN_REFRESH_SECONDS} and ${MAX_REFRESH_SECONDS} seconds, got: ${seconds}`,
1547
1775
  );
1548
1776
  }
1549
1777
  refreshMs = seconds * 1000;
@@ -1581,8 +1809,9 @@ const KEY_TABLE = [
1581
1809
  ["PgUp / PgDn", "Move a page at a time"],
1582
1810
  ["Enter", "Open the selected item or accept a prompt"],
1583
1811
  ["r", "Refresh the current tab now"],
1812
+ ["w", "Adjust table column widths"],
1584
1813
  ["?", "Show the keys (any key closes it)"],
1585
- ["q / Esc / Ctrl+C", "Quit"],
1814
+ ["q / Esc / Ctrl+C", "Quit (Esc leaves width mode)"],
1586
1815
  ];
1587
1816
  const KEY_COL = Math.max(...KEY_TABLE.map(([k]) => k.length)) + 3;
1588
1817
  const keyTableLines = () => KEY_TABLE.map(([k, d]) => `${k.padEnd(KEY_COL)}${d}`);
@@ -1643,6 +1872,9 @@ Environment:
1643
1872
  GH_HOST=host GitHub Enterprise or EMU host to send every call to.
1644
1873
  A host-qualified GH_REPO does not route \`gh api\`;
1645
1874
  this and --repo host/owner/name both do.
1875
+ GH_GLANCE_REFRESH=<seconds>
1876
+ Active-tab poll interval, ${MIN_REFRESH_SECONDS}-${MAX_REFRESH_SECONDS}
1877
+ (--refresh takes precedence)
1646
1878
  GH_GLANCE_ICONS=unicode Plain-unicode status icons (no Nerd Font needed)
1647
1879
  GH_GLANCE_NO_ANIMATION=1 Freeze the spinner (no motion)
1648
1880
  NO_COLOR=1 Disable colour (status stays readable)
@@ -1660,7 +1892,20 @@ Environment:
1660
1892
  if (IS_MAIN) {
1661
1893
  let opts;
1662
1894
  try {
1663
- opts = validateArgs(parseArgs(process.argv.slice(2)), TAB_KEYS);
1895
+ const argvOpts = parseArgs(process.argv.slice(2));
1896
+ // The flag wins, which is the precedence GH_REPO already advertises -- but
1897
+ // not its mechanism: GH_REPO is read at each use site, because `gh` honours
1898
+ // it natively and a repo slug needs no validation. An interval does, so it
1899
+ // is resolved once, here, and substituted before validation -- an
1900
+ // out-of-range GH_GLANCE_REFRESH is then refused by the same two messages
1901
+ // an out-of-range --refresh gets, rather than by a second copy of the
1902
+ // bounds. It still reaches runtime through the one write site below rather
1903
+ // than being assigned beside it.
1904
+ if (argvOpts.refresh === null && process.env.GH_GLANCE_REFRESH) {
1905
+ argvOpts.refresh = process.env.GH_GLANCE_REFRESH;
1906
+ argvOpts.refreshSource = "GH_GLANCE_REFRESH";
1907
+ }
1908
+ opts = validateArgs(argvOpts, TAB_KEYS);
1664
1909
  } catch (err) {
1665
1910
  // Exit 2 for every argv problem, unchanged from when the only possible
1666
1911
  // problem was an unrecognised flag. CI asserts this code.
@@ -1734,10 +1979,10 @@ if (IS_MAIN) {
1734
1979
  }
1735
1980
 
1736
1981
  const ReactModule = await import("react");
1737
- const { render, Box, Text, useStdout, useInput, useStdin, useApp } = await import("ink");
1982
+ const { render, measureElement, Box, Text, useStdout, useInput, useStdin, useApp } = await import("ink");
1738
1983
 
1739
1984
  const React = ReactModule.default;
1740
- const { useState, useEffect, useRef } = ReactModule;
1985
+ const { useState, useEffect, useMemo, useRef, useCallback } = ReactModule;
1741
1986
  const e = React.createElement;
1742
1987
 
1743
1988
  // The NODE_ENV escape hatch above is genuinely useful -- React's warnings are
@@ -1840,10 +2085,10 @@ const ERROR_TEXT = "red";
1840
2085
  // column is otherwise a private-use codepoint that announces as nothing --
1841
2086
  // leaving every row with no status at all. It is derived from the same lookup
1842
2087
  // that picks the glyph, so the two cannot drift apart.
1843
- function Column({ width, grow, children, bold, color, dim, wrap, label }) {
2088
+ function Column({ width, grow, children, bold, color, dim, wrap, label, marginRight = 1 }) {
1844
2089
  return e(
1845
2090
  Box,
1846
- { width, flexGrow: grow ? 1 : 0, flexShrink: grow ? 1 : 0, marginRight: 1 },
2091
+ { width, flexGrow: grow ? 1 : 0, flexShrink: grow ? 1 : 0, marginRight },
1847
2092
  e(
1848
2093
  Text,
1849
2094
  { bold, color, dimColor: dim, wrap: wrap ?? "truncate-end", "aria-label": label },
@@ -1852,10 +2097,35 @@ function Column({ width, grow, children, bold, color, dim, wrap, label }) {
1852
2097
  );
1853
2098
  }
1854
2099
 
1855
- function HeaderCells({ cells }) {
2100
+ // Each descriptor still owns one gutter cell, but an adjustable column owns
2101
+ // the edge that faces the flexible TITLE/SUMMARY reservoir. Fixed columns after
2102
+ // the grow cell therefore use the preceding descriptor's trailing gutter;
2103
+ // fixed columns before it use their own. Keeping this mapping pure lets the
2104
+ // pointer phase share the exact same geometry as the visible grips.
2105
+ function headerGutterKeyAt(cells, index, growIndex) {
2106
+ if (!Array.isArray(cells) || !Number.isSafeInteger(index) || index < 0 || index >= cells.length) {
2107
+ return null;
2108
+ }
2109
+ if (growIndex < 0) return null;
2110
+ if (index < growIndex) {
2111
+ return isAdjustableWidthColumn(cells[index]) ? cells[index].key : null;
2112
+ }
2113
+ return isAdjustableWidthColumn(cells[index + 1]) ? cells[index + 1].key : null;
2114
+ }
2115
+
2116
+ function headerGutterKey(cells, index) {
2117
+ const growIndex = Array.isArray(cells)
2118
+ ? cells.findIndex((column) => column.props?.grow)
2119
+ : -1;
2120
+ return headerGutterKeyAt(cells, index, growIndex);
2121
+ }
2122
+
2123
+ function HeaderCells({ cells, selectedWidthKey = null, headerRef = null }) {
2124
+ const growIndex = cells.findIndex((column) => column.props?.grow);
1856
2125
  return e(
1857
2126
  Box,
1858
2127
  {
2128
+ ref: headerRef,
1859
2129
  flexDirection: "row",
1860
2130
  borderStyle: "single",
1861
2131
  borderTop: false,
@@ -1863,7 +2133,198 @@ function HeaderCells({ cells }) {
1863
2133
  borderRight: false,
1864
2134
  borderColor: BORDER_COLOR,
1865
2135
  },
1866
- ...cells.map((c, i) => e(Column, { key: i, ...c.props, bold: true, dim: true }, c.label)),
2136
+ ...cells.flatMap((c, index) => {
2137
+ const gripKey = headerGutterKeyAt(cells, index, growIndex);
2138
+ const selected = gripKey !== null && gripKey === selectedWidthKey;
2139
+ return [
2140
+ e(
2141
+ Column,
2142
+ { key: `${c.key}:cell`, ...c.props, bold: true, dim: true, marginRight: 0 },
2143
+ c.label,
2144
+ ),
2145
+ e(
2146
+ Text,
2147
+ {
2148
+ key: `${c.key}:gutter`,
2149
+ color: selected ? TITLE_COLOR : BORDER_COLOR,
2150
+ bold: selected,
2151
+ dimColor: gripKey !== null && !selected,
2152
+ "aria-hidden": true,
2153
+ },
2154
+ gripKey === null ? " " : "│",
2155
+ ),
2156
+ ];
2157
+ }),
2158
+ );
2159
+ }
2160
+
2161
+ const MemoHeaderCells = React.memo(HeaderCells);
2162
+
2163
+ function parseSgrMouse(input) {
2164
+ if (typeof input !== "string") return null;
2165
+ const match = /^\[<(\d+);(\d+);(\d+)([Mm])$/.exec(input);
2166
+ if (!match) return null;
2167
+
2168
+ const code = Number(match[1]);
2169
+ const encodedX = Number(match[2]);
2170
+ const encodedY = Number(match[3]);
2171
+ if (
2172
+ !Number.isSafeInteger(code) ||
2173
+ !Number.isSafeInteger(encodedX) ||
2174
+ !Number.isSafeInteger(encodedY) ||
2175
+ encodedX < 1 ||
2176
+ encodedY < 1 ||
2177
+ (code !== 0 && code !== 32)
2178
+ ) {
2179
+ return null;
2180
+ }
2181
+
2182
+ return {
2183
+ x: encodedX - 1,
2184
+ y: encodedY - 1,
2185
+ action: match[4] === "m" ? "release" : code === 32 ? "drag" : "press",
2186
+ };
2187
+ }
2188
+
2189
+ function dividerHandles({ header, metrics }) {
2190
+ if (
2191
+ !Array.isArray(header) ||
2192
+ !metrics ||
2193
+ !Number.isSafeInteger(metrics.x) ||
2194
+ !Number.isSafeInteger(metrics.y) ||
2195
+ !Number.isSafeInteger(metrics.width) ||
2196
+ !Number.isSafeInteger(metrics.height) ||
2197
+ metrics.x < 0 ||
2198
+ metrics.y < 0 ||
2199
+ metrics.width < 1 ||
2200
+ metrics.height < 1
2201
+ ) {
2202
+ return [];
2203
+ }
2204
+
2205
+ const growIndexes = header
2206
+ .map((column, index) => column.props?.grow ? index : -1)
2207
+ .filter((index) => index >= 0);
2208
+ if (growIndexes.length !== 1) return [];
2209
+ const growIndex = growIndexes[0];
2210
+ const fixedWidth = header.reduce((sum, column, index) => {
2211
+ if (index === growIndex) return sum;
2212
+ return Number.isSafeInteger(column.props?.width) && column.props.width >= 0
2213
+ ? sum + column.props.width
2214
+ : Number.NaN;
2215
+ }, 0);
2216
+ const growWidth = metrics.width - header.length - fixedWidth;
2217
+ const yEnd = metrics.y + metrics.height;
2218
+ if (!Number.isSafeInteger(growWidth) || growWidth < 0 || !Number.isSafeInteger(yEnd)) return [];
2219
+
2220
+ const handles = [];
2221
+ let x = metrics.x;
2222
+ for (let index = 0; index < header.length; index += 1) {
2223
+ const contentWidth = index === growIndex ? growWidth : header[index].props.width;
2224
+ const gutterX = x + contentWidth;
2225
+ const key = headerGutterKeyAt(header, index, growIndex);
2226
+ if (key !== null) {
2227
+ const ownerIndex = index < growIndex ? index : index + 1;
2228
+ const owner = header[ownerIndex];
2229
+ if (!isAdjustableWidthColumn(owner) || owner.key !== key) return [];
2230
+ handles.push({
2231
+ key,
2232
+ x: gutterX,
2233
+ yStart: metrics.y,
2234
+ yEnd,
2235
+ width: owner.props.width,
2236
+ direction: ownerIndex < growIndex ? 1 : -1,
2237
+ });
2238
+ }
2239
+ x = gutterX + 1;
2240
+ }
2241
+ return handles;
2242
+ }
2243
+
2244
+ function hitDivider(handles, point, tolerance = 1) {
2245
+ if (
2246
+ !Array.isArray(handles) ||
2247
+ !point ||
2248
+ !Number.isSafeInteger(point.x) ||
2249
+ !Number.isSafeInteger(point.y) ||
2250
+ !Number.isSafeInteger(tolerance) ||
2251
+ tolerance < 0
2252
+ ) {
2253
+ return null;
2254
+ }
2255
+
2256
+ let nearest = null;
2257
+ let nearestDistance = Number.POSITIVE_INFINITY;
2258
+ for (const handle of handles) {
2259
+ if (
2260
+ !Number.isSafeInteger(handle?.x) ||
2261
+ !Number.isSafeInteger(handle.yStart) ||
2262
+ !Number.isSafeInteger(handle.yEnd) ||
2263
+ point.y < handle.yStart ||
2264
+ point.y >= handle.yEnd
2265
+ ) {
2266
+ continue;
2267
+ }
2268
+ const distance = Math.abs(point.x - handle.x);
2269
+ if (
2270
+ distance <= tolerance &&
2271
+ (distance < nearestDistance ||
2272
+ (distance === nearestDistance && (nearest === null || handle.x < nearest.x)))
2273
+ ) {
2274
+ nearest = handle;
2275
+ nearestDistance = distance;
2276
+ }
2277
+ }
2278
+ return nearest;
2279
+ }
2280
+
2281
+ function beginDividerDrag({ event, handles, tabKey, tolerance = 1 }) {
2282
+ if (event?.action !== "press" || typeof tabKey !== "string" || tabKey.length === 0) {
2283
+ return null;
2284
+ }
2285
+ const handle = hitDivider(handles, event, tolerance);
2286
+ if (!handle) return null;
2287
+ return {
2288
+ tabKey,
2289
+ key: handle.key,
2290
+ startX: event.x,
2291
+ startWidth: handle.width,
2292
+ direction: handle.direction,
2293
+ };
2294
+ }
2295
+
2296
+ function draggedWidth({
2297
+ drag,
2298
+ event,
2299
+ tabKey,
2300
+ fullHeaderVisible,
2301
+ layoutValid = true,
2302
+ }) {
2303
+ if (
2304
+ !drag ||
2305
+ event?.action !== "drag" ||
2306
+ drag.tabKey !== tabKey ||
2307
+ fullHeaderVisible !== true ||
2308
+ layoutValid !== true ||
2309
+ !Number.isSafeInteger(drag.startX) ||
2310
+ !Number.isSafeInteger(drag.startWidth) ||
2311
+ (drag.direction !== 1 && drag.direction !== -1) ||
2312
+ !Number.isSafeInteger(event.x)
2313
+ ) {
2314
+ return null;
2315
+ }
2316
+ const nextWidth = drag.startWidth + drag.direction * (event.x - drag.startX);
2317
+ return Number.isSafeInteger(nextWidth) ? { key: drag.key, nextWidth } : null;
2318
+ }
2319
+
2320
+ function sameElementMetrics(left, right) {
2321
+ return (
2322
+ left != null &&
2323
+ right != null &&
2324
+ left.x === right.x &&
2325
+ left.y === right.y &&
2326
+ left.width === right.width &&
2327
+ left.height === right.height
1867
2328
  );
1868
2329
  }
1869
2330
 
@@ -1986,23 +2447,23 @@ function runStatusIcon(run, spin) {
1986
2447
  }
1987
2448
 
1988
2449
  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 } },
2450
+ { key: "status", label: "", props: { width: 3 }, adjustable: false },
2451
+ { key: "title", label: "TITLE", props: { grow: true }, adjustable: false },
2452
+ { key: "workflow", label: "WORKFLOW", props: { width: 10 }, adjustable: true, minWidth: 5 },
2453
+ { key: "branch", label: "BRANCH", props: { width: 14 }, adjustable: true, minWidth: 6 },
2454
+ { key: "time", label: "TIME", props: { width: 7 }, adjustable: true, minWidth: 5 },
2455
+ { key: "updated", label: "UPDATED", props: { width: 8 }, adjustable: true, minWidth: 6 },
1995
2456
  ];
1996
2457
 
1997
2458
  // Dropped in order of least value first: BRANCH and WORKFLOW are inferable
1998
2459
  // from the title far more often than the status icon or the age are.
1999
2460
  const ACTIONS_HEADER_COMPACT = [
2000
- { label: "", props: { width: 3 } },
2001
- { label: "TITLE", props: { grow: true } },
2002
- { label: "UPDATED", props: { width: 8 } },
2461
+ { key: "status", label: "", props: { width: 3 } },
2462
+ { key: "title", label: "TITLE", props: { grow: true } },
2463
+ { key: "updated", label: "UPDATED", props: { width: 8 } },
2003
2464
  ];
2004
2465
 
2005
- function ActionsRow({ item, now, spin, compact, cursor }) {
2466
+ function ActionsRow({ item, now, spin, compact, cursor, columns }) {
2006
2467
  const { icon, color, label } = runStatusIcon(item, spin);
2007
2468
  const started = new Date(item.startedAt);
2008
2469
  const finished = item.status === "completed" ? new Date(item.updatedAt) : now;
@@ -2010,52 +2471,52 @@ function ActionsRow({ item, now, spin, compact, cursor }) {
2010
2471
  return e(
2011
2472
  Box,
2012
2473
  { 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)),
2474
+ e(Column, { ...columnProps(columns, "status"), color, label }, `${cursor ? ">" : " "}${icon}`),
2475
+ e(Column, columnProps(columns, "title"), item.displayTitle),
2476
+ e(Column, { ...columnProps(columns, "updated"), dim: true }, formatAge(new Date(item.updatedAt), now)),
2016
2477
  );
2017
2478
  }
2018
2479
  return e(
2019
2480
  Box,
2020
2481
  { flexDirection: "row" },
2021
- e(Column, { width: 3, color, label }, `${cursor ? ">" : " "}${icon}`),
2022
- e(Column, { grow: true }, item.displayTitle),
2482
+ e(Column, { ...columnProps(columns, "status"), color, label }, `${cursor ? ">" : " "}${icon}`),
2483
+ e(Column, columnProps(columns, "title"), item.displayTitle),
2023
2484
  // The run number is the actionable half and used to be the first thing
2024
2485
  // 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)),
2486
+ e(Column, { ...columnProps(columns, "workflow"), color: IDENTIFIER }, `#${item.number} ${item.workflowName}`),
2487
+ e(Column, { ...columnProps(columns, "branch"), color: REF, wrap: "truncate-middle" }, item.headBranch),
2488
+ e(Column, columnProps(columns, "time"), formatDuration(finished - started)),
2489
+ e(Column, { ...columnProps(columns, "updated"), dim: true }, formatAge(new Date(item.updatedAt), now)),
2029
2490
  );
2030
2491
  }
2031
2492
 
2032
2493
  // ---------- Issues tab ----------
2033
2494
 
2034
2495
  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 } },
2496
+ { key: "status", label: "", props: { width: 3 }, adjustable: false },
2497
+ { key: "title", label: "TITLE", props: { grow: true }, adjustable: false },
2498
+ { key: "author", label: "AUTHOR", props: { width: 12 }, adjustable: true, minWidth: 6 },
2499
+ { key: "label", label: "LABEL", props: { width: 14 }, adjustable: true, minWidth: 6 },
2500
+ { key: "updated", label: "UPDATED", props: { width: 8 }, adjustable: true, minWidth: 6 },
2040
2501
  ];
2041
2502
 
2042
2503
  const ISSUES_HEADER_COMPACT = [
2043
- { label: "", props: { width: 3 } },
2044
- { label: "TITLE", props: { grow: true } },
2045
- { label: "UPDATED", props: { width: 8 } },
2504
+ { key: "status", label: "", props: { width: 3 } },
2505
+ { key: "title", label: "TITLE", props: { grow: true } },
2506
+ { key: "updated", label: "UPDATED", props: { width: 8 } },
2046
2507
  ];
2047
2508
 
2048
- function IssueRow({ item, now, compact, cursor }) {
2509
+ function IssueRow({ item, now, compact, cursor, columns }) {
2049
2510
  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}`),
2511
+ e(Column, { key: "status", ...columnProps(columns, "status"), color: OK, label: "open issue" }, `${cursor ? ">" : " "}${OCT.issueOpened}`),
2512
+ e(Column, { key: "title", ...columnProps(columns, "title") }, `#${item.number} ${item.title}`),
2052
2513
  ];
2053
2514
  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));
2515
+ cells.push(e(Column, { key: "author", ...columnProps(columns, "author"), color: IDENTIFIER }, item.author));
2516
+ cells.push(e(Column, { key: "label", ...columnProps(columns, "label"), color: REF }, item.label));
2056
2517
  }
2057
2518
  cells.push(
2058
- e(Column, { key: "u", width: 8, dim: true }, formatAge(new Date(item.updatedAt), now)),
2519
+ e(Column, { key: "updated", ...columnProps(columns, "updated"), dim: true }, formatAge(new Date(item.updatedAt), now)),
2059
2520
  );
2060
2521
  return e(Box, { flexDirection: "row" }, ...cells);
2061
2522
  }
@@ -2063,18 +2524,18 @@ function IssueRow({ item, now, compact, cursor }) {
2063
2524
  // ---------- Pull requests tab ----------
2064
2525
 
2065
2526
  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 } },
2527
+ { key: "status", label: "", props: { width: 3 }, adjustable: false },
2528
+ { key: "title", label: "TITLE", props: { grow: true }, adjustable: false },
2529
+ { key: "author", label: "AUTHOR", props: { width: 12 }, adjustable: true, minWidth: 6 },
2530
+ { key: "branch", label: "BRANCH", props: { width: 14 }, adjustable: true, minWidth: 6 },
2531
+ { key: "review", label: "REVIEW", props: { width: 10 }, adjustable: true, minWidth: 7 },
2532
+ { key: "updated", label: "UPDATED", props: { width: 8 }, adjustable: true, minWidth: 6 },
2072
2533
  ];
2073
2534
 
2074
2535
  const PRS_HEADER_COMPACT = [
2075
- { label: "", props: { width: 3 } },
2076
- { label: "TITLE", props: { grow: true } },
2077
- { label: "REVIEW", props: { width: 10 } },
2536
+ { key: "status", label: "", props: { width: 3 } },
2537
+ { key: "title", label: "TITLE", props: { grow: true } },
2538
+ { key: "review", label: "REVIEW", props: { width: 10 } },
2078
2539
  ];
2079
2540
 
2080
2541
  const REVIEW_LABEL = {
@@ -2084,25 +2545,25 @@ const REVIEW_LABEL = {
2084
2545
  };
2085
2546
  const REVIEW_NONE = { label: "", color: INERT };
2086
2547
 
2087
- function PRRow({ item, now, compact, cursor }) {
2548
+ function PRRow({ item, now, compact, cursor, columns }) {
2088
2549
  const prIcon = item.isDraft
2089
2550
  ? { icon: OCT.pullRequestDraft, color: INERT, label: "draft pull request" }
2090
2551
  : { icon: OCT.pullRequest, color: OK, label: "open pull request" };
2091
2552
  const review = pick(REVIEW_LABEL, item.reviewDecision, REVIEW_NONE);
2092
2553
  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}`),
2554
+ e(Column, { key: "status", ...columnProps(columns, "status"), color: prIcon.color, label: prIcon.label }, `${cursor ? ">" : " "}${prIcon.icon}`),
2555
+ e(Column, { key: "title", ...columnProps(columns, "title") }, `#${item.number} ${item.title}`),
2095
2556
  ];
2096
2557
  if (!compact) {
2097
- cells.push(e(Column, { key: "a", width: 12, color: IDENTIFIER }, item.author));
2558
+ cells.push(e(Column, { key: "author", ...columnProps(columns, "author"), color: IDENTIFIER }, item.author));
2098
2559
  cells.push(
2099
- e(Column, { key: "b", width: 14, color: REF, wrap: "truncate-middle" }, item.headRefName),
2560
+ e(Column, { key: "branch", ...columnProps(columns, "branch"), color: REF, wrap: "truncate-middle" }, item.headRefName),
2100
2561
  );
2101
2562
  }
2102
- cells.push(e(Column, { key: "r", width: 10, color: review.color }, review.label));
2563
+ cells.push(e(Column, { key: "review", ...columnProps(columns, "review"), color: review.color }, review.label));
2103
2564
  if (!compact) {
2104
2565
  cells.push(
2105
- e(Column, { key: "u", width: 8, dim: true }, formatAge(new Date(item.updatedAt), now)),
2566
+ e(Column, { key: "updated", ...columnProps(columns, "updated"), dim: true }, formatAge(new Date(item.updatedAt), now)),
2106
2567
  );
2107
2568
  }
2108
2569
  return e(Box, { flexDirection: "row" }, ...cells);
@@ -2116,19 +2577,19 @@ function PRRow({ item, now, compact, cursor }) {
2116
2577
  // text column states it outright; the width comes out of PACKAGE / FILE, whose
2117
2578
  // contents were already being truncated.
2118
2579
  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 } },
2580
+ { key: "status", label: "", props: { width: 3 }, adjustable: false },
2581
+ { key: "severity", label: "SEV", props: { width: 4 }, adjustable: false },
2582
+ { key: "package", label: "PACKAGE / FILE", props: { width: 16 }, adjustable: true, minWidth: 6 },
2583
+ { key: "summary", label: "SUMMARY", props: { grow: true }, adjustable: false },
2584
+ { key: "age", label: "AGE", props: { width: 8 }, adjustable: true, minWidth: 6 },
2124
2585
  ];
2125
2586
 
2126
2587
  // SEV is the last thing to drop on this tab: it is the whole point of the pane
2127
2588
  // and the only non-colour severity channel.
2128
2589
  const SECURITY_HEADER_COMPACT = [
2129
- { label: "", props: { width: 3 } },
2130
- { label: "SEV", props: { width: 4 } },
2131
- { label: "SUMMARY", props: { grow: true } },
2590
+ { key: "status", label: "", props: { width: 3 } },
2591
+ { key: "severity", label: "SEV", props: { width: 4 } },
2592
+ { key: "summary", label: "SUMMARY", props: { grow: true } },
2132
2593
  ];
2133
2594
 
2134
2595
  const SEVERITY_STYLE = {
@@ -2141,19 +2602,19 @@ const SEVERITY_STYLE = {
2141
2602
  };
2142
2603
  const SEVERITY_UNKNOWN = SEVERITY_STYLE.unknown;
2143
2604
 
2144
- function SecurityRow({ item, now, compact, cursor }) {
2605
+ function SecurityRow({ item, now, compact, cursor, columns }) {
2145
2606
  const sev = pick(SEVERITY_STYLE, item.severity, SEVERITY_UNKNOWN);
2146
2607
  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),
2608
+ e(Column, { key: "status", ...columnProps(columns, "status"), color: sev.color, label: `${sev.short} severity` }, `${cursor ? ">" : " "}${OCT.shield}`),
2609
+ e(Column, { key: "severity", ...columnProps(columns, "severity"), color: sev.color }, sev.short),
2149
2610
  ];
2150
2611
  if (!compact) {
2151
- cells.push(e(Column, { key: "p", width: 16, color: IDENTIFIER }, item.detail || item.kind));
2612
+ cells.push(e(Column, { key: "package", ...columnProps(columns, "package"), color: IDENTIFIER }, item.detail || item.kind));
2152
2613
  }
2153
- cells.push(e(Column, { key: "t", grow: true }, item.title));
2614
+ cells.push(e(Column, { key: "summary", ...columnProps(columns, "summary") }, item.title));
2154
2615
  if (!compact) {
2155
2616
  cells.push(
2156
- e(Column, { key: "a", width: 8, dim: true }, formatAge(new Date(item.createdAt), now)),
2617
+ e(Column, { key: "age", ...columnProps(columns, "age"), dim: true }, formatAge(new Date(item.createdAt), now)),
2157
2618
  );
2158
2619
  }
2159
2620
  return e(Box, { flexDirection: "row" }, ...cells);
@@ -2214,6 +2675,181 @@ const TABS = [
2214
2675
  },
2215
2676
  ];
2216
2677
 
2678
+ function tabForKey(key) {
2679
+ return TABS.find((tab) => tab.key === key);
2680
+ }
2681
+
2682
+ const EMPTY_WIDTH_OVERRIDES = Object.freeze({});
2683
+
2684
+ function columnProps(columns, key) {
2685
+ const column = columns.find((candidate) => candidate.key === key);
2686
+ if (!column) throw new Error(`Unknown column: ${key}`);
2687
+ return column.props;
2688
+ }
2689
+
2690
+ function isAdjustableWidthColumn(column) {
2691
+ return (
2692
+ column?.adjustable &&
2693
+ Number.isSafeInteger(column.props.width) &&
2694
+ Number.isSafeInteger(column.minWidth)
2695
+ );
2696
+ }
2697
+
2698
+ function adjustableWidthKeys(tab) {
2699
+ if (!tab || !Array.isArray(tab.header)) return [];
2700
+ return tab.header.filter(isAdjustableWidthColumn).map((column) => column.key);
2701
+ }
2702
+
2703
+ function selectWidthKey(tab, rememberedKey = null) {
2704
+ const keys = adjustableWidthKeys(tab);
2705
+ return keys.includes(rememberedKey) ? rememberedKey : (keys[0] ?? null);
2706
+ }
2707
+
2708
+ function cycleWidthKey(tab, selectedKey, direction) {
2709
+ if (!Number.isSafeInteger(direction) || direction === 0) return selectWidthKey(tab, selectedKey);
2710
+ const keys = adjustableWidthKeys(tab);
2711
+ if (keys.length === 0) return null;
2712
+ const current = keys.indexOf(selectedKey);
2713
+ if (current < 0) return direction > 0 ? keys[0] : keys[keys.length - 1];
2714
+ return keys[(current + Math.sign(direction) + keys.length) % keys.length];
2715
+ }
2716
+
2717
+ function resolveHeader(base, overrides = EMPTY_WIDTH_OVERRIDES) {
2718
+ let changed = false;
2719
+ const resolved = base.map((column) => {
2720
+ if (
2721
+ !isAdjustableWidthColumn(column) ||
2722
+ overrides == null ||
2723
+ !Object.hasOwn(overrides, column.key) ||
2724
+ !Number.isSafeInteger(overrides[column.key])
2725
+ ) {
2726
+ return column;
2727
+ }
2728
+
2729
+ const width = Math.max(column.minWidth, overrides[column.key]);
2730
+ if (width === column.props.width) return column;
2731
+ changed = true;
2732
+ return { ...column, props: { ...column.props, width } };
2733
+ });
2734
+ return changed ? resolved : base;
2735
+ }
2736
+
2737
+ function fitHeaderToFrame(preferred, defaults, frameCols) {
2738
+ const preferredFloor = minimumWidthFor(preferred);
2739
+ if (preferredFloor <= frameCols) return preferred;
2740
+ if (minimumWidthFor(defaults) > frameCols) return null;
2741
+
2742
+ let remaining = preferredFloor - frameCols;
2743
+ return preferred.map((column) => {
2744
+ if (remaining <= 0 || !Number.isSafeInteger(column.props.width)) return column;
2745
+ const defaultColumn = defaults.find((candidate) => candidate.key === column.key);
2746
+ const defaultWidth = defaultColumn?.props.width;
2747
+ if (!Number.isSafeInteger(defaultWidth) || column.props.width <= defaultWidth) return column;
2748
+
2749
+ const shrinkBy = Math.min(remaining, column.props.width - defaultWidth);
2750
+ remaining -= shrinkBy;
2751
+ return { ...column, props: { ...column.props, width: column.props.width - shrinkBy } };
2752
+ });
2753
+ }
2754
+
2755
+ function effectiveHeaderFor(tab, tabOverrides, frameCols) {
2756
+ return fitHeaderToFrame(
2757
+ resolveHeader(tab.header, tabOverrides),
2758
+ tab.header,
2759
+ frameCols,
2760
+ );
2761
+ }
2762
+
2763
+ function adjustWidth({ header, key, delta, frameCols }) {
2764
+ if (!Number.isSafeInteger(delta) || delta === 0 || !Number.isSafeInteger(frameCols)) return header;
2765
+ const index = header.findIndex((column) => column.key === key);
2766
+ if (index < 0) return header;
2767
+
2768
+ const column = header[index];
2769
+ if (!isAdjustableWidthColumn(column)) return header;
2770
+
2771
+ const available = Math.max(0, frameCols - minimumWidthFor(header));
2772
+ const maximum = column.props.width + available;
2773
+ const width = Math.min(maximum, Math.max(column.minWidth, column.props.width + delta));
2774
+ if (width === column.props.width) return header;
2775
+
2776
+ const adjusted = [...header];
2777
+ adjusted[index] = { ...column, props: { ...column.props, width } };
2778
+ return adjusted;
2779
+ }
2780
+
2781
+ function removeWidthOverride(overrides, tabKey, key) {
2782
+ if (!isRecord(overrides)) return overrides;
2783
+ const tabOverrides = overrides[tabKey];
2784
+ if (!isRecord(tabOverrides) || !Object.hasOwn(tabOverrides, key)) return overrides;
2785
+
2786
+ const nextTab = { ...tabOverrides };
2787
+ delete nextTab[key];
2788
+ if (Object.keys(nextTab).length > 0) return { ...overrides, [tabKey]: nextTab };
2789
+
2790
+ const next = { ...overrides };
2791
+ delete next[tabKey];
2792
+ return next;
2793
+ }
2794
+
2795
+ // One immutable preference reducer for keyboard deltas and the pointer phase's
2796
+ // absolute drag snapshots. The optional geometry arguments are the live fitted
2797
+ // header and frame budget; omitting them gives unit callers semantic-minimum
2798
+ // clamping without inventing a terminal width.
2799
+ function updateWidthPreference({
2800
+ overrides,
2801
+ tab,
2802
+ key,
2803
+ nextWidth,
2804
+ effectiveHeader,
2805
+ frameCols,
2806
+ }) {
2807
+ if (!isRecord(overrides) || !tab || !Array.isArray(tab.header) || !Number.isSafeInteger(nextWidth)) {
2808
+ return overrides;
2809
+ }
2810
+ const defaultColumn = tab.header.find((column) => column.key === key);
2811
+ if (!isAdjustableWidthColumn(defaultColumn)) return overrides;
2812
+
2813
+ const tabOverrides = isRecord(overrides[tab.key]) ? overrides[tab.key] : EMPTY_WIDTH_OVERRIDES;
2814
+ const currentHeader = Array.isArray(effectiveHeader)
2815
+ ? effectiveHeader
2816
+ : resolveHeader(tab.header, tabOverrides);
2817
+ const currentColumn = currentHeader.find((column) => column.key === key);
2818
+ if (!isAdjustableWidthColumn(currentColumn)) return overrides;
2819
+
2820
+ const delta = nextWidth - currentColumn.props.width;
2821
+ const liveFrameCols = Number.isSafeInteger(frameCols)
2822
+ ? frameCols
2823
+ : minimumWidthFor(currentHeader) + Math.max(0, delta);
2824
+ const adjusted = adjustWidth({ header: currentHeader, key, delta, frameCols: liveFrameCols });
2825
+ if (adjusted === currentHeader) return overrides;
2826
+ const width = adjusted.find((column) => column.key === key).props.width;
2827
+ const hasOverride = Object.hasOwn(tabOverrides, key);
2828
+
2829
+ if (width === defaultColumn.props.width) {
2830
+ return removeWidthOverride(overrides, tab.key, key);
2831
+ }
2832
+
2833
+ if (hasOverride && tabOverrides[key] === width) return overrides;
2834
+ return { ...overrides, [tab.key]: { ...tabOverrides, [key]: width } };
2835
+ }
2836
+
2837
+ function resetWidthPreference(overrides, tabKey, key) {
2838
+ if (!isRecord(overrides)) return overrides;
2839
+ const tab = tabForKey(tabKey);
2840
+ const column = tab?.header.find((candidate) => candidate.key === key);
2841
+ if (!isAdjustableWidthColumn(column)) return overrides;
2842
+ return removeWidthOverride(overrides, tabKey, key);
2843
+ }
2844
+
2845
+ function resetTabWidthPreferences(overrides, tabKey) {
2846
+ if (!isRecord(overrides) || !Object.hasOwn(overrides, tabKey)) return overrides;
2847
+ if (!tabForKey(tabKey)) return overrides;
2848
+ const next = { ...overrides };
2849
+ delete next[tabKey];
2850
+ return next;
2851
+ }
2852
+
2217
2853
  // The narrowest width each tab's table can render without its fixed columns
2218
2854
  // overflowing the frame. Derived from the header descriptors rather than
2219
2855
  // hard-coded, so adding or resizing a column cannot silently invalidate it.
@@ -2226,6 +2862,227 @@ function minimumWidthFor(header) {
2226
2862
  // + 2 border verticals, + 2 paddingX, + at least 4 columns for TITLE.
2227
2863
  return fixed + 8;
2228
2864
  }
2865
+
2866
+ // ---------- Width preferences ----------
2867
+
2868
+ const WIDTH_PREFERENCES_VERSION = 1;
2869
+ let widthPreferenceTempSequence = 0;
2870
+
2871
+ function widthPreferencesPath({
2872
+ env = process.env,
2873
+ platform = process.platform,
2874
+ home = homedir(),
2875
+ } = {}) {
2876
+ const xdgRoot = env?.XDG_CONFIG_HOME;
2877
+ const root =
2878
+ typeof xdgRoot === "string" && xdgRoot.length > 0 && isAbsolute(xdgRoot)
2879
+ ? xdgRoot
2880
+ : platform === "darwin"
2881
+ ? join(home, "Library", "Application Support")
2882
+ : join(home, ".config");
2883
+ return join(root, "gh-glance", "preferences.json");
2884
+ }
2885
+
2886
+ function isRecord(value) {
2887
+ return value !== null && typeof value === "object" && !Array.isArray(value);
2888
+ }
2889
+
2890
+ function normalizeWidthOverrides(overrides, tabs = TABS, { omitDefaults = false } = {}) {
2891
+ if (!isRecord(overrides)) return {};
2892
+
2893
+ const normalized = {};
2894
+ for (const tab of tabs) {
2895
+ if (!Object.hasOwn(overrides, tab.key) || !isRecord(overrides[tab.key])) continue;
2896
+
2897
+ const source = overrides[tab.key];
2898
+ const tabOverrides = {};
2899
+ for (const column of tab.header) {
2900
+ if (!isAdjustableWidthColumn(column) || !Object.hasOwn(source, column.key)) continue;
2901
+ const width = source[column.key];
2902
+ if (!Number.isSafeInteger(width) || width < column.minWidth) continue;
2903
+ if (omitDefaults && width === column.props.width) continue;
2904
+ tabOverrides[column.key] = width;
2905
+ }
2906
+ if (Object.keys(tabOverrides).length > 0) normalized[tab.key] = tabOverrides;
2907
+ }
2908
+ return normalized;
2909
+ }
2910
+
2911
+ function decodeWidthPreferences(raw, tabs = TABS) {
2912
+ let document;
2913
+ try {
2914
+ document = JSON.parse(raw);
2915
+ } catch (error) {
2916
+ return { preferences: {}, error };
2917
+ }
2918
+
2919
+ if (
2920
+ !isRecord(document) ||
2921
+ !Object.hasOwn(document, "version") ||
2922
+ document.version !== WIDTH_PREFERENCES_VERSION ||
2923
+ !Object.hasOwn(document, "tabs") ||
2924
+ !isRecord(document.tabs)
2925
+ ) {
2926
+ return { preferences: {}, error: new Error("Unsupported width preferences document") };
2927
+ }
2928
+
2929
+ return { preferences: normalizeWidthOverrides(document.tabs, tabs), error: null };
2930
+ }
2931
+
2932
+ function parseWidthPreferences(raw, tabs = TABS) {
2933
+ return decodeWidthPreferences(raw, tabs).preferences;
2934
+ }
2935
+
2936
+ function serializeWidthPreferences(overrides, tabs = TABS) {
2937
+ const document = {
2938
+ version: WIDTH_PREFERENCES_VERSION,
2939
+ tabs: normalizeWidthOverrides(overrides, tabs, { omitDefaults: true }),
2940
+ };
2941
+ return `${JSON.stringify(document, null, 2)}\n`;
2942
+ }
2943
+
2944
+ function loadWidthPreferences(path, tabs = TABS) {
2945
+ let raw;
2946
+ try {
2947
+ raw = readFileSync(path, "utf8");
2948
+ } catch (error) {
2949
+ return error?.code === "ENOENT"
2950
+ ? { preferences: {}, error: null }
2951
+ : { preferences: {}, error };
2952
+ }
2953
+ return decodeWidthPreferences(raw, tabs);
2954
+ }
2955
+
2956
+ function saveWidthPreferences(path, overrides, tabs = TABS) {
2957
+ let tempPath = null;
2958
+ try {
2959
+ const payload = serializeWidthPreferences(overrides, tabs);
2960
+ const parent = dirname(path);
2961
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
2962
+ widthPreferenceTempSequence += 1;
2963
+ tempPath = `${path}.${process.pid}.${Date.now()}.${widthPreferenceTempSequence}.tmp`;
2964
+ writeFileSync(tempPath, payload, {
2965
+ encoding: "utf8",
2966
+ mode: 0o600,
2967
+ flag: "wx",
2968
+ });
2969
+ renameSync(tempPath, path);
2970
+ return { ok: true };
2971
+ } catch (error) {
2972
+ if (tempPath !== null) {
2973
+ try {
2974
+ unlinkSync(tempPath);
2975
+ } catch {
2976
+ // The write may have failed before creating it. Cleanup is best effort
2977
+ // and deliberately targets only this operation's exact temporary file.
2978
+ }
2979
+ }
2980
+ return { ok: false, error };
2981
+ }
2982
+ }
2983
+
2984
+ function createWidthPreferenceWriter({ write, delay = 200, onResult = () => {} }) {
2985
+ let latest = EMPTY_WIDTH_OVERRIDES;
2986
+ let generation = 0;
2987
+ let persistedGeneration = 0;
2988
+ let timer = null;
2989
+ let flushPromise = null;
2990
+
2991
+ function report(result) {
2992
+ try {
2993
+ onResult(result);
2994
+ } catch {
2995
+ // Persistence reporting is advisory and must not reach Ink's render path.
2996
+ }
2997
+ return result;
2998
+ }
2999
+
3000
+ function failed(error) {
3001
+ return { ok: false, error };
3002
+ }
3003
+
3004
+ async function writeGeneration(value) {
3005
+ let pending;
3006
+ try {
3007
+ // Keep this call before the first await. The production writer is
3008
+ // synchronous, so an unmount flush completes the filesystem replacement
3009
+ // before React hands terminal teardown back to its caller.
3010
+ pending = write(value);
3011
+ } catch (error) {
3012
+ return failed(error);
3013
+ }
3014
+
3015
+ try {
3016
+ const result = await pending;
3017
+ return result?.ok === false ? result : (result ?? { ok: true });
3018
+ } catch (error) {
3019
+ return failed(error);
3020
+ }
3021
+ }
3022
+
3023
+ async function drain() {
3024
+ let result = { ok: true, written: false };
3025
+ while (persistedGeneration < generation) {
3026
+ const attemptedGeneration = generation;
3027
+ const value = latest;
3028
+ result = await writeGeneration(value);
3029
+
3030
+ const isLatest = attemptedGeneration === generation;
3031
+ if (result.ok !== false) {
3032
+ persistedGeneration = Math.max(persistedGeneration, attemptedGeneration);
3033
+ }
3034
+
3035
+ // The loop serializes a superseding write behind this completion, so its
3036
+ // result owns the warning state. A stale completion is deliberately not
3037
+ // reported, while async replacements can neither race on disk nor report
3038
+ // out of order.
3039
+ if (!isLatest) continue;
3040
+ report(result);
3041
+ // Leave the latest generation dirty after failure. A later flush/dispose
3042
+ // retries it; concurrent callers still share this completed attempt.
3043
+ if (result.ok === false) return result;
3044
+ }
3045
+ return result;
3046
+ }
3047
+
3048
+ function flush() {
3049
+ if (timer !== null) {
3050
+ clearTimeout(timer);
3051
+ timer = null;
3052
+ }
3053
+ if (flushPromise !== null) return flushPromise;
3054
+ if (persistedGeneration >= generation) {
3055
+ return Promise.resolve({ ok: true, written: false });
3056
+ }
3057
+
3058
+ const pending = drain().finally(() => {
3059
+ if (flushPromise === pending) flushPromise = null;
3060
+ });
3061
+ flushPromise = pending;
3062
+ return pending;
3063
+ }
3064
+
3065
+ function schedule(value) {
3066
+ latest = value;
3067
+ generation += 1;
3068
+ if (timer === null) {
3069
+ timer = setTimeout(() => {
3070
+ timer = null;
3071
+ void flush();
3072
+ }, delay);
3073
+ timer.unref?.();
3074
+ } else {
3075
+ timer.refresh();
3076
+ }
3077
+ }
3078
+
3079
+ return {
3080
+ schedule,
3081
+ flush,
3082
+ dispose: flush,
3083
+ };
3084
+ }
3085
+
2229
3086
  // Below this the full column set cannot render without its fixed columns
2230
3087
  // overflowing the frame -- rows hard-wrap and ink switches to clearing and
2231
3088
  // repainting the whole screen every frame. Worse, in the band just above the
@@ -2314,6 +3171,7 @@ const KEY_HINTS = [
2314
3171
  { label: "Move", keys: "↑↓" },
2315
3172
  { label: "Open", keys: "Ent" },
2316
3173
  { label: "Refresh", keys: "r" },
3174
+ { label: "Width", keys: "w" },
2317
3175
  { label: "Quit", keys: "q" },
2318
3176
  ];
2319
3177
 
@@ -2340,6 +3198,26 @@ const REMOTE_SETUP_NONINTERACTIVE_LINES = [
2340
3198
  // finishes. Wide enough for the spinner, a space and "Fetching".
2341
3199
  const FETCHING_WIDTH = 12;
2342
3200
 
3201
+ function widthStatusText({ label, width, cols, saveError = false }) {
3202
+ const budget = Number.isSafeInteger(cols) ? Math.max(0, cols) : 0;
3203
+ const safeLabel = String(label ?? "").replace(/[^\x20-\x7e]/g, "?");
3204
+ const safeWidth = Number.isSafeInteger(width) ? String(width) : "?";
3205
+ const variants = saveError
3206
+ ? [
3207
+ `Width: ${safeLabel} ${safeWidth} Widths not saved`,
3208
+ `${safeLabel} ${safeWidth} Widths not saved`,
3209
+ "Widths not saved",
3210
+ ]
3211
+ : [
3212
+ `Width: ${safeLabel} ${safeWidth} Tab select <- -> resize r reset Esc done`,
3213
+ `Width: ${safeLabel} ${safeWidth} <- -> resize r reset Esc done`,
3214
+ `${safeLabel} ${safeWidth} <- -> r reset Esc done`,
3215
+ `${safeLabel} ${safeWidth} <- -> r Esc`,
3216
+ ];
3217
+ const fitting = variants.find((variant) => variant.length <= budget);
3218
+ return fitting ?? variants[variants.length - 1].slice(0, budget);
3219
+ }
3220
+
2343
3221
  // Compact drops the labels and keeps the keys, separated by a space:
2344
3222
  // "↑↓ Ent r q". No constant for its width -- nothing branches on it, and the
2345
3223
  // keys are short enough that it fits anywhere the frame itself does.
@@ -2356,7 +3234,37 @@ const FETCHING_WIDTH = 12;
2356
3234
  // finding at a glance, so they get the accent colour and the words describing
2357
3235
  // them stay dim. The accent is the panel-title cyan rather than the amber used
2358
3236
  // for in-progress status, so amber means exactly one thing across the product.
2359
- function StatusBar({ fetching, spin, stale, interactive, cols, remoteSetup = false }) {
3237
+ function StatusBar({
3238
+ fetching,
3239
+ spin,
3240
+ stale,
3241
+ throttle,
3242
+ interactive,
3243
+ cols,
3244
+ remoteSetup = false,
3245
+ widthMode = false,
3246
+ widthColumn = null,
3247
+ widthSaveError = null,
3248
+ }) {
3249
+ // Width mode owns the whole bar, so the throttle badge is deliberately not
3250
+ // shown here: this state is transient and explicitly entered, and the widened
3251
+ // interval is still reported the moment the user leaves it.
3252
+ if (widthMode && widthColumn) {
3253
+ return e(
3254
+ Box,
3255
+ { flexDirection: "row" },
3256
+ e(
3257
+ Text,
3258
+ { color: widthSaveError ? ATTENTION : undefined, wrap: "truncate-end" },
3259
+ widthStatusText({
3260
+ label: widthColumn.label,
3261
+ width: widthColumn.props.width,
3262
+ cols,
3263
+ saveError: Boolean(widthSaveError),
3264
+ }),
3265
+ ),
3266
+ );
3267
+ }
2360
3268
  // Without raw mode none of the key handlers run, so advertising them would be
2361
3269
  // telling the user something untrue about what the app can do. Ctrl+C still
2362
3270
  // works there, because the tty delivers a real SIGINT.
@@ -2393,6 +3301,12 @@ function StatusBar({ fetching, spin, stale, interactive, cols, remoteSetup = fal
2393
3301
  stale
2394
3302
  ? e(Box, { marginRight: 1, flexShrink: 0 }, e(Text, { color: ATTENTION }, stale))
2395
3303
  : null,
3304
+ // Same free-slot treatment as `stale` above. Dim rather than ATTENTION: a
3305
+ // widened interval is the throttle working, not a failure, and colouring it
3306
+ // like a problem would send the user looking for one.
3307
+ throttle
3308
+ ? e(Box, { marginRight: 1, flexShrink: 0 }, e(Text, { dimColor: true }, throttle))
3309
+ : null,
2396
3310
  ...hints
2397
3311
  .flatMap((hint, i) => [
2398
3312
  i > 0 &&
@@ -2462,8 +3376,39 @@ function useTerminalSize(stdout) {
2462
3376
  function App({ onCreateRemote = () => {} } = {}) {
2463
3377
  const { stdout } = useStdout();
2464
3378
  const { isRawModeSupported } = useStdin();
2465
- const { exit } = useApp();
3379
+ const { exit, suspendTerminal } = useApp();
2466
3380
  const [activeIndex, setActiveIndex] = useState(runtime.initialTabIndex);
3381
+ const [preferencePath] = useState(() => widthPreferencesPath());
3382
+ const [widthOverrides, setWidthOverrides] = useState(
3383
+ () => loadWidthPreferences(preferencePath).preferences,
3384
+ );
3385
+ const widthOverridesRef = useRef(widthOverrides);
3386
+ widthOverridesRef.current = widthOverrides;
3387
+ const [widthSaveError, setWidthSaveError] = useState(null);
3388
+ const widthPreferencesMountedRef = useRef(true);
3389
+ const [widthPreferenceWriter] = useState(() =>
3390
+ createWidthPreferenceWriter({
3391
+ write: (overrides) => saveWidthPreferences(preferencePath, overrides),
3392
+ onResult: (result) => {
3393
+ if (!widthPreferencesMountedRef.current) return;
3394
+ setWidthSaveError(
3395
+ result?.ok === false
3396
+ ? (result.error ?? new Error("Width preferences could not be saved"))
3397
+ : null,
3398
+ );
3399
+ },
3400
+ }),
3401
+ );
3402
+ useEffect(() => {
3403
+ widthPreferencesMountedRef.current = true;
3404
+ return () => {
3405
+ // saveWidthPreferences() itself runs synchronously inside dispose(), but
3406
+ // writer result reporting settles through a promise. Mark unmounted first
3407
+ // so that later callback cannot enqueue state on a departing App.
3408
+ widthPreferencesMountedRef.current = false;
3409
+ void widthPreferenceWriter.dispose();
3410
+ };
3411
+ }, [widthPreferenceWriter]);
2467
3412
  // `null` means "never resolved" -- distinct from `[]`, which means "resolved
2468
3413
  // and genuinely empty". The tab bar and the body render those differently.
2469
3414
  const [data, setData] = useState({ actions: null, issues: null, prs: null, security: null });
@@ -2487,6 +3432,9 @@ function App({ onCreateRemote = () => {} } = {}) {
2487
3432
  const [errors, setErrors] = useState({ actions: null, issues: null, prs: null, security: null });
2488
3433
  const [failureContext, setFailureContext] = useState(null);
2489
3434
  const [loading, setLoading] = useState({ actions: true, issues: true, prs: true, security: true });
3435
+ // The widened poll interval in ms while the adaptive throttle is holding one,
3436
+ // or null at the configured refresh. Only ever set from inside the poll effect.
3437
+ const [throttleMs, setThrottleMs] = useState(null);
2490
3438
  const [now, setNow] = useState(new Date());
2491
3439
  const { rows, cols, useShortLabels } = useTerminalSize(stdout);
2492
3440
  const [frame, setFrame] = useState(0);
@@ -2495,6 +3443,8 @@ function App({ onCreateRemote = () => {} } = {}) {
2495
3443
  // renders byte-identical frames and ink still writes nothing.
2496
3444
  const [selected, setSelected] = useState({});
2497
3445
  const [offset, setOffset] = useState({});
3446
+ const [widthMode, setWidthMode] = useState(false);
3447
+ const [selectedWidthKeyByTab, setSelectedWidthKeyByTab] = useState({});
2498
3448
 
2499
3449
  const tab = TABS[activeIndex];
2500
3450
  const tabError = errors[tab.key];
@@ -2590,6 +3540,185 @@ function App({ onCreateRemote = () => {} } = {}) {
2590
3540
  showHelpRef.current = showHelp;
2591
3541
  const remoteSetupRef = useRef(false);
2592
3542
  remoteSetupRef.current = remoteSetup;
3543
+ const headerRef = useRef(null);
3544
+ const dragRef = useRef(null);
3545
+ const resizeRef = useRef({
3546
+ active: false,
3547
+ tabKey: "actions",
3548
+ selectedKey: null,
3549
+ effectiveHeader: null,
3550
+ frameCols: 0,
3551
+ compact: true,
3552
+ fullHeaderVisible: false,
3553
+ });
3554
+
3555
+ useEffect(() => {
3556
+ if (!interactive) return;
3557
+ enableMouseReporting();
3558
+ return () => {
3559
+ dragRef.current = null;
3560
+ disableMouseReporting();
3561
+ };
3562
+ }, [interactive]);
3563
+
3564
+ function applyWidthOverrides(next) {
3565
+ const current = widthOverridesRef.current;
3566
+ if (next === current) return false;
3567
+ widthOverridesRef.current = next;
3568
+ setWidthOverrides(next);
3569
+ // Schedule in the input turn, not a post-render effect. Reset, mode exit and
3570
+ // quit all flush immediately, and must not race ahead of React committing the
3571
+ // state that contains the latest width.
3572
+ widthPreferenceWriter.schedule(next);
3573
+
3574
+ const geometry = resizeRef.current;
3575
+ const activeTab = tabForKey(geometry.tabKey);
3576
+ if (activeTab) {
3577
+ const nextTabOverrides = pick(next, activeTab.key, EMPTY_WIDTH_OVERRIDES) ??
3578
+ EMPTY_WIDTH_OVERRIDES;
3579
+ const effective = effectiveHeaderFor(activeTab, nextTabOverrides, geometry.frameCols);
3580
+ resizeRef.current = {
3581
+ ...geometry,
3582
+ effectiveHeader: effective,
3583
+ compact: effective == null,
3584
+ fullHeaderVisible: geometry.fullHeaderVisible && effective != null,
3585
+ };
3586
+ }
3587
+ return true;
3588
+ }
3589
+
3590
+ function flushWidthPreferences() {
3591
+ void widthPreferenceWriter.flush();
3592
+ }
3593
+
3594
+ function leaveWidthMode() {
3595
+ dragRef.current = null;
3596
+ resizeRef.current = { ...resizeRef.current, active: false };
3597
+ setWidthMode(false);
3598
+ flushWidthPreferences();
3599
+ }
3600
+
3601
+ function rememberWidthSelection(key) {
3602
+ if (key === null) return;
3603
+ const tabKey = resizeRef.current.tabKey;
3604
+ resizeRef.current = { ...resizeRef.current, selectedKey: key };
3605
+ setSelectedWidthKeyByTab((current) =>
3606
+ current[tabKey] === key ? current : { ...current, [tabKey]: key },
3607
+ );
3608
+ }
3609
+
3610
+ function enterWidthMode(requestedKey = null) {
3611
+ const geometry = resizeRef.current;
3612
+ if (!geometry.fullHeaderVisible || geometry.compact) return false;
3613
+ const activeTab = tabForKey(geometry.tabKey);
3614
+ const selectedKey = selectWidthKey(activeTab, requestedKey ?? geometry.selectedKey);
3615
+ if (selectedKey === null) return false;
3616
+ rememberWidthSelection(selectedKey);
3617
+ resizeRef.current = { ...resizeRef.current, active: true, selectedKey };
3618
+ setWidthMode(true);
3619
+ return true;
3620
+ }
3621
+
3622
+ function resizeSelectedWidth(delta) {
3623
+ const geometry = resizeRef.current;
3624
+ if (!geometry.active || !geometry.fullHeaderVisible || geometry.compact) return;
3625
+ const activeTab = tabForKey(geometry.tabKey);
3626
+ const column = geometry.effectiveHeader?.find(
3627
+ (candidate) => candidate.key === geometry.selectedKey,
3628
+ );
3629
+ if (!activeTab || !isAdjustableWidthColumn(column)) return;
3630
+ const next = updateWidthPreference({
3631
+ overrides: widthOverridesRef.current,
3632
+ tab: activeTab,
3633
+ key: geometry.selectedKey,
3634
+ nextWidth: column.props.width + delta,
3635
+ effectiveHeader: geometry.effectiveHeader,
3636
+ frameCols: geometry.frameCols,
3637
+ });
3638
+ applyWidthOverrides(next);
3639
+ }
3640
+
3641
+ function resetSelectedWidth() {
3642
+ dragRef.current = null;
3643
+ const { tabKey, selectedKey } = resizeRef.current;
3644
+ applyWidthOverrides(resetWidthPreference(widthOverridesRef.current, tabKey, selectedKey));
3645
+ flushWidthPreferences();
3646
+ }
3647
+
3648
+ function resetActiveTabWidths() {
3649
+ dragRef.current = null;
3650
+ const { tabKey } = resizeRef.current;
3651
+ applyWidthOverrides(resetTabWidthPreferences(widthOverridesRef.current, tabKey));
3652
+ flushWidthPreferences();
3653
+ }
3654
+
3655
+ const cancelWidthDrag = useCallback(() => {
3656
+ if (dragRef.current === null) return false;
3657
+ dragRef.current = null;
3658
+ void widthPreferenceWriter.flush();
3659
+ return true;
3660
+ }, [widthPreferenceWriter]);
3661
+
3662
+ const measuredHeader = useCallback(() => {
3663
+ return headerRef.current ? measureElement(headerRef.current) : null;
3664
+ }, []);
3665
+
3666
+ function handleSgrMouse(event) {
3667
+ if (event.action === "release") {
3668
+ dragRef.current = null;
3669
+ flushWidthPreferences();
3670
+ return;
3671
+ }
3672
+
3673
+ const geometry = resizeRef.current;
3674
+ if (event.action === "press") {
3675
+ if (!interactive || !geometry.fullHeaderVisible || geometry.compact) return;
3676
+ const metrics = measuredHeader();
3677
+ if (!metrics) return;
3678
+ const drag = beginDividerDrag({
3679
+ event,
3680
+ handles: dividerHandles({ header: geometry.effectiveHeader, metrics }),
3681
+ tabKey: geometry.tabKey,
3682
+ });
3683
+ if (!drag || !enterWidthMode(drag.key)) return;
3684
+ dragRef.current = { ...drag, metrics };
3685
+ return;
3686
+ }
3687
+
3688
+ const drag = dragRef.current;
3689
+ if (!drag) return;
3690
+ const metrics = measuredHeader();
3691
+ const layoutValid = sameElementMetrics(drag.metrics, metrics);
3692
+ const proposal = draggedWidth({
3693
+ drag,
3694
+ event,
3695
+ tabKey: geometry.tabKey,
3696
+ fullHeaderVisible: geometry.fullHeaderVisible,
3697
+ layoutValid,
3698
+ });
3699
+ if (!proposal) {
3700
+ if (!layoutValid || drag.tabKey !== geometry.tabKey || !geometry.fullHeaderVisible) {
3701
+ cancelWidthDrag();
3702
+ }
3703
+ return;
3704
+ }
3705
+
3706
+ const activeTab = tabForKey(geometry.tabKey);
3707
+ if (!activeTab) {
3708
+ cancelWidthDrag();
3709
+ return;
3710
+ }
3711
+ applyWidthOverrides(
3712
+ updateWidthPreference({
3713
+ overrides: widthOverridesRef.current,
3714
+ tab: activeTab,
3715
+ key: proposal.key,
3716
+ nextWidth: proposal.nextWidth,
3717
+ effectiveHeader: geometry.effectiveHeader,
3718
+ frameCols: geometry.frameCols,
3719
+ }),
3720
+ );
3721
+ }
2593
3722
 
2594
3723
  function moveSelection(delta) {
2595
3724
  const { items, key: currentKey, bodyRows: rows_, tabKey, offset: offsetRaw } = navRef.current;
@@ -2665,7 +3794,38 @@ function App({ onCreateRemote = () => {} } = {}) {
2665
3794
 
2666
3795
  useInput(
2667
3796
  (input, key) => {
2668
- if (input === "q" || key.escape) {
3797
+ // Ink delivers a complete unrecognised CSI token with its leading Escape
3798
+ // removed. Consume the whole SGR namespace here, including unsupported
3799
+ // buttons/modifiers, so no report can fall through to keyboard bindings.
3800
+ if (input.startsWith("[<")) {
3801
+ const mouse = parseSgrMouse(input);
3802
+ if (mouse) handleSgrMouse(mouse);
3803
+ return;
3804
+ }
3805
+ if (input === "q" || (input === "c" && key.ctrl)) {
3806
+ flushWidthPreferences();
3807
+ exit();
3808
+ } else if (resizeRef.current.active) {
3809
+ if (input === "w" || key.return || key.escape) {
3810
+ leaveWidthMode();
3811
+ } else if (key.tab) {
3812
+ const activeTab = tabForKey(resizeRef.current.tabKey);
3813
+ rememberWidthSelection(
3814
+ cycleWidthKey(activeTab, resizeRef.current.selectedKey, key.shift ? -1 : 1),
3815
+ );
3816
+ } else if (key.leftArrow) {
3817
+ resizeSelectedWidth(key.shift ? -5 : -1);
3818
+ } else if (key.rightArrow) {
3819
+ resizeSelectedWidth(key.shift ? 5 : 1);
3820
+ } else if (input === "r") {
3821
+ resetSelectedWidth();
3822
+ } else if (input === "R") {
3823
+ resetActiveTabWidths();
3824
+ }
3825
+ // Width mode owns every other key. In particular, digits, arrows, Tab,
3826
+ // Enter and r must never fall through to their ordinary meanings.
3827
+ } else if (key.escape) {
3828
+ flushWidthPreferences();
2669
3829
  exit();
2670
3830
  } else if (showHelpRef.current) {
2671
3831
  // Any key dismisses -- except quit, handled above, which must never be
@@ -2675,6 +3835,8 @@ function App({ onCreateRemote = () => {} } = {}) {
2675
3835
  setShowHelp(false);
2676
3836
  } else if (input === "?") {
2677
3837
  setShowHelp(true);
3838
+ } else if (input === "w") {
3839
+ enterWidthMode();
2678
3840
  } else if (key.downArrow || input === "j") {
2679
3841
  moveSelection(1);
2680
3842
  } else if (key.upArrow || input === "k") {
@@ -2684,7 +3846,7 @@ function App({ onCreateRemote = () => {} } = {}) {
2684
3846
  } else if (key.pageUp) {
2685
3847
  moveSelection(-pageStep);
2686
3848
  } else if (key.return) {
2687
- if (remoteSetupRef.current) onCreateRemote();
3849
+ if (remoteSetupRef.current) onCreateRemote(suspendTerminal);
2688
3850
  else openSelected();
2689
3851
  } else if (input === "r") {
2690
3852
  // Goes through the same per-tab in-flight guard as the poll loop, so
@@ -2723,6 +3885,13 @@ function App({ onCreateRemote = () => {} } = {}) {
2723
3885
  // ref or a setState function, both of which the rule treats as stable -- so
2724
3886
  // this needs no suppression, and if one ever becomes necessary that is the
2725
3887
  // signal that a real dependency crept in.
3888
+ //
3889
+ // The adaptive throttle re-arms the timer, which is not a contradiction of the
3890
+ // above: `rearm` swaps the interval from *inside* this effect, so the effect
3891
+ // still runs exactly once and no closure, ref or AbortController is ever
3892
+ // recreated. What is created once is the effect; what changes is only the
3893
+ // delay `tick` is scheduled at. Do not move the re-arm into a dependency
3894
+ // array -- that is precisely the teardown this comment exists to prevent.
2726
3895
  useEffect(() => {
2727
3896
  let cancelled = false;
2728
3897
  let ticks = 0;
@@ -2759,6 +3928,10 @@ function App({ onCreateRemote = () => {} } = {}) {
2759
3928
  setLoading((l) => (l[key] ? l : { ...l, [key]: true }));
2760
3929
  return run()
2761
3930
  .then((result) => {
3931
+ // Billed before the `cancelled` early return: the requests were made
3932
+ // and counted by GitHub whether or not this process still wants the
3933
+ // answer.
3934
+ restSpentTotal += result?.restSpent ?? 0;
2762
3935
  if (cancelled) return;
2763
3936
  const { raw, parse, limit } = result;
2764
3937
  // Identical payload: skip the parse *and* the state update. Returning
@@ -2808,6 +3981,11 @@ function App({ onCreateRemote = () => {} } = {}) {
2808
3981
  }
2809
3982
  })
2810
3983
  .catch((err) => {
3984
+ // A rejected fetch carries no result, so its own spend is unknowable
3985
+ // here; bill the tab's table cost instead. A request that failed after
3986
+ // reaching GitHub still counted, and over-billing by an aborted call
3987
+ // errs toward throttling harder, which is the safe direction.
3988
+ restSpentTotal += REST_PER_FETCH[key] ?? 0;
2811
3989
  if (cancelled || err?.name === "AbortError") return;
2812
3990
  cleanRef.current[key] = false;
2813
3991
  // Preserve both the verdict and the bounded raw error in state. The
@@ -2838,7 +4016,7 @@ function App({ onCreateRemote = () => {} } = {}) {
2838
4016
  // exactly the moment the pane looks broken and they reach for it.
2839
4017
  function fetchTab(key, { force = false } = {}) {
2840
4018
  const signal = controller.signal;
2841
- const descriptor = TABS.find((t) => t.key === key);
4019
+ const descriptor = tabForKey(key);
2842
4020
  if (!force && backoffActive(`tab:${key}`, performance.now())) return Promise.resolve();
2843
4021
  if (force) clearBackoff(`tab:${key}`);
2844
4022
  // Every tab carries its own fetcher on the registry, so adding a tab is a
@@ -2854,6 +4032,10 @@ function App({ onCreateRemote = () => {} } = {}) {
2854
4032
  // so stop spawning subprocesses once any endpoint proves there is no
2855
4033
  // remote. The interval may still wake, but this branch is otherwise idle.
2856
4034
  if (remoteSetupRef.current) return;
4035
+ // Kicked off, never awaited: the budget probe must not delay the fetches
4036
+ // this tick is for. `probeInFlight` keeps a slow probe from stampeding.
4037
+ const nowMs = Date.now();
4038
+ if (!probeInFlight && nowMs - lastProbeAt >= BUDGET_PROBE_MS) void probeBudget(nowMs);
2857
4039
  // Only the tab you're looking at needs to keep up with REFRESH_MS. The
2858
4040
  // other three exist to keep the tab-bar counts honest, which tolerates
2859
4041
  // being a minute behind -- so they refresh every BACKGROUND_EVERY ticks
@@ -2874,11 +4056,64 @@ function App({ onCreateRemote = () => {} } = {}) {
2874
4056
  );
2875
4057
  }
2876
4058
 
4059
+ // The adaptive throttle. `appliedMs` is what the timer is currently set to;
4060
+ // runtime.refreshMs stays the floor it may widen from and never tighten past.
4061
+ let appliedMs = runtime.refreshMs;
4062
+ let intervalId = null;
4063
+ // 0 rather than `now`, so the first tick probes immediately: a pane started
4064
+ // into an already-drained budget must back off at once rather than spend a
4065
+ // minute at full rate first.
4066
+ let lastProbeAt = 0;
4067
+ let probeInFlight = false;
4068
+ // The open probe window, and the last share it was able to measure. Both are
4069
+ // carried by nextProbeWindow/inferShare rather than reset every probe -- see
4070
+ // those functions for why a throttled pane must not re-measure from scratch.
4071
+ let probeWindow = null;
4072
+ let lastShare = 1;
4073
+
4074
+ function rearm(ms) {
4075
+ appliedMs = ms;
4076
+ if (intervalId) clearInterval(intervalId);
4077
+ intervalId = setInterval(tick, ms);
4078
+ }
4079
+
4080
+ async function probeBudget(nowMs) {
4081
+ probeInFlight = true;
4082
+ lastProbeAt = nowMs; // set before awaiting, so ticks cannot stampede
4083
+ try {
4084
+ // `controller` is this effect's own AbortController, so a probe still in
4085
+ // flight at unmount is killed along with everything else.
4086
+ const budget = await readCoreBudget(controller.signal);
4087
+ if (cancelled || !budget) return;
4088
+ const { sample, next } = nextProbeWindow(probeWindow, budget, restSpentTotal);
4089
+ probeWindow = next;
4090
+ // Updated before the law reads it. inferShare is idempotent, so passing
4091
+ // the already-updated value cannot change the answer -- it just keeps
4092
+ // one variable holding the current share rather than two.
4093
+ lastShare = inferShare(sample, lastShare);
4094
+
4095
+ const target = adaptiveRefreshMs({
4096
+ budget,
4097
+ sample,
4098
+ lastShare,
4099
+ floorMs: runtime.refreshMs,
4100
+ nowMs: Date.now(),
4101
+ restPerTick: restPerTick(TABS[activeIndexRef.current].key),
4102
+ });
4103
+ if (adaptiveChangeWorthApplying(appliedMs, target)) {
4104
+ rearm(target);
4105
+ setThrottleMs(target > runtime.refreshMs ? target : null);
4106
+ }
4107
+ } finally {
4108
+ probeInFlight = false;
4109
+ }
4110
+ }
4111
+
2877
4112
  tick();
2878
- const id = setInterval(tick, runtime.refreshMs);
4113
+ rearm(runtime.refreshMs);
2879
4114
  return () => {
2880
4115
  cancelled = true;
2881
- clearInterval(id);
4116
+ clearInterval(intervalId);
2882
4117
  if (contextCoordinatorRef.current === coordinator) contextCoordinatorRef.current = null;
2883
4118
  // `cancelled` stops state updates from a promise that already resolved;
2884
4119
  // the signal stops the subprocess itself, so quitting doesn't orphan up to
@@ -3062,8 +4297,14 @@ function App({ onCreateRemote = () => {} } = {}) {
3062
4297
  // the cost -- different tabs showing different column counts at the same width
3063
4298
  // -- is not something you can actually see. MIN_TABLE_WIDTH stays as the
3064
4299
  // exported worst case, which is what the tests pin.
3065
- const compact = frameCols < minimumWidthFor(tab.header);
3066
- const header = compact ? tab.compactHeader : tab.header;
4300
+ const tabOverrides = pick(widthOverridesRef.current, tab.key, EMPTY_WIDTH_OVERRIDES) ??
4301
+ EMPTY_WIDTH_OVERRIDES;
4302
+ const effectiveHeader = useMemo(
4303
+ () => effectiveHeaderFor(tab, tabOverrides, frameCols),
4304
+ [tab, tabOverrides, frameCols],
4305
+ );
4306
+ const compact = effectiveHeader == null;
4307
+ const header = compact ? tab.compactHeader : effectiveHeader;
3067
4308
 
3068
4309
  // Below the compact set's own floor even the fixed columns overflow, which
3069
4310
  // hard-wraps every row and drives ink into clearing and repainting the whole
@@ -3072,6 +4313,50 @@ function App({ onCreateRemote = () => {} } = {}) {
3072
4313
  // sits after usableSize(), which substitutes DEFAULT_COLS for a 0 reported
3073
4314
  // mid-resize, so a transient zero cannot be mistaken for a genuinely tiny pane.
3074
4315
  const tooNarrow = frameCols < MIN_COMPACT_WIDTH;
4316
+ const fullHeaderVisible = !compact && !showHelp && !tooNarrow && !remoteSetup;
4317
+ const selectedWidthKey = selectedWidthKeyByTab[tab.key] ?? null;
4318
+ const selectedWidthColumn = effectiveHeader?.find(
4319
+ (column) => column.key === selectedWidthKey,
4320
+ ) ?? null;
4321
+ resizeRef.current = {
4322
+ active: widthMode && fullHeaderVisible && selectedWidthColumn !== null,
4323
+ tabKey: tab.key,
4324
+ selectedKey: selectedWidthKey,
4325
+ effectiveHeader,
4326
+ frameCols,
4327
+ compact,
4328
+ fullHeaderVisible,
4329
+ };
4330
+
4331
+ // A resize, help/setup transition, or replacement layout can remove the full
4332
+ // header without a keypress. Stop owning input immediately through resizeRef,
4333
+ // then settle the visible mode state and durable write in the effect.
4334
+ useEffect(() => {
4335
+ let flushed = false;
4336
+ const drag = dragRef.current;
4337
+ if (drag) {
4338
+ const metrics = measuredHeader();
4339
+ if (
4340
+ drag.tabKey !== tab.key ||
4341
+ !fullHeaderVisible ||
4342
+ !sameElementMetrics(drag.metrics, metrics)
4343
+ ) {
4344
+ flushed = cancelWidthDrag();
4345
+ }
4346
+ }
4347
+ if (!widthMode || fullHeaderVisible) return;
4348
+ setWidthMode(false);
4349
+ if (!flushed) void widthPreferenceWriter.flush();
4350
+ }, [
4351
+ widthMode,
4352
+ fullHeaderVisible,
4353
+ tab.key,
4354
+ frameCols,
4355
+ extraLines,
4356
+ cancelWidthDrag,
4357
+ measuredHeader,
4358
+ widthPreferenceWriter,
4359
+ ]);
3075
4360
 
3076
4361
  return e(
3077
4362
  Box,
@@ -3148,7 +4433,14 @@ function App({ onCreateRemote = () => {} } = {}) {
3148
4433
  line,
3149
4434
  ),
3150
4435
  ),
3151
- !showHelp && !tooNarrow && !remoteSetup && e(HeaderCells, { cells: header }),
4436
+ !showHelp &&
4437
+ !tooNarrow &&
4438
+ !remoteSetup &&
4439
+ e(MemoHeaderCells, {
4440
+ cells: header,
4441
+ selectedWidthKey: resizeRef.current.active ? selectedWidthKey : null,
4442
+ headerRef,
4443
+ }),
3152
4444
  ...(tooNarrow || showHelp || remoteSetup ? [] : visibleItems).map((item) => {
3153
4445
  const key = itemKey(item);
3154
4446
  return e(
@@ -3158,6 +4450,7 @@ function App({ onCreateRemote = () => {} } = {}) {
3158
4450
  item,
3159
4451
  now,
3160
4452
  compact,
4453
+ columns: header,
3161
4454
  cursor: key === selectedKey,
3162
4455
  // Only an *executing* Actions row reads this. It used to go to every
3163
4456
  // visible row on the tab, so a prop that changes several times a
@@ -3208,9 +4501,13 @@ function App({ onCreateRemote = () => {} } = {}) {
3208
4501
  fetching: Object.values(loading).some(Boolean),
3209
4502
  spin: showSpinner ? spin : null,
3210
4503
  stale: staleLabel,
4504
+ throttle: throttleMs ? `throttled ${Math.round(throttleMs / 1000)}s` : null,
3211
4505
  interactive,
3212
4506
  cols: frameCols,
3213
4507
  remoteSetup,
4508
+ widthMode: resizeRef.current.active,
4509
+ widthColumn: selectedWidthColumn,
4510
+ widthSaveError,
3214
4511
  }),
3215
4512
  );
3216
4513
  }
@@ -3224,16 +4521,58 @@ function enterAlternateScreen() {
3224
4521
  process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H");
3225
4522
  }
3226
4523
 
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;
4524
+ function createTerminalLifecycle(write) {
4525
+ if (typeof write !== "function") throw new TypeError("write must be a function");
4526
+ let mouseReportingEnabled = false;
4527
+ let screenRestored = false;
4528
+
4529
+ function enableMouseReporting() {
4530
+ if (mouseReportingEnabled || screenRestored) return false;
4531
+ write("\x1b[?1002h\x1b[?1006h");
4532
+ mouseReportingEnabled = true;
4533
+ return true;
4534
+ }
4535
+
4536
+ function disableMouseReporting() {
4537
+ if (!mouseReportingEnabled) return false;
4538
+ write("\x1b[?1002l\x1b[?1006l");
4539
+ mouseReportingEnabled = false;
4540
+ return true;
4541
+ }
4542
+
4543
+ function restoreScreen() {
4544
+ if (screenRestored) return false;
4545
+ disableMouseReporting();
4546
+ screenRestored = true;
4547
+ // Ink restores the cursor only through its own unmount path, which an
4548
+ // explicit process.exit() skips.
4549
+ write("\x1b[?25h\x1b[?1049l");
4550
+ return true;
4551
+ }
4552
+
4553
+ return {
4554
+ enableMouseReporting,
4555
+ disableMouseReporting,
4556
+ restoreScreen,
4557
+ isMouseReportingEnabled: () => mouseReportingEnabled,
4558
+ };
4559
+ }
4560
+
4561
+ const terminalLifecycle = createTerminalLifecycle((output) => process.stdout.write(output));
4562
+
4563
+ function enableMouseReporting() {
4564
+ return terminalLifecycle.enableMouseReporting();
4565
+ }
4566
+
4567
+ function disableMouseReporting() {
4568
+ return terminalLifecycle.disableMouseReporting();
4569
+ }
4570
+
4571
+ // Idempotent because both the lifecycle controller and this process-level
4572
+ // wrapper share one restored state. Mouse modes are always disabled before the
4573
+ // alternate buffer is released, including when this is only the exit backstop.
3233
4574
  function restoreScreen() {
3234
- if (screenRestored) return;
3235
- screenRestored = true;
3236
- process.stdout.write("\x1b[?25h\x1b[?1049l");
4575
+ return terminalLifecycle.restoreScreen();
3237
4576
  }
3238
4577
 
3239
4578
  // A crash used to be indistinguishable from a clean quit. Ink catches render
@@ -3242,18 +4581,18 @@ function restoreScreen() {
3242
4581
  // simply vanished and any wrapper saw success. Restore the primary buffer
3243
4582
  // *first*, then write, or the fix reproduces the problem it is fixing.
3244
4583
  //
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.
4584
+ // Unmount before restore so Ink's final repaint stays in the alternate buffer.
4585
+ // A crash before app assignment, or another failure during unmount, can skip the
4586
+ // poll effect's cleanup, so abortLiveRequests() remains an explicit backstop.
4587
+ // It runs *after* restoreScreen() and is wrapped because an exception there must
4588
+ // not replace the stack trace this handler exists to print.
3251
4589
  //
3252
4590
  // Both messages go through redact(): a stack can carry a URL with inline
3253
4591
  // credentials, and this output is what a user pastes into a bug report -- the
3254
4592
  // same reasoning --doctor already applies to its own report.
3255
- function installCrashHandlers() {
4593
+ function installCrashHandlers(unmountApp) {
3256
4594
  const fail = (label) => (err) => {
4595
+ unmountApp();
3257
4596
  restoreScreen();
3258
4597
  try {
3259
4598
  abortLiveRequests();
@@ -3269,8 +4608,16 @@ function installCrashHandlers() {
3269
4608
  }
3270
4609
 
3271
4610
  if (IS_MAIN) {
4611
+ let app;
4612
+ const unmountApp = () => {
4613
+ try {
4614
+ app?.unmount();
4615
+ } catch {
4616
+ // Teardown is best effort. Every caller still restores the terminal.
4617
+ }
4618
+ };
3272
4619
  disarmDevBuildLeak();
3273
- installCrashHandlers();
4620
+ installCrashHandlers(unmountApp);
3274
4621
  enterAlternateScreen();
3275
4622
  process.on("exit", restoreScreen);
3276
4623
 
@@ -3284,40 +4631,71 @@ if (IS_MAIN) {
3284
4631
  // root box is the terminal height. Windows is already documented as untested
3285
4632
  // (README Limitations), and the pty harness covers the two platforms that are
3286
4633
  // supported, so the flag is verified where it is claimed to work.
3287
- let app;
3288
4634
  let remoteSetupStarted = false;
3289
- const createRemote = () => {
4635
+ const createRemote = (suspendTerminal) => {
3290
4636
  if (remoteSetupStarted) return;
3291
4637
  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
- });
4638
+
4639
+ // Release Ink's raw-mode/parser state before the child inherits stdin.
4640
+ // Unmounting alone can leave an active readable dispatch racing the first
4641
+ // bytes typed for gh, so one immediate boundary lets that dispatch return;
4642
+ // the explicit stream cleanup below then prevents any future parent reads.
4643
+ void suspendTerminal()
4644
+ .then(async () => {
4645
+ await new Promise((resolve) => setImmediate(resolve));
4646
+ unmountApp();
4647
+ // This is a permanent handoff, so the parent must not retain any stream
4648
+ // consumer that can race the child for terminal bytes. Pausing/removing
4649
+ // Node listeners does not close fd 0; the spawned process still inherits
4650
+ // the same canonical TTY directly from the operating system.
4651
+ process.stdin.pause();
4652
+ process.stdin.removeAllListeners("readable");
4653
+ process.stdin.removeAllListeners("data");
4654
+ restoreScreen();
4655
+ abortLiveRequests();
4656
+
4657
+ await new Promise((resolve) => {
4658
+ // Plain `gh repo create` is the interactive form. Supplying --source
4659
+ // here would switch gh to non-interactive mode and require gh-glance
4660
+ // to choose a visibility on the user's behalf, which this consent
4661
+ // boundary must never do. The prompt tells the user which interactive
4662
+ // path matches this folder.
4663
+ const child = spawn("gh", ["repo", "create"], {
4664
+ stdio: "inherit",
4665
+ env: process.env,
4666
+ });
4667
+ setupChild = child;
4668
+ child.once("error", (err) => {
4669
+ if (setupChild === child) setupChild = null;
4670
+ console.error(
4671
+ `gh-glance: could not start repository setup: ${redact(shortErr(err))}`,
4672
+ );
4673
+ process.exitCode = 1;
4674
+ resolve();
4675
+ });
4676
+ child.once("exit", (code, signal) => {
4677
+ if (setupChild === child) setupChild = null;
4678
+ process.exitCode = signal ? 1 : (code ?? 1);
4679
+ if (code === 0) {
4680
+ console.log(
4681
+ "gh repo create finished. Run gh-glance again when this folder has a remote.",
4682
+ );
4683
+ }
4684
+ resolve();
4685
+ });
4686
+ });
4687
+ })
4688
+ .catch((err) => {
4689
+ unmountApp();
4690
+ restoreScreen();
4691
+ try {
4692
+ abortLiveRequests();
4693
+ } catch {
4694
+ // Preserve the handoff error below.
4695
+ }
4696
+ console.error(`gh-glance: could not hand off the terminal: ${redact(shortErr(err))}`);
4697
+ process.exitCode = 1;
4698
+ });
3321
4699
  };
3322
4700
  app = render(e(App, { onCreateRemote: createRemote }), { incrementalRendering: true });
3323
4701
 
@@ -3347,12 +4725,7 @@ if (IS_MAIN) {
3347
4725
  if (signalExitStarted) return;
3348
4726
  signalExitStarted = true;
3349
4727
  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
- }
4728
+ unmountApp();
3356
4729
  restoreScreen();
3357
4730
  process.exit(code);
3358
4731
  };
@@ -3387,6 +4760,22 @@ export {
3387
4760
  MIN_REFRESH_SECONDS,
3388
4761
  MAX_REFRESH_SECONDS,
3389
4762
  TAB_KEYS,
4763
+ ALERT_SOURCES,
4764
+ REST_PER_FETCH,
4765
+ GRAPHQL_PER_FETCH,
4766
+ projectedHourlyCost,
4767
+ REFRESH_MS,
4768
+ BACKGROUND_EVERY,
4769
+ adaptiveRefreshMs,
4770
+ adaptiveChangeWorthApplying,
4771
+ sampleIsUsable,
4772
+ inferShare,
4773
+ nextProbeWindow,
4774
+ restPerTick,
4775
+ MAX_ADAPTIVE_REFRESH_MS,
4776
+ BUDGET_SAFETY,
4777
+ MIN_SAMPLE_CALLS,
4778
+ ADAPTIVE_HYSTERESIS,
3390
4779
  safe,
3391
4780
  shortErr,
3392
4781
  isUnavailable,
@@ -3411,17 +4800,44 @@ export {
3411
4800
  usableSize,
3412
4801
  severityRank,
3413
4802
  pick,
4803
+ columnProps,
4804
+ adjustableWidthKeys,
4805
+ selectWidthKey,
4806
+ cycleWidthKey,
4807
+ resolveHeader,
4808
+ fitHeaderToFrame,
4809
+ adjustWidth,
4810
+ updateWidthPreference,
4811
+ resetWidthPreference,
4812
+ resetTabWidthPreferences,
3414
4813
  minimumWidthFor,
4814
+ WIDTH_PREFERENCES_VERSION,
4815
+ widthPreferencesPath,
4816
+ parseWidthPreferences,
4817
+ serializeWidthPreferences,
4818
+ loadWidthPreferences,
4819
+ saveWidthPreferences,
4820
+ createWidthPreferenceWriter,
3415
4821
  runStatusIcon,
3416
4822
  RUN_STATUS_ICON,
3417
4823
  SEVERITY_STYLE,
3418
4824
  REVIEW_LABEL,
3419
4825
  MIN_TABLE_WIDTH,
3420
4826
  MIN_COMPACT_WIDTH,
4827
+ TABS,
3421
4828
  OCT_NERD,
3422
4829
  OCT_UNICODE,
3423
4830
  KEY_TABLE,
3424
4831
  KEY_HINTS,
4832
+ widthStatusText,
4833
+ headerGutterKey,
4834
+ HeaderCells,
4835
+ parseSgrMouse,
4836
+ dividerHandles,
4837
+ hitDivider,
4838
+ beginDividerDrag,
4839
+ draggedWidth,
4840
+ createTerminalLifecycle,
3425
4841
  REMOTE_SETUP_HINTS,
3426
4842
  REMOTE_SETUP_LINES,
3427
4843
  REMOTE_SETUP_NONINTERACTIVE_LINES,