gh-glance 0.4.0 → 0.5.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 +209 -0
  2. package/README.md +88 -40
  3. package/index.mjs +632 -115
  4. package/package.json +2 -1
package/index.mjs CHANGED
@@ -47,6 +47,12 @@ const IS_MAIN = detectMainModule();
47
47
 
48
48
  const REFRESH_MS = 5000;
49
49
 
50
+ // The row cursor otherwise persists forever once you touch a movement key --
51
+ // useful while you're actually scanning a list, noise once you've moved on and
52
+ // left the pane running in a corner of the screen. 60s idle (measured from the
53
+ // last movement, not from tab switches or Enter) quietly drops it.
54
+ const SELECTION_IDLE_MS = 60_000;
55
+
50
56
  // `gh run list` costs roughly linearly in --limit (measured: ~1.2s at 20 runs,
51
57
  // ~3.0s at 100, ~4.9s at 150), so asking for a fixed 150 to render ~35 visible
52
58
  // rows was paying several seconds per refresh for rows nobody sees. Actions is
@@ -68,7 +74,15 @@ const LIST_LIMIT = 150;
68
74
  // page. The previous --paginate walked the repo's entire alert history --
69
75
  // mostly closed alerts -- and then discarded them client-side.
70
76
  const ALERT_PER_PAGE = 100;
71
- const ALERT_QUERY = `?state=open&per_page=${ALERT_PER_PAGE}`;
77
+ // Ordering is pinned rather than left to each endpoint's default. Severity
78
+ // sorting happens client-side over whatever this returns, so on a repo with more
79
+ // than a page of open alerts the *page boundary* decides what can be ranked at
80
+ // all -- a critical alert sitting 150th by some endpoint's arbitrary default was
81
+ // simply never fetched, and the pane showed "100+" with a screen of moderates.
82
+ // Newest-first at least makes the cut deterministic and explainable, and it
83
+ // survives GitHub changing a default underneath us. All three endpoints accept
84
+ // these parameters.
85
+ const ALERT_QUERY = `?state=open&per_page=${ALERT_PER_PAGE}&sort=created&direction=desc`;
72
86
 
73
87
  // Inactive tabs only feed the tab-bar counts, so they refresh every Nth tick
74
88
  // rather than every tick. Raised from 4 to 12 (a 60s cycle at the default
@@ -110,6 +124,16 @@ const BACKOFF_STEPS_MS = [60_000, 300_000, 1_800_000, 3_600_000];
110
124
  // night costs -- two probes a minute per endpoint rather than one per tick.
111
125
  const AUTH_RETRY_MS = [30_000];
112
126
 
127
+ // A rate limit is the third shape: it clears on GitHub's schedule, usually
128
+ // within the hour, and continuing to hammer is actively counterproductive --
129
+ // the secondary limiter keys on sustained request rate against a token that is
130
+ // already limited, so re-asking every tick can turn a self-clearing primary
131
+ // limit into a longer block, and the block applies to the *token*, not to this
132
+ // tool. A wedged pane could therefore degrade `git push` and everything else.
133
+ // One minute, flat: long enough to stop amplifying, short enough that the pane
134
+ // is current again well before a reset the user never notices.
135
+ const RATE_LIMIT_RETRY_MS = [60_000];
136
+
113
137
  // Past this, the active tab's data is old enough to say so. Deliberately
114
138
  // coarse: `now` only advances on minute boundaries when nothing is in progress,
115
139
  // so a minute-granular staleness label costs zero extra redraws, whereas a
@@ -143,7 +167,14 @@ const TAB_KEYS = ["actions", "issues", "prs", "security"];
143
167
  // dots per frame, reading as a filled blob that matches the circle icons'
144
168
  // visual weight while staying in the same width-1 braille block.
145
169
  const SPINNER = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
146
- const SPINNER_MS = 100;
170
+ // 200ms, not 100: the spinner is the single largest CPU term in the app, because
171
+ // every frame makes ink rebuild and diff the whole output string. Measured on a
172
+ // 40-row pane: 100ms costs 7.8% of a core and 9.8 MB/hr of terminal writes,
173
+ // 200ms costs 3.9% and 5.2 MB/hr, against a 0.33% idle floor. The motion is
174
+ // load-bearing -- it is the only thing separating an executing run from a queued
175
+ // one (see RUN_RUNNING_STATIC) -- so it may be slowed but never stopped. Eight
176
+ // frames at 200ms is a 1.6s cycle, still well inside one refresh.
177
+ const SPINNER_MS = 200;
147
178
 
148
179
  // Motion opt-out. This pane is designed to sit in peripheral vision for hours,
149
180
  // which is exactly where repetitive motion is most costly for users with
@@ -170,15 +201,27 @@ const ANIMATE = !process.env.GH_GLANCE_NO_ANIMATION;
170
201
  // on the composed output stream, which would strip ink's own SGR codes, the
171
202
  // box-drawing in PanelEdge, and the alternate-screen escapes, i.e. the whole UI.
172
203
  //
173
- // Strip C0, DEL and C1 only. Emoji (including ZWJ sequences and variation
204
+ // Strip C0, DEL, C1 -- and the explicit bidi overrides/isolates. Emoji (including ZWJ sequences and variation
174
205
  // selectors), CJK and other wide characters, combining marks and RTL text must
175
206
  // survive untouched -- ink's width arithmetic depends on measuring them
176
207
  // correctly, and the app's own Nerd Font glyphs live in the private use area, so
177
208
  // anything shaped like "strip non-ASCII" would erase every status icon on
178
209
  // screen. Control runs collapse to a single space rather than being deleted, so
179
210
  // "a\nb" reads as "a b" rather than "ab".
211
+ //
212
+ // U+202A-U+202E and U+2066-U+2069 (LRE/RLE/PDF/LRO/RLO and the isolates) are
213
+ // stripped in a *separate* pass because they must be deleted rather than
214
+ // collapsed to a space: ink measures them as zero columns, so replacing one with
215
+ // a space would add a visible column and shift every cell to its right -- the
216
+ // exact desync this file works to avoid. Without this, one RLO in an issue title
217
+ // makes the rest of that cell render reversed on any terminal with bidi
218
+ // reordering, so the row displays something other than its data. Deliberately
219
+ // NOT extended to U+200E/U+200F or to general category Cf: those are how
220
+ // legitimate mixed-direction Arabic and Hebrew titles render correctly, and
221
+ // preserving real RTL text is a stated property of this function.
180
222
  // eslint-disable-next-line no-control-regex -- matching control characters is the entire purpose
181
223
  const CONTROL_CHARS = /[--Ÿ]+/g;
224
+ const BIDI_OVERRIDES = /[‪-‮⁦-⁩]/g;
182
225
 
183
226
  // Titles are rendered at whatever length GitHub returns, and ink memoizes
184
227
  // wrapped text in a module-level cache it never evicts -- so unbounded remote
@@ -190,8 +233,14 @@ const CONTROL_CHARS = /[--Ÿ]+/g;
190
233
  const MAX_FIELD_LENGTH = 300;
191
234
 
192
235
  function safe(value) {
193
- if (typeof value !== "string") return value == null ? "" : String(value);
194
- const cleaned = value.replace(CONTROL_CHARS, " ").trim();
236
+ // Coerce first, sanitize always. Returning a non-string early skipped both the
237
+ // control-character strip and the length clamp, so an array or an object with a
238
+ // toString() came through untouched -- `safe(["x"])` returned the escape
239
+ // verbatim. GitHub returns strings for every field this is called on today, so
240
+ // the guarantee was being provided by the upstream schema rather than by the
241
+ // function that claims to provide it.
242
+ const value_ = typeof value === "string" ? value : value == null ? "" : String(value);
243
+ const cleaned = value_.replace(BIDI_OVERRIDES, "").replace(CONTROL_CHARS, " ").trim();
195
244
  const points = Array.from(cleaned);
196
245
  return points.length > MAX_FIELD_LENGTH ? points.slice(0, MAX_FIELD_LENGTH).join("") : cleaned;
197
246
  }
@@ -320,6 +369,26 @@ function classify(err) {
320
369
  return "other";
321
370
  }
322
371
 
372
+ // What to put on screen for a verdict, in the voice the preflight already uses:
373
+ // say what to do, not what the subprocess printed. The three tabs built on list
374
+ // commands used to render raw `gh` stderr, so the failures people actually hit --
375
+ // expired auth, a rate limit, a dropped network -- arrived as an untranslated
376
+ // fragment of somebody else's CLI with no statement of what to do about it,
377
+ // while the alert path one tab over had been classifying and translating all
378
+ // along.
379
+ //
380
+ // `other` is deliberately absent. It is the unclassified bucket, and the raw
381
+ // message is the most useful thing available for it -- inventing a remedy for a
382
+ // failure nobody recognised would be worse than showing what happened. That
383
+ // matters more than it looks: AUTH_MARKERS is deliberately broad, and a false
384
+ // positive that only picked a retry ladder was cheap, whereas one that also
385
+ // rewrites the on-screen text turns into a confidently wrong instruction.
386
+ const VERDICT_REMEDY = {
387
+ "auth-problem": "GitHub authorization failed -- try `gh auth login` or `gh auth refresh`",
388
+ "rate-limited": "GitHub rate limit reached -- backing off, this clears on its own",
389
+ unavailable: "not available for this repository",
390
+ };
391
+
323
392
  // Some pty wrappers (and a terminal mid-resize) report a size of 0 or
324
393
  // undefined. Taking that literally collapses the table to a single row (or
325
394
  // draws a zero-width border), so fall back to sane defaults until real
@@ -352,19 +421,54 @@ const GH_ENV_OVERRIDES = {
352
421
  GH_PAGER: "cat",
353
422
  };
354
423
 
424
+ // The single redaction boundary for everything this process prints outside the
425
+ // dashboard: the --verbose log, the crash handler's stack, and the --doctor
426
+ // report. Declared here, above all three, rather than beside runDoctor() -- it
427
+ // used to sit in the Diagnostics section and be reached backwards by hoisting.
428
+ //
429
+ // Token shapes and URL userinfo only. gh error messages quote the URL they
430
+ // failed on, so that is a real path for a credential to arrive in output a user
431
+ // is invited to paste into a bug report.
432
+ function redact(text) {
433
+ return String(text)
434
+ .replace(/gh[pousr]_[A-Za-z0-9]{16,}/g, "<redacted-token>")
435
+ .replace(/github_pat_[A-Za-z0-9_]{16,}/g, "<redacted-token>")
436
+ .replace(/\/\/[^/\s:@]+:[^/\s@]+@/g, "//<redacted>@");
437
+ }
438
+
355
439
  // Verbose output goes to stderr and never to stdout: stdout is ink's frame
356
440
  // stream, and writing anything else into it corrupts the diff and the
357
441
  // alternate-screen state. The argv block refuses --verbose while stderr is still
358
442
  // a terminal, so these lines always land in a file rather than on top of the
359
443
  // dashboard.
444
+ //
445
+ // The outcome is redacted for the same reason --doctor redacts its report: on a
446
+ // failure it carries gh's own stderr, and gh error messages quote the URL they
447
+ // failed on -- which is a real path for a token or a proxy credential to arrive
448
+ // here. The README tells users to run `--verbose 2>gh-glance.log` and attach the
449
+ // result to a bug report, so this is one of the three artifacts that leave the
450
+ // machine, and it was the only one with no redaction boundary.
360
451
  function logGh(args, startedAt, outcome) {
361
452
  if (!runtime.verbose) return;
362
453
  const ms = Date.now() - startedAt;
363
454
  process.stderr.write(
364
- `${new Date().toISOString()} gh ${args.join(" ")} -- ${outcome} in ${ms}ms\n`,
455
+ `${new Date().toISOString()} gh ${args.join(" ")} -- ${redact(outcome)} in ${ms}ms\n`,
365
456
  );
366
457
  }
367
458
 
459
+ // The poll loop's AbortController, published here so the crash handlers can
460
+ // reach it. A module-level handle rather than a ref because the crash path runs
461
+ // outside React entirely -- there is no component left to read a ref from. Kept
462
+ // in this section, above every consumer, so nothing has to reach downward for it.
463
+ let liveAbort = null;
464
+ function registerLiveAbort(controller) {
465
+ liveAbort = controller;
466
+ }
467
+ function abortLiveRequests() {
468
+ liveAbort?.abort();
469
+ liveAbort = null;
470
+ }
471
+
368
472
  async function runGh(args, { signal } = {}) {
369
473
  const startedAt = Date.now();
370
474
  try {
@@ -605,11 +709,15 @@ function backoffActive(key, now) {
605
709
  return Boolean(state) && now < state.until;
606
710
  }
607
711
 
608
- // Which ladder each verdict takes. "rate-limited" and "other" are absent on
609
- // purpose: a rate limit clears on its own schedule and a network drop clears
610
- // when the network does, so both keep today's behaviour of re-asking every tick
611
- // with the real message on screen.
612
- const FAILURE_LADDER = { unavailable: BACKOFF_STEPS_MS, "auth-problem": AUTH_RETRY_MS };
712
+ // Which ladder each verdict takes. "other" stays absent on purpose: a network
713
+ // drop clears when the network does, and re-asking every tick is how the pane
714
+ // comes back the moment it does. "rate-limited" used to be absent for the same
715
+ // stated reason, which had it backwards -- see RATE_LIMIT_RETRY_MS.
716
+ const FAILURE_LADDER = {
717
+ unavailable: BACKOFF_STEPS_MS,
718
+ "auth-problem": AUTH_RETRY_MS,
719
+ "rate-limited": RATE_LIMIT_RETRY_MS,
720
+ };
613
721
 
614
722
  // The ladder is a parameter rather than a second near-identical function,
615
723
  // which is the drift the ALERT_SOURCES comment below warns about: two copies of
@@ -626,8 +734,11 @@ function clearBackoff(key) {
626
734
 
627
735
  async function fetchAlertSource(source, signal, now) {
628
736
  if (backoffActive(source.key, now)) {
629
- const note = alertBackoff.get(source.key).note;
630
- return { raw: `backoff:${note}`, parse: () => ({ alerts: [], note, truncated: false }) };
737
+ const { note, verdict } = alertBackoff.get(source.key);
738
+ // The verdict is replayed alongside the note. Replaying only the note left
739
+ // the tab unable to tell "Dependabot is switched off here" from "we cannot
740
+ // see Dependabot" for the whole length of a backoff window.
741
+ return { raw: `backoff:${note}`, parse: () => ({ alerts: [], note, verdict, truncated: false }) };
631
742
  }
632
743
  try {
633
744
  const raw = await runGh(alertArgs(source), { signal });
@@ -639,6 +750,7 @@ async function fetchAlertSource(source, signal, now) {
639
750
  return {
640
751
  alerts: rows.map(source.map),
641
752
  note: null,
753
+ verdict: "ok",
642
754
  truncated: rows.length >= ALERT_PER_PAGE,
643
755
  };
644
756
  },
@@ -653,9 +765,12 @@ async function fetchAlertSource(source, signal, now) {
653
765
  const steps = pick(FAILURE_LADDER, verdict, null);
654
766
  if (steps) {
655
767
  recordFailure(source.key, now, steps);
656
- alertBackoff.get(source.key).note = note;
768
+ Object.assign(alertBackoff.get(source.key), { note, verdict });
657
769
  }
658
- return { raw: `unavailable:${note}`, parse: () => ({ alerts: [], note, truncated: false }) };
770
+ return {
771
+ raw: `unavailable:${note}`,
772
+ parse: () => ({ alerts: [], note, verdict, truncated: false }),
773
+ };
659
774
  }
660
775
  }
661
776
 
@@ -672,7 +787,14 @@ function severityRank(severity) {
672
787
  }
673
788
 
674
789
  async function fetchSecurity(signal) {
675
- const now = Date.now();
790
+ // Monotonic, not wall-clock. These deadlines measure *elapsed* time, and
791
+ // Date.now() can jump: a laptop resume or an NTP correction stepping the clock
792
+ // backwards would hold an alert source in backoff for up to an hour of apparent
793
+ // time that never passed -- on a security surface, which is exactly what the
794
+ // capped ladder exists to prevent. Staleness deliberately stays on Date.now()
795
+ // for the mirror-image reason: a sleep gap is the thing it reports, and
796
+ // performance.now() does not advance across suspend.
797
+ const now = performance.now();
676
798
  const parts = await Promise.all(ALERT_SOURCES.map((s) => fetchAlertSource(s, signal, now)));
677
799
  return {
678
800
  // Joined with a NUL so a change in any one of the three shows up as a
@@ -691,6 +813,15 @@ async function fetchSecurity(signal) {
691
813
  return {
692
814
  alerts,
693
815
  notes: parsed.map((p) => p.note).filter(Boolean),
816
+ // "Blind" is not the same as "switched off", and the tab bar could not
817
+ // tell them apart: fetchAlertSource never rejects, so a repo whose three
818
+ // endpoints all 403'd on an expired SAML session rendered
819
+ // `4:Security (0)` -- byte-identical to a genuinely clean repo, on the
820
+ // one surface where a false all-clear is the worst possible answer.
821
+ // `unavailable` is excluded deliberately: that verdict IS an answer about
822
+ // the repository, and most repos have Advanced Security switched off, so
823
+ // treating it as blindness would mark almost everyone permanently unsure.
824
+ blind: parsed.some((p) => p.verdict != null && p.verdict !== "ok" && p.verdict !== "unavailable"),
694
825
  truncated: parsed.some((p) => p.truncated),
695
826
  };
696
827
  },
@@ -773,19 +904,9 @@ async function preflight() {
773
904
  // makes it a disclosure surface before it is anything else. So redaction is not
774
905
  // a review note applied per call site -- it is applied once, at the single point
775
906
  // where the report is assembled, the same way safe() is the one boundary for
776
- // untrusted remote strings rather than six.
777
- //
778
- // Token shapes are gh's own prefixes (gho_/ghp_/ghu_/ghs_/ghr_ and
779
- // github_pat_). URL userinfo covers a proxy variable, a git remote with an
780
- // embedded credential, and anything else that carries `user:password@` -- gh
781
- // error messages quote the URL they failed on, so that is a real path for one
782
- // to arrive here.
783
- function redact(text) {
784
- return String(text)
785
- .replace(/gh[pousr]_[A-Za-z0-9]{16,}/g, "<redacted-token>")
786
- .replace(/github_pat_[A-Za-z0-9_]{16,}/g, "<redacted-token>")
787
- .replace(/\/\/[^/\s:@]+:[^/\s@]+@/g, "//<redacted>@");
788
- }
907
+ // untrusted remote strings rather than six. redact() itself now lives up in the
908
+ // gh subprocess section, because the --verbose log and the crash handler need it
909
+ // too and nothing should reach backwards for it.
789
910
 
790
911
  // Values that are the thing being diagnosed, so they are printed as-is.
791
912
  const DOCTOR_ENV_PLAIN = [
@@ -799,10 +920,9 @@ const DOCTOR_ENV_PLAIN = [
799
920
  "NODE_ENV",
800
921
  ];
801
922
  const DOCTOR_ENV_PROXY = ["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY"];
802
- // Reported as present or absent and never printed, not even a prefix. Matched
803
- // by name so a variable nobody listed here -- GITHUB_EMU_TOKEN, some wrapper's
804
- // GH_API_KEY -- is still caught by shape rather than by having been foreseen.
805
- const SECRET_NAME = /_(TOKEN|SECRET|PASSWORD|KEY)$/i;
923
+ // Listed so they always get a line even when unset -- the absence of GH_TOKEN is
924
+ // itself a diagnosis. Their values are never printed; see envValue(), where
925
+ // presence-only is the default for everything outside DOCTOR_ENV_PLAIN.
806
926
  const DOCTOR_ENV_SECRET = ["GH_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_TOKEN"];
807
927
 
808
928
  const NOT_SET = "not set";
@@ -833,12 +953,21 @@ function doctorEnvNames() {
833
953
  return [...named, ...extra];
834
954
  }
835
955
 
956
+ // Presence-only is the DEFAULT; printing a value is the opt-in, and the opt-in
957
+ // list is curated above. This was the other way round -- print unless the *name*
958
+ // ended in _TOKEN/_SECRET/_PASSWORD/_KEY -- which meant the discovery loop above
959
+ // harvested variables nobody had reviewed and printed them in full. Verified
960
+ // leaks under the old rule: GH_APP_PEM (a whole RSA private key), GITHUB_OAUTH,
961
+ // GITHUB_PAT, GH_COOKIE, GH_CREDENTIALS. redact() only catches GitHub *token*
962
+ // shapes, so anything else sailed through. This report is advertised as safe to
963
+ // paste into a bug report, so the failure mode has to be a less useful line, not
964
+ // a disclosed credential.
836
965
  function envValue(name) {
837
966
  const value = process.env[name];
838
- if (SECRET_NAME.test(name)) return value ? "set" : NOT_SET;
839
967
  if (!value) return NOT_SET;
840
968
  if (DOCTOR_ENV_PROXY.includes(name)) return proxySummary(value);
841
- return value;
969
+ if (DOCTOR_ENV_PLAIN.includes(name)) return value;
970
+ return "set";
842
971
  }
843
972
 
844
973
  // gh writes some of this to stdout and some to stderr depending on version and
@@ -910,6 +1039,58 @@ function targetSource() {
910
1039
  return "git remote (or none, if this is not a checkout)";
911
1040
  }
912
1041
 
1042
+ // How much of the hourly API budget is left, and roughly how fast this
1043
+ // configuration spends it. The steady-state cost is not small and was invisible:
1044
+ // at the default 5s refresh with the Security tab open it is around 2,200 REST
1045
+ // requests an hour -- about 44% of a personal token's 5,000 -- and `--refresh 2`
1046
+ // projects past the limit outright, so it exhausts inside the hour, every hour.
1047
+ // The budget is shared with everything else the token does, so the first symptom
1048
+ // is usually "GitHub is broken" somewhere else entirely.
1049
+ //
1050
+ // `gh api rate_limit` is documented as not counting against the limit, and it
1051
+ // measures as free (verified: delta 0), so this is safe to run on a diagnostic
1052
+ // path. GHES tenants can be configured with a different ceiling, which is
1053
+ // exactly why this reports the server's own numbers rather than asserting 5,000.
1054
+ async function rateBudget() {
1055
+ try {
1056
+ const raw = await runGh(["api", "rate_limit"]);
1057
+ const { resources } = JSON.parse(raw);
1058
+ // formatAge() is for timestamps in the past and returns "-" for a future one,
1059
+ // so the reset is rendered as a forward interval instead.
1060
+ const fmt = (r) => {
1061
+ if (!r) return "(absent)";
1062
+ const inMs = r.reset * 1000 - Date.now();
1063
+ const resets = inMs > 0 ? `resets in ${formatDuration(inMs)}` : "reset due";
1064
+ return `${r.remaining}/${r.limit} left, ${resets}`;
1065
+ };
1066
+ return { core: fmt(resources?.core), graphql: fmt(resources?.graphql) };
1067
+ } catch (err) {
1068
+ return { core: `unavailable (${shortErr(err)})`, graphql: "unavailable" };
1069
+ }
1070
+ }
1071
+
1072
+ // Requests per hour this configuration will spend once it settles, derived from
1073
+ // the same constants the poll loop uses rather than from a number written down
1074
+ // once and left to rot. The active tab refreshes every tick; the other three
1075
+ // every BACKGROUND_EVERY ticks. Security is three REST calls per fetch, Actions
1076
+ // one, and Issues/PRs go to GraphQL instead (two POSTs each, because --search
1077
+ // routes through the search connection).
1078
+ function projectedHourlyCost(activeKey) {
1079
+ const perHour = 3_600_000 / runtime.refreshMs;
1080
+ const restCalls = { actions: 1, issues: 0, prs: 0, security: ALERT_SOURCES.length };
1081
+ const graphqlCalls = { actions: 0, issues: 2, prs: 2, security: 0 };
1082
+ let rest = 0;
1083
+ let graphql = 0;
1084
+ // TAB_KEYS rather than TABS: this runs on the --doctor path, which deliberately
1085
+ // returns before the render tree is reached, and TABS is declared down there.
1086
+ for (const key of TAB_KEYS) {
1087
+ const ticks = key === activeKey ? perHour : perHour / BACKGROUND_EVERY;
1088
+ rest += ticks * restCalls[key];
1089
+ graphql += ticks * graphqlCalls[key];
1090
+ }
1091
+ return { rest: Math.round(rest), graphql: Math.round(graphql) };
1092
+ }
1093
+
913
1094
  async function runDoctor() {
914
1095
  // A live backoff would make an alert probe silently skip and report nothing,
915
1096
  // which is the opposite of what a diagnostic run is for.
@@ -925,10 +1106,11 @@ async function runDoctor() {
925
1106
  // allSettled, and every probe already resolves rather than rejects, so one
926
1107
  // slow endpoint cannot suppress the other five. runGh's own GH_TIMEOUT_MS
927
1108
  // bounds each one.
928
- const [ghVersion, authStatus, remote, results] = await Promise.all([
1109
+ const [ghVersion, authStatus, remote, budget, results] = await Promise.all([
929
1110
  captureGh(["--version"]),
930
1111
  captureGh(["auth", "status"]),
931
1112
  gitRemote(),
1113
+ rateBudget(),
932
1114
  Promise.all(probes.map(([name, args]) => probe(name, args))),
933
1115
  ]);
934
1116
 
@@ -948,6 +1130,18 @@ async function runDoctor() {
948
1130
  field("slug", runtime.repo ?? "(inferred from the working directory)"),
949
1131
  field("git remote", remote),
950
1132
  "",
1133
+ ...section("API budget"),
1134
+ field("REST core", budget.core),
1135
+ field("GraphQL", budget.graphql),
1136
+ field(
1137
+ "this config spends",
1138
+ (() => {
1139
+ const active = TAB_KEYS[runtime.initialTabIndex] ?? TAB_KEYS[0];
1140
+ const { rest, graphql } = projectedHourlyCost(active);
1141
+ return `~${rest} REST + ~${graphql} GraphQL per hour (refresh ${runtime.refreshMs / 1000}s, "${active}" active)`;
1142
+ })(),
1143
+ ),
1144
+ "",
951
1145
  ...section("Environment"),
952
1146
  ...doctorEnvNames().map((name) => field(name, envValue(name))),
953
1147
  "",
@@ -982,7 +1176,19 @@ async function runDoctor() {
982
1176
  // path would be a request-forgery primitive against arbitrary endpoints -- so it
983
1177
  // is validated once, here at the boundary, against exactly what GitHub allows in
984
1178
  // an owner or repository name.
985
- const REPO_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/;
1179
+ //
1180
+ // The name half requires at least one character that is not a dot. Without that
1181
+ // it accepted `owner/..`, which `apiPath()` spliced into
1182
+ // `repos/owner/../dependabot/alerts` -- and gh forwards the dot segment
1183
+ // unnormalized, so GitHub resolves it server-side to a *different endpoint*
1184
+ // (verified: it lands on Get-a-repository). The value is operator-supplied, so
1185
+ // this was never a cross-user vulnerability, but it broke the one invariant the
1186
+ // comment above claims. A trailing `.git` is rejected for the same reason: it is
1187
+ // not a name GitHub issues, so accepting it can only mean someone pasted a clone
1188
+ // URL's tail. Names that merely *contain* or *start with* a dot stay valid --
1189
+ // `owner/.github` and `owner/docs.example.com` are both real.
1190
+ const REPO_PATTERN =
1191
+ /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?\/(?=[A-Za-z0-9._-]*[A-Za-z0-9_-])(?!.*\.git$)[A-Za-z0-9._-]+$/;
986
1192
 
987
1193
  // A dot is mandatory, and that is the safety property rather than a nicety.
988
1194
  // Without it "owner/name/extra" -- already in the hostile-input list -- would
@@ -1049,8 +1255,13 @@ function parseArgs(argv) {
1049
1255
  return argv[i];
1050
1256
  };
1051
1257
 
1258
+ // No `-v`. It used to mean --version, which is the conventional reading, but
1259
+ // this CLI also has --verbose -- so `gh-glance -v 2>log`, which is what you
1260
+ // type when you want the log, printed a version string and exited 0. That is
1261
+ // the one argv path that failed quietly in a surface built to fail loudly.
1262
+ // Unknown now, so it exits 2 and points at --help.
1052
1263
  if (arg === "--help" || arg === "-h") opts.help = true;
1053
- else if (arg === "--version" || arg === "-v") opts.showVersion = true;
1264
+ else if (arg === "--version") opts.showVersion = true;
1054
1265
  else if (arg === "--verbose") opts.verbose = true;
1055
1266
  else if (arg === "--doctor") opts.doctor = true;
1056
1267
  else if (arg === "--repo" || arg === "-R" || arg.startsWith("--repo=")) {
@@ -1105,6 +1316,24 @@ function validateArgs(opts, tabKeys) {
1105
1316
 
1106
1317
  const { version } = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"));
1107
1318
 
1319
+ // The one key table. --help renders it, the `?` overlay renders it, and the
1320
+ // status bar's KEY_HINTS is the deliberately short subset of it -- so a binding
1321
+ // added in one place cannot go missing from the others, which is the same rule
1322
+ // this file already applies to argv vectors and to classify().
1323
+ const KEY_TABLE = [
1324
+ ["1 2 3 4", "Actions / Issues / Pull requests / Security"],
1325
+ ["Left / Right", "Previous / next tab"],
1326
+ ["Tab / Shift+Tab", "Next / previous tab"],
1327
+ ["Up / Down, j / k", "Move the cursor between rows"],
1328
+ ["PgUp / PgDn", "Move a page at a time"],
1329
+ ["Enter", "Open the selected item in your browser"],
1330
+ ["r", "Refresh the current tab now"],
1331
+ ["?", "Show the keys (any key closes it)"],
1332
+ ["q / Esc / Ctrl+C", "Quit"],
1333
+ ];
1334
+ const KEY_COL = Math.max(...KEY_TABLE.map(([k]) => k.length)) + 3;
1335
+ const keyTableLines = () => KEY_TABLE.map(([k, d]) => `${k.padEnd(KEY_COL)}${d}`);
1336
+
1108
1337
  const HELP = `gh-glance ${version} -- a live-refreshing GitHub dashboard for a narrow terminal pane.
1109
1338
 
1110
1339
  Usage:
@@ -1150,14 +1379,11 @@ Status icons are GitHub Octicons and need a Nerd Font. Without one, set
1150
1379
  GH_GLANCE_ICONS=unicode for plain-unicode equivalents.
1151
1380
 
1152
1381
  Keys:
1153
- 1 2 3 4 Actions / Issues / Pull requests / Security
1154
- Left / Right Previous / next tab
1155
- Tab / Shift+Tab Next / previous tab
1156
- Up / Down, j / k Move the cursor between rows
1157
- PgUp / PgDn Move a page at a time
1158
- Enter Open the selected item in your browser
1159
- r Refresh the current tab now
1160
- q / Esc / Ctrl+C Quit
1382
+ ${keyTableLines()
1383
+ .map((line) => ` ${line}`)
1384
+ .join("\n")}
1385
+
1386
+ The cursor clears itself after 60s with no movement.
1161
1387
 
1162
1388
  Environment:
1163
1389
  GH_REPO=owner/name Watch a specific repository (--repo takes precedence)
@@ -1167,6 +1393,7 @@ Environment:
1167
1393
  GH_GLANCE_ICONS=unicode Plain-unicode status icons (no Nerd Font needed)
1168
1394
  GH_GLANCE_NO_ANIMATION=1 Freeze the spinner (no motion)
1169
1395
  NO_COLOR=1 Disable colour (status stays readable)
1396
+ INK_SCREEN_READER=true Linear, unthrottled rendering (unverified -- see README)
1170
1397
  `;
1171
1398
 
1172
1399
  // argv and the TTY guard run *before* react and ink are imported. Loading ink
@@ -1188,6 +1415,20 @@ if (IS_MAIN) {
1188
1415
  process.exit(2);
1189
1416
  }
1190
1417
 
1418
+ // One place argv becomes runtime state. It used to be two -- the --doctor
1419
+ // branch set repo and host, the dashboard path set all five -- and they had
1420
+ // already drifted: `--doctor --verbose` was accepted and silently dropped, so
1421
+ // the one flag that shows what was actually sent to gh did nothing on the one
1422
+ // command built to explain what gh-glance is doing. Same for --refresh and
1423
+ // --tab. Applying everything up front cannot corrupt the doctor path, which
1424
+ // never renders with ink; the --verbose TTY refusal below stays *below* the
1425
+ // doctor branch on purpose, since that combination is useful and harmless.
1426
+ runtime.repo = opts.repo;
1427
+ runtime.host = opts.host;
1428
+ runtime.verbose = opts.verbose;
1429
+ if (opts.refreshMs !== null) runtime.refreshMs = opts.refreshMs;
1430
+ if (opts.tabKey !== null) runtime.initialTabIndex = TAB_KEYS.indexOf(opts.tabKey);
1431
+
1191
1432
  // Checked after parsing rather than before, so `gh-glance --repo` with no
1192
1433
  // value reports the missing value rather than silently printing help.
1193
1434
  if (opts.help) {
@@ -1203,11 +1444,8 @@ if (IS_MAIN) {
1203
1444
  // here on purpose -- ahead of the non-TTY refusal, because the whole point is
1204
1445
  // `gh-glance --doctor > report.txt`, and ahead of preflight(), because a
1205
1446
  // missing gh and a cwd outside a repository are exactly the conditions worth
1206
- // reporting rather than exiting 3 over. The repo target is applied first so
1207
- // the probes exercise the real one.
1447
+ // reporting rather than exiting 3 over.
1208
1448
  if (opts.doctor) {
1209
- runtime.repo = opts.repo;
1210
- runtime.host = opts.host;
1211
1449
  console.log(await runDoctor());
1212
1450
  process.exit(0);
1213
1451
  }
@@ -1225,12 +1463,6 @@ if (IS_MAIN) {
1225
1463
  process.exit(2);
1226
1464
  }
1227
1465
 
1228
- runtime.repo = opts.repo;
1229
- runtime.host = opts.host;
1230
- runtime.verbose = opts.verbose;
1231
- if (opts.refreshMs !== null) runtime.refreshMs = opts.refreshMs;
1232
- if (opts.tabKey !== null) runtime.initialTabIndex = TAB_KEYS.indexOf(opts.tabKey);
1233
-
1234
1466
  // This is a full-screen live dashboard, not a reporting command -- piping it
1235
1467
  // somewhere would emit an endless stream of redraw frames, so fail fast with
1236
1468
  // an explanation instead. Exit code 1 is asserted by CI.
@@ -1311,7 +1543,15 @@ const OCT_UNICODE = {
1311
1543
  shield: "s",
1312
1544
  };
1313
1545
 
1314
- const OCT = process.env.GH_GLANCE_ICONS === "unicode" ? OCT_UNICODE : OCT_NERD;
1546
+ // Whether the private-use glyphs are in play. There is no way to ask a terminal
1547
+ // whether it can draw them -- the only honest probe is writing one and reading
1548
+ // the cursor back, which hangs on terminals that never answer -- so instead the
1549
+ // app says, once, where the escape hatch is. Without a Nerd Font every icon is a
1550
+ // blank box, which reads as "this program is broken" rather than "install a
1551
+ // font", and the remedy currently lives only in --help and the README: both of
1552
+ // which require quitting the full-screen app you are trying to evaluate.
1553
+ const USING_NERD_ICONS = process.env.GH_GLANCE_ICONS !== "unicode";
1554
+ const OCT = USING_NERD_ICONS ? OCT_NERD : OCT_UNICODE;
1315
1555
 
1316
1556
  // ---------- Shared layout primitives ----------
1317
1557
 
@@ -1474,7 +1714,7 @@ const RUN_PENDING_ICON = { icon: OCT.dotFill, color: ATTENTION, label: "queued"
1474
1714
  const RUN_RUNNING_STATIC = { icon: OCT.alertFill, color: ATTENTION, label: "running" };
1475
1715
 
1476
1716
  // Remote strings index these tables, and a plain object literal answers
1477
- // inherited keys -- `SEVERITY_COLOR["constructor"]` returned a function, which
1717
+ // inherited keys -- `SEVERITY_STYLE["constructor"]` returned a function, which
1478
1718
  // reaches ink's colour prop and throws, killing the render. `??` cannot catch
1479
1719
  // that, because an inherited value is not nullish. Code-scanning severity comes
1480
1720
  // from uploaded SARIF, so it is genuinely attacker-influenced.
@@ -1751,7 +1991,7 @@ const MIN_COMPACT_WIDTH = Math.max(...TABS.map((t) => minimumWidthFor(t.compactH
1751
1991
  const TAB_LABEL_FULL_WIDTH = 78;
1752
1992
  const TAB_LABEL_HYSTERESIS = 4;
1753
1993
 
1754
- function TabBar({ activeIndex, counts, firstLoad, failed, spin, useShort }) {
1994
+ function TabBar({ activeIndex, counts, brokenCI, firstLoad, failed, spin, useShort }) {
1755
1995
  return e(
1756
1996
  Box,
1757
1997
  { flexDirection: "row" },
@@ -1760,7 +2000,12 @@ function TabBar({ activeIndex, counts, firstLoad, failed, spin, useShort }) {
1760
2000
  const count = counts[tab.key];
1761
2001
  // A tab that has never resolved shows the spinner where its count will
1762
2002
  // go, so the first load reads as "working" rather than "empty".
1763
- const suffix = count == null ? (firstLoad[tab.key] ? ` ${spin}` : "") : ` (${count})`;
2003
+ const suffix =
2004
+ count == null
2005
+ ? firstLoad[tab.key]
2006
+ ? ` ${spin}`
2007
+ : ""
2008
+ : ` (${count}${brokenCI[tab.key] ? "!" : ""})`;
1764
2009
  const name = useShort ? tab.short : tab.label;
1765
2010
  // Bold and inverse are both stripped at chalk level 0 (NO_COLOR, or a
1766
2011
  // dumb terminal), which left no indication at all of which tab was
@@ -1792,18 +2037,28 @@ function TabBar({ activeIndex, counts, firstLoad, failed, spin, useShort }) {
1792
2037
 
1793
2038
  // ---------- Status bar ----------
1794
2039
 
1795
- // Every glyph here is width-1 ASCII, deliberately. The arrows, the return
1796
- // symbol and the box-drawing separator are all East-Asian-Ambiguous: ink
1797
- // measures them as two columns in its width model, and a status bar built from
1798
- // them overflowed an 80-column terminal by six columns once selection added a
1799
- // hint. Same trap the unicode icon table already documents -- prefer strictly
1800
- // narrow ASCII over pretty-but-ambiguous.
2040
+ // Every glyph here is width-1 ASCII, deliberately, with one exception: the
2041
+ // Move arrows. The return symbol and the box-drawing separator are still
2042
+ // East-Asian-Ambiguous and stay out for that reason -- ink measures them as
2043
+ // two columns in its width model, and a status bar built from them overflowed
2044
+ // an 80-column terminal by six columns once selection added a hint. Same trap
2045
+ // the unicode icon table already documents -- prefer strictly narrow ASCII
2046
+ // over pretty-but-ambiguous. The arrow pair is a deliberate exception, and it
2047
+ // is NOT covered by a width assertion -- an earlier version of this comment
2048
+ // claimed `npm run test:pty` would catch a double-width rendering, and that was
2049
+ // wrong twice over. Each hint is wrap: "truncate-end", so the failure mode is
2050
+ // silent text loss rather than overflow, which no width check can see; and at 80
2051
+ // columns the panel border is 79 cells against a 54-cell status bar, so the bar
2052
+ // is not what sets the maximum anyway -- the arrows would have to add 25 columns
2053
+ // to move it, not 2. What actually bounds the risk is that there are two of
2054
+ // them and the compact breakpoint below keeps the whole set inside the frame.
2055
+ // Anything wider added here needs its own check, not this comment's assurance.
1801
2056
  //
1802
2057
  // Tab switching is not listed: the tab bar already renders "1:Actions", so the
1803
2058
  // digits document themselves, and the arrow keys are in --help. The hints that
1804
2059
  // survive are the ones nothing else on screen reveals.
1805
2060
  const KEY_HINTS = [
1806
- { label: "Move", keys: "jk" },
2061
+ { label: "Move", keys: "↑↓" },
1807
2062
  { label: "Open", keys: "Ent" },
1808
2063
  { label: "Refresh", keys: "r" },
1809
2064
  { label: "Quit", keys: "q" },
@@ -1813,17 +2068,41 @@ const KEY_HINTS = [
1813
2068
  // finishes. Wide enough for the spinner, a space and "Fetching".
1814
2069
  const FETCHING_WIDTH = 12;
1815
2070
 
2071
+ // Width the full hint set needs: every "Label: keys" plus a " | " between each.
2072
+ // Derived from KEY_HINTS rather than written down, for the same reason
2073
+ // minimumWidthFor() derives the table breakpoint from its header descriptors --
2074
+ // a copy change must not be able to invalidate the number.
2075
+ const HINTS_FULL_WIDTH =
2076
+ KEY_HINTS.reduce((sum, h) => sum + h.label.length + 2 + [...h.keys].length, 0) +
2077
+ (KEY_HINTS.length - 1) * 3;
2078
+ // Compact drops the labels and keeps the keys, separated by a space:
2079
+ // "↑↓ Ent r q". No constant for its width -- nothing branches on it, and the
2080
+ // keys are short enough that it fits anywhere the frame itself does.
2081
+ //
2082
+ // The band where the full set fits. Below it the bar was the one part of the
2083
+ // layout with no width awareness -- the table swaps to a compact header, the tab
2084
+ // bar swaps to short labels, the panel edges drop their labels, and the status
2085
+ // bar just let ink truncate. Because each hint is wrap: "truncate-end", the
2086
+ // failure was silent text loss rather than overflow: at 45 columns the rendered
2087
+ // bar read "Move: | Open: | Refresh |Quit:…" -- the arrows gone, Refresh
2088
+ // missing its key, and Quit, the last entry, first to be cut. That is the one
2089
+ // hint a confused first-time user needs, in a full-screen alternate-screen app.
2090
+ const STATUS_FULL_WIDTH = FETCHING_WIDTH + HINTS_FULL_WIDTH;
2091
+
1816
2092
  // Two tones rather than one flat gray: the keys you press are the part worth
1817
2093
  // finding at a glance, so they get the accent colour and the words describing
1818
2094
  // them stay dim. The accent is the panel-title cyan rather than the amber used
1819
2095
  // for in-progress status, so amber means exactly one thing across the product.
1820
- function StatusBar({ fetching, spin, stale, interactive }) {
2096
+ function StatusBar({ fetching, spin, stale, interactive, cols }) {
1821
2097
  // Without raw mode none of the key handlers run, so advertising them would be
1822
2098
  // telling the user something untrue about what the app can do. Ctrl+C still
1823
2099
  // works there, because the tty delivers a real SIGINT.
1824
2100
  const hints = interactive
1825
2101
  ? KEY_HINTS
1826
2102
  : [{ label: "Quit", keys: "^C" }];
2103
+ // Measured against the *full* set even when a compact one is rendered, so the
2104
+ // breakpoint does not move as the bar's own contents change.
2105
+ const compact = interactive && cols < STATUS_FULL_WIDTH;
1827
2106
  return e(
1828
2107
  Box,
1829
2108
  { flexDirection: "row" },
@@ -1849,11 +2128,12 @@ function StatusBar({ fetching, spin, stale, interactive }) {
1849
2128
  : null,
1850
2129
  ...hints
1851
2130
  .flatMap((hint, i) => [
1852
- i > 0 && e(Text, { key: `sep${i}`, color: BORDER_COLOR }, " | "),
2131
+ i > 0 &&
2132
+ e(Text, { key: `sep${i}`, color: BORDER_COLOR }, compact ? " " : " | "),
1853
2133
  e(
1854
2134
  Text,
1855
2135
  { key: hint.label, wrap: "truncate-end" },
1856
- e(Text, { dimColor: true }, `${hint.label}: `),
2136
+ compact ? null : e(Text, { dimColor: true }, `${hint.label}: `),
1857
2137
  e(Text, { color: TITLE_COLOR, bold: true }, hint.keys),
1858
2138
  ),
1859
2139
  ])
@@ -1921,6 +2201,12 @@ function App() {
1921
2201
  security: null,
1922
2202
  });
1923
2203
  const [securityNotes, setSecurityNotes] = useState([]);
2204
+ // Whether the Security tab is currently unable to see its endpoints, as
2205
+ // opposed to seeing that they are switched off. Drives the count marker.
2206
+ const [securityBlind, setSecurityBlind] = useState(false);
2207
+ // The `?` overlay. Renders only on a keypress, so consecutive idle frames are
2208
+ // still byte-identical and the redraw suppression is untouched.
2209
+ const [showHelp, setShowHelp] = useState(false);
1924
2210
  const [errors, setErrors] = useState({ actions: null, issues: null, prs: null, security: null });
1925
2211
  const [loading, setLoading] = useState({ actions: true, issues: true, prs: true, security: true });
1926
2212
  const [now, setNow] = useState(new Date());
@@ -1933,7 +2219,29 @@ function App() {
1933
2219
  const [offset, setOffset] = useState({});
1934
2220
 
1935
2221
  const tab = TABS[activeIndex];
1936
- const extraLines = (errors[tab.key] ? 1 : 0) + (tab.key === "security" ? securityNotes.length : 0);
2222
+ // The not-enabled notes are collapsed into a single line. Each unavailable
2223
+ // alert source used to contribute a full-width row above the column header,
2224
+ // and on any repo without Advanced Security that is two permanent lines --
2225
+ // roughly 10% of a twenty-row pane, forever, restating a fact that will never
2226
+ // change, on the tab whose job is making real alerts stand out. Failures that
2227
+ // are NOT "not enabled" keep their own lines: those are actionable and
2228
+ // transient, and they are the ones worth the space. Derived here at render
2229
+ // time rather than cached alongside the notes, because the unchanged-payload
2230
+ // short-circuit can skip parse() entirely and a cached string would go stale.
2231
+ const NOT_ENABLED = /not enabled/i;
2232
+ const disabledNotes = securityNotes.filter((n) => NOT_ENABLED.test(n));
2233
+ const otherNotes = securityNotes.filter((n) => !NOT_ENABLED.test(n));
2234
+ const securityLines =
2235
+ disabledNotes.length > 1
2236
+ ? [
2237
+ `${disabledNotes.map((n) => n.split(":")[0]).join(", ")}: not enabled here`,
2238
+ ...otherNotes,
2239
+ ]
2240
+ : securityNotes;
2241
+ // Counts the lines actually rendered, not the notes collected -- getting this
2242
+ // wrong by one row is what makes ink repaint the whole frame.
2243
+ const extraLines =
2244
+ (errors[tab.key] ? 1 : 0) + (tab.key === "security" ? securityLines.length : 0);
1937
2245
  // Reserve lines for: the tab bar and the divider under it (2), the panel's
1938
2246
  // top and bottom edges (2), the column header and its separator (2), and
1939
2247
  // the status line (1), plus a 1-line safety margin. Slack is absorbed by
@@ -1965,8 +2273,12 @@ function App() {
1965
2273
  const inFlightRef = useRef({});
1966
2274
  const rawRef = useRef({});
1967
2275
  const cleanRef = useRef({});
2276
+ // Last *successful* poll per tab, wall-clock. Wall-clock on purpose: a laptop
2277
+ // sleeping is exactly the gap this is meant to report, and a monotonic clock
2278
+ // does not advance across suspend. Never written on the failure path, or a
2279
+ // persistently failing tab would report itself fresh forever.
2280
+ const lastOkRef = useRef({});
1968
2281
  const fetchTabRef = useRef(null);
1969
- const abortRef = useRef(null);
1970
2282
 
1971
2283
  const interactive = Boolean(isRawModeSupported);
1972
2284
 
@@ -1974,28 +2286,40 @@ function App() {
1974
2286
  // slice, so the movement handlers read through a ref -- the same pattern the
1975
2287
  // poll loop uses for runLimitRef and activeIndexRef, and for the same reason:
1976
2288
  // the closure must see current values without being rebuilt on every change.
1977
- const navRef = useRef({ items: [], key: null, bodyRows: 1, tabKey: "actions" });
2289
+ const navRef = useRef({ items: [], key: null, bodyRows: 1, tabKey: "actions", offset: 0 });
1978
2290
  navRef.current = {
1979
2291
  items: data[tab.key] ?? [],
1980
2292
  key: selected[tab.key] ?? null,
1981
2293
  bodyRows,
1982
2294
  tabKey: tab.key,
2295
+ offset: offset[tab.key] ?? 0,
1983
2296
  };
1984
2297
  const pageStep = Math.max(1, bodyRows - 1);
1985
2298
 
1986
- const abortSelectionRef = useRef(null);
2299
+ // Read through a ref for the same reason the poll loop does: the useInput
2300
+ // closure must see the current value without being rebuilt on every toggle.
2301
+ const showHelpRef = useRef(false);
2302
+ showHelpRef.current = showHelp;
1987
2303
 
1988
2304
  function moveSelection(delta) {
1989
- const { items, key: currentKey, bodyRows: rows_, tabKey } = navRef.current;
2305
+ const { items, key: currentKey, bodyRows: rows_, tabKey, offset: offsetRaw } = navRef.current;
1990
2306
  if (items.length === 0) return;
1991
2307
  const current = currentKey == null ? -1 : items.findIndex((i) => itemKey(i) === currentKey);
1992
- // From "nothing selected", down lands on the first row and up on the last,
1993
- // which is what every list in this category does.
2308
+ const maxStart = Math.max(0, items.length - rows_);
2309
+ // Re-clamped here for the same reason the render path re-clamps: the payload
2310
+ // can shrink between ticks, so a stored offset can point past the end.
2311
+ const start = Math.min(Math.max(0, offsetRaw), maxStart);
2312
+ // From "nothing selected", seed from what is on screen -- down takes the
2313
+ // first visible row, up the last. It used to take row 0 / the final row of
2314
+ // the whole list, which meant the 60s idle clear silently cost you your
2315
+ // place: scroll to row 80 of 150, read for a minute, press down, and you
2316
+ // were back at row 1 with no scroll animation to notice. For an unscrolled
2317
+ // list that fits the pane this is the same behaviour as before.
1994
2318
  const next =
1995
2319
  current === -1
1996
2320
  ? delta > 0
1997
- ? 0
1998
- : items.length - 1
2321
+ ? start
2322
+ : Math.min(items.length - 1, start + rows_ - 1)
1999
2323
  : Math.min(items.length - 1, Math.max(0, current + delta));
2000
2324
  setSelected((s) => ({ ...s, [tabKey]: itemKey(items[next]) }));
2001
2325
  // Keep the cursor on screen. Only the offset needed to reveal it changes,
@@ -2010,24 +2334,57 @@ function App() {
2010
2334
  });
2011
2335
  }
2012
2336
 
2337
+ // Rearms on every call to moveSelection (it's the only thing that changes
2338
+ // `selected`), so this fires exactly 60s after the *last* movement -- tab
2339
+ // switches and Enter don't count as activity and don't push it back. Clears
2340
+ // every tab's cursor at once rather than just the visible one, so a tab you
2341
+ // switch back to after being idle doesn't still show a stale row marked.
2342
+ useEffect(() => {
2343
+ if (Object.keys(selected).length === 0) return;
2344
+ const timer = setTimeout(() => setSelected({}), SELECTION_IDLE_MS);
2345
+ return () => clearTimeout(timer);
2346
+ }, [selected]);
2347
+
2348
+ // Guarded per item, not globally: holding Enter down produces terminal key
2349
+ // repeat at ~30/s, and every event used to spawn another `gh <kind> view --web`
2350
+ // and open another browser tab. The `r` key next to it already guards this way
2351
+ // through the poll loop's in-flight map; Enter simply never did. Keyed by item
2352
+ // rather than by tab so that moving to a different row and opening it
2353
+ // immediately still works -- selection does not move on Enter, so a second
2354
+ // press within the window is always a duplicate of the first.
2355
+ const openingRef = useRef({});
2356
+
2013
2357
  function openSelected() {
2014
2358
  const { items, key: currentKey, tabKey } = navRef.current;
2015
2359
  const item = items.find((i) => itemKey(i) === currentKey);
2016
2360
  if (!item) return;
2017
- const controller = new AbortController();
2018
- abortSelectionRef.current = controller;
2361
+ const guard = `${tabKey}:${itemKey(item)}`;
2362
+ if (openingRef.current[guard]) return;
2363
+ openingRef.current[guard] = true;
2019
2364
  // Fire and forget: a browser launch must not block the render loop, and a
2020
2365
  // failure surfaces through the tab's normal error line rather than as an
2021
2366
  // unhandled rejection.
2022
- openInBrowser(tabKey, item, controller.signal).catch((err) => {
2023
- setErrors((x) => ({ ...x, [tabKey]: shortErr(err) }));
2024
- });
2367
+ openInBrowser(tabKey, item)
2368
+ .catch((err) => {
2369
+ setErrors((x) => ({ ...x, [tabKey]: shortErr(err) }));
2370
+ })
2371
+ .finally(() => {
2372
+ delete openingRef.current[guard];
2373
+ });
2025
2374
  }
2026
2375
 
2027
2376
  useInput(
2028
2377
  (input, key) => {
2029
2378
  if (input === "q" || key.escape) {
2030
2379
  exit();
2380
+ } else if (showHelpRef.current) {
2381
+ // Any key dismisses -- except quit, handled above, which must never be
2382
+ // swallowed by a modal in a full-screen app. Deliberately does not fall
2383
+ // through to the binding the key would normally trigger: closing the
2384
+ // overlay is the whole intent of that press.
2385
+ setShowHelp(false);
2386
+ } else if (input === "?") {
2387
+ setShowHelp(true);
2031
2388
  } else if (key.downArrow || input === "j") {
2032
2389
  moveSelection(1);
2033
2390
  } else if (key.upArrow || input === "k") {
@@ -2040,8 +2397,11 @@ function App() {
2040
2397
  openSelected();
2041
2398
  } else if (input === "r") {
2042
2399
  // Goes through the same per-tab in-flight guard as the poll loop, so
2043
- // holding the key down cannot stack concurrent subprocesses.
2044
- fetchTabRef.current?.(TABS[activeIndexRef.current].key);
2400
+ // holding the key down cannot stack concurrent subprocesses. `force`
2401
+ // bypasses (and clears) any failure backoff: this key is the user saying
2402
+ // "try again now", and a refresh that silently declined to refresh would
2403
+ // be worse than no key at all.
2404
+ fetchTabRef.current?.(TABS[activeIndexRef.current].key, { force: true });
2045
2405
  } else if (input >= "1" && input <= String(TABS.length)) {
2046
2406
  setActiveIndex(Number(input) - 1);
2047
2407
  } else if (key.tab && key.shift) {
@@ -2075,7 +2435,7 @@ function App() {
2075
2435
  let cancelled = false;
2076
2436
  let ticks = 0;
2077
2437
  const controller = new AbortController();
2078
- abortRef.current = controller;
2438
+ registerLiveAbort(controller);
2079
2439
 
2080
2440
  // Each tab commits its own result the moment it lands instead of waiting on
2081
2441
  // a Promise.allSettled barrier. Actions is by far the slowest fetch, so
@@ -2101,6 +2461,19 @@ function App() {
2101
2461
  // at the top of this handler and the early return fired before parse()
2102
2462
  // could be retried, leaving the tab permanently showing "no runs" with
2103
2463
  // no error at all. Both now happen only after a successful parse.
2464
+ // Freshness is recorded on every *successful poll*, including the
2465
+ // identical-payload path below, and deliberately not in `meta`. It used
2466
+ // to ride on meta.at, which is only written past the early return -- so
2467
+ // on a quiet repo, where every payload is byte-identical by design, the
2468
+ // timestamp froze at the last time data changed and the status bar
2469
+ // accrued a growing "stale 2h13m" while every poll was succeeding on
2470
+ // schedule. The indicator fired loudest in the one state that is
2471
+ // completely healthy. A ref rather than state because writing state here
2472
+ // would allocate a new object every tick and permanently defeat the
2473
+ // React bail-out this early return exists to preserve.
2474
+ lastOkRef.current[key] = Date.now();
2475
+ // Clear on the first success or a single failure latches the ladder.
2476
+ clearBackoff(`tab:${key}`);
2104
2477
  if (rawRef.current[key] === raw && cleanRef.current[key]) {
2105
2478
  setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
2106
2479
  return;
@@ -2121,11 +2494,27 @@ function App() {
2121
2494
  },
2122
2495
  }));
2123
2496
  if (value.notes) setSecurityNotes(value.notes);
2497
+ if (key === "security") {
2498
+ setSecurityBlind((b) => (b === Boolean(value.blind) ? b : Boolean(value.blind)));
2499
+ }
2124
2500
  })
2125
2501
  .catch((err) => {
2126
2502
  if (cancelled || err?.name === "AbortError") return;
2127
2503
  cleanRef.current[key] = false;
2128
- setErrors((x) => ({ ...x, [key]: shortErr(err) }));
2504
+ // Classified, not dumped. The three list tabs used to put raw `gh`
2505
+ // stderr on screen while the alert path one tab over translated the
2506
+ // same verdicts into something actionable. VERDICT_REMEDY replaces the
2507
+ // message rather than prefixing it, because the error slot is exactly
2508
+ // one line and a prefix would truncate away the diagnostic half.
2509
+ const verdict = classify(err);
2510
+ setErrors((x) => ({ ...x, [key]: pick(VERDICT_REMEDY, verdict, null) ?? shortErr(err) }));
2511
+ // ...and back off, which the list tabs never did at all. A tab wedged
2512
+ // on an expired token used to re-spawn `gh` every tick forever -- 720
2513
+ // subprocesses an hour, indefinitely, against a token that is already
2514
+ // refusing. "other" has no ladder on purpose: a network drop should
2515
+ // recover on the very next tick once the network is back.
2516
+ const steps = pick(FAILURE_LADDER, verdict, null);
2517
+ if (steps) recordFailure(`tab:${key}`, performance.now(), steps);
2129
2518
  })
2130
2519
  .finally(() => {
2131
2520
  inFlightRef.current[key] = false;
@@ -2133,9 +2522,15 @@ function App() {
2133
2522
  });
2134
2523
  }
2135
2524
 
2136
- function fetchTab(key) {
2525
+ // `force` is what the `r` key passes, and it is the whole reason the backoff
2526
+ // above is safe to add. Without it, pressing refresh on a backed-off tab
2527
+ // would silently do nothing -- taking away the one control the user has at
2528
+ // exactly the moment the pane looks broken and they reach for it.
2529
+ function fetchTab(key, { force = false } = {}) {
2137
2530
  const signal = controller.signal;
2138
2531
  const descriptor = TABS.find((t) => t.key === key);
2532
+ if (!force && backoffActive(`tab:${key}`, performance.now())) return Promise.resolve();
2533
+ if (force) clearBackoff(`tab:${key}`);
2139
2534
  // Every tab carries its own fetcher on the registry, so adding a tab is a
2140
2535
  // TABS entry plus a fetcher rather than an entry plus an edit to a chain
2141
2536
  // in a different part of the file.
@@ -2151,7 +2546,10 @@ function App() {
2151
2546
  const active = TABS[activeIndexRef.current].key;
2152
2547
  const due = ticks % BACKGROUND_EVERY === 0 ? TABS.map((t) => t.key) : [active];
2153
2548
  ticks += 1;
2154
- await Promise.allSettled(due.map(fetchTab));
2549
+ // Mapped explicitly rather than passing fetchTab by reference: Array.map
2550
+ // hands the callback an index as its second argument, which would land in
2551
+ // the options bag and make every background tab look force-refreshed.
2552
+ await Promise.allSettled(due.map((key) => fetchTab(key)));
2155
2553
  if (cancelled) return;
2156
2554
  // Advancing `now` is what forces a redraw, so only do it when it can
2157
2555
  // actually change what's on screen: durations of in-progress runs tick
@@ -2189,7 +2587,18 @@ function App() {
2189
2587
  // idle hour, and the exact opposite of the redraw suppression above. The
2190
2588
  // first load genuinely is worth animating, because an empty pane with no
2191
2589
  // motion reads as broken; after that, only a run actually executing is.
2192
- const firstLoad = Object.fromEntries(TABS.map((t) => [t.key, data[t.key] == null]));
2590
+ // "Never resolved" and "never *succeeded*" are different states, and conflating
2591
+ // them pinned the spinner on forever. setData is only ever called on success,
2592
+ // so a tab whose fetch keeps failing -- offline laptop, VPN down, expired auth,
2593
+ // Actions disabled on the repo -- kept data === null, kept firstLoad true, and
2594
+ // kept the 100ms interval running for the life of the process while the body
2595
+ // rendered "loading actions..." directly above the error explaining it had
2596
+ // failed. Measured at 7.8% of a core and 9.8 MB/hr of terminal writes,
2597
+ // indefinitely, on the single most ordinary failure there is. Motion now means
2598
+ // "still working"; the error line means "not working"; nothing claims both.
2599
+ const firstLoad = Object.fromEntries(
2600
+ TABS.map((t) => [t.key, data[t.key] == null && !errors[t.key]]),
2601
+ );
2193
2602
  const anyFirstLoad = Object.values(firstLoad).some(Boolean);
2194
2603
  const showSpinner = ANIMATE && (anyFirstLoad || hasRunningVisible);
2195
2604
  useEffect(() => {
@@ -2217,15 +2626,30 @@ function App() {
2217
2626
  // the count stop moving through a genuine change on any repo big enough
2218
2627
  // for this tool to matter.
2219
2628
  const suffix = meta[t.key]?.truncated ? "+" : "";
2220
- // Newest first, so the head of the list is the run that decides whether
2221
- // CI is currently red.
2222
- const broken =
2629
+ // A blind Security tab reports "?" rather than a number it cannot stand
2630
+ // behind. Zero alerts and zero visibility look identical otherwise.
2631
+ if (t.key === "security" && securityBlind) return [t.key, "?"];
2632
+ return [t.key, `${list.length}${suffix}`];
2633
+ }),
2634
+ );
2635
+ // Kept out of `counts` because the two have different audiences. In the tab bar
2636
+ // `!` sits next to a label and means "the newest run failed"; interpolated into
2637
+ // the frame's bottom edge it produced "4 of 4!", where there is nothing for it
2638
+ // to attach to and it reads as emphasis or a typo. `+` composes fine in both
2639
+ // places, so only this one had to move. Newest first, so the head of the list
2640
+ // is the run that decides whether CI is currently red.
2641
+ const brokenCI = Object.fromEntries(
2642
+ TABS.map((t) => {
2643
+ const list = data[t.key];
2644
+ return [
2645
+ t.key,
2223
2646
  t.key === "actions" &&
2224
- list.length > 0 &&
2225
- list[0].status === "completed" &&
2226
- list[0].conclusion !== "success" &&
2227
- list[0].conclusion !== "skipped";
2228
- return [t.key, `${list.length}${suffix}${broken ? "!" : ""}`];
2647
+ Array.isArray(list) &&
2648
+ list.length > 0 &&
2649
+ list[0].status === "completed" &&
2650
+ list[0].conclusion !== "success" &&
2651
+ list[0].conclusion !== "skipped",
2652
+ ];
2229
2653
  }),
2230
2654
  );
2231
2655
  const failed = Object.fromEntries(TABS.map((t) => [t.key, Boolean(errors[t.key])]));
@@ -2234,7 +2658,11 @@ function App() {
2234
2658
  // poll only surfaces on the tab you have selected, and after a laptop sleep
2235
2659
  // every pane is plausible and wrong. Threshold-gated and minute-granular on
2236
2660
  // purpose -- see STALE_AFTER_MS.
2237
- const lastOk = meta[tab.key]?.at;
2661
+ // Read from the ref written on every successful poll, not from meta.at, which
2662
+ // only moves when the *payload* changes -- see lastOkRef. Reading a ref during
2663
+ // render lags by one render, which is harmless here because `now` advances on
2664
+ // its own and the label is minute-granular by design.
2665
+ const lastOk = lastOkRef.current[tab.key];
2238
2666
  const staleFor = lastOk == null ? null : now.getTime() - lastOk;
2239
2667
  const staleThreshold = Math.max(STALE_AFTER_MS, runtime.refreshMs * 6);
2240
2668
  const staleLabel =
@@ -2274,27 +2702,61 @@ function App() {
2274
2702
  // terminal is doing the clipping.
2275
2703
  const frameCols = Math.max(1, cols - 1);
2276
2704
 
2705
+ // Name the repository in the top edge when it was chosen explicitly, because
2706
+ // then it is not the one you would guess from the working directory. Nothing
2707
+ // on screen said which repo a pane was watching, and the documented workflow is
2708
+ // several panes side by side plus `--repo` from anywhere -- so telling them
2709
+ // apart meant quitting and running --doctor. Only the explicit cases are shown:
2710
+ // resolving the *inferred* repo would cost a subprocess call at startup for a
2711
+ // label that, by definition, names the directory you are already sitting in.
2712
+ //
2713
+ // GH_REPO is read straight from the environment and never went through
2714
+ // parseRepoTarget, so unlike runtime.repo it is unvalidated -- safe() before it
2715
+ // is drawn, on the same rule as every other string this app does not own.
2716
+ // Dropped before the tab name rather than with it: PanelEdge drops its whole
2717
+ // label when it cannot seat one, so a single concatenated string would take the
2718
+ // tab name down with the target at ordinary widths.
2719
+ const target = runtime.repo ?? safe(process.env.GH_REPO ?? "");
2720
+ const withTarget = target ? `${tab.label} · ${target}` : tab.label;
2721
+ const topLabel = frameCols - 3 - withTarget.length >= 0 ? withTarget : tab.label;
2722
+
2277
2723
  // Fixed columns deliberately do not shrink, so BRANCH and TIME stay readable
2278
2724
  // at ordinary widths. Narrow panes drop columns instead, which keeps the
2279
2725
  // frame, the tab bar and the quit hint on screen -- the previous behaviour
2280
2726
  // pushed all three off the bottom. Measured against frameCols, the width the
2281
2727
  // frame itself actually gets, not the raw terminal width.
2282
- const compact = frameCols < MIN_TABLE_WIDTH;
2728
+ // Per tab, not one global flag. MIN_TABLE_WIDTH is a max across all four, so
2729
+ // the widest tab was deciding for the narrowest: Pull requests needs 61
2730
+ // columns and Security only 44, which meant Security dropped two columns a
2731
+ // full 17 columns before it had to. Only one tab is on screen at a time, so
2732
+ // the cost -- different tabs showing different column counts at the same width
2733
+ // -- is not something you can actually see. MIN_TABLE_WIDTH stays as the
2734
+ // exported worst case, which is what the tests pin.
2735
+ const compact = frameCols < minimumWidthFor(tab.header);
2283
2736
  const header = compact ? tab.compactHeader : tab.header;
2284
2737
 
2738
+ // Below the compact set's own floor even the fixed columns overflow, which
2739
+ // hard-wraps every row and drives ink into clearing and repainting the whole
2740
+ // screen each frame -- the one failure mode this file is most engineered to
2741
+ // avoid, and it was reachable simply by dragging a sidebar narrow. The guard
2742
+ // sits after usableSize(), which substitutes DEFAULT_COLS for a 0 reported
2743
+ // mid-resize, so a transient zero cannot be mistaken for a genuinely tiny pane.
2744
+ const tooNarrow = frameCols < MIN_COMPACT_WIDTH;
2745
+
2285
2746
  return e(
2286
2747
  Box,
2287
2748
  { flexDirection: "column", width: frameCols, height: Math.min(rows, usableSize(stdout?.rows, rows)) },
2288
2749
  e(TabBar, {
2289
2750
  activeIndex,
2290
2751
  counts,
2752
+ brokenCI,
2291
2753
  firstLoad,
2292
2754
  failed,
2293
2755
  spin,
2294
2756
  useShort: useShortLabels,
2295
2757
  }),
2296
2758
  e(Divider, { width: frameCols }),
2297
- e(PanelEdge, { width: frameCols, top: true, label: tab.label, labelColor: TITLE_COLOR }),
2759
+ e(PanelEdge, { width: frameCols, top: true, label: topLabel, labelColor: TITLE_COLOR }),
2298
2760
  e(
2299
2761
  Box,
2300
2762
  {
@@ -2311,13 +2773,31 @@ function App() {
2311
2773
  // truncate-end is what makes the one-line reservation in `extraLines`
2312
2774
  // true by construction. Predicting the wrapped height instead would mean
2313
2775
  // duplicating ink's width model, and would still be wrong on resize.
2314
- error && e(Text, { color: ERROR_TEXT, wrap: "truncate-end" }, error),
2315
- tab.key === "security" &&
2316
- securityNotes.map((note, i) =>
2776
+ // Below the compact floor, say so instead of rendering a table that cannot
2777
+ // fit. The frame, the tab bar and the quit hint stay, so widening the pane
2778
+ // recovers immediately -- this is a render-time branch and touches no state.
2779
+ // Short enough to survive at the widths it actually appears at -- a message
2780
+ // about the pane being too narrow that is itself truncated would be a joke.
2781
+ // Clamped to bodyRows so it can never push the frame past `rows` and
2782
+ // trigger the full repaint the height reservation exists to prevent, and
2783
+ // truncate-end so it degrades rather than wraps in a narrow pane. Every
2784
+ // glyph in KEY_TABLE is plain ASCII, which is what keeps it safe here.
2785
+ showHelp &&
2786
+ keyTableLines()
2787
+ .slice(0, bodyRows)
2788
+ .map((line, i) => e(Text, { key: `help${i}`, wrap: "truncate-end" }, line)),
2789
+ !showHelp &&
2790
+ tooNarrow &&
2791
+ e(Text, { dimColor: true, wrap: "truncate-end" }, "too narrow"),
2792
+ !showHelp && !tooNarrow && error && e(Text, { color: ERROR_TEXT, wrap: "truncate-end" }, error),
2793
+ !showHelp &&
2794
+ !tooNarrow &&
2795
+ tab.key === "security" &&
2796
+ securityLines.map((note, i) =>
2317
2797
  e(Text, { key: i, dimColor: true, wrap: "truncate-end" }, note),
2318
2798
  ),
2319
- e(HeaderCells, { cells: header }),
2320
- ...visibleItems.map((item) => {
2799
+ !showHelp && !tooNarrow && e(HeaderCells, { cells: header }),
2800
+ ...(tooNarrow || showHelp ? [] : visibleItems).map((item) => {
2321
2801
  const key = itemKey(item);
2322
2802
  return e(
2323
2803
  RowBoundary,
@@ -2327,10 +2807,13 @@ function App() {
2327
2807
  now,
2328
2808
  compact,
2329
2809
  cursor: key === selectedKey,
2330
- // Only the Actions rows read this. Passing it to the others would
2331
- // change a prop ten times a second on every tab and defeat the
2332
- // memoisation below for no visible effect.
2333
- spin: tab.key === "actions" && showSpinner ? spin : null,
2810
+ // Only an *executing* Actions row reads this. It used to go to every
2811
+ // visible row on the tab, so a prop that changes several times a
2812
+ // second defeated the memoisation below for all of them rather than
2813
+ // for the one or two actually spinning -- measured at 7,960 row
2814
+ // renders over 20s against 238 with this condition.
2815
+ spin:
2816
+ tab.key === "actions" && showSpinner && item.status === "in_progress" ? spin : null,
2334
2817
  }),
2335
2818
  );
2336
2819
  }),
@@ -2338,13 +2821,25 @@ function App() {
2338
2821
  // which is the difference between a dashboard that looks hung and one
2339
2822
  // that looks correct. Driven by "never resolved" rather than by the
2340
2823
  // loading flag, which toggles on every tick and made a settled-empty tab
2341
- // swap its message every five seconds.
2342
- visibleItems.length === 0 &&
2824
+ // swap its message every five seconds. Suppressed entirely when a tab has
2825
+ // an error and has never resolved: the error line directly above already
2826
+ // says what happened, and "no runs" underneath it reads as a fact about the
2827
+ // repository rather than the absence of an answer.
2828
+ !showHelp &&
2829
+ !tooNarrow &&
2830
+ visibleItems.length === 0 &&
2831
+ !(error && items == null) &&
2343
2832
  e(
2344
2833
  Text,
2345
2834
  { dimColor: true },
2346
2835
  firstLoad[tab.key]
2347
- ? `${showSpinner ? spin : SPINNER[0]} loading ${tab.label.toLowerCase()}…`
2836
+ ? `${showSpinner ? spin : SPINNER[0]} loading ${tab.label.toLowerCase()}…` +
2837
+ // Shown only while the first fetch is in flight, so it costs a
2838
+ // second of one line at startup and nothing after -- and it lands
2839
+ // exactly when someone is staring at a fresh pane deciding whether
2840
+ // the thing works. Suppressed once icons are already ASCII, since
2841
+ // then there is nothing to fix.
2842
+ (USING_NERD_ICONS ? " (icons blank? GH_GLANCE_ICONS=unicode)" : "")
2348
2843
  : tab.key === "security" && securityNotes.length === ALERT_SOURCES.length
2349
2844
  ? "no alert sources available"
2350
2845
  : `no ${tab.countLabel}`,
@@ -2360,6 +2855,7 @@ function App() {
2360
2855
  spin: showSpinner ? spin : null,
2361
2856
  stale: staleLabel,
2362
2857
  interactive,
2858
+ cols: frameCols,
2363
2859
  }),
2364
2860
  );
2365
2861
  }
@@ -2390,11 +2886,27 @@ function restoreScreen() {
2390
2886
  // then discarded -- and nothing ever set a non-zero exit code, so the dashboard
2391
2887
  // simply vanished and any wrapper saw success. Restore the primary buffer
2392
2888
  // *first*, then write, or the fix reproduces the problem it is fixing.
2889
+ //
2890
+ // Aborting in-flight `gh` children is the poll effect's job on every other exit
2891
+ // path, but its cleanup only runs through ink's unmount -- which an explicit
2892
+ // process.exit() skips. Without this, a crash orphaned up to six subprocesses
2893
+ // for the length of GH_TIMEOUT_MS. The abort runs *after* restoreScreen() and is
2894
+ // wrapped, because an exception thrown here would replace the stack trace this
2895
+ // handler exists to print with an unrelated one.
2896
+ //
2897
+ // Both messages go through redact(): a stack can carry a URL with inline
2898
+ // credentials, and this output is what a user pastes into a bug report -- the
2899
+ // same reasoning --doctor already applies to its own report.
2393
2900
  function installCrashHandlers() {
2394
2901
  const fail = (label) => (err) => {
2395
2902
  restoreScreen();
2903
+ try {
2904
+ abortLiveRequests();
2905
+ } catch {
2906
+ // Nothing useful to do about a failure here, and the stack below matters more.
2907
+ }
2396
2908
  console.error(`gh-glance: ${label}`);
2397
- console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
2909
+ console.error(redact(err instanceof Error ? (err.stack ?? err.message) : String(err)));
2398
2910
  process.exit(1);
2399
2911
  };
2400
2912
  process.on("uncaughtException", fail("crashed"));
@@ -2489,4 +3001,9 @@ export {
2489
3001
  MIN_COMPACT_WIDTH,
2490
3002
  OCT_NERD,
2491
3003
  OCT_UNICODE,
3004
+ KEY_TABLE,
3005
+ KEY_HINTS,
3006
+ VERDICT_REMEDY,
3007
+ RATE_LIMIT_RETRY_MS,
3008
+ FAILURE_LADDER,
2492
3009
  };