gh-glance 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/README.md +85 -39
  3. package/index.mjs +640 -112
  4. package/package.json +2 -1
package/index.mjs CHANGED
@@ -53,6 +53,23 @@ const REFRESH_MS = 5000;
53
53
  // last movement, not from tab switches or Enter) quietly drops it.
54
54
  const SELECTION_IDLE_MS = 60_000;
55
55
 
56
+ // How long a first fetch has to still be unresolved before the loading line
57
+ // offers the Nerd Font escape hatch. The hint used to be unconditional, which
58
+ // put a line naming an environment variable in front of every user on every
59
+ // start to catch the minority whose terminal cannot draw the glyphs -- a dim
60
+ // "loading actions…" reads as "working", the same line with a remedy attached
61
+ // reads as a warning, and it was on screen for the whole first fetch every time.
62
+ //
63
+ // The threshold is set from the fetch that actually gates it, not from a round
64
+ // number: the hint hangs off the *visible* tab's loading line, and the tab you
65
+ // land on is Actions, whose `gh run list` is by far the slowest call here
66
+ // (measured against this repo: issues/PRs/alerts resolve 0.6-1.1s after the
67
+ // first frame, runs 1.4-3.0s). 1.5s would have fired on most ordinary starts --
68
+ // i.e. changed nothing. 3s sits past the slow end of that range, so a start that
69
+ // reaches it is genuinely stuck rather than merely fetching, which is exactly
70
+ // the moment the remedy is worth a line.
71
+ const ICON_HINT_AFTER_MS = 3000;
72
+
56
73
  // `gh run list` costs roughly linearly in --limit (measured: ~1.2s at 20 runs,
57
74
  // ~3.0s at 100, ~4.9s at 150), so asking for a fixed 150 to render ~35 visible
58
75
  // rows was paying several seconds per refresh for rows nobody sees. Actions is
@@ -74,7 +91,15 @@ const LIST_LIMIT = 150;
74
91
  // page. The previous --paginate walked the repo's entire alert history --
75
92
  // mostly closed alerts -- and then discarded them client-side.
76
93
  const ALERT_PER_PAGE = 100;
77
- const ALERT_QUERY = `?state=open&per_page=${ALERT_PER_PAGE}`;
94
+ // Ordering is pinned rather than left to each endpoint's default. Severity
95
+ // sorting happens client-side over whatever this returns, so on a repo with more
96
+ // than a page of open alerts the *page boundary* decides what can be ranked at
97
+ // all -- a critical alert sitting 150th by some endpoint's arbitrary default was
98
+ // simply never fetched, and the pane showed "100+" with a screen of moderates.
99
+ // Newest-first at least makes the cut deterministic and explainable, and it
100
+ // survives GitHub changing a default underneath us. All three endpoints accept
101
+ // these parameters.
102
+ const ALERT_QUERY = `?state=open&per_page=${ALERT_PER_PAGE}&sort=created&direction=desc`;
78
103
 
79
104
  // Inactive tabs only feed the tab-bar counts, so they refresh every Nth tick
80
105
  // rather than every tick. Raised from 4 to 12 (a 60s cycle at the default
@@ -116,6 +141,16 @@ const BACKOFF_STEPS_MS = [60_000, 300_000, 1_800_000, 3_600_000];
116
141
  // night costs -- two probes a minute per endpoint rather than one per tick.
117
142
  const AUTH_RETRY_MS = [30_000];
118
143
 
144
+ // A rate limit is the third shape: it clears on GitHub's schedule, usually
145
+ // within the hour, and continuing to hammer is actively counterproductive --
146
+ // the secondary limiter keys on sustained request rate against a token that is
147
+ // already limited, so re-asking every tick can turn a self-clearing primary
148
+ // limit into a longer block, and the block applies to the *token*, not to this
149
+ // tool. A wedged pane could therefore degrade `git push` and everything else.
150
+ // One minute, flat: long enough to stop amplifying, short enough that the pane
151
+ // is current again well before a reset the user never notices.
152
+ const RATE_LIMIT_RETRY_MS = [60_000];
153
+
119
154
  // Past this, the active tab's data is old enough to say so. Deliberately
120
155
  // coarse: `now` only advances on minute boundaries when nothing is in progress,
121
156
  // so a minute-granular staleness label costs zero extra redraws, whereas a
@@ -149,7 +184,14 @@ const TAB_KEYS = ["actions", "issues", "prs", "security"];
149
184
  // dots per frame, reading as a filled blob that matches the circle icons'
150
185
  // visual weight while staying in the same width-1 braille block.
151
186
  const SPINNER = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
152
- const SPINNER_MS = 100;
187
+ // 200ms, not 100: the spinner is the single largest CPU term in the app, because
188
+ // every frame makes ink rebuild and diff the whole output string. Measured on a
189
+ // 40-row pane: 100ms costs 7.8% of a core and 9.8 MB/hr of terminal writes,
190
+ // 200ms costs 3.9% and 5.2 MB/hr, against a 0.33% idle floor. The motion is
191
+ // load-bearing -- it is the only thing separating an executing run from a queued
192
+ // one (see RUN_RUNNING_STATIC) -- so it may be slowed but never stopped. Eight
193
+ // frames at 200ms is a 1.6s cycle, still well inside one refresh.
194
+ const SPINNER_MS = 200;
153
195
 
154
196
  // Motion opt-out. This pane is designed to sit in peripheral vision for hours,
155
197
  // which is exactly where repetitive motion is most costly for users with
@@ -176,15 +218,27 @@ const ANIMATE = !process.env.GH_GLANCE_NO_ANIMATION;
176
218
  // on the composed output stream, which would strip ink's own SGR codes, the
177
219
  // box-drawing in PanelEdge, and the alternate-screen escapes, i.e. the whole UI.
178
220
  //
179
- // Strip C0, DEL and C1 only. Emoji (including ZWJ sequences and variation
221
+ // Strip C0, DEL, C1 -- and the explicit bidi overrides/isolates. Emoji (including ZWJ sequences and variation
180
222
  // selectors), CJK and other wide characters, combining marks and RTL text must
181
223
  // survive untouched -- ink's width arithmetic depends on measuring them
182
224
  // correctly, and the app's own Nerd Font glyphs live in the private use area, so
183
225
  // anything shaped like "strip non-ASCII" would erase every status icon on
184
226
  // screen. Control runs collapse to a single space rather than being deleted, so
185
227
  // "a\nb" reads as "a b" rather than "ab".
228
+ //
229
+ // U+202A-U+202E and U+2066-U+2069 (LRE/RLE/PDF/LRO/RLO and the isolates) are
230
+ // stripped in a *separate* pass because they must be deleted rather than
231
+ // collapsed to a space: ink measures them as zero columns, so replacing one with
232
+ // a space would add a visible column and shift every cell to its right -- the
233
+ // exact desync this file works to avoid. Without this, one RLO in an issue title
234
+ // makes the rest of that cell render reversed on any terminal with bidi
235
+ // reordering, so the row displays something other than its data. Deliberately
236
+ // NOT extended to U+200E/U+200F or to general category Cf: those are how
237
+ // legitimate mixed-direction Arabic and Hebrew titles render correctly, and
238
+ // preserving real RTL text is a stated property of this function.
186
239
  // eslint-disable-next-line no-control-regex -- matching control characters is the entire purpose
187
240
  const CONTROL_CHARS = /[--Ÿ]+/g;
241
+ const BIDI_OVERRIDES = /[‪-‮⁦-⁩]/g;
188
242
 
189
243
  // Titles are rendered at whatever length GitHub returns, and ink memoizes
190
244
  // wrapped text in a module-level cache it never evicts -- so unbounded remote
@@ -196,8 +250,14 @@ const CONTROL_CHARS = /[--Ÿ]+/g;
196
250
  const MAX_FIELD_LENGTH = 300;
197
251
 
198
252
  function safe(value) {
199
- if (typeof value !== "string") return value == null ? "" : String(value);
200
- const cleaned = value.replace(CONTROL_CHARS, " ").trim();
253
+ // Coerce first, sanitize always. Returning a non-string early skipped both the
254
+ // control-character strip and the length clamp, so an array or an object with a
255
+ // toString() came through untouched -- `safe(["x"])` returned the escape
256
+ // verbatim. GitHub returns strings for every field this is called on today, so
257
+ // the guarantee was being provided by the upstream schema rather than by the
258
+ // function that claims to provide it.
259
+ const value_ = typeof value === "string" ? value : value == null ? "" : String(value);
260
+ const cleaned = value_.replace(BIDI_OVERRIDES, "").replace(CONTROL_CHARS, " ").trim();
201
261
  const points = Array.from(cleaned);
202
262
  return points.length > MAX_FIELD_LENGTH ? points.slice(0, MAX_FIELD_LENGTH).join("") : cleaned;
203
263
  }
@@ -326,6 +386,26 @@ function classify(err) {
326
386
  return "other";
327
387
  }
328
388
 
389
+ // What to put on screen for a verdict, in the voice the preflight already uses:
390
+ // say what to do, not what the subprocess printed. The three tabs built on list
391
+ // commands used to render raw `gh` stderr, so the failures people actually hit --
392
+ // expired auth, a rate limit, a dropped network -- arrived as an untranslated
393
+ // fragment of somebody else's CLI with no statement of what to do about it,
394
+ // while the alert path one tab over had been classifying and translating all
395
+ // along.
396
+ //
397
+ // `other` is deliberately absent. It is the unclassified bucket, and the raw
398
+ // message is the most useful thing available for it -- inventing a remedy for a
399
+ // failure nobody recognised would be worse than showing what happened. That
400
+ // matters more than it looks: AUTH_MARKERS is deliberately broad, and a false
401
+ // positive that only picked a retry ladder was cheap, whereas one that also
402
+ // rewrites the on-screen text turns into a confidently wrong instruction.
403
+ const VERDICT_REMEDY = {
404
+ "auth-problem": "GitHub authorization failed -- try `gh auth login` or `gh auth refresh`",
405
+ "rate-limited": "GitHub rate limit reached -- backing off, this clears on its own",
406
+ unavailable: "not available for this repository",
407
+ };
408
+
329
409
  // Some pty wrappers (and a terminal mid-resize) report a size of 0 or
330
410
  // undefined. Taking that literally collapses the table to a single row (or
331
411
  // draws a zero-width border), so fall back to sane defaults until real
@@ -358,19 +438,54 @@ const GH_ENV_OVERRIDES = {
358
438
  GH_PAGER: "cat",
359
439
  };
360
440
 
441
+ // The single redaction boundary for everything this process prints outside the
442
+ // dashboard: the --verbose log, the crash handler's stack, and the --doctor
443
+ // report. Declared here, above all three, rather than beside runDoctor() -- it
444
+ // used to sit in the Diagnostics section and be reached backwards by hoisting.
445
+ //
446
+ // Token shapes and URL userinfo only. gh error messages quote the URL they
447
+ // failed on, so that is a real path for a credential to arrive in output a user
448
+ // is invited to paste into a bug report.
449
+ function redact(text) {
450
+ return String(text)
451
+ .replace(/gh[pousr]_[A-Za-z0-9]{16,}/g, "<redacted-token>")
452
+ .replace(/github_pat_[A-Za-z0-9_]{16,}/g, "<redacted-token>")
453
+ .replace(/\/\/[^/\s:@]+:[^/\s@]+@/g, "//<redacted>@");
454
+ }
455
+
361
456
  // Verbose output goes to stderr and never to stdout: stdout is ink's frame
362
457
  // stream, and writing anything else into it corrupts the diff and the
363
458
  // alternate-screen state. The argv block refuses --verbose while stderr is still
364
459
  // a terminal, so these lines always land in a file rather than on top of the
365
460
  // dashboard.
461
+ //
462
+ // The outcome is redacted for the same reason --doctor redacts its report: on a
463
+ // failure it carries gh's own stderr, and gh error messages quote the URL they
464
+ // failed on -- which is a real path for a token or a proxy credential to arrive
465
+ // here. The README tells users to run `--verbose 2>gh-glance.log` and attach the
466
+ // result to a bug report, so this is one of the three artifacts that leave the
467
+ // machine, and it was the only one with no redaction boundary.
366
468
  function logGh(args, startedAt, outcome) {
367
469
  if (!runtime.verbose) return;
368
470
  const ms = Date.now() - startedAt;
369
471
  process.stderr.write(
370
- `${new Date().toISOString()} gh ${args.join(" ")} -- ${outcome} in ${ms}ms\n`,
472
+ `${new Date().toISOString()} gh ${args.join(" ")} -- ${redact(outcome)} in ${ms}ms\n`,
371
473
  );
372
474
  }
373
475
 
476
+ // The poll loop's AbortController, published here so the crash handlers can
477
+ // reach it. A module-level handle rather than a ref because the crash path runs
478
+ // outside React entirely -- there is no component left to read a ref from. Kept
479
+ // in this section, above every consumer, so nothing has to reach downward for it.
480
+ let liveAbort = null;
481
+ function registerLiveAbort(controller) {
482
+ liveAbort = controller;
483
+ }
484
+ function abortLiveRequests() {
485
+ liveAbort?.abort();
486
+ liveAbort = null;
487
+ }
488
+
374
489
  async function runGh(args, { signal } = {}) {
375
490
  const startedAt = Date.now();
376
491
  try {
@@ -611,11 +726,15 @@ function backoffActive(key, now) {
611
726
  return Boolean(state) && now < state.until;
612
727
  }
613
728
 
614
- // Which ladder each verdict takes. "rate-limited" and "other" are absent on
615
- // purpose: a rate limit clears on its own schedule and a network drop clears
616
- // when the network does, so both keep today's behaviour of re-asking every tick
617
- // with the real message on screen.
618
- const FAILURE_LADDER = { unavailable: BACKOFF_STEPS_MS, "auth-problem": AUTH_RETRY_MS };
729
+ // Which ladder each verdict takes. "other" stays absent on purpose: a network
730
+ // drop clears when the network does, and re-asking every tick is how the pane
731
+ // comes back the moment it does. "rate-limited" used to be absent for the same
732
+ // stated reason, which had it backwards -- see RATE_LIMIT_RETRY_MS.
733
+ const FAILURE_LADDER = {
734
+ unavailable: BACKOFF_STEPS_MS,
735
+ "auth-problem": AUTH_RETRY_MS,
736
+ "rate-limited": RATE_LIMIT_RETRY_MS,
737
+ };
619
738
 
620
739
  // The ladder is a parameter rather than a second near-identical function,
621
740
  // which is the drift the ALERT_SOURCES comment below warns about: two copies of
@@ -632,8 +751,11 @@ function clearBackoff(key) {
632
751
 
633
752
  async function fetchAlertSource(source, signal, now) {
634
753
  if (backoffActive(source.key, now)) {
635
- const note = alertBackoff.get(source.key).note;
636
- return { raw: `backoff:${note}`, parse: () => ({ alerts: [], note, truncated: false }) };
754
+ const { note, verdict } = alertBackoff.get(source.key);
755
+ // The verdict is replayed alongside the note. Replaying only the note left
756
+ // the tab unable to tell "Dependabot is switched off here" from "we cannot
757
+ // see Dependabot" for the whole length of a backoff window.
758
+ return { raw: `backoff:${note}`, parse: () => ({ alerts: [], note, verdict, truncated: false }) };
637
759
  }
638
760
  try {
639
761
  const raw = await runGh(alertArgs(source), { signal });
@@ -645,6 +767,7 @@ async function fetchAlertSource(source, signal, now) {
645
767
  return {
646
768
  alerts: rows.map(source.map),
647
769
  note: null,
770
+ verdict: "ok",
648
771
  truncated: rows.length >= ALERT_PER_PAGE,
649
772
  };
650
773
  },
@@ -659,9 +782,12 @@ async function fetchAlertSource(source, signal, now) {
659
782
  const steps = pick(FAILURE_LADDER, verdict, null);
660
783
  if (steps) {
661
784
  recordFailure(source.key, now, steps);
662
- alertBackoff.get(source.key).note = note;
785
+ Object.assign(alertBackoff.get(source.key), { note, verdict });
663
786
  }
664
- return { raw: `unavailable:${note}`, parse: () => ({ alerts: [], note, truncated: false }) };
787
+ return {
788
+ raw: `unavailable:${note}`,
789
+ parse: () => ({ alerts: [], note, verdict, truncated: false }),
790
+ };
665
791
  }
666
792
  }
667
793
 
@@ -678,7 +804,14 @@ function severityRank(severity) {
678
804
  }
679
805
 
680
806
  async function fetchSecurity(signal) {
681
- const now = Date.now();
807
+ // Monotonic, not wall-clock. These deadlines measure *elapsed* time, and
808
+ // Date.now() can jump: a laptop resume or an NTP correction stepping the clock
809
+ // backwards would hold an alert source in backoff for up to an hour of apparent
810
+ // time that never passed -- on a security surface, which is exactly what the
811
+ // capped ladder exists to prevent. Staleness deliberately stays on Date.now()
812
+ // for the mirror-image reason: a sleep gap is the thing it reports, and
813
+ // performance.now() does not advance across suspend.
814
+ const now = performance.now();
682
815
  const parts = await Promise.all(ALERT_SOURCES.map((s) => fetchAlertSource(s, signal, now)));
683
816
  return {
684
817
  // Joined with a NUL so a change in any one of the three shows up as a
@@ -697,6 +830,15 @@ async function fetchSecurity(signal) {
697
830
  return {
698
831
  alerts,
699
832
  notes: parsed.map((p) => p.note).filter(Boolean),
833
+ // "Blind" is not the same as "switched off", and the tab bar could not
834
+ // tell them apart: fetchAlertSource never rejects, so a repo whose three
835
+ // endpoints all 403'd on an expired SAML session rendered
836
+ // `4:Security (0)` -- byte-identical to a genuinely clean repo, on the
837
+ // one surface where a false all-clear is the worst possible answer.
838
+ // `unavailable` is excluded deliberately: that verdict IS an answer about
839
+ // the repository, and most repos have Advanced Security switched off, so
840
+ // treating it as blindness would mark almost everyone permanently unsure.
841
+ blind: parsed.some((p) => p.verdict != null && p.verdict !== "ok" && p.verdict !== "unavailable"),
700
842
  truncated: parsed.some((p) => p.truncated),
701
843
  };
702
844
  },
@@ -779,19 +921,9 @@ async function preflight() {
779
921
  // makes it a disclosure surface before it is anything else. So redaction is not
780
922
  // a review note applied per call site -- it is applied once, at the single point
781
923
  // where the report is assembled, the same way safe() is the one boundary for
782
- // untrusted remote strings rather than six.
783
- //
784
- // Token shapes are gh's own prefixes (gho_/ghp_/ghu_/ghs_/ghr_ and
785
- // github_pat_). URL userinfo covers a proxy variable, a git remote with an
786
- // embedded credential, and anything else that carries `user:password@` -- gh
787
- // error messages quote the URL they failed on, so that is a real path for one
788
- // to arrive here.
789
- function redact(text) {
790
- return String(text)
791
- .replace(/gh[pousr]_[A-Za-z0-9]{16,}/g, "<redacted-token>")
792
- .replace(/github_pat_[A-Za-z0-9_]{16,}/g, "<redacted-token>")
793
- .replace(/\/\/[^/\s:@]+:[^/\s@]+@/g, "//<redacted>@");
794
- }
924
+ // untrusted remote strings rather than six. redact() itself now lives up in the
925
+ // gh subprocess section, because the --verbose log and the crash handler need it
926
+ // too and nothing should reach backwards for it.
795
927
 
796
928
  // Values that are the thing being diagnosed, so they are printed as-is.
797
929
  const DOCTOR_ENV_PLAIN = [
@@ -805,10 +937,9 @@ const DOCTOR_ENV_PLAIN = [
805
937
  "NODE_ENV",
806
938
  ];
807
939
  const DOCTOR_ENV_PROXY = ["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY"];
808
- // Reported as present or absent and never printed, not even a prefix. Matched
809
- // by name so a variable nobody listed here -- GITHUB_EMU_TOKEN, some wrapper's
810
- // GH_API_KEY -- is still caught by shape rather than by having been foreseen.
811
- const SECRET_NAME = /_(TOKEN|SECRET|PASSWORD|KEY)$/i;
940
+ // Listed so they always get a line even when unset -- the absence of GH_TOKEN is
941
+ // itself a diagnosis. Their values are never printed; see envValue(), where
942
+ // presence-only is the default for everything outside DOCTOR_ENV_PLAIN.
812
943
  const DOCTOR_ENV_SECRET = ["GH_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_TOKEN"];
813
944
 
814
945
  const NOT_SET = "not set";
@@ -839,12 +970,21 @@ function doctorEnvNames() {
839
970
  return [...named, ...extra];
840
971
  }
841
972
 
973
+ // Presence-only is the DEFAULT; printing a value is the opt-in, and the opt-in
974
+ // list is curated above. This was the other way round -- print unless the *name*
975
+ // ended in _TOKEN/_SECRET/_PASSWORD/_KEY -- which meant the discovery loop above
976
+ // harvested variables nobody had reviewed and printed them in full. Verified
977
+ // leaks under the old rule: GH_APP_PEM (a whole RSA private key), GITHUB_OAUTH,
978
+ // GITHUB_PAT, GH_COOKIE, GH_CREDENTIALS. redact() only catches GitHub *token*
979
+ // shapes, so anything else sailed through. This report is advertised as safe to
980
+ // paste into a bug report, so the failure mode has to be a less useful line, not
981
+ // a disclosed credential.
842
982
  function envValue(name) {
843
983
  const value = process.env[name];
844
- if (SECRET_NAME.test(name)) return value ? "set" : NOT_SET;
845
984
  if (!value) return NOT_SET;
846
985
  if (DOCTOR_ENV_PROXY.includes(name)) return proxySummary(value);
847
- return value;
986
+ if (DOCTOR_ENV_PLAIN.includes(name)) return value;
987
+ return "set";
848
988
  }
849
989
 
850
990
  // gh writes some of this to stdout and some to stderr depending on version and
@@ -916,6 +1056,58 @@ function targetSource() {
916
1056
  return "git remote (or none, if this is not a checkout)";
917
1057
  }
918
1058
 
1059
+ // How much of the hourly API budget is left, and roughly how fast this
1060
+ // configuration spends it. The steady-state cost is not small and was invisible:
1061
+ // at the default 5s refresh with the Security tab open it is around 2,200 REST
1062
+ // requests an hour -- about 44% of a personal token's 5,000 -- and `--refresh 2`
1063
+ // projects past the limit outright, so it exhausts inside the hour, every hour.
1064
+ // The budget is shared with everything else the token does, so the first symptom
1065
+ // is usually "GitHub is broken" somewhere else entirely.
1066
+ //
1067
+ // `gh api rate_limit` is documented as not counting against the limit, and it
1068
+ // measures as free (verified: delta 0), so this is safe to run on a diagnostic
1069
+ // path. GHES tenants can be configured with a different ceiling, which is
1070
+ // exactly why this reports the server's own numbers rather than asserting 5,000.
1071
+ async function rateBudget() {
1072
+ try {
1073
+ const raw = await runGh(["api", "rate_limit"]);
1074
+ const { resources } = JSON.parse(raw);
1075
+ // formatAge() is for timestamps in the past and returns "-" for a future one,
1076
+ // so the reset is rendered as a forward interval instead.
1077
+ const fmt = (r) => {
1078
+ if (!r) return "(absent)";
1079
+ const inMs = r.reset * 1000 - Date.now();
1080
+ const resets = inMs > 0 ? `resets in ${formatDuration(inMs)}` : "reset due";
1081
+ return `${r.remaining}/${r.limit} left, ${resets}`;
1082
+ };
1083
+ return { core: fmt(resources?.core), graphql: fmt(resources?.graphql) };
1084
+ } catch (err) {
1085
+ return { core: `unavailable (${shortErr(err)})`, graphql: "unavailable" };
1086
+ }
1087
+ }
1088
+
1089
+ // Requests per hour this configuration will spend once it settles, derived from
1090
+ // the same constants the poll loop uses rather than from a number written down
1091
+ // once and left to rot. The active tab refreshes every tick; the other three
1092
+ // every BACKGROUND_EVERY ticks. Security is three REST calls per fetch, Actions
1093
+ // one, and Issues/PRs go to GraphQL instead (two POSTs each, because --search
1094
+ // routes through the search connection).
1095
+ function projectedHourlyCost(activeKey) {
1096
+ const perHour = 3_600_000 / runtime.refreshMs;
1097
+ const restCalls = { actions: 1, issues: 0, prs: 0, security: ALERT_SOURCES.length };
1098
+ const graphqlCalls = { actions: 0, issues: 2, prs: 2, security: 0 };
1099
+ let rest = 0;
1100
+ let graphql = 0;
1101
+ // TAB_KEYS rather than TABS: this runs on the --doctor path, which deliberately
1102
+ // returns before the render tree is reached, and TABS is declared down there.
1103
+ for (const key of TAB_KEYS) {
1104
+ const ticks = key === activeKey ? perHour : perHour / BACKGROUND_EVERY;
1105
+ rest += ticks * restCalls[key];
1106
+ graphql += ticks * graphqlCalls[key];
1107
+ }
1108
+ return { rest: Math.round(rest), graphql: Math.round(graphql) };
1109
+ }
1110
+
919
1111
  async function runDoctor() {
920
1112
  // A live backoff would make an alert probe silently skip and report nothing,
921
1113
  // which is the opposite of what a diagnostic run is for.
@@ -931,10 +1123,11 @@ async function runDoctor() {
931
1123
  // allSettled, and every probe already resolves rather than rejects, so one
932
1124
  // slow endpoint cannot suppress the other five. runGh's own GH_TIMEOUT_MS
933
1125
  // bounds each one.
934
- const [ghVersion, authStatus, remote, results] = await Promise.all([
1126
+ const [ghVersion, authStatus, remote, budget, results] = await Promise.all([
935
1127
  captureGh(["--version"]),
936
1128
  captureGh(["auth", "status"]),
937
1129
  gitRemote(),
1130
+ rateBudget(),
938
1131
  Promise.all(probes.map(([name, args]) => probe(name, args))),
939
1132
  ]);
940
1133
 
@@ -954,6 +1147,18 @@ async function runDoctor() {
954
1147
  field("slug", runtime.repo ?? "(inferred from the working directory)"),
955
1148
  field("git remote", remote),
956
1149
  "",
1150
+ ...section("API budget"),
1151
+ field("REST core", budget.core),
1152
+ field("GraphQL", budget.graphql),
1153
+ field(
1154
+ "this config spends",
1155
+ (() => {
1156
+ const active = TAB_KEYS[runtime.initialTabIndex] ?? TAB_KEYS[0];
1157
+ const { rest, graphql } = projectedHourlyCost(active);
1158
+ return `~${rest} REST + ~${graphql} GraphQL per hour (refresh ${runtime.refreshMs / 1000}s, "${active}" active)`;
1159
+ })(),
1160
+ ),
1161
+ "",
957
1162
  ...section("Environment"),
958
1163
  ...doctorEnvNames().map((name) => field(name, envValue(name))),
959
1164
  "",
@@ -988,7 +1193,19 @@ async function runDoctor() {
988
1193
  // path would be a request-forgery primitive against arbitrary endpoints -- so it
989
1194
  // is validated once, here at the boundary, against exactly what GitHub allows in
990
1195
  // an owner or repository name.
991
- const REPO_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/;
1196
+ //
1197
+ // The name half requires at least one character that is not a dot. Without that
1198
+ // it accepted `owner/..`, which `apiPath()` spliced into
1199
+ // `repos/owner/../dependabot/alerts` -- and gh forwards the dot segment
1200
+ // unnormalized, so GitHub resolves it server-side to a *different endpoint*
1201
+ // (verified: it lands on Get-a-repository). The value is operator-supplied, so
1202
+ // this was never a cross-user vulnerability, but it broke the one invariant the
1203
+ // comment above claims. A trailing `.git` is rejected for the same reason: it is
1204
+ // not a name GitHub issues, so accepting it can only mean someone pasted a clone
1205
+ // URL's tail. Names that merely *contain* or *start with* a dot stay valid --
1206
+ // `owner/.github` and `owner/docs.example.com` are both real.
1207
+ const REPO_PATTERN =
1208
+ /^[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._-]+$/;
992
1209
 
993
1210
  // A dot is mandatory, and that is the safety property rather than a nicety.
994
1211
  // Without it "owner/name/extra" -- already in the hostile-input list -- would
@@ -1055,8 +1272,13 @@ function parseArgs(argv) {
1055
1272
  return argv[i];
1056
1273
  };
1057
1274
 
1275
+ // No `-v`. It used to mean --version, which is the conventional reading, but
1276
+ // this CLI also has --verbose -- so `gh-glance -v 2>log`, which is what you
1277
+ // type when you want the log, printed a version string and exited 0. That is
1278
+ // the one argv path that failed quietly in a surface built to fail loudly.
1279
+ // Unknown now, so it exits 2 and points at --help.
1058
1280
  if (arg === "--help" || arg === "-h") opts.help = true;
1059
- else if (arg === "--version" || arg === "-v") opts.showVersion = true;
1281
+ else if (arg === "--version") opts.showVersion = true;
1060
1282
  else if (arg === "--verbose") opts.verbose = true;
1061
1283
  else if (arg === "--doctor") opts.doctor = true;
1062
1284
  else if (arg === "--repo" || arg === "-R" || arg.startsWith("--repo=")) {
@@ -1111,6 +1333,24 @@ function validateArgs(opts, tabKeys) {
1111
1333
 
1112
1334
  const { version } = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"));
1113
1335
 
1336
+ // The one key table. --help renders it, the `?` overlay renders it, and the
1337
+ // status bar's KEY_HINTS is the deliberately short subset of it -- so a binding
1338
+ // added in one place cannot go missing from the others, which is the same rule
1339
+ // this file already applies to argv vectors and to classify().
1340
+ const KEY_TABLE = [
1341
+ ["1 2 3 4", "Actions / Issues / Pull requests / Security"],
1342
+ ["Left / Right", "Previous / next tab"],
1343
+ ["Tab / Shift+Tab", "Next / previous tab"],
1344
+ ["Up / Down, j / k", "Move the cursor between rows"],
1345
+ ["PgUp / PgDn", "Move a page at a time"],
1346
+ ["Enter", "Open the selected item in your browser"],
1347
+ ["r", "Refresh the current tab now"],
1348
+ ["?", "Show the keys (any key closes it)"],
1349
+ ["q / Esc / Ctrl+C", "Quit"],
1350
+ ];
1351
+ const KEY_COL = Math.max(...KEY_TABLE.map(([k]) => k.length)) + 3;
1352
+ const keyTableLines = () => KEY_TABLE.map(([k, d]) => `${k.padEnd(KEY_COL)}${d}`);
1353
+
1114
1354
  const HELP = `gh-glance ${version} -- a live-refreshing GitHub dashboard for a narrow terminal pane.
1115
1355
 
1116
1356
  Usage:
@@ -1156,14 +1396,9 @@ Status icons are GitHub Octicons and need a Nerd Font. Without one, set
1156
1396
  GH_GLANCE_ICONS=unicode for plain-unicode equivalents.
1157
1397
 
1158
1398
  Keys:
1159
- 1 2 3 4 Actions / Issues / Pull requests / Security
1160
- Left / Right Previous / next tab
1161
- Tab / Shift+Tab Next / previous tab
1162
- Up / Down, j / k Move the cursor between rows
1163
- PgUp / PgDn Move a page at a time
1164
- Enter Open the selected item in your browser
1165
- r Refresh the current tab now
1166
- q / Esc / Ctrl+C Quit
1399
+ ${keyTableLines()
1400
+ .map((line) => ` ${line}`)
1401
+ .join("\n")}
1167
1402
 
1168
1403
  The cursor clears itself after 60s with no movement.
1169
1404
 
@@ -1175,6 +1410,7 @@ Environment:
1175
1410
  GH_GLANCE_ICONS=unicode Plain-unicode status icons (no Nerd Font needed)
1176
1411
  GH_GLANCE_NO_ANIMATION=1 Freeze the spinner (no motion)
1177
1412
  NO_COLOR=1 Disable colour (status stays readable)
1413
+ INK_SCREEN_READER=true Linear, unthrottled rendering (unverified -- see README)
1178
1414
  `;
1179
1415
 
1180
1416
  // argv and the TTY guard run *before* react and ink are imported. Loading ink
@@ -1196,6 +1432,20 @@ if (IS_MAIN) {
1196
1432
  process.exit(2);
1197
1433
  }
1198
1434
 
1435
+ // One place argv becomes runtime state. It used to be two -- the --doctor
1436
+ // branch set repo and host, the dashboard path set all five -- and they had
1437
+ // already drifted: `--doctor --verbose` was accepted and silently dropped, so
1438
+ // the one flag that shows what was actually sent to gh did nothing on the one
1439
+ // command built to explain what gh-glance is doing. Same for --refresh and
1440
+ // --tab. Applying everything up front cannot corrupt the doctor path, which
1441
+ // never renders with ink; the --verbose TTY refusal below stays *below* the
1442
+ // doctor branch on purpose, since that combination is useful and harmless.
1443
+ runtime.repo = opts.repo;
1444
+ runtime.host = opts.host;
1445
+ runtime.verbose = opts.verbose;
1446
+ if (opts.refreshMs !== null) runtime.refreshMs = opts.refreshMs;
1447
+ if (opts.tabKey !== null) runtime.initialTabIndex = TAB_KEYS.indexOf(opts.tabKey);
1448
+
1199
1449
  // Checked after parsing rather than before, so `gh-glance --repo` with no
1200
1450
  // value reports the missing value rather than silently printing help.
1201
1451
  if (opts.help) {
@@ -1211,11 +1461,8 @@ if (IS_MAIN) {
1211
1461
  // here on purpose -- ahead of the non-TTY refusal, because the whole point is
1212
1462
  // `gh-glance --doctor > report.txt`, and ahead of preflight(), because a
1213
1463
  // missing gh and a cwd outside a repository are exactly the conditions worth
1214
- // reporting rather than exiting 3 over. The repo target is applied first so
1215
- // the probes exercise the real one.
1464
+ // reporting rather than exiting 3 over.
1216
1465
  if (opts.doctor) {
1217
- runtime.repo = opts.repo;
1218
- runtime.host = opts.host;
1219
1466
  console.log(await runDoctor());
1220
1467
  process.exit(0);
1221
1468
  }
@@ -1233,12 +1480,6 @@ if (IS_MAIN) {
1233
1480
  process.exit(2);
1234
1481
  }
1235
1482
 
1236
- runtime.repo = opts.repo;
1237
- runtime.host = opts.host;
1238
- runtime.verbose = opts.verbose;
1239
- if (opts.refreshMs !== null) runtime.refreshMs = opts.refreshMs;
1240
- if (opts.tabKey !== null) runtime.initialTabIndex = TAB_KEYS.indexOf(opts.tabKey);
1241
-
1242
1483
  // This is a full-screen live dashboard, not a reporting command -- piping it
1243
1484
  // somewhere would emit an endless stream of redraw frames, so fail fast with
1244
1485
  // an explanation instead. Exit code 1 is asserted by CI.
@@ -1319,7 +1560,15 @@ const OCT_UNICODE = {
1319
1560
  shield: "s",
1320
1561
  };
1321
1562
 
1322
- const OCT = process.env.GH_GLANCE_ICONS === "unicode" ? OCT_UNICODE : OCT_NERD;
1563
+ // Whether the private-use glyphs are in play. There is no way to ask a terminal
1564
+ // whether it can draw them -- the only honest probe is writing one and reading
1565
+ // the cursor back, which hangs on terminals that never answer -- so instead the
1566
+ // app says, once, where the escape hatch is. Without a Nerd Font every icon is a
1567
+ // blank box, which reads as "this program is broken" rather than "install a
1568
+ // font", and the remedy currently lives only in --help and the README: both of
1569
+ // which require quitting the full-screen app you are trying to evaluate.
1570
+ const USING_NERD_ICONS = process.env.GH_GLANCE_ICONS !== "unicode";
1571
+ const OCT = USING_NERD_ICONS ? OCT_NERD : OCT_UNICODE;
1323
1572
 
1324
1573
  // ---------- Shared layout primitives ----------
1325
1574
 
@@ -1482,7 +1731,7 @@ const RUN_PENDING_ICON = { icon: OCT.dotFill, color: ATTENTION, label: "queued"
1482
1731
  const RUN_RUNNING_STATIC = { icon: OCT.alertFill, color: ATTENTION, label: "running" };
1483
1732
 
1484
1733
  // Remote strings index these tables, and a plain object literal answers
1485
- // inherited keys -- `SEVERITY_COLOR["constructor"]` returned a function, which
1734
+ // inherited keys -- `SEVERITY_STYLE["constructor"]` returned a function, which
1486
1735
  // reaches ink's colour prop and throws, killing the render. `??` cannot catch
1487
1736
  // that, because an inherited value is not nullish. Code-scanning severity comes
1488
1737
  // from uploaded SARIF, so it is genuinely attacker-influenced.
@@ -1759,7 +2008,7 @@ const MIN_COMPACT_WIDTH = Math.max(...TABS.map((t) => minimumWidthFor(t.compactH
1759
2008
  const TAB_LABEL_FULL_WIDTH = 78;
1760
2009
  const TAB_LABEL_HYSTERESIS = 4;
1761
2010
 
1762
- function TabBar({ activeIndex, counts, firstLoad, failed, spin, useShort }) {
2011
+ function TabBar({ activeIndex, counts, brokenCI, firstLoad, failed, spin, useShort }) {
1763
2012
  return e(
1764
2013
  Box,
1765
2014
  { flexDirection: "row" },
@@ -1768,7 +2017,12 @@ function TabBar({ activeIndex, counts, firstLoad, failed, spin, useShort }) {
1768
2017
  const count = counts[tab.key];
1769
2018
  // A tab that has never resolved shows the spinner where its count will
1770
2019
  // go, so the first load reads as "working" rather than "empty".
1771
- const suffix = count == null ? (firstLoad[tab.key] ? ` ${spin}` : "") : ` (${count})`;
2020
+ const suffix =
2021
+ count == null
2022
+ ? firstLoad[tab.key]
2023
+ ? ` ${spin}`
2024
+ : ""
2025
+ : ` (${count}${brokenCI[tab.key] ? "!" : ""})`;
1772
2026
  const name = useShort ? tab.short : tab.label;
1773
2027
  // Bold and inverse are both stripped at chalk level 0 (NO_COLOR, or a
1774
2028
  // dumb terminal), which left no indication at all of which tab was
@@ -1806,10 +2060,16 @@ function TabBar({ activeIndex, counts, firstLoad, failed, spin, useShort }) {
1806
2060
  // two columns in its width model, and a status bar built from them overflowed
1807
2061
  // an 80-column terminal by six columns once selection added a hint. Same trap
1808
2062
  // the unicode icon table already documents -- prefer strictly narrow ASCII
1809
- // over pretty-but-ambiguous. The arrow pair is a deliberate, tested exception:
1810
- // `npm run test:pty` asserts the rendered frame stays within the terminal
1811
- // width, so a font/terminal combo that renders them double-width fails loudly
1812
- // there instead of silently shifting columns.
2063
+ // over pretty-but-ambiguous. The arrow pair is a deliberate exception, and it
2064
+ // is NOT covered by a width assertion -- an earlier version of this comment
2065
+ // claimed `npm run test:pty` would catch a double-width rendering, and that was
2066
+ // wrong twice over. Each hint is wrap: "truncate-end", so the failure mode is
2067
+ // silent text loss rather than overflow, which no width check can see; and at 80
2068
+ // columns the panel border is 79 cells against a 54-cell status bar, so the bar
2069
+ // is not what sets the maximum anyway -- the arrows would have to add 25 columns
2070
+ // to move it, not 2. What actually bounds the risk is that there are two of
2071
+ // them and the compact breakpoint below keeps the whole set inside the frame.
2072
+ // Anything wider added here needs its own check, not this comment's assurance.
1813
2073
  //
1814
2074
  // Tab switching is not listed: the tab bar already renders "1:Actions", so the
1815
2075
  // digits document themselves, and the arrow keys are in --help. The hints that
@@ -1825,17 +2085,41 @@ const KEY_HINTS = [
1825
2085
  // finishes. Wide enough for the spinner, a space and "Fetching".
1826
2086
  const FETCHING_WIDTH = 12;
1827
2087
 
2088
+ // Width the full hint set needs: every "Label: keys" plus a " | " between each.
2089
+ // Derived from KEY_HINTS rather than written down, for the same reason
2090
+ // minimumWidthFor() derives the table breakpoint from its header descriptors --
2091
+ // a copy change must not be able to invalidate the number.
2092
+ const HINTS_FULL_WIDTH =
2093
+ KEY_HINTS.reduce((sum, h) => sum + h.label.length + 2 + [...h.keys].length, 0) +
2094
+ (KEY_HINTS.length - 1) * 3;
2095
+ // Compact drops the labels and keeps the keys, separated by a space:
2096
+ // "↑↓ Ent r q". No constant for its width -- nothing branches on it, and the
2097
+ // keys are short enough that it fits anywhere the frame itself does.
2098
+ //
2099
+ // The band where the full set fits. Below it the bar was the one part of the
2100
+ // layout with no width awareness -- the table swaps to a compact header, the tab
2101
+ // bar swaps to short labels, the panel edges drop their labels, and the status
2102
+ // bar just let ink truncate. Because each hint is wrap: "truncate-end", the
2103
+ // failure was silent text loss rather than overflow: at 45 columns the rendered
2104
+ // bar read "Move: | Open: | Refresh |Quit:…" -- the arrows gone, Refresh
2105
+ // missing its key, and Quit, the last entry, first to be cut. That is the one
2106
+ // hint a confused first-time user needs, in a full-screen alternate-screen app.
2107
+ const STATUS_FULL_WIDTH = FETCHING_WIDTH + HINTS_FULL_WIDTH;
2108
+
1828
2109
  // Two tones rather than one flat gray: the keys you press are the part worth
1829
2110
  // finding at a glance, so they get the accent colour and the words describing
1830
2111
  // them stay dim. The accent is the panel-title cyan rather than the amber used
1831
2112
  // for in-progress status, so amber means exactly one thing across the product.
1832
- function StatusBar({ fetching, spin, stale, interactive }) {
2113
+ function StatusBar({ fetching, spin, stale, interactive, cols }) {
1833
2114
  // Without raw mode none of the key handlers run, so advertising them would be
1834
2115
  // telling the user something untrue about what the app can do. Ctrl+C still
1835
2116
  // works there, because the tty delivers a real SIGINT.
1836
2117
  const hints = interactive
1837
2118
  ? KEY_HINTS
1838
2119
  : [{ label: "Quit", keys: "^C" }];
2120
+ // Measured against the *full* set even when a compact one is rendered, so the
2121
+ // breakpoint does not move as the bar's own contents change.
2122
+ const compact = interactive && cols < STATUS_FULL_WIDTH;
1839
2123
  return e(
1840
2124
  Box,
1841
2125
  { flexDirection: "row" },
@@ -1861,11 +2145,12 @@ function StatusBar({ fetching, spin, stale, interactive }) {
1861
2145
  : null,
1862
2146
  ...hints
1863
2147
  .flatMap((hint, i) => [
1864
- i > 0 && e(Text, { key: `sep${i}`, color: BORDER_COLOR }, " | "),
2148
+ i > 0 &&
2149
+ e(Text, { key: `sep${i}`, color: BORDER_COLOR }, compact ? " " : " | "),
1865
2150
  e(
1866
2151
  Text,
1867
2152
  { key: hint.label, wrap: "truncate-end" },
1868
- e(Text, { dimColor: true }, `${hint.label}: `),
2153
+ compact ? null : e(Text, { dimColor: true }, `${hint.label}: `),
1869
2154
  e(Text, { color: TITLE_COLOR, bold: true }, hint.keys),
1870
2155
  ),
1871
2156
  ])
@@ -1933,6 +2218,16 @@ function App() {
1933
2218
  security: null,
1934
2219
  });
1935
2220
  const [securityNotes, setSecurityNotes] = useState([]);
2221
+ // Whether the Security tab is currently unable to see its endpoints, as
2222
+ // opposed to seeing that they are switched off. Drives the count marker.
2223
+ const [securityBlind, setSecurityBlind] = useState(false);
2224
+ // The `?` overlay. Renders only on a keypress, so consecutive idle frames are
2225
+ // still byte-identical and the redraw suppression is untouched.
2226
+ const [showHelp, setShowHelp] = useState(false);
2227
+ // Set once, ICON_HINT_AFTER_MS into a first load that is still running. Never
2228
+ // reset: it only gates a line that a resolved tab stops rendering anyway, and
2229
+ // clearing it would cost a second state write for no visible difference.
2230
+ const [iconHintDue, setIconHintDue] = useState(false);
1936
2231
  const [errors, setErrors] = useState({ actions: null, issues: null, prs: null, security: null });
1937
2232
  const [loading, setLoading] = useState({ actions: true, issues: true, prs: true, security: true });
1938
2233
  const [now, setNow] = useState(new Date());
@@ -1945,7 +2240,29 @@ function App() {
1945
2240
  const [offset, setOffset] = useState({});
1946
2241
 
1947
2242
  const tab = TABS[activeIndex];
1948
- const extraLines = (errors[tab.key] ? 1 : 0) + (tab.key === "security" ? securityNotes.length : 0);
2243
+ // The not-enabled notes are collapsed into a single line. Each unavailable
2244
+ // alert source used to contribute a full-width row above the column header,
2245
+ // and on any repo without Advanced Security that is two permanent lines --
2246
+ // roughly 10% of a twenty-row pane, forever, restating a fact that will never
2247
+ // change, on the tab whose job is making real alerts stand out. Failures that
2248
+ // are NOT "not enabled" keep their own lines: those are actionable and
2249
+ // transient, and they are the ones worth the space. Derived here at render
2250
+ // time rather than cached alongside the notes, because the unchanged-payload
2251
+ // short-circuit can skip parse() entirely and a cached string would go stale.
2252
+ const NOT_ENABLED = /not enabled/i;
2253
+ const disabledNotes = securityNotes.filter((n) => NOT_ENABLED.test(n));
2254
+ const otherNotes = securityNotes.filter((n) => !NOT_ENABLED.test(n));
2255
+ const securityLines =
2256
+ disabledNotes.length > 1
2257
+ ? [
2258
+ `${disabledNotes.map((n) => n.split(":")[0]).join(", ")}: not enabled here`,
2259
+ ...otherNotes,
2260
+ ]
2261
+ : securityNotes;
2262
+ // Counts the lines actually rendered, not the notes collected -- getting this
2263
+ // wrong by one row is what makes ink repaint the whole frame.
2264
+ const extraLines =
2265
+ (errors[tab.key] ? 1 : 0) + (tab.key === "security" ? securityLines.length : 0);
1949
2266
  // Reserve lines for: the tab bar and the divider under it (2), the panel's
1950
2267
  // top and bottom edges (2), the column header and its separator (2), and
1951
2268
  // the status line (1), plus a 1-line safety margin. Slack is absorbed by
@@ -1977,8 +2294,12 @@ function App() {
1977
2294
  const inFlightRef = useRef({});
1978
2295
  const rawRef = useRef({});
1979
2296
  const cleanRef = useRef({});
2297
+ // Last *successful* poll per tab, wall-clock. Wall-clock on purpose: a laptop
2298
+ // sleeping is exactly the gap this is meant to report, and a monotonic clock
2299
+ // does not advance across suspend. Never written on the failure path, or a
2300
+ // persistently failing tab would report itself fresh forever.
2301
+ const lastOkRef = useRef({});
1980
2302
  const fetchTabRef = useRef(null);
1981
- const abortRef = useRef(null);
1982
2303
 
1983
2304
  const interactive = Boolean(isRawModeSupported);
1984
2305
 
@@ -1986,28 +2307,40 @@ function App() {
1986
2307
  // slice, so the movement handlers read through a ref -- the same pattern the
1987
2308
  // poll loop uses for runLimitRef and activeIndexRef, and for the same reason:
1988
2309
  // the closure must see current values without being rebuilt on every change.
1989
- const navRef = useRef({ items: [], key: null, bodyRows: 1, tabKey: "actions" });
2310
+ const navRef = useRef({ items: [], key: null, bodyRows: 1, tabKey: "actions", offset: 0 });
1990
2311
  navRef.current = {
1991
2312
  items: data[tab.key] ?? [],
1992
2313
  key: selected[tab.key] ?? null,
1993
2314
  bodyRows,
1994
2315
  tabKey: tab.key,
2316
+ offset: offset[tab.key] ?? 0,
1995
2317
  };
1996
2318
  const pageStep = Math.max(1, bodyRows - 1);
1997
2319
 
1998
- const abortSelectionRef = useRef(null);
2320
+ // Read through a ref for the same reason the poll loop does: the useInput
2321
+ // closure must see the current value without being rebuilt on every toggle.
2322
+ const showHelpRef = useRef(false);
2323
+ showHelpRef.current = showHelp;
1999
2324
 
2000
2325
  function moveSelection(delta) {
2001
- const { items, key: currentKey, bodyRows: rows_, tabKey } = navRef.current;
2326
+ const { items, key: currentKey, bodyRows: rows_, tabKey, offset: offsetRaw } = navRef.current;
2002
2327
  if (items.length === 0) return;
2003
2328
  const current = currentKey == null ? -1 : items.findIndex((i) => itemKey(i) === currentKey);
2004
- // From "nothing selected", down lands on the first row and up on the last,
2005
- // which is what every list in this category does.
2329
+ const maxStart = Math.max(0, items.length - rows_);
2330
+ // Re-clamped here for the same reason the render path re-clamps: the payload
2331
+ // can shrink between ticks, so a stored offset can point past the end.
2332
+ const start = Math.min(Math.max(0, offsetRaw), maxStart);
2333
+ // From "nothing selected", seed from what is on screen -- down takes the
2334
+ // first visible row, up the last. It used to take row 0 / the final row of
2335
+ // the whole list, which meant the 60s idle clear silently cost you your
2336
+ // place: scroll to row 80 of 150, read for a minute, press down, and you
2337
+ // were back at row 1 with no scroll animation to notice. For an unscrolled
2338
+ // list that fits the pane this is the same behaviour as before.
2006
2339
  const next =
2007
2340
  current === -1
2008
2341
  ? delta > 0
2009
- ? 0
2010
- : items.length - 1
2342
+ ? start
2343
+ : Math.min(items.length - 1, start + rows_ - 1)
2011
2344
  : Math.min(items.length - 1, Math.max(0, current + delta));
2012
2345
  setSelected((s) => ({ ...s, [tabKey]: itemKey(items[next]) }));
2013
2346
  // Keep the cursor on screen. Only the offset needed to reveal it changes,
@@ -2033,24 +2366,46 @@ function App() {
2033
2366
  return () => clearTimeout(timer);
2034
2367
  }, [selected]);
2035
2368
 
2369
+ // Guarded per item, not globally: holding Enter down produces terminal key
2370
+ // repeat at ~30/s, and every event used to spawn another `gh <kind> view --web`
2371
+ // and open another browser tab. The `r` key next to it already guards this way
2372
+ // through the poll loop's in-flight map; Enter simply never did. Keyed by item
2373
+ // rather than by tab so that moving to a different row and opening it
2374
+ // immediately still works -- selection does not move on Enter, so a second
2375
+ // press within the window is always a duplicate of the first.
2376
+ const openingRef = useRef({});
2377
+
2036
2378
  function openSelected() {
2037
2379
  const { items, key: currentKey, tabKey } = navRef.current;
2038
2380
  const item = items.find((i) => itemKey(i) === currentKey);
2039
2381
  if (!item) return;
2040
- const controller = new AbortController();
2041
- abortSelectionRef.current = controller;
2382
+ const guard = `${tabKey}:${itemKey(item)}`;
2383
+ if (openingRef.current[guard]) return;
2384
+ openingRef.current[guard] = true;
2042
2385
  // Fire and forget: a browser launch must not block the render loop, and a
2043
2386
  // failure surfaces through the tab's normal error line rather than as an
2044
2387
  // unhandled rejection.
2045
- openInBrowser(tabKey, item, controller.signal).catch((err) => {
2046
- setErrors((x) => ({ ...x, [tabKey]: shortErr(err) }));
2047
- });
2388
+ openInBrowser(tabKey, item)
2389
+ .catch((err) => {
2390
+ setErrors((x) => ({ ...x, [tabKey]: shortErr(err) }));
2391
+ })
2392
+ .finally(() => {
2393
+ delete openingRef.current[guard];
2394
+ });
2048
2395
  }
2049
2396
 
2050
2397
  useInput(
2051
2398
  (input, key) => {
2052
2399
  if (input === "q" || key.escape) {
2053
2400
  exit();
2401
+ } else if (showHelpRef.current) {
2402
+ // Any key dismisses -- except quit, handled above, which must never be
2403
+ // swallowed by a modal in a full-screen app. Deliberately does not fall
2404
+ // through to the binding the key would normally trigger: closing the
2405
+ // overlay is the whole intent of that press.
2406
+ setShowHelp(false);
2407
+ } else if (input === "?") {
2408
+ setShowHelp(true);
2054
2409
  } else if (key.downArrow || input === "j") {
2055
2410
  moveSelection(1);
2056
2411
  } else if (key.upArrow || input === "k") {
@@ -2063,8 +2418,11 @@ function App() {
2063
2418
  openSelected();
2064
2419
  } else if (input === "r") {
2065
2420
  // Goes through the same per-tab in-flight guard as the poll loop, so
2066
- // holding the key down cannot stack concurrent subprocesses.
2067
- fetchTabRef.current?.(TABS[activeIndexRef.current].key);
2421
+ // holding the key down cannot stack concurrent subprocesses. `force`
2422
+ // bypasses (and clears) any failure backoff: this key is the user saying
2423
+ // "try again now", and a refresh that silently declined to refresh would
2424
+ // be worse than no key at all.
2425
+ fetchTabRef.current?.(TABS[activeIndexRef.current].key, { force: true });
2068
2426
  } else if (input >= "1" && input <= String(TABS.length)) {
2069
2427
  setActiveIndex(Number(input) - 1);
2070
2428
  } else if (key.tab && key.shift) {
@@ -2098,7 +2456,7 @@ function App() {
2098
2456
  let cancelled = false;
2099
2457
  let ticks = 0;
2100
2458
  const controller = new AbortController();
2101
- abortRef.current = controller;
2459
+ registerLiveAbort(controller);
2102
2460
 
2103
2461
  // Each tab commits its own result the moment it lands instead of waiting on
2104
2462
  // a Promise.allSettled barrier. Actions is by far the slowest fetch, so
@@ -2124,6 +2482,19 @@ function App() {
2124
2482
  // at the top of this handler and the early return fired before parse()
2125
2483
  // could be retried, leaving the tab permanently showing "no runs" with
2126
2484
  // no error at all. Both now happen only after a successful parse.
2485
+ // Freshness is recorded on every *successful poll*, including the
2486
+ // identical-payload path below, and deliberately not in `meta`. It used
2487
+ // to ride on meta.at, which is only written past the early return -- so
2488
+ // on a quiet repo, where every payload is byte-identical by design, the
2489
+ // timestamp froze at the last time data changed and the status bar
2490
+ // accrued a growing "stale 2h13m" while every poll was succeeding on
2491
+ // schedule. The indicator fired loudest in the one state that is
2492
+ // completely healthy. A ref rather than state because writing state here
2493
+ // would allocate a new object every tick and permanently defeat the
2494
+ // React bail-out this early return exists to preserve.
2495
+ lastOkRef.current[key] = Date.now();
2496
+ // Clear on the first success or a single failure latches the ladder.
2497
+ clearBackoff(`tab:${key}`);
2127
2498
  if (rawRef.current[key] === raw && cleanRef.current[key]) {
2128
2499
  setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
2129
2500
  return;
@@ -2144,11 +2515,27 @@ function App() {
2144
2515
  },
2145
2516
  }));
2146
2517
  if (value.notes) setSecurityNotes(value.notes);
2518
+ if (key === "security") {
2519
+ setSecurityBlind((b) => (b === Boolean(value.blind) ? b : Boolean(value.blind)));
2520
+ }
2147
2521
  })
2148
2522
  .catch((err) => {
2149
2523
  if (cancelled || err?.name === "AbortError") return;
2150
2524
  cleanRef.current[key] = false;
2151
- setErrors((x) => ({ ...x, [key]: shortErr(err) }));
2525
+ // Classified, not dumped. The three list tabs used to put raw `gh`
2526
+ // stderr on screen while the alert path one tab over translated the
2527
+ // same verdicts into something actionable. VERDICT_REMEDY replaces the
2528
+ // message rather than prefixing it, because the error slot is exactly
2529
+ // one line and a prefix would truncate away the diagnostic half.
2530
+ const verdict = classify(err);
2531
+ setErrors((x) => ({ ...x, [key]: pick(VERDICT_REMEDY, verdict, null) ?? shortErr(err) }));
2532
+ // ...and back off, which the list tabs never did at all. A tab wedged
2533
+ // on an expired token used to re-spawn `gh` every tick forever -- 720
2534
+ // subprocesses an hour, indefinitely, against a token that is already
2535
+ // refusing. "other" has no ladder on purpose: a network drop should
2536
+ // recover on the very next tick once the network is back.
2537
+ const steps = pick(FAILURE_LADDER, verdict, null);
2538
+ if (steps) recordFailure(`tab:${key}`, performance.now(), steps);
2152
2539
  })
2153
2540
  .finally(() => {
2154
2541
  inFlightRef.current[key] = false;
@@ -2156,9 +2543,15 @@ function App() {
2156
2543
  });
2157
2544
  }
2158
2545
 
2159
- function fetchTab(key) {
2546
+ // `force` is what the `r` key passes, and it is the whole reason the backoff
2547
+ // above is safe to add. Without it, pressing refresh on a backed-off tab
2548
+ // would silently do nothing -- taking away the one control the user has at
2549
+ // exactly the moment the pane looks broken and they reach for it.
2550
+ function fetchTab(key, { force = false } = {}) {
2160
2551
  const signal = controller.signal;
2161
2552
  const descriptor = TABS.find((t) => t.key === key);
2553
+ if (!force && backoffActive(`tab:${key}`, performance.now())) return Promise.resolve();
2554
+ if (force) clearBackoff(`tab:${key}`);
2162
2555
  // Every tab carries its own fetcher on the registry, so adding a tab is a
2163
2556
  // TABS entry plus a fetcher rather than an entry plus an edit to a chain
2164
2557
  // in a different part of the file.
@@ -2174,7 +2567,10 @@ function App() {
2174
2567
  const active = TABS[activeIndexRef.current].key;
2175
2568
  const due = ticks % BACKGROUND_EVERY === 0 ? TABS.map((t) => t.key) : [active];
2176
2569
  ticks += 1;
2177
- await Promise.allSettled(due.map(fetchTab));
2570
+ // Mapped explicitly rather than passing fetchTab by reference: Array.map
2571
+ // hands the callback an index as its second argument, which would land in
2572
+ // the options bag and make every background tab look force-refreshed.
2573
+ await Promise.allSettled(due.map((key) => fetchTab(key)));
2178
2574
  if (cancelled) return;
2179
2575
  // Advancing `now` is what forces a redraw, so only do it when it can
2180
2576
  // actually change what's on screen: durations of in-progress runs tick
@@ -2212,7 +2608,18 @@ function App() {
2212
2608
  // idle hour, and the exact opposite of the redraw suppression above. The
2213
2609
  // first load genuinely is worth animating, because an empty pane with no
2214
2610
  // motion reads as broken; after that, only a run actually executing is.
2215
- const firstLoad = Object.fromEntries(TABS.map((t) => [t.key, data[t.key] == null]));
2611
+ // "Never resolved" and "never *succeeded*" are different states, and conflating
2612
+ // them pinned the spinner on forever. setData is only ever called on success,
2613
+ // so a tab whose fetch keeps failing -- offline laptop, VPN down, expired auth,
2614
+ // Actions disabled on the repo -- kept data === null, kept firstLoad true, and
2615
+ // kept the 100ms interval running for the life of the process while the body
2616
+ // rendered "loading actions..." directly above the error explaining it had
2617
+ // failed. Measured at 7.8% of a core and 9.8 MB/hr of terminal writes,
2618
+ // indefinitely, on the single most ordinary failure there is. Motion now means
2619
+ // "still working"; the error line means "not working"; nothing claims both.
2620
+ const firstLoad = Object.fromEntries(
2621
+ TABS.map((t) => [t.key, data[t.key] == null && !errors[t.key]]),
2622
+ );
2216
2623
  const anyFirstLoad = Object.values(firstLoad).some(Boolean);
2217
2624
  const showSpinner = ANIMATE && (anyFirstLoad || hasRunningVisible);
2218
2625
  useEffect(() => {
@@ -2227,6 +2634,18 @@ function App() {
2227
2634
  return () => clearInterval(id);
2228
2635
  }, [showSpinner]);
2229
2636
 
2637
+ // Armed on the same condition the loading line renders on, so a start that
2638
+ // resolves inside ICON_HINT_AFTER_MS clears the timer on the way past and
2639
+ // never writes state at all -- the fast path stays exactly as many renders as
2640
+ // it was. `anyFirstLoad` excludes tabs that failed (see firstLoad above), so a
2641
+ // wedged tab cannot hold this armed: an error line already says what happened,
2642
+ // and a font hint underneath it would be answering a question nobody asked.
2643
+ useEffect(() => {
2644
+ if (!anyFirstLoad || iconHintDue) return;
2645
+ const timer = setTimeout(() => setIconHintDue(true), ICON_HINT_AFTER_MS);
2646
+ return () => clearTimeout(timer);
2647
+ }, [anyFirstLoad, iconHintDue]);
2648
+
2230
2649
  const items = data[tab.key];
2231
2650
  const error = errors[tab.key];
2232
2651
  const spin = SPINNER[frame % SPINNER.length];
@@ -2240,15 +2659,30 @@ function App() {
2240
2659
  // the count stop moving through a genuine change on any repo big enough
2241
2660
  // for this tool to matter.
2242
2661
  const suffix = meta[t.key]?.truncated ? "+" : "";
2243
- // Newest first, so the head of the list is the run that decides whether
2244
- // CI is currently red.
2245
- const broken =
2662
+ // A blind Security tab reports "?" rather than a number it cannot stand
2663
+ // behind. Zero alerts and zero visibility look identical otherwise.
2664
+ if (t.key === "security" && securityBlind) return [t.key, "?"];
2665
+ return [t.key, `${list.length}${suffix}`];
2666
+ }),
2667
+ );
2668
+ // Kept out of `counts` because the two have different audiences. In the tab bar
2669
+ // `!` sits next to a label and means "the newest run failed"; interpolated into
2670
+ // the frame's bottom edge it produced "4 of 4!", where there is nothing for it
2671
+ // to attach to and it reads as emphasis or a typo. `+` composes fine in both
2672
+ // places, so only this one had to move. Newest first, so the head of the list
2673
+ // is the run that decides whether CI is currently red.
2674
+ const brokenCI = Object.fromEntries(
2675
+ TABS.map((t) => {
2676
+ const list = data[t.key];
2677
+ return [
2678
+ t.key,
2246
2679
  t.key === "actions" &&
2247
- list.length > 0 &&
2248
- list[0].status === "completed" &&
2249
- list[0].conclusion !== "success" &&
2250
- list[0].conclusion !== "skipped";
2251
- return [t.key, `${list.length}${suffix}${broken ? "!" : ""}`];
2680
+ Array.isArray(list) &&
2681
+ list.length > 0 &&
2682
+ list[0].status === "completed" &&
2683
+ list[0].conclusion !== "success" &&
2684
+ list[0].conclusion !== "skipped",
2685
+ ];
2252
2686
  }),
2253
2687
  );
2254
2688
  const failed = Object.fromEntries(TABS.map((t) => [t.key, Boolean(errors[t.key])]));
@@ -2257,7 +2691,11 @@ function App() {
2257
2691
  // poll only surfaces on the tab you have selected, and after a laptop sleep
2258
2692
  // every pane is plausible and wrong. Threshold-gated and minute-granular on
2259
2693
  // purpose -- see STALE_AFTER_MS.
2260
- const lastOk = meta[tab.key]?.at;
2694
+ // Read from the ref written on every successful poll, not from meta.at, which
2695
+ // only moves when the *payload* changes -- see lastOkRef. Reading a ref during
2696
+ // render lags by one render, which is harmless here because `now` advances on
2697
+ // its own and the label is minute-granular by design.
2698
+ const lastOk = lastOkRef.current[tab.key];
2261
2699
  const staleFor = lastOk == null ? null : now.getTime() - lastOk;
2262
2700
  const staleThreshold = Math.max(STALE_AFTER_MS, runtime.refreshMs * 6);
2263
2701
  const staleLabel =
@@ -2297,27 +2735,61 @@ function App() {
2297
2735
  // terminal is doing the clipping.
2298
2736
  const frameCols = Math.max(1, cols - 1);
2299
2737
 
2738
+ // Name the repository in the top edge when it was chosen explicitly, because
2739
+ // then it is not the one you would guess from the working directory. Nothing
2740
+ // on screen said which repo a pane was watching, and the documented workflow is
2741
+ // several panes side by side plus `--repo` from anywhere -- so telling them
2742
+ // apart meant quitting and running --doctor. Only the explicit cases are shown:
2743
+ // resolving the *inferred* repo would cost a subprocess call at startup for a
2744
+ // label that, by definition, names the directory you are already sitting in.
2745
+ //
2746
+ // GH_REPO is read straight from the environment and never went through
2747
+ // parseRepoTarget, so unlike runtime.repo it is unvalidated -- safe() before it
2748
+ // is drawn, on the same rule as every other string this app does not own.
2749
+ // Dropped before the tab name rather than with it: PanelEdge drops its whole
2750
+ // label when it cannot seat one, so a single concatenated string would take the
2751
+ // tab name down with the target at ordinary widths.
2752
+ const target = runtime.repo ?? safe(process.env.GH_REPO ?? "");
2753
+ const withTarget = target ? `${tab.label} · ${target}` : tab.label;
2754
+ const topLabel = frameCols - 3 - withTarget.length >= 0 ? withTarget : tab.label;
2755
+
2300
2756
  // Fixed columns deliberately do not shrink, so BRANCH and TIME stay readable
2301
2757
  // at ordinary widths. Narrow panes drop columns instead, which keeps the
2302
2758
  // frame, the tab bar and the quit hint on screen -- the previous behaviour
2303
2759
  // pushed all three off the bottom. Measured against frameCols, the width the
2304
2760
  // frame itself actually gets, not the raw terminal width.
2305
- const compact = frameCols < MIN_TABLE_WIDTH;
2761
+ // Per tab, not one global flag. MIN_TABLE_WIDTH is a max across all four, so
2762
+ // the widest tab was deciding for the narrowest: Pull requests needs 61
2763
+ // columns and Security only 44, which meant Security dropped two columns a
2764
+ // full 17 columns before it had to. Only one tab is on screen at a time, so
2765
+ // the cost -- different tabs showing different column counts at the same width
2766
+ // -- is not something you can actually see. MIN_TABLE_WIDTH stays as the
2767
+ // exported worst case, which is what the tests pin.
2768
+ const compact = frameCols < minimumWidthFor(tab.header);
2306
2769
  const header = compact ? tab.compactHeader : tab.header;
2307
2770
 
2771
+ // Below the compact set's own floor even the fixed columns overflow, which
2772
+ // hard-wraps every row and drives ink into clearing and repainting the whole
2773
+ // screen each frame -- the one failure mode this file is most engineered to
2774
+ // avoid, and it was reachable simply by dragging a sidebar narrow. The guard
2775
+ // sits after usableSize(), which substitutes DEFAULT_COLS for a 0 reported
2776
+ // mid-resize, so a transient zero cannot be mistaken for a genuinely tiny pane.
2777
+ const tooNarrow = frameCols < MIN_COMPACT_WIDTH;
2778
+
2308
2779
  return e(
2309
2780
  Box,
2310
2781
  { flexDirection: "column", width: frameCols, height: Math.min(rows, usableSize(stdout?.rows, rows)) },
2311
2782
  e(TabBar, {
2312
2783
  activeIndex,
2313
2784
  counts,
2785
+ brokenCI,
2314
2786
  firstLoad,
2315
2787
  failed,
2316
2788
  spin,
2317
2789
  useShort: useShortLabels,
2318
2790
  }),
2319
2791
  e(Divider, { width: frameCols }),
2320
- e(PanelEdge, { width: frameCols, top: true, label: tab.label, labelColor: TITLE_COLOR }),
2792
+ e(PanelEdge, { width: frameCols, top: true, label: topLabel, labelColor: TITLE_COLOR }),
2321
2793
  e(
2322
2794
  Box,
2323
2795
  {
@@ -2334,13 +2806,31 @@ function App() {
2334
2806
  // truncate-end is what makes the one-line reservation in `extraLines`
2335
2807
  // true by construction. Predicting the wrapped height instead would mean
2336
2808
  // duplicating ink's width model, and would still be wrong on resize.
2337
- error && e(Text, { color: ERROR_TEXT, wrap: "truncate-end" }, error),
2338
- tab.key === "security" &&
2339
- securityNotes.map((note, i) =>
2809
+ // Below the compact floor, say so instead of rendering a table that cannot
2810
+ // fit. The frame, the tab bar and the quit hint stay, so widening the pane
2811
+ // recovers immediately -- this is a render-time branch and touches no state.
2812
+ // Short enough to survive at the widths it actually appears at -- a message
2813
+ // about the pane being too narrow that is itself truncated would be a joke.
2814
+ // Clamped to bodyRows so it can never push the frame past `rows` and
2815
+ // trigger the full repaint the height reservation exists to prevent, and
2816
+ // truncate-end so it degrades rather than wraps in a narrow pane. Every
2817
+ // glyph in KEY_TABLE is plain ASCII, which is what keeps it safe here.
2818
+ showHelp &&
2819
+ keyTableLines()
2820
+ .slice(0, bodyRows)
2821
+ .map((line, i) => e(Text, { key: `help${i}`, wrap: "truncate-end" }, line)),
2822
+ !showHelp &&
2823
+ tooNarrow &&
2824
+ e(Text, { dimColor: true, wrap: "truncate-end" }, "too narrow"),
2825
+ !showHelp && !tooNarrow && error && e(Text, { color: ERROR_TEXT, wrap: "truncate-end" }, error),
2826
+ !showHelp &&
2827
+ !tooNarrow &&
2828
+ tab.key === "security" &&
2829
+ securityLines.map((note, i) =>
2340
2830
  e(Text, { key: i, dimColor: true, wrap: "truncate-end" }, note),
2341
2831
  ),
2342
- e(HeaderCells, { cells: header }),
2343
- ...visibleItems.map((item) => {
2832
+ !showHelp && !tooNarrow && e(HeaderCells, { cells: header }),
2833
+ ...(tooNarrow || showHelp ? [] : visibleItems).map((item) => {
2344
2834
  const key = itemKey(item);
2345
2835
  return e(
2346
2836
  RowBoundary,
@@ -2350,10 +2840,13 @@ function App() {
2350
2840
  now,
2351
2841
  compact,
2352
2842
  cursor: key === selectedKey,
2353
- // Only the Actions rows read this. Passing it to the others would
2354
- // change a prop ten times a second on every tab and defeat the
2355
- // memoisation below for no visible effect.
2356
- spin: tab.key === "actions" && showSpinner ? spin : null,
2843
+ // Only an *executing* Actions row reads this. It used to go to every
2844
+ // visible row on the tab, so a prop that changes several times a
2845
+ // second defeated the memoisation below for all of them rather than
2846
+ // for the one or two actually spinning -- measured at 7,960 row
2847
+ // renders over 20s against 238 with this condition.
2848
+ spin:
2849
+ tab.key === "actions" && showSpinner && item.status === "in_progress" ? spin : null,
2357
2850
  }),
2358
2851
  );
2359
2852
  }),
@@ -2361,13 +2854,26 @@ function App() {
2361
2854
  // which is the difference between a dashboard that looks hung and one
2362
2855
  // that looks correct. Driven by "never resolved" rather than by the
2363
2856
  // loading flag, which toggles on every tick and made a settled-empty tab
2364
- // swap its message every five seconds.
2365
- visibleItems.length === 0 &&
2857
+ // swap its message every five seconds. Suppressed entirely when a tab has
2858
+ // an error and has never resolved: the error line directly above already
2859
+ // says what happened, and "no runs" underneath it reads as a fact about the
2860
+ // repository rather than the absence of an answer.
2861
+ !showHelp &&
2862
+ !tooNarrow &&
2863
+ visibleItems.length === 0 &&
2864
+ !(error && items == null) &&
2366
2865
  e(
2367
2866
  Text,
2368
2867
  { dimColor: true },
2369
2868
  firstLoad[tab.key]
2370
- ? `${showSpinner ? spin : SPINNER[0]} loading ${tab.label.toLowerCase()}…`
2869
+ ? `${showSpinner ? spin : SPINNER[0]} loading ${tab.label.toLowerCase()}…` +
2870
+ // Appended only once a first fetch has been running for
2871
+ // ICON_HINT_AFTER_MS, so an ordinary start never shows it: the
2872
+ // remedy is for someone looking at a pane of blank boxes, and by
2873
+ // then they have been looking long enough to want an explanation.
2874
+ // Suppressed once icons are already ASCII, since then there is
2875
+ // nothing to fix.
2876
+ (USING_NERD_ICONS && iconHintDue ? " (icons blank? GH_GLANCE_ICONS=unicode)" : "")
2371
2877
  : tab.key === "security" && securityNotes.length === ALERT_SOURCES.length
2372
2878
  ? "no alert sources available"
2373
2879
  : `no ${tab.countLabel}`,
@@ -2383,6 +2889,7 @@ function App() {
2383
2889
  spin: showSpinner ? spin : null,
2384
2890
  stale: staleLabel,
2385
2891
  interactive,
2892
+ cols: frameCols,
2386
2893
  }),
2387
2894
  );
2388
2895
  }
@@ -2413,11 +2920,27 @@ function restoreScreen() {
2413
2920
  // then discarded -- and nothing ever set a non-zero exit code, so the dashboard
2414
2921
  // simply vanished and any wrapper saw success. Restore the primary buffer
2415
2922
  // *first*, then write, or the fix reproduces the problem it is fixing.
2923
+ //
2924
+ // Aborting in-flight `gh` children is the poll effect's job on every other exit
2925
+ // path, but its cleanup only runs through ink's unmount -- which an explicit
2926
+ // process.exit() skips. Without this, a crash orphaned up to six subprocesses
2927
+ // for the length of GH_TIMEOUT_MS. The abort runs *after* restoreScreen() and is
2928
+ // wrapped, because an exception thrown here would replace the stack trace this
2929
+ // handler exists to print with an unrelated one.
2930
+ //
2931
+ // Both messages go through redact(): a stack can carry a URL with inline
2932
+ // credentials, and this output is what a user pastes into a bug report -- the
2933
+ // same reasoning --doctor already applies to its own report.
2416
2934
  function installCrashHandlers() {
2417
2935
  const fail = (label) => (err) => {
2418
2936
  restoreScreen();
2937
+ try {
2938
+ abortLiveRequests();
2939
+ } catch {
2940
+ // Nothing useful to do about a failure here, and the stack below matters more.
2941
+ }
2419
2942
  console.error(`gh-glance: ${label}`);
2420
- console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
2943
+ console.error(redact(err instanceof Error ? (err.stack ?? err.message) : String(err)));
2421
2944
  process.exit(1);
2422
2945
  };
2423
2946
  process.on("uncaughtException", fail("crashed"));
@@ -2512,4 +3035,9 @@ export {
2512
3035
  MIN_COMPACT_WIDTH,
2513
3036
  OCT_NERD,
2514
3037
  OCT_UNICODE,
3038
+ KEY_TABLE,
3039
+ KEY_HINTS,
3040
+ VERDICT_REMEDY,
3041
+ RATE_LIMIT_RETRY_MS,
3042
+ FAILURE_LADDER,
2515
3043
  };