gh-glance 0.3.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 (5) hide show
  1. package/CHANGELOG.md +200 -0
  2. package/LICENSE +21 -0
  3. package/README.md +277 -0
  4. package/index.mjs +2071 -0
  5. package/package.json +57 -0
package/index.mjs ADDED
@@ -0,0 +1,2071 @@
1
+ #!/usr/bin/env node
2
+ // gh-glance -- a live-refreshing GitHub dashboard sized for a narrow terminal
3
+ // pane: Actions, Issues, Pull Requests, and Security (Dependabot/
4
+ // code-scanning/secret-scanning alerts). All data comes from `gh` (issue/pr/
5
+ // run list + `gh api` for security alerts) -- no direct GitHub API calls of
6
+ // our own. Renders with ink so redraws are diffed in place, no full-screen
7
+ // clear/flash on refresh.
8
+
9
+ // React's development build instruments every render with performance.measure()
10
+ // calls for its DevTools timeline. In a browser those entries are dropped once
11
+ // the timeline buffer fills; in Node the user-timing buffer is never trimmed,
12
+ // so each render leaves a PerformanceMeasure behind permanently. A dashboard
13
+ // that redraws for hours accumulates them until the heap is gone -- measured at
14
+ // ~14 entries/s, which is a fatal "JavaScript heap out of memory" after a few
15
+ // hours of uptime. Selecting React's production build turns the instrumentation
16
+ // off at the source (verified: 0 entries, flat heap).
17
+ //
18
+ // react and ink pick their build by reading NODE_ENV at import time, so the flag
19
+ // has to be set before either is loaded. This assignment must stay the first
20
+ // statement in the file. NODE_ENV is only defaulted, not overwritten, so
21
+ // `NODE_ENV=development gh-glance` still gets React's warnings -- see
22
+ // disarmDevBuildLeak() for how that path is kept survivable rather than fatal.
23
+ process.env.NODE_ENV ??= "production";
24
+
25
+ import { execFile } from "node:child_process";
26
+ import { readFileSync, realpathSync } from "node:fs";
27
+ import { fileURLToPath } from "node:url";
28
+ import { promisify } from "node:util";
29
+
30
+ const execFileAsync = promisify(execFile);
31
+
32
+ // Running as the CLI vs. being imported by a test. Everything with a side
33
+ // effect -- argv handling, the TTY guard, the preflight, entering the alternate
34
+ // screen, render() -- hangs off this, so `import("./index.mjs")` is inert and
35
+ // the pure helpers below are unit-testable. Compared through realpath because
36
+ // `npm link` puts a symlink on PATH, so argv[1] and this module's own URL are
37
+ // different strings for the same file.
38
+ function detectMainModule() {
39
+ try {
40
+ const self = realpathSync(fileURLToPath(import.meta.url));
41
+ return Boolean(process.argv[1]) && realpathSync(process.argv[1]) === self;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+ const IS_MAIN = detectMainModule();
47
+
48
+ const REFRESH_MS = 5000;
49
+
50
+ // `gh run list` costs roughly linearly in --limit (measured: ~1.2s at 20 runs,
51
+ // ~3.0s at 100, ~4.9s at 150), so asking for a fixed 150 to render ~35 visible
52
+ // rows was paying several seconds per refresh for rows nobody sees. Actions is
53
+ // a scrolling log whose count is arbitrary anyway, so it fetches only what the
54
+ // pane can show -- one extra row tells us whether to render the count as "n+".
55
+ const MIN_RUN_LIMIT = 20;
56
+
57
+ // Ceiling on the pane-height-derived run limit. Without it a very tall terminal
58
+ // asks for enough runs that the fetch outlasts REFRESH_MS, at which point ticks
59
+ // are permanently absorbed by the in-flight guard and the effective refresh rate
60
+ // silently becomes whatever `gh` can sustain.
61
+ const MAX_RUN_LIMIT = 60;
62
+
63
+ // Issues and PRs are sets rather than logs: the count *is* the signal, so these
64
+ // stay generous. They are also far cheaper, being bounded by what's open.
65
+ const LIST_LIMIT = 150;
66
+
67
+ // Alert endpoints are filtered server-side to open items and capped at one
68
+ // page. The previous --paginate walked the repo's entire alert history --
69
+ // mostly closed alerts -- and then discarded them client-side.
70
+ const ALERT_PER_PAGE = 100;
71
+ const ALERT_QUERY = `?state=open&per_page=${ALERT_PER_PAGE}`;
72
+
73
+ // Inactive tabs only feed the tab-bar counts, so they refresh every Nth tick
74
+ // rather than every tick. Raised from 4 to 12 (a 60s cycle at the default
75
+ // refresh) because the measured steady-state cost was 1,980-2,520 REST calls
76
+ // per hour against a 5,000/hr limit -- 40-50% of the user's entire budget for a
77
+ // single pane. Only the tab you are looking at needs REFRESH_MS latency; the
78
+ // other three exist to keep counts honest, which tolerates a minute.
79
+ const BACKGROUND_EVERY = 12;
80
+
81
+ // A stalled `gh` used to wedge a tab permanently: the promise never settled, so
82
+ // the in-flight guard was never cleared and that tab stopped fetching for the
83
+ // life of the process while the spinner kept insisting it was working. The
84
+ // timeout has to clear the slowest *legitimate* fetch by a wide margin -- the
85
+ // MIN_RUN_LIMIT note above measures ~4.9s at 150 runs -- so it sits far above
86
+ // REFRESH_MS rather than near it. SIGKILL because `gh` mid-TLS-handshake can
87
+ // ignore SIGTERM.
88
+ const GH_TIMEOUT_MS = 30_000;
89
+
90
+ // Node's execFile defaults to a 1 MiB stdout buffer. A repo with ~100 open
91
+ // Dependabot alerts returns ~800 KB of advisory JSON (measured 7.3 KB/advisory)
92
+ // and was within reach of the cliff -- where the rejection was swallowed into a
93
+ // note and the tab rendered "Security (0)", i.e. the repos with the most to
94
+ // report looked the cleanest. Paired with the --jq projection below, which cuts
95
+ // the payload ~48x, this is headroom rather than an allocation.
96
+ const GH_MAX_BUFFER = 16 * 1024 * 1024;
97
+
98
+ // Endpoints that answer "this feature is not enabled here" do so with a 403/404
99
+ // on every single refresh. Re-asking forever costs a process spawn and a
100
+ // rate-limit unit each time (measured up to 11,520 wasted calls per 8h session),
101
+ // so a negative result backs off -- but never permanently, because Advanced
102
+ // Security can be switched on mid-session and a latched "not enabled" would be
103
+ // a lie about a security surface.
104
+ const BACKOFF_STEPS_MS = [60_000, 300_000, 1_800_000, 3_600_000];
105
+
106
+ // Past this, the active tab's data is old enough to say so. Deliberately
107
+ // coarse: `now` only advances on minute boundaries when nothing is in progress,
108
+ // so a minute-granular staleness label costs zero extra redraws, whereas a
109
+ // live "updated Ns ago" would make every frame differ and undo the
110
+ // byte-identical-idle-frame property the rest of this file works to keep.
111
+ const STALE_AFTER_MS = 30_000;
112
+
113
+ // Mutable because the fetchers below are defined before argv is parsed, and the
114
+ // argv block is what fills this in. Everything here has a working default, so
115
+ // the zero-argument invocation the README documents behaves exactly as before.
116
+ const runtime = {
117
+ repo: null, // null means "let gh infer it from the git remote", as today
118
+ refreshMs: REFRESH_MS,
119
+ verbose: false,
120
+ initialTabIndex: 0,
121
+ };
122
+
123
+ // The four tab keys, needed by --tab validation which runs long before the TABS
124
+ // table itself is built.
125
+ const TAB_KEYS = ["actions", "issues", "prs", "security"];
126
+
127
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
128
+ const SPINNER_MS = 100;
129
+
130
+ // Motion opt-out. This pane is designed to sit in peripheral vision for hours,
131
+ // which is exactly where repetitive motion is most costly for users with
132
+ // vestibular sensitivity -- and a 20-minute workflow run means 20 minutes of
133
+ // 10fps animation with no way to stop it.
134
+ const ANIMATE = !process.env.GH_GLANCE_NO_ANIMATION;
135
+
136
+ // ---------- Untrusted input ----------
137
+
138
+ // Everything `gh` returns is chosen by strangers: on a public repo anyone can
139
+ // open an issue or a fork PR and pick the title, and a commit subject (which is
140
+ // what `displayTitle` is for push runs) has no byte restrictions at all.
141
+ //
142
+ // ink strips CSI sequences but -- verified against ink 7.1.1 -- deliberately
143
+ // preserves OSC sequences, SGR, and bare C0 controls so callers can pass
144
+ // chalk-styled strings through. So OSC 8 hyperlinks survive (an attacker-chosen
145
+ // clickable URL with no visual tell), CR survives (rewinds the cursor and
146
+ // overwrites the row above, which can forge or blank a neighbouring row --
147
+ // including making a critical alert read as clean), BEL survives (rings once
148
+ // per redraw, every 5s), and LF survives (one hostile title inflates to N lines
149
+ // and evicts other rows out of a height-clamped frame entirely).
150
+ //
151
+ // So sanitize here, at the data boundary, inside the parse() closures -- never
152
+ // on the composed output stream, which would strip ink's own SGR codes, the
153
+ // box-drawing in PanelEdge, and the alternate-screen escapes, i.e. the whole UI.
154
+ //
155
+ // Strip C0, DEL and C1 only. Emoji (including ZWJ sequences and variation
156
+ // selectors), CJK and other wide characters, combining marks and RTL text must
157
+ // survive untouched -- ink's width arithmetic depends on measuring them
158
+ // correctly, and the app's own Nerd Font glyphs live in the private use area, so
159
+ // anything shaped like "strip non-ASCII" would erase every status icon on
160
+ // screen. Control runs collapse to a single space rather than being deleted, so
161
+ // "a\nb" reads as "a b" rather than "ab".
162
+ // eslint-disable-next-line no-control-regex -- matching control characters is the entire purpose
163
+ const CONTROL_CHARS = /[--Ÿ]+/g;
164
+
165
+ // Titles are rendered at whatever length GitHub returns, and ink memoizes
166
+ // wrapped text in a module-level cache it never evicts -- so unbounded remote
167
+ // strings are unbounded retention in a process meant to run for hours. Clamp
168
+ // far above any realistic column width so ink's own ellipsis stays what the user
169
+ // actually sees, and clamp by codepoint: slicing mid-surrogate produces a string
170
+ // whose measured width disagrees with what the terminal draws, which
171
+ // desynchronizes every column to its right.
172
+ const MAX_FIELD_LENGTH = 300;
173
+
174
+ function safe(value) {
175
+ if (typeof value !== "string") return value == null ? "" : String(value);
176
+ const cleaned = value.replace(CONTROL_CHARS, " ").trim();
177
+ const points = Array.from(cleaned);
178
+ return points.length > MAX_FIELD_LENGTH ? points.slice(0, MAX_FIELD_LENGTH).join("") : cleaned;
179
+ }
180
+
181
+ // ---------- Formatting ----------
182
+
183
+ // The `ms < 0` guard does not catch NaN (every NaN comparison is false), and
184
+ // every caller builds a Date from an unvalidated API field -- so a null, absent
185
+ // or Go-zero timestamp used to render "NaNd ago" or "InfinityhNaNm" straight
186
+ // into the table. A dash reads as absent data and fits the fixed 7- and
187
+ // 8-column cells; dropping the row instead would make the count disagree with
188
+ // the body, which is worse.
189
+ const NO_VALUE = "-";
190
+
191
+ function formatDuration(ms) {
192
+ if (!Number.isFinite(ms)) return NO_VALUE;
193
+ if (ms < 0) ms = 0;
194
+ const totalSeconds = Math.floor(ms / 1000);
195
+ const h = Math.floor(totalSeconds / 3600);
196
+ const m = Math.floor((totalSeconds % 3600) / 60);
197
+ const s = totalSeconds % 60;
198
+ if (h > 0) return `${h}h${m}m`;
199
+ if (m > 0) return `${m}m${s}s`;
200
+ return `${s}s`;
201
+ }
202
+
203
+ function formatAge(date, now) {
204
+ const ms = now - date;
205
+ if (!Number.isFinite(ms)) return NO_VALUE;
206
+ const minutes = Math.floor(ms / 60000);
207
+ if (minutes < 1) return "just now";
208
+ if (minutes < 60) return `${minutes}m ago`;
209
+ const hours = Math.floor(minutes / 60);
210
+ if (hours < 24) return `${hours}h ago`;
211
+ const days = Math.floor(hours / 24);
212
+ return `${days}d ago`;
213
+ }
214
+
215
+ // `shortErr` used to read err.shortMessage first -- an execa property that
216
+ // node:child_process never sets, so the branch was dead and every error fell
217
+ // through to err.message, which is the whole reconstructed command line
218
+ // (~150 characters of --json field names for the Actions fetch) followed by the
219
+ // part the user actually needs. Rendered against a layout that reserves exactly
220
+ // one row for it, that overflowed the frame and made ink full-clear and repaint
221
+ // every frame -- the flicker this file's header claims the design avoids.
222
+ //
223
+ // stderr carries the useful line, so prefer it. Keep the err.message fallback:
224
+ // a missing binary rejects with code ENOENT, an empty stderr and only
225
+ // "spawn gh ENOENT" as the message. Newlines are collapsed rather than kept,
226
+ // because ink honours an embedded \n inside a single <Text> and one row is all
227
+ // the layout has budgeted.
228
+ const MAX_ERR_LENGTH = 120;
229
+
230
+ function shortErr(err) {
231
+ if (err?.killed && err?.signal) return `gh timed out after ${GH_TIMEOUT_MS / 1000}s`;
232
+ const raw = (typeof err?.stderr === "string" && err.stderr.trim()) || err?.message || String(err);
233
+ const withoutPreamble = String(raw).replace(/^Command failed:[^\n]*\n?/, "");
234
+ const collapsed = withoutPreamble.replace(CONTROL_CHARS, " ").replace(/\s+/g, " ").trim();
235
+ const text = collapsed || String(raw).trim() || "unknown error";
236
+ return text.length > MAX_ERR_LENGTH ? `${text.slice(0, MAX_ERR_LENGTH - 1)}…` : text;
237
+ }
238
+
239
+ // `gh api` puts the HTTP status in stderr and exits 1, so err.code is the
240
+ // process exit code and says nothing about the cause. 403/404 is the honest
241
+ // "you can't see this here" -- everything else (auth expiry, rate limiting,
242
+ // DNS, a 502) is a real failure that used to be reported as a confident,
243
+ // plausible, and wrong claim that the feature was switched off.
244
+ function isUnavailable(err) {
245
+ return /HTTP (403|404)/.test(String(err?.stderr ?? err?.message ?? ""));
246
+ }
247
+
248
+ function isRateLimited(err) {
249
+ return /rate limit|secondary rate|API rate limit/i.test(String(err?.stderr ?? err?.message ?? ""));
250
+ }
251
+
252
+ // Some pty wrappers (and a terminal mid-resize) report a size of 0 or
253
+ // undefined. Taking that literally collapses the table to a single row (or
254
+ // draws a zero-width border), so fall back to sane defaults until real
255
+ // dimensions arrive.
256
+ const DEFAULT_ROWS = 30;
257
+ const DEFAULT_COLS = 80;
258
+ function usableSize(value, fallback) {
259
+ return typeof value === "number" && value > 0 ? value : fallback;
260
+ }
261
+
262
+ // ---------- gh subprocess boundary ----------
263
+
264
+ // One seam for every `gh` call. Previously six call sites each passed nothing
265
+ // but argv, which meant six places to add a timeout, six to add a buffer size,
266
+ // six to neutralize the environment -- and it is why the three alert fetchers
267
+ // silently drifted into handling errors three different ways.
268
+ //
269
+ // The environment is *overridden*, never replaced: `gh` needs GH_TOKEN,
270
+ // GH_HOST (GitHub Enterprise), GH_CONFIG_DIR, HOME and the proxy variables to
271
+ // work at all, and GH_REPO is the one documented way to point this tool at
272
+ // another repository. GH_FORCE_TTY is the interesting one -- people export it
273
+ // in shell profiles to get rich `gh` output through pipes, and it makes
274
+ // `gh --json` emit ANSI-coloured pretty-printed JSON that JSON.parse rejects
275
+ // outright (verified), which broke all four tabs at once with an error pointing
276
+ // at JSON rather than at their environment.
277
+ const GH_ENV_OVERRIDES = {
278
+ GH_FORCE_TTY: "",
279
+ NO_COLOR: "1",
280
+ CLICOLOR_FORCE: "0",
281
+ GH_PAGER: "cat",
282
+ };
283
+
284
+ // Verbose output goes to stderr and never to stdout: stdout is ink's frame
285
+ // stream, and writing anything else into it corrupts the diff and the
286
+ // alternate-screen state. The argv block refuses --verbose while stderr is still
287
+ // a terminal, so these lines always land in a file rather than on top of the
288
+ // dashboard.
289
+ function logGh(args, startedAt, outcome) {
290
+ if (!runtime.verbose) return;
291
+ const ms = Date.now() - startedAt;
292
+ process.stderr.write(
293
+ `${new Date().toISOString()} gh ${args.join(" ")} -- ${outcome} in ${ms}ms\n`,
294
+ );
295
+ }
296
+
297
+ async function runGh(args, { signal } = {}) {
298
+ const startedAt = Date.now();
299
+ try {
300
+ const { stdout } = await execFileAsync("gh", args, {
301
+ timeout: GH_TIMEOUT_MS,
302
+ killSignal: "SIGKILL",
303
+ maxBuffer: GH_MAX_BUFFER,
304
+ env: { ...process.env, ...GH_ENV_OVERRIDES },
305
+ signal,
306
+ });
307
+ logGh(args, startedAt, `ok ${stdout.length}B`);
308
+ return stdout;
309
+ } catch (err) {
310
+ logGh(args, startedAt, `FAILED ${shortErr(err)}`);
311
+ throw err;
312
+ }
313
+ }
314
+
315
+ // `--repo` for the list subcommands. Empty when unset, so the argv vector stays
316
+ // byte-identical to what shipped before.
317
+ function repoArgs() {
318
+ return runtime.repo ? ["--repo", runtime.repo] : [];
319
+ }
320
+
321
+ // `gh api` has no --repo; it resolves the {owner}/{repo} placeholder from the
322
+ // working directory. Substituting the validated value is what makes --repo work
323
+ // for the alert endpoints. REPO_PATTERN is why this is safe to interpolate into
324
+ // a request path.
325
+ function apiPath(path) {
326
+ return runtime.repo ? path.replace("{owner}/{repo}", runtime.repo) : path;
327
+ }
328
+
329
+ // ---------- Data fetchers ----------
330
+
331
+ async function fetchActions(limit, signal) {
332
+ const raw = await runGh(
333
+ [
334
+ "run",
335
+ "list",
336
+ ...repoArgs(),
337
+ "--limit",
338
+ String(limit),
339
+ "--json",
340
+ "databaseId,displayTitle,workflowName,number,headBranch,status,conclusion,startedAt,updatedAt",
341
+ ],
342
+ { signal },
343
+ );
344
+ return {
345
+ raw,
346
+ limit,
347
+ parse: () =>
348
+ JSON.parse(raw).map((r) => ({
349
+ databaseId: r.databaseId,
350
+ displayTitle: safe(r.displayTitle),
351
+ workflowName: safe(r.workflowName),
352
+ number: r.number,
353
+ headBranch: safe(r.headBranch),
354
+ status: r.status,
355
+ conclusion: r.conclusion,
356
+ startedAt: r.startedAt,
357
+ updatedAt: r.updatedAt,
358
+ })),
359
+ };
360
+ }
361
+
362
+ // Both list endpoints return created-descending by default, while the row
363
+ // renders updatedAt under a column headed AGE -- so the visible column was
364
+ // non-monotonic (verified against cli/cli: a PR updated yesterday sat below one
365
+ // updated three days earlier) and, worse, truncating to pane height dropped the
366
+ // oldest-*created* rows. A PR opened six months ago and reviewed five minutes
367
+ // ago was invisible. Sorting server-side fixes the ordering and the truncation
368
+ // criterion together; verified that --search composes with --state open and
369
+ // returns the same result count.
370
+ const SORT_RECENT = ["--search", "sort:updated-desc"];
371
+
372
+ async function fetchIssues(signal) {
373
+ const raw = await runGh(
374
+ [
375
+ "issue",
376
+ "list",
377
+ ...repoArgs(),
378
+ "--state",
379
+ "open",
380
+ "--limit",
381
+ String(LIST_LIMIT),
382
+ ...SORT_RECENT,
383
+ "--json",
384
+ "number,title,author,labels,createdAt,updatedAt",
385
+ ],
386
+ { signal },
387
+ );
388
+ return {
389
+ raw,
390
+ limit: LIST_LIMIT,
391
+ parse: () =>
392
+ JSON.parse(raw).map((i) => ({
393
+ number: i.number,
394
+ title: safe(i.title),
395
+ author: safe(i.author?.login ?? ""),
396
+ label: safe(i.labels?.[0]?.name ?? ""),
397
+ updatedAt: i.updatedAt,
398
+ })),
399
+ };
400
+ }
401
+
402
+ async function fetchPRs(signal) {
403
+ const raw = await runGh(
404
+ [
405
+ "pr",
406
+ "list",
407
+ ...repoArgs(),
408
+ "--state",
409
+ "open",
410
+ "--limit",
411
+ String(LIST_LIMIT),
412
+ ...SORT_RECENT,
413
+ "--json",
414
+ "number,title,author,headRefName,isDraft,reviewDecision,createdAt,updatedAt",
415
+ ],
416
+ { signal },
417
+ );
418
+ return {
419
+ raw,
420
+ limit: LIST_LIMIT,
421
+ parse: () =>
422
+ JSON.parse(raw).map((p) => ({
423
+ number: p.number,
424
+ title: safe(p.title),
425
+ author: safe(p.author?.login ?? ""),
426
+ headRefName: safe(p.headRefName),
427
+ isDraft: p.isDraft,
428
+ reviewDecision: p.reviewDecision,
429
+ updatedAt: p.updatedAt,
430
+ })),
431
+ };
432
+ }
433
+
434
+ // The three alert endpoints were three near-identical 26-line blocks that had
435
+ // already drifted apart -- only the Dependabot one reported the real error,
436
+ // which is exactly the divergence a shared seam prevents. Severity and the
437
+ // unavailable-note stay per-endpoint parameters rather than becoming constants,
438
+ // because secret scanning deliberately hardcodes critical severity where the
439
+ // other two derive it from the payload.
440
+ //
441
+ // --jq projects the fields we keep server-side inside `gh`: the Dependabot
442
+ // response carries a full advisory object per alert (~7.3 KB measured), of which
443
+ // this app displays five scalars. Measured 729,866 bytes -> 15,261 for 100
444
+ // alerts. That shrinks the parse, the retained rawRef string, and the maxBuffer
445
+ // exposure in one move -- but note it runs client-side, so it does not reduce
446
+ // network bytes or the rate-limit cost. `state` is kept in the projection so the
447
+ // belt-and-braces open filter below still has something to test.
448
+ const ALERT_SOURCES = [
449
+ {
450
+ key: "dependabot",
451
+ name: "Dependabot alerts",
452
+ path: `repos/{owner}/{repo}/dependabot/alerts${ALERT_QUERY}`,
453
+ jq: "[.[] | {number, state, created_at, severity: .security_advisory.severity, title: .security_advisory.summary, detail: .dependency.package.name}]",
454
+ unavailable: "Dependabot alerts: unavailable (not enabled for this repository)",
455
+ map: (a) => ({
456
+ id: `dependabot-${a.number}`,
457
+ kind: "Dependabot",
458
+ severity: safe(a.severity ?? "unknown"),
459
+ title: safe(a.title ?? "(no summary)"),
460
+ detail: safe(a.detail ?? ""),
461
+ createdAt: a.created_at,
462
+ }),
463
+ },
464
+ {
465
+ key: "codeScanning",
466
+ name: "Code scanning",
467
+ path: `repos/{owner}/{repo}/code-scanning/alerts${ALERT_QUERY}`,
468
+ jq: "[.[] | {number, state, created_at, severity: (.rule.security_severity_level // .rule.severity), title: (.rule.description // .rule.name), detail: .most_recent_instance.location.path}]",
469
+ unavailable: "Code scanning: not enabled (needs GitHub Advanced Security)",
470
+ map: (a) => ({
471
+ id: `codeql-${a.number}`,
472
+ kind: "CodeQL",
473
+ severity: safe(a.severity ?? "unknown"),
474
+ title: safe(a.title ?? "(no description)"),
475
+ detail: safe(a.detail ?? ""),
476
+ createdAt: a.created_at,
477
+ }),
478
+ },
479
+ {
480
+ key: "secretScanning",
481
+ name: "Secret scanning",
482
+ path: `repos/{owner}/{repo}/secret-scanning/alerts${ALERT_QUERY}`,
483
+ jq: "[.[] | {number, state, created_at, title: (.secret_type_display_name // .secret_type)}]",
484
+ unavailable: "Secret scanning: not enabled for this repository",
485
+ map: (a) => ({
486
+ id: `secret-${a.number}`,
487
+ kind: "Secret",
488
+ // A leaked credential is always critical; there is no severity field to
489
+ // derive one from, and this is what puts it in the red bucket below.
490
+ severity: "critical",
491
+ title: safe(a.title ?? "(unknown secret type)"),
492
+ detail: "",
493
+ createdAt: a.created_at,
494
+ }),
495
+ },
496
+ ];
497
+
498
+ // Per-source backoff. Keyed by source so one unavailable endpoint cannot slow
499
+ // the other two, and capped rather than permanent so enabling Advanced Security
500
+ // mid-session is picked up within the hour.
501
+ const alertBackoff = new Map();
502
+
503
+ function backoffActive(key, now) {
504
+ const state = alertBackoff.get(key);
505
+ return Boolean(state) && now < state.until;
506
+ }
507
+
508
+ function recordFailure(key, now) {
509
+ const previous = alertBackoff.get(key);
510
+ const step = Math.min((previous?.step ?? -1) + 1, BACKOFF_STEPS_MS.length - 1);
511
+ alertBackoff.set(key, { step, until: now + BACKOFF_STEPS_MS[step] });
512
+ }
513
+
514
+ function clearBackoff(key) {
515
+ alertBackoff.delete(key);
516
+ }
517
+
518
+ async function fetchAlertSource(source, signal, now) {
519
+ if (backoffActive(source.key, now)) {
520
+ const note = alertBackoff.get(source.key).note;
521
+ return { raw: `backoff:${note}`, parse: () => ({ alerts: [], note, truncated: false }) };
522
+ }
523
+ try {
524
+ const raw = await runGh(["api", apiPath(source.path), "--jq", source.jq], { signal });
525
+ clearBackoff(source.key);
526
+ return {
527
+ raw,
528
+ parse: () => {
529
+ const rows = JSON.parse(raw).filter((a) => a.state === "open");
530
+ return {
531
+ alerts: rows.map(source.map),
532
+ note: null,
533
+ truncated: rows.length >= ALERT_PER_PAGE,
534
+ };
535
+ },
536
+ };
537
+ } catch (err) {
538
+ // A 403/404 means the feature genuinely is not available here and is worth
539
+ // backing off. Anything else -- expired token, rate limit, network -- is
540
+ // transient and must surface as itself rather than as a confident and wrong
541
+ // claim about the repo's configuration, and must not be latched.
542
+ const unavailable = isUnavailable(err) && !isRateLimited(err);
543
+ const note = unavailable ? source.unavailable : `${source.name}: ${shortErr(err)}`;
544
+ if (unavailable) {
545
+ recordFailure(source.key, now);
546
+ alertBackoff.get(source.key).note = note;
547
+ }
548
+ return { raw: `unavailable:${note}`, parse: () => ({ alerts: [], note, truncated: false }) };
549
+ }
550
+ }
551
+
552
+ // Colour alone said which alerts mattered, but the list was ordered by the
553
+ // accident of which endpoint answered first: all Dependabot by age, then all
554
+ // CodeQL, then all secret-scanning -- which put leaked credentials last, below
555
+ // the fold, on a pane that shows ~25 rows. Rank explicitly, with createdAt as a
556
+ // total tiebreaker so an unchanged payload cannot reshuffle unrelated rows and
557
+ // defeat the redraw suppression.
558
+ const SEVERITY_RANK = { critical: 0, high: 1, medium: 2, moderate: 2, low: 3, unknown: 4 };
559
+
560
+ function severityRank(severity) {
561
+ return Object.hasOwn(SEVERITY_RANK, severity) ? SEVERITY_RANK[severity] : SEVERITY_RANK.unknown;
562
+ }
563
+
564
+ async function fetchSecurity(signal) {
565
+ const now = Date.now();
566
+ const parts = await Promise.all(ALERT_SOURCES.map((s) => fetchAlertSource(s, signal, now)));
567
+ return {
568
+ // Joined with a NUL so a change in any one of the three shows up as a
569
+ // change in the combined payload, without risk of two different splits
570
+ // colliding on the same string.
571
+ raw: parts.map((p) => p.raw).join("\0"),
572
+ limit: ALERT_PER_PAGE,
573
+ parse: () => {
574
+ const parsed = parts.map((p) => p.parse());
575
+ const alerts = parsed.flatMap((p) => p.alerts);
576
+ alerts.sort((a, b) => {
577
+ const bySeverity = severityRank(a.severity) - severityRank(b.severity);
578
+ if (bySeverity !== 0) return bySeverity;
579
+ return String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? ""));
580
+ });
581
+ return {
582
+ alerts,
583
+ notes: parsed.map((p) => p.note).filter(Boolean),
584
+ truncated: parsed.some((p) => p.truncated),
585
+ };
586
+ },
587
+ };
588
+ }
589
+
590
+ // ---------- Selection ----------
591
+
592
+ // The cursor tracks the ITEM, never the row index. Rows arrive newest-first and
593
+ // a new run pushes everything down every few seconds, so an index-based cursor
594
+ // would drift under the reader continuously. Keyed the same way the render loop
595
+ // keys its rows.
596
+ function itemKey(item) {
597
+ return item?.id ?? item?.databaseId ?? item?.number ?? null;
598
+ }
599
+
600
+ // `gh <kind> view --web` for the tabs that have a per-item command. The Security
601
+ // tab is absent on purpose: alerts have no `gh` view subcommand, so there is
602
+ // nothing honest to open.
603
+ const OPENABLE = { actions: "run", issues: "issue", prs: "pr" };
604
+
605
+ // Output is captured rather than inherited. `gh ... --web` prints "Opening ...
606
+ // in your browser" to stdout, and stdout is ink's frame stream -- letting that
607
+ // through would corrupt the diff and the alternate screen.
608
+ async function openInBrowser(tabKey, item, signal) {
609
+ const kind = pick(OPENABLE, tabKey, null);
610
+ const number = item?.number ?? item?.databaseId;
611
+ if (!kind || number == null) return;
612
+ await runGh([kind, "view", ...repoArgs(), String(number), "--web"], { signal });
613
+ }
614
+
615
+ // ---------- Startup preflight ----------
616
+
617
+ // Three failures are guaranteed on a fresh machine or a terminal that happens
618
+ // to be somewhere else: no `gh`, and not inside a repository. Each used to fail
619
+ // four times over -- once per tab -- as a multi-line subprocess dump rendered
620
+ // *inside the alternate screen*, which the exit handler then wiped, so the
621
+ // diagnosis was unreadable and then gone.
622
+ //
623
+ // Only locally-determinable, non-transient conditions belong here. Auth and
624
+ // network failures deliberately do not: `gh auth status` makes a network call,
625
+ // and exiting on it would make the tool unusable offline where today it simply
626
+ // recovers when the network returns. Those keep flowing through the in-pane
627
+ // error path, which is now worth reading.
628
+ async function preflight() {
629
+ try {
630
+ await execFileAsync("gh", ["--version"], { timeout: GH_TIMEOUT_MS });
631
+ } catch (err) {
632
+ if (err?.code === "ENOENT") {
633
+ return "gh-glance: the gh CLI is not installed.\nInstall it from https://cli.github.com, then run `gh auth login`.";
634
+ }
635
+ return `gh-glance: could not run gh: ${shortErr(err)}`;
636
+ }
637
+ // Only meaningful when the repository is being inferred from the working
638
+ // directory. With --repo or GH_REPO the cwd is irrelevant, and refusing to
639
+ // start outside a checkout would defeat the flag's whole purpose -- watching a
640
+ // repository you have not cloned.
641
+ if (runtime.repo || process.env.GH_REPO) return null;
642
+ try {
643
+ await execFileAsync("git", ["rev-parse", "--git-dir"], { timeout: GH_TIMEOUT_MS });
644
+ } catch {
645
+ return (
646
+ "gh-glance: not inside a git repository.\n" +
647
+ "Run it from a cloned GitHub repository, or pass --repo owner/name."
648
+ );
649
+ }
650
+ return null;
651
+ }
652
+
653
+ // ---------- Command line ----------
654
+
655
+ // The repository name is the only user-supplied value that reaches a subprocess
656
+ // argument *and* gets interpolated into a `gh api` path. execFile with an array
657
+ // means there is no shell to inject into, but an unvalidated value in the API
658
+ // path would be a request-forgery primitive against arbitrary endpoints -- so it
659
+ // is validated once, here at the boundary, against exactly what GitHub allows in
660
+ // an owner or repository name.
661
+ const REPO_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/;
662
+
663
+ // Below roughly two seconds a fetch cannot finish before the next tick, so the
664
+ // in-flight guard absorbs every other one and the effective rate is whatever
665
+ // `gh` can sustain -- the requested interval silently stops being real. Clamping
666
+ // with a stated minimum is honest where silently accepting it would not be.
667
+ const MIN_REFRESH_SECONDS = 2;
668
+ const MAX_REFRESH_SECONDS = 3600;
669
+
670
+ // The argv surface was a strict allowlist that exited 2 on anything unknown.
671
+ // That is a feature, not an accident -- a typo fails loudly instead of being
672
+ // ignored -- so widening it keeps the same shape: every flag is named here, and
673
+ // anything else still exits 2.
674
+ function parseArgs(argv) {
675
+ const opts = { help: false, showVersion: false, repo: null, refresh: null, tab: null, verbose: false };
676
+ for (let i = 0; i < argv.length; i += 1) {
677
+ const arg = argv[i];
678
+ const takeValue = (name) => {
679
+ const inline = arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : null;
680
+ if (inline !== null) return inline;
681
+ i += 1;
682
+ if (i >= argv.length) throw new Error(`${name} needs a value`);
683
+ return argv[i];
684
+ };
685
+
686
+ if (arg === "--help" || arg === "-h") opts.help = true;
687
+ else if (arg === "--version" || arg === "-v") opts.showVersion = true;
688
+ else if (arg === "--verbose") opts.verbose = true;
689
+ else if (arg === "--repo" || arg === "-R" || arg.startsWith("--repo=")) {
690
+ opts.repo = takeValue("--repo");
691
+ } else if (arg === "--refresh" || arg.startsWith("--refresh=")) {
692
+ opts.refresh = takeValue("--refresh");
693
+ } else if (arg === "--tab" || arg.startsWith("--tab=")) {
694
+ opts.tab = takeValue("--tab");
695
+ } else {
696
+ throw new Error(`unknown argument: ${arg}`);
697
+ }
698
+ }
699
+ return opts;
700
+ }
701
+
702
+ // Returns { repo, refreshMs, tabKey, verbose } or throws with a message that
703
+ // says what to do about it.
704
+ function validateArgs(opts, tabKeys) {
705
+ if (opts.repo !== null && !REPO_PATTERN.test(opts.repo)) {
706
+ throw new Error(`--repo must look like owner/name, got: ${opts.repo}`);
707
+ }
708
+
709
+ let refreshMs = null;
710
+ if (opts.refresh !== null) {
711
+ const seconds = Number(opts.refresh);
712
+ if (!Number.isFinite(seconds) || !Number.isInteger(seconds)) {
713
+ throw new Error(`--refresh must be a whole number of seconds, got: ${opts.refresh}`);
714
+ }
715
+ if (seconds < MIN_REFRESH_SECONDS || seconds > MAX_REFRESH_SECONDS) {
716
+ throw new Error(
717
+ `--refresh must be between ${MIN_REFRESH_SECONDS} and ${MAX_REFRESH_SECONDS} seconds, got: ${seconds}`,
718
+ );
719
+ }
720
+ refreshMs = seconds * 1000;
721
+ }
722
+
723
+ if (opts.tab !== null && !tabKeys.includes(opts.tab)) {
724
+ throw new Error(`--tab must be one of ${tabKeys.join(", ")}, got: ${opts.tab}`);
725
+ }
726
+
727
+ return {
728
+ help: opts.help,
729
+ showVersion: opts.showVersion,
730
+ repo: opts.repo,
731
+ refreshMs,
732
+ tabKey: opts.tab,
733
+ verbose: opts.verbose,
734
+ };
735
+ }
736
+
737
+ // ---------- Entry point ----------
738
+
739
+ const { version } = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"));
740
+
741
+ const HELP = `gh-glance ${version} -- a live-refreshing GitHub dashboard for a narrow terminal pane.
742
+
743
+ Usage:
744
+ gh-glance Run the dashboard in the current repository
745
+ gh-glance --repo owner/name Watch a specific repository instead
746
+ gh-glance --refresh 15 Poll the active tab every 15 seconds
747
+ gh-glance --tab security Start on a specific tab
748
+ gh-glance --verbose 2>log Log every gh call to a file (see below)
749
+ gh-glance --help Show this help
750
+ gh-glance --version Show the version
751
+
752
+ Options:
753
+ -R, --repo <owner/name> Repository to watch. Without it the repo is inferred
754
+ from the git remote, the same way \`gh\` does it.
755
+ --refresh <seconds> Active-tab poll interval (${MIN_REFRESH_SECONDS}-${MAX_REFRESH_SECONDS},
756
+ default ${REFRESH_MS / 1000}). Background tabs stay at
757
+ ${BACKGROUND_EVERY}x this.
758
+ --tab <name> Tab to start on: ${TAB_KEYS.join(", ")}.
759
+ --verbose Write one line per gh invocation to stderr. stderr
760
+ must be redirected -- writing it to the terminal
761
+ would draw over the dashboard, so this refuses to
762
+ start otherwise.
763
+
764
+ Run it from inside a locally cloned GitHub repository; the repo is inferred
765
+ from the git remote, the same way \`gh\` does it. Requires the \`gh\` CLI
766
+ (2.20 or newer), authenticated via \`gh auth login\`.
767
+
768
+ The active tab refreshes every ${REFRESH_MS / 1000}s; the other three refresh every
769
+ ${(REFRESH_MS * BACKGROUND_EVERY) / 1000}s to keep their counts honest without spending the API rate limit.
770
+
771
+ Status icons are GitHub Octicons and need a Nerd Font. Without one, set
772
+ GH_GLANCE_ICONS=unicode for plain-unicode equivalents.
773
+
774
+ Keys:
775
+ 1 2 3 4 Actions / Issues / Pull requests / Security
776
+ Left / Right Previous / next tab
777
+ Tab / Shift+Tab Next / previous tab
778
+ Up / Down, j / k Move the cursor between rows
779
+ PgUp / PgDn Move a page at a time
780
+ Enter Open the selected item in your browser
781
+ r Refresh the current tab now
782
+ q / Esc / Ctrl+C Quit
783
+
784
+ Environment:
785
+ GH_REPO=owner/name Watch a specific repository (--repo takes precedence)
786
+ GH_GLANCE_ICONS=unicode Plain-unicode status icons (no Nerd Font needed)
787
+ GH_GLANCE_NO_ANIMATION=1 Freeze the spinner (no motion)
788
+ NO_COLOR=1 Disable colour (status stays readable)
789
+ `;
790
+
791
+ // argv and the TTY guard run *before* react and ink are imported. Loading ink
792
+ // costs a measured 137ms and 46MB of RSS, which is a poor trade for printing a
793
+ // version string -- and `--version`/`--help` are exactly the paths shell
794
+ // completions, npx and the CI smoke job hit. The NODE_ENV assignment at the top
795
+ // of the file stays where it is; only the imports moved down.
796
+ //
797
+ // Precedence is preserved: an unknown argument is reported before the non-TTY
798
+ // refusal, because a typo is the more actionable of the two.
799
+ if (IS_MAIN) {
800
+ let opts;
801
+ try {
802
+ opts = validateArgs(parseArgs(process.argv.slice(2)), TAB_KEYS);
803
+ } catch (err) {
804
+ // Exit 2 for every argv problem, unchanged from when the only possible
805
+ // problem was an unrecognised flag. CI asserts this code.
806
+ console.error(`gh-glance: ${err.message}\nRun \`gh-glance --help\` for usage.`);
807
+ process.exit(2);
808
+ }
809
+
810
+ // Checked after parsing rather than before, so `gh-glance --repo` with no
811
+ // value reports the missing value rather than silently printing help.
812
+ if (opts.help) {
813
+ console.log(HELP);
814
+ process.exit(0);
815
+ }
816
+ if (opts.showVersion) {
817
+ console.log(version);
818
+ process.exit(0);
819
+ }
820
+
821
+ // Verbose output must never reach stdout -- that is ink's frame stream, and
822
+ // anything else in it corrupts the diff and the alternate-screen state. stderr
823
+ // is safe only once it is redirected somewhere; while it is still the
824
+ // terminal, these lines would be painted straight over the dashboard. Refusing
825
+ // is better than quietly producing a corrupted screen.
826
+ if (opts.verbose && process.stderr.isTTY) {
827
+ console.error(
828
+ "gh-glance: --verbose writes a log to stderr, which would draw over the dashboard.\n" +
829
+ "Redirect it to a file, e.g. `gh-glance --verbose 2>gh-glance.log`.",
830
+ );
831
+ process.exit(2);
832
+ }
833
+
834
+ runtime.repo = opts.repo;
835
+ runtime.verbose = opts.verbose;
836
+ if (opts.refreshMs !== null) runtime.refreshMs = opts.refreshMs;
837
+ if (opts.tabKey !== null) runtime.initialTabIndex = TAB_KEYS.indexOf(opts.tabKey);
838
+
839
+ // This is a full-screen live dashboard, not a reporting command -- piping it
840
+ // somewhere would emit an endless stream of redraw frames, so fail fast with
841
+ // an explanation instead. Exit code 1 is asserted by CI.
842
+ if (!process.stdout.isTTY) {
843
+ console.error(
844
+ "gh-glance: stdout is not a terminal. This is an interactive dashboard and can't be piped or redirected.",
845
+ );
846
+ process.exit(1);
847
+ }
848
+
849
+ const problem = await preflight();
850
+ if (problem) {
851
+ console.error(problem);
852
+ process.exit(3);
853
+ }
854
+ }
855
+
856
+ const ReactModule = await import("react");
857
+ const { render, Box, Text, useStdout, useInput, useStdin, useApp } = await import("ink");
858
+
859
+ const React = ReactModule.default;
860
+ const { useState, useEffect, useRef } = ReactModule;
861
+ const e = React.createElement;
862
+
863
+ // The NODE_ENV escape hatch above is genuinely useful -- React's warnings are
864
+ // worth having -- but the development build is what caused the fatal heap
865
+ // growth in the first place, and anyone with NODE_ENV=development exported from
866
+ // a shell profile or a dev container would silently get it back. Node never
867
+ // trims the user-timing buffer on its own, so clearing it periodically bounds
868
+ // the growth at a few seconds' worth of entries while leaving every warning
869
+ // intact. Scoped to the non-production branch: it discards any other consumer's
870
+ // marks, and there are none here today.
871
+ function disarmDevBuildLeak() {
872
+ if (process.env.NODE_ENV === "production") return;
873
+ console.error(
874
+ "gh-glance: NODE_ENV is not 'production', so React's development build is loaded.\n" +
875
+ " Expect higher memory use. Unset NODE_ENV for the production build.",
876
+ );
877
+ setInterval(() => {
878
+ performance.clearMeasures();
879
+ performance.clearMarks();
880
+ }, 10_000).unref();
881
+ }
882
+
883
+ // ---------- Octicons (via the Nerd Font glyph set -- private-use-area
884
+ // codepoints from ryanoasis/nerd-fonts glyphnames.json). Same icon shapes
885
+ // github.com uses, not emoji. ----------
886
+ const OCT_NERD = {
887
+ checkCircleFill: "",
888
+ xCircleFill: "",
889
+ skipFill: "",
890
+ dotFill: "",
891
+ alertFill: "",
892
+ issueOpened: "",
893
+ pullRequest: "",
894
+ pullRequestDraft: "",
895
+ shield: "",
896
+ };
897
+
898
+ // Without a Nerd Font every one of those renders as a blank box, which meant
899
+ // status was carried by the colour of a blank box -- and by nothing at all for
900
+ // a colour-blind user or anyone running NO_COLOR. The README's remedy was to
901
+ // edit the glyph table in the installed source, which stops working the moment
902
+ // this ships as a package. Keys are identical to the table above by
903
+ // construction; a missing key renders an empty cell, which is the exact symptom
904
+ // this exists to fix. Every substitute is deliberately width-1 ASCII: the
905
+ // prettier candidates are East-Asian-Ambiguous and render two cells wide in
906
+ // some terminals, which shifts every column to their right.
907
+ const OCT_UNICODE = {
908
+ checkCircleFill: "+",
909
+ xCircleFill: "x",
910
+ skipFill: "-",
911
+ dotFill: "o",
912
+ alertFill: "!",
913
+ issueOpened: "i",
914
+ pullRequest: "p",
915
+ pullRequestDraft: "d",
916
+ shield: "s",
917
+ };
918
+
919
+ const OCT = process.env.GH_GLANCE_ICONS === "unicode" ? OCT_UNICODE : OCT_NERD;
920
+
921
+ // ---------- Shared layout primitives ----------
922
+
923
+ // ANSI 8 ("gray", i.e. bright-black) sits close to the background on most dark
924
+ // themes -- legible in a screenshot, not at a glance. Secondary text uses
925
+ // dimColor instead of an absolute colour, so de-emphasis is computed against
926
+ // whatever foreground the user actually has: the previous ANSI 7 was tuned for
927
+ // a dark terminal and became unreadable on a light one, while on a dark theme
928
+ // it matched the default foreground closely enough that the intended hierarchy
929
+ // never appeared at all. "gray" is kept for the frame and separators, where
930
+ // receding is the whole point.
931
+ const BORDER_COLOR = "gray";
932
+ const TITLE_COLOR = "cyanBright";
933
+ const IDENTIFIER = "blue";
934
+ const REF = "magenta";
935
+
936
+ // Status colours, named by what they mean rather than what they look like. The
937
+ // meaning was previously carried only by repetition -- 28 inline literals across
938
+ // six values -- so a reader had to infer that redBright meant "failed" here and
939
+ // "critical" there.
940
+ //
941
+ // INERT and BORDER_COLOR are the same value on purpose and must stay separate
942
+ // names: one is chrome that should recede, the other is content that is
943
+ // genuinely de-emphasised. Merging them means the next person who retunes the
944
+ // frame colour silently restyles every skipped run.
945
+ const OK = "greenBright";
946
+ const BAD = "redBright";
947
+ const ATTENTION = "yellowBright";
948
+ const INERT = "gray";
949
+ const ERROR_TEXT = "red";
950
+
951
+ // `label` becomes the cell's text under INK_SCREEN_READER, where the icon
952
+ // column is otherwise a private-use codepoint that announces as nothing --
953
+ // leaving every row with no status at all. It is derived from the same lookup
954
+ // that picks the glyph, so the two cannot drift apart.
955
+ function Column({ width, grow, children, bold, color, dim, wrap, label }) {
956
+ return e(
957
+ Box,
958
+ { width, flexGrow: grow ? 1 : 0, flexShrink: grow ? 1 : 0, marginRight: 1 },
959
+ e(
960
+ Text,
961
+ { bold, color, dimColor: dim, wrap: wrap ?? "truncate-end", "aria-label": label },
962
+ children,
963
+ ),
964
+ );
965
+ }
966
+
967
+ function HeaderCells({ cells }) {
968
+ return e(
969
+ Box,
970
+ {
971
+ flexDirection: "row",
972
+ borderStyle: "single",
973
+ borderTop: false,
974
+ borderLeft: false,
975
+ borderRight: false,
976
+ borderColor: BORDER_COLOR,
977
+ },
978
+ ...cells.map((c, i) => e(Column, { key: i, ...c.props, bold: true, dim: true }, c.label)),
979
+ );
980
+ }
981
+
982
+ // ---------- Panel frame ----------
983
+
984
+ // Ink can draw a border but not label one, so the horizontal edges are plain
985
+ // text and the box between them contributes only its verticals. Drawing them
986
+ // ourselves is what lets the tab name sit in the top edge and the row count in
987
+ // the bottom, the way lazygit labels its panels.
988
+ function PanelEdge({ width, top, label, labelColor }) {
989
+ const [open, close] = top ? ["╭", "╮"] : ["╰", "╯"];
990
+ const text = label ? ` ${label} ` : "";
991
+ // The title hugs the left corner and the count the right, so each edge
992
+ // spends a single dash on the side its label isn't on.
993
+ const fill = width - 3 - text.length;
994
+ // Too narrow to seat the label without wrapping the line: keep the frame,
995
+ // drop the text.
996
+ if (fill < 0) {
997
+ return e(Text, { color: BORDER_COLOR, "aria-hidden": true }, open + "─".repeat(Math.max(0, width - 2)) + close);
998
+ }
999
+ return e(
1000
+ Text,
1001
+ { color: BORDER_COLOR, "aria-hidden": true },
1002
+ open,
1003
+ top ? "─" : "─".repeat(fill),
1004
+ e(Text, { color: labelColor, bold: true }, text),
1005
+ top ? "─".repeat(fill) : "─",
1006
+ close,
1007
+ );
1008
+ }
1009
+
1010
+ // ---------- Error containment ----------
1011
+
1012
+ // Every field on screen is defined by GitHub and will keep changing shape. With
1013
+ // the rows mapped straight into the tree, one unexpected value took the whole
1014
+ // dashboard down -- and because the process sits in the alternate screen, the
1015
+ // stack trace was wiped by the restore on the way out, so the tool simply
1016
+ // vanished. A per-row boundary turns that into one visibly broken row.
1017
+ class RowBoundary extends React.Component {
1018
+ constructor(props) {
1019
+ super(props);
1020
+ this.state = { failed: false };
1021
+ }
1022
+
1023
+ static getDerivedStateFromError() {
1024
+ return { failed: true };
1025
+ }
1026
+
1027
+ // Reset when the underlying record changes, or a single bad row would keep
1028
+ // its slot blank long after the record that caused it is gone.
1029
+ static getDerivedStateFromProps(props, state) {
1030
+ return state.failed && props.resetKey !== state.key
1031
+ ? { failed: false, key: props.resetKey }
1032
+ : { ...state, key: props.resetKey };
1033
+ }
1034
+
1035
+ render() {
1036
+ if (this.state.failed) {
1037
+ return e(Text, { color: BAD }, "! this row could not be rendered");
1038
+ }
1039
+ return this.props.children;
1040
+ }
1041
+ }
1042
+
1043
+ // ---------- Actions tab ----------
1044
+
1045
+ // Four conclusions used to collapse into one grey glyph and timed_out was
1046
+ // pixel-identical to a plain failure. Splitting them uses only codepoints
1047
+ // already verified present in the glyph table above -- introducing new
1048
+ // private-use codepoints risks rendering as a blank box, which is precisely the
1049
+ // failure the unicode fallback exists to fix. Residual aliasing under NO_COLOR
1050
+ // (timed_out vs action_required; skipped vs queued) is resolved by
1051
+ // GH_GLANCE_ICONS=unicode, where every state has its own ASCII character.
1052
+ const RUN_STATUS_ICON = {
1053
+ success: { icon: OCT.checkCircleFill, color: OK, label: "success" },
1054
+ failure: { icon: OCT.xCircleFill, color: BAD, label: "failed" },
1055
+ startup_failure: { icon: OCT.xCircleFill, color: BAD, label: "startup failure" },
1056
+ timed_out: { icon: OCT.alertFill, color: BAD, label: "timed out" },
1057
+ action_required: { icon: OCT.alertFill, color: ATTENTION, label: "action required" },
1058
+ cancelled: { icon: OCT.skipFill, color: INERT, label: "cancelled" },
1059
+ skipped: { icon: OCT.dotFill, color: INERT, label: "skipped" },
1060
+ neutral: { icon: OCT.dotFill, color: INERT, label: "neutral" },
1061
+ stale: { icon: OCT.dotFill, color: INERT, label: "stale" },
1062
+ };
1063
+ const RUN_UNKNOWN_ICON = { icon: "?", color: INERT, label: "unknown" };
1064
+
1065
+ // github.com draws a run that is actually executing as an amber circle with a
1066
+ // turning segment, and one that is merely queued as the same amber standing
1067
+ // still -- the motion, not the colour, is what separates them. With animation
1068
+ // disabled there is no motion to distinguish them, so the running state falls
1069
+ // back to the alert glyph rather than silently reading as queued.
1070
+ const RUN_PENDING_ICON = { icon: OCT.dotFill, color: ATTENTION, label: "queued" };
1071
+ const RUN_RUNNING_STATIC = { icon: OCT.alertFill, color: ATTENTION, label: "running" };
1072
+
1073
+ // Remote strings index these tables, and a plain object literal answers
1074
+ // inherited keys -- `SEVERITY_COLOR["constructor"]` returned a function, which
1075
+ // reaches ink's colour prop and throws, killing the render. `??` cannot catch
1076
+ // that, because an inherited value is not nullish. Code-scanning severity comes
1077
+ // from uploaded SARIF, so it is genuinely attacker-influenced.
1078
+ function pick(table, key, fallback) {
1079
+ return typeof key === "string" && Object.hasOwn(table, key) ? table[key] : fallback;
1080
+ }
1081
+
1082
+ function runStatusIcon(run, spin) {
1083
+ if (run.status === "in_progress") {
1084
+ return spin
1085
+ ? { icon: spin, color: ATTENTION, label: "running" }
1086
+ : RUN_RUNNING_STATIC;
1087
+ }
1088
+ if (run.status !== "completed") return RUN_PENDING_ICON;
1089
+ return pick(RUN_STATUS_ICON, run.conclusion, RUN_UNKNOWN_ICON);
1090
+ }
1091
+
1092
+ const ACTIONS_HEADER = [
1093
+ { label: "", props: { width: 3 } },
1094
+ { label: "TITLE", props: { grow: true } },
1095
+ { label: "WORKFLOW", props: { width: 10 } },
1096
+ { label: "BRANCH", props: { width: 14 } },
1097
+ { label: "TIME", props: { width: 7 } },
1098
+ { label: "UPDATED", props: { width: 8 } },
1099
+ ];
1100
+
1101
+ // Dropped in order of least value first: BRANCH and WORKFLOW are inferable
1102
+ // from the title far more often than the status icon or the age are.
1103
+ const ACTIONS_HEADER_COMPACT = [
1104
+ { label: "", props: { width: 3 } },
1105
+ { label: "TITLE", props: { grow: true } },
1106
+ { label: "UPDATED", props: { width: 8 } },
1107
+ ];
1108
+
1109
+ function ActionsRow({ item, now, spin, compact, cursor }) {
1110
+ const { icon, color, label } = runStatusIcon(item, spin);
1111
+ const started = new Date(item.startedAt);
1112
+ const finished = item.status === "completed" ? new Date(item.updatedAt) : now;
1113
+ if (compact) {
1114
+ return e(
1115
+ Box,
1116
+ { flexDirection: "row" },
1117
+ e(Column, { width: 3, color, label }, `${cursor ? ">" : " "}${icon}`),
1118
+ e(Column, { grow: true }, item.displayTitle),
1119
+ e(Column, { width: 8, dim: true }, formatAge(new Date(item.updatedAt), now)),
1120
+ );
1121
+ }
1122
+ return e(
1123
+ Box,
1124
+ { flexDirection: "row" },
1125
+ e(Column, { width: 3, color, label }, `${cursor ? ">" : " "}${icon}`),
1126
+ e(Column, { grow: true }, item.displayTitle),
1127
+ // The run number is the actionable half and used to be the first thing
1128
+ // truncation ate, since it sat at the tail of a 10-column cell.
1129
+ e(Column, { width: 10, color: IDENTIFIER }, `#${item.number} ${item.workflowName}`),
1130
+ e(Column, { width: 14, color: REF, wrap: "truncate-middle" }, item.headBranch),
1131
+ e(Column, { width: 7 }, formatDuration(finished - started)),
1132
+ e(Column, { width: 8, dim: true }, formatAge(new Date(item.updatedAt), now)),
1133
+ );
1134
+ }
1135
+
1136
+ // ---------- Issues tab ----------
1137
+
1138
+ const ISSUES_HEADER = [
1139
+ { label: "", props: { width: 3 } },
1140
+ { label: "TITLE", props: { grow: true } },
1141
+ { label: "AUTHOR", props: { width: 12 } },
1142
+ { label: "LABEL", props: { width: 14 } },
1143
+ { label: "UPDATED", props: { width: 8 } },
1144
+ ];
1145
+
1146
+ const ISSUES_HEADER_COMPACT = [
1147
+ { label: "", props: { width: 3 } },
1148
+ { label: "TITLE", props: { grow: true } },
1149
+ { label: "UPDATED", props: { width: 8 } },
1150
+ ];
1151
+
1152
+ function IssueRow({ item, now, compact, cursor }) {
1153
+ const cells = [
1154
+ e(Column, { key: "i", width: 3, color: OK, label: "open issue" }, `${cursor ? ">" : " "}${OCT.issueOpened}`),
1155
+ e(Column, { key: "t", grow: true }, `#${item.number} ${item.title}`),
1156
+ ];
1157
+ if (!compact) {
1158
+ cells.push(e(Column, { key: "a", width: 12, color: IDENTIFIER }, item.author));
1159
+ cells.push(e(Column, { key: "l", width: 14, color: REF }, item.label));
1160
+ }
1161
+ cells.push(
1162
+ e(Column, { key: "u", width: 8, dim: true }, formatAge(new Date(item.updatedAt), now)),
1163
+ );
1164
+ return e(Box, { flexDirection: "row" }, ...cells);
1165
+ }
1166
+
1167
+ // ---------- Pull requests tab ----------
1168
+
1169
+ const PRS_HEADER = [
1170
+ { label: "", props: { width: 3 } },
1171
+ { label: "TITLE", props: { grow: true } },
1172
+ { label: "AUTHOR", props: { width: 12 } },
1173
+ { label: "BRANCH", props: { width: 14 } },
1174
+ { label: "REVIEW", props: { width: 10 } },
1175
+ { label: "UPDATED", props: { width: 8 } },
1176
+ ];
1177
+
1178
+ const PRS_HEADER_COMPACT = [
1179
+ { label: "", props: { width: 3 } },
1180
+ { label: "TITLE", props: { grow: true } },
1181
+ { label: "REVIEW", props: { width: 10 } },
1182
+ ];
1183
+
1184
+ const REVIEW_LABEL = {
1185
+ APPROVED: { label: "approved", color: OK },
1186
+ CHANGES_REQUESTED: { label: "changes", color: BAD },
1187
+ REVIEW_REQUIRED: { label: "pending", color: ATTENTION },
1188
+ };
1189
+ const REVIEW_NONE = { label: "", color: INERT };
1190
+
1191
+ function PRRow({ item, now, compact, cursor }) {
1192
+ const prIcon = item.isDraft
1193
+ ? { icon: OCT.pullRequestDraft, color: INERT, label: "draft pull request" }
1194
+ : { icon: OCT.pullRequest, color: OK, label: "open pull request" };
1195
+ const review = pick(REVIEW_LABEL, item.reviewDecision, REVIEW_NONE);
1196
+ const cells = [
1197
+ e(Column, { key: "i", width: 3, color: prIcon.color, label: prIcon.label }, `${cursor ? ">" : " "}${prIcon.icon}`),
1198
+ e(Column, { key: "t", grow: true }, `#${item.number} ${item.title}`),
1199
+ ];
1200
+ if (!compact) {
1201
+ cells.push(e(Column, { key: "a", width: 12, color: IDENTIFIER }, item.author));
1202
+ cells.push(
1203
+ e(Column, { key: "b", width: 14, color: REF, wrap: "truncate-middle" }, item.headRefName),
1204
+ );
1205
+ }
1206
+ cells.push(e(Column, { key: "r", width: 10, color: review.color }, review.label));
1207
+ if (!compact) {
1208
+ cells.push(
1209
+ e(Column, { key: "u", width: 8, dim: true }, formatAge(new Date(item.updatedAt), now)),
1210
+ );
1211
+ }
1212
+ return e(Box, { flexDirection: "row" }, ...cells);
1213
+ }
1214
+
1215
+ // ---------- Security tab ----------
1216
+
1217
+ // Severity used to be carried by the shield glyph's colour alone, which made a
1218
+ // critical alert and a high one pixel-identical (both redBright) and erased the
1219
+ // distinction entirely under NO_COLOR or for a colour-blind reader. A 4-wide
1220
+ // text column states it outright; the width comes out of PACKAGE / FILE, whose
1221
+ // contents were already being truncated.
1222
+ const SECURITY_HEADER = [
1223
+ { label: "", props: { width: 3 } },
1224
+ { label: "SEV", props: { width: 4 } },
1225
+ { label: "PACKAGE / FILE", props: { width: 16 } },
1226
+ { label: "SUMMARY", props: { grow: true } },
1227
+ { label: "AGE", props: { width: 8 } },
1228
+ ];
1229
+
1230
+ // SEV is the last thing to drop on this tab: it is the whole point of the pane
1231
+ // and the only non-colour severity channel.
1232
+ const SECURITY_HEADER_COMPACT = [
1233
+ { label: "", props: { width: 3 } },
1234
+ { label: "SEV", props: { width: 4 } },
1235
+ { label: "SUMMARY", props: { grow: true } },
1236
+ ];
1237
+
1238
+ const SEVERITY_STYLE = {
1239
+ critical: { color: BAD, short: "crit" },
1240
+ high: { color: BAD, short: "high" },
1241
+ medium: { color: ATTENTION, short: "med" },
1242
+ moderate: { color: ATTENTION, short: "med" },
1243
+ low: { color: INERT, short: "low" },
1244
+ unknown: { color: INERT, short: "?" },
1245
+ };
1246
+ const SEVERITY_UNKNOWN = SEVERITY_STYLE.unknown;
1247
+
1248
+ function SecurityRow({ item, now, compact, cursor }) {
1249
+ const sev = pick(SEVERITY_STYLE, item.severity, SEVERITY_UNKNOWN);
1250
+ const cells = [
1251
+ e(Column, { key: "i", width: 3, color: sev.color, label: `${sev.short} severity` }, `${cursor ? ">" : " "}${OCT.shield}`),
1252
+ e(Column, { key: "s", width: 4, color: sev.color }, sev.short),
1253
+ ];
1254
+ if (!compact) {
1255
+ cells.push(e(Column, { key: "p", width: 16, color: IDENTIFIER }, item.detail || item.kind));
1256
+ }
1257
+ cells.push(e(Column, { key: "t", grow: true }, item.title));
1258
+ if (!compact) {
1259
+ cells.push(
1260
+ e(Column, { key: "a", width: 8, dim: true }, formatAge(new Date(item.createdAt), now)),
1261
+ );
1262
+ }
1263
+ return e(Box, { flexDirection: "row" }, ...cells);
1264
+ }
1265
+
1266
+ // ---------- Tabs ----------
1267
+
1268
+ // Memoised because their inputs are stable by construction. Two things must
1269
+ // stay true for that to hold: `now` must keep being replaced rather than
1270
+ // mutated, and the raw-payload bail-out must keep producing fresh item objects
1271
+ // when data genuinely changes. In-place mutation of a parsed item would make a
1272
+ // memoised row silently stop updating.
1273
+ const MemoActionsRow = React.memo(ActionsRow);
1274
+ const MemoIssueRow = React.memo(IssueRow);
1275
+ const MemoPRRow = React.memo(PRRow);
1276
+ const MemoSecurityRow = React.memo(SecurityRow);
1277
+
1278
+ const TABS = [
1279
+ {
1280
+ key: "actions",
1281
+ fetch: ({ signal, runLimit }) => fetchActions(runLimit, signal),
1282
+ label: "Actions",
1283
+ short: "Actions",
1284
+ header: ACTIONS_HEADER,
1285
+ compactHeader: ACTIONS_HEADER_COMPACT,
1286
+ Row: MemoActionsRow,
1287
+ countLabel: "runs",
1288
+ },
1289
+ {
1290
+ key: "issues",
1291
+ fetch: ({ signal }) => fetchIssues(signal),
1292
+ label: "Issues",
1293
+ short: "Issues",
1294
+ header: ISSUES_HEADER,
1295
+ compactHeader: ISSUES_HEADER_COMPACT,
1296
+ Row: MemoIssueRow,
1297
+ countLabel: "open issues",
1298
+ },
1299
+ {
1300
+ key: "prs",
1301
+ fetch: ({ signal }) => fetchPRs(signal),
1302
+ label: "Pull requests",
1303
+ short: "PRs",
1304
+ header: PRS_HEADER,
1305
+ compactHeader: PRS_HEADER_COMPACT,
1306
+ Row: MemoPRRow,
1307
+ countLabel: "open PRs",
1308
+ },
1309
+ {
1310
+ key: "security",
1311
+ fetch: ({ signal }) => fetchSecurity(signal),
1312
+ label: "Security",
1313
+ short: "Security",
1314
+ header: SECURITY_HEADER,
1315
+ compactHeader: SECURITY_HEADER_COMPACT,
1316
+ Row: MemoSecurityRow,
1317
+ countLabel: "alerts",
1318
+ },
1319
+ ];
1320
+
1321
+ // The narrowest width each tab's table can render without its fixed columns
1322
+ // overflowing the frame. Derived from the header descriptors rather than
1323
+ // hard-coded, so adding or resizing a column cannot silently invalidate it.
1324
+ // Below this the fixed columns (which deliberately do not shrink, so that
1325
+ // BRANCH and TIME stay readable at ordinary widths) push past the terminal edge,
1326
+ // the rows hard-wrap, and ink switches to clearing and repainting the whole
1327
+ // screen every frame.
1328
+ function minimumWidthFor(header) {
1329
+ const fixed = header.reduce((sum, cell) => sum + (cell.props.width ?? 0) + 1, 0);
1330
+ // + 2 border verticals, + 2 paddingX, + at least 4 columns for TITLE.
1331
+ return fixed + 8;
1332
+ }
1333
+ // Below this the full column set cannot render without its fixed columns
1334
+ // overflowing the frame -- rows hard-wrap and ink switches to clearing and
1335
+ // repainting the whole screen every frame. Worse, in the band just above the
1336
+ // hard failure the frame still looked correct while the TITLE column silently
1337
+ // rendered empty (measured: completely blank at 52 columns). So switch to the
1338
+ // compact column set rather than letting either happen.
1339
+ const MIN_TABLE_WIDTH = Math.max(...TABS.map((t) => minimumWidthFor(t.header)));
1340
+ // The compact set needs roughly a third as much, so the frame, tab bar and
1341
+ // status line survive at any width a terminal is realistically set to.
1342
+ const MIN_COMPACT_WIDTH = Math.max(...TABS.map((t) => minimumWidthFor(t.compactHeader)));
1343
+
1344
+ // The tab bar is laid out independently of the table and wraps at its own
1345
+ // width. Full labels need ~77 columns; short labels ~57. Hysteresis on the
1346
+ // breakpoint so a pane parked exactly on it does not flip labels on every
1347
+ // resize event and defeat the unchanged-frame optimization.
1348
+ const TAB_LABEL_FULL_WIDTH = 78;
1349
+ const TAB_LABEL_HYSTERESIS = 4;
1350
+
1351
+ function TabBar({ activeIndex, counts, firstLoad, failed, spin, useShort }) {
1352
+ return e(
1353
+ Box,
1354
+ { flexDirection: "row" },
1355
+ ...TABS.map((tab, i) => {
1356
+ const active = i === activeIndex;
1357
+ const count = counts[tab.key];
1358
+ // A tab that has never resolved shows the spinner where its count will
1359
+ // go, so the first load reads as "working" rather than "empty".
1360
+ const suffix = count == null ? (firstLoad[tab.key] ? ` ${spin}` : "") : ` (${count})`;
1361
+ const name = useShort ? tab.short : tab.label;
1362
+ // Bold and inverse are both stripped at chalk level 0 (NO_COLOR, or a
1363
+ // dumb terminal), which left no indication at all of which tab was
1364
+ // selected. Brackets survive; the padding they replace keeps the width
1365
+ // identical, so the bar does not shift between the two modes.
1366
+ const label = active ? `[${i + 1}:${name}${suffix}]` : ` ${i + 1}:${name}${suffix} `;
1367
+ return e(
1368
+ Box,
1369
+ { key: tab.key, marginRight: 2 },
1370
+ e(
1371
+ Text,
1372
+ {
1373
+ bold: active,
1374
+ inverse: active,
1375
+ // A tab whose fetches are failing kept rendering its last good
1376
+ // count at full confidence, and the error banner is only visible on
1377
+ // the tab you have selected -- so a background tab could fail for an
1378
+ // hour with nothing on screen to say so.
1379
+ color: failed[tab.key] ? "redBright" : undefined,
1380
+ dimColor: !active && !failed[tab.key],
1381
+ wrap: "truncate-end",
1382
+ },
1383
+ label,
1384
+ ),
1385
+ );
1386
+ }),
1387
+ );
1388
+ }
1389
+
1390
+ // ---------- Status bar ----------
1391
+
1392
+ // Every glyph here is width-1 ASCII, deliberately. The arrows, the return
1393
+ // symbol and the box-drawing separator are all East-Asian-Ambiguous: ink
1394
+ // measures them as two columns in its width model, and a status bar built from
1395
+ // them overflowed an 80-column terminal by six columns once selection added a
1396
+ // hint. Same trap the unicode icon table already documents -- prefer strictly
1397
+ // narrow ASCII over pretty-but-ambiguous.
1398
+ //
1399
+ // Tab switching is not listed: the tab bar already renders "1:Actions", so the
1400
+ // digits document themselves, and the arrow keys are in --help. The hints that
1401
+ // survive are the ones nothing else on screen reveals.
1402
+ const KEY_HINTS = [
1403
+ { label: "Move", keys: "jk" },
1404
+ { label: "Open", keys: "Ent" },
1405
+ { label: "Refresh", keys: "r" },
1406
+ { label: "Quit", keys: "q" },
1407
+ ];
1408
+
1409
+ // Reserved so the hints don't shift sideways every time a refresh starts and
1410
+ // finishes. Wide enough for the spinner, a space and "Fetching".
1411
+ const FETCHING_WIDTH = 12;
1412
+ // Likewise for the staleness slot, which is empty most of the time.
1413
+ const STALE_WIDTH = 10;
1414
+
1415
+ // Two tones rather than one flat gray: the keys you press are the part worth
1416
+ // finding at a glance, so they get the accent colour and the words describing
1417
+ // them stay dim. The accent is the panel-title cyan rather than the amber used
1418
+ // for in-progress status, so amber means exactly one thing across the product.
1419
+ function StatusBar({ fetching, spin, stale, interactive }) {
1420
+ // Without raw mode none of the key handlers run, so advertising them would be
1421
+ // telling the user something untrue about what the app can do. Ctrl+C still
1422
+ // works there, because the tty delivers a real SIGINT.
1423
+ const hints = interactive
1424
+ ? KEY_HINTS
1425
+ : [{ label: "Quit", keys: "^C" }];
1426
+ return e(
1427
+ Box,
1428
+ { flexDirection: "row" },
1429
+ e(
1430
+ Box,
1431
+ { width: FETCHING_WIDTH, flexShrink: 0 },
1432
+ // Pinned to the resting frame when idle: the counter also drives the run
1433
+ // icons, so borrowing it here would set this turning while a workflow
1434
+ // executes -- saying "fetching" at a moment when nothing is being
1435
+ // fetched.
1436
+ e(
1437
+ Text,
1438
+ { color: fetching ? TITLE_COLOR : undefined, dimColor: !fetching },
1439
+ `${fetching && spin ? spin : SPINNER[0]} Fetching`,
1440
+ ),
1441
+ ),
1442
+ e(
1443
+ Box,
1444
+ { width: STALE_WIDTH, flexShrink: 0 },
1445
+ stale ? e(Text, { color: ATTENTION }, stale) : null,
1446
+ ),
1447
+ ...hints
1448
+ .flatMap((hint, i) => [
1449
+ i > 0 && e(Text, { key: `sep${i}`, color: BORDER_COLOR }, " | "),
1450
+ e(
1451
+ Text,
1452
+ { key: hint.label, wrap: "truncate-end" },
1453
+ e(Text, { dimColor: true }, `${hint.label}: `),
1454
+ e(Text, { color: TITLE_COLOR, bold: true }, hint.keys),
1455
+ ),
1456
+ ])
1457
+ .filter(Boolean),
1458
+ );
1459
+ }
1460
+
1461
+ // ---------- Layout ----------
1462
+
1463
+ // Terminal size, and the label breakpoint that depends on it.
1464
+ //
1465
+ // The usableSize guard is applied on every read path, not just the first: pty
1466
+ // wrappers and a terminal mid-resize report 0 or undefined, and taking either
1467
+ // literally collapses the table. ink's own useWindowSize does not apply that
1468
+ // fallback, which is why this stays hand-rolled.
1469
+ function useTerminalSize(stdout) {
1470
+ const [size, setSize] = useState(() => ({
1471
+ rows: usableSize(stdout?.rows, DEFAULT_ROWS),
1472
+ cols: usableSize(stdout?.columns, DEFAULT_COLS),
1473
+ useShortLabels: usableSize(stdout?.columns, DEFAULT_COLS) < TAB_LABEL_FULL_WIDTH,
1474
+ }));
1475
+
1476
+ useEffect(() => {
1477
+ if (!stdout) return;
1478
+ function onResize() {
1479
+ const cols = usableSize(stdout.columns, DEFAULT_COLS);
1480
+ const rows = usableSize(stdout.rows, DEFAULT_ROWS);
1481
+ setSize((previous) => {
1482
+ // Hysteresis on the label breakpoint: switch to short labels below it,
1483
+ // back to full only once comfortably above, so a pane dragged along the
1484
+ // boundary does not emit a different frame on every resize event.
1485
+ const useShortLabels = previous.useShortLabels
1486
+ ? cols < TAB_LABEL_FULL_WIDTH + TAB_LABEL_HYSTERESIS
1487
+ : cols < TAB_LABEL_FULL_WIDTH;
1488
+ // Same object when nothing moved, so a resize event that changes
1489
+ // nothing cannot cost a redraw.
1490
+ return previous.rows === rows &&
1491
+ previous.cols === cols &&
1492
+ previous.useShortLabels === useShortLabels
1493
+ ? previous
1494
+ : { rows, cols, useShortLabels };
1495
+ });
1496
+ }
1497
+ stdout.on("resize", onResize);
1498
+ return () => stdout.off("resize", onResize);
1499
+ }, [stdout]);
1500
+
1501
+ return size;
1502
+ }
1503
+
1504
+ // ---------- App ----------
1505
+
1506
+ function App() {
1507
+ const { stdout } = useStdout();
1508
+ const { isRawModeSupported } = useStdin();
1509
+ const { exit } = useApp();
1510
+ const [activeIndex, setActiveIndex] = useState(runtime.initialTabIndex);
1511
+ // `null` means "never resolved" -- distinct from `[]`, which means "resolved
1512
+ // and genuinely empty". The tab bar and the body render those differently.
1513
+ const [data, setData] = useState({ actions: null, issues: null, prs: null, security: null });
1514
+ const [meta, setMeta] = useState({
1515
+ actions: null,
1516
+ issues: null,
1517
+ prs: null,
1518
+ security: null,
1519
+ });
1520
+ const [securityNotes, setSecurityNotes] = useState([]);
1521
+ const [errors, setErrors] = useState({ actions: null, issues: null, prs: null, security: null });
1522
+ const [loading, setLoading] = useState({ actions: true, issues: true, prs: true, security: true });
1523
+ const [now, setNow] = useState(new Date());
1524
+ const { rows, cols, useShortLabels } = useTerminalSize(stdout);
1525
+ const [frame, setFrame] = useState(0);
1526
+ // Per tab, so switching away and back keeps your place. Keyed by item, and
1527
+ // both are plain state: they change only on a keypress, so an idle repo still
1528
+ // renders byte-identical frames and ink still writes nothing.
1529
+ const [selected, setSelected] = useState({});
1530
+ const [offset, setOffset] = useState({});
1531
+
1532
+ const tab = TABS[activeIndex];
1533
+ const extraLines = (errors[tab.key] ? 1 : 0) + (tab.key === "security" ? securityNotes.length : 0);
1534
+ // Reserve lines for: the panel's top and bottom edges (2), the column header
1535
+ // and its separator (2), the tab bar (1) and the status line (1), plus a
1536
+ // 1-line safety margin. Slack is absorbed by the spacer in the tree below.
1537
+ const bodyRows = Math.max(1, rows - 7 - extraLines);
1538
+
1539
+ // Read at fetch time rather than being a hook dependency, so dragging the
1540
+ // pane wider doesn't cancel and restart in-flight requests -- the next tick
1541
+ // simply asks for the new size.
1542
+ const runLimitRef = useRef(0);
1543
+ runLimitRef.current = Math.min(Math.max(bodyRows + 1, MIN_RUN_LIMIT), MAX_RUN_LIMIT);
1544
+
1545
+ // Read inside the polling closure, which is created once on mount and must
1546
+ // not be torn down and rebuilt every time you press a tab key.
1547
+ const activeIndexRef = useRef(activeIndex);
1548
+ activeIndexRef.current = activeIndex;
1549
+
1550
+ // An unfinished run's elapsed time is the only thing on screen that changes
1551
+ // faster than once a minute, so it decides how often `now` has to advance.
1552
+ const hasInProgressRef = useRef(false);
1553
+ hasInProgressRef.current = (data.actions ?? []).some((r) => r.status !== "completed");
1554
+
1555
+ // Queued runs don't spin -- standing still is how they read as queued -- and
1556
+ // the icons only exist on the Actions tab, so a run turning behind another
1557
+ // tab would be ten redraws a second nobody can see.
1558
+ const hasRunningVisible =
1559
+ ANIMATE && tab.key === "actions" && (data.actions ?? []).some((r) => r.status === "in_progress");
1560
+
1561
+ const inFlightRef = useRef({});
1562
+ const rawRef = useRef({});
1563
+ const cleanRef = useRef({});
1564
+ const fetchTabRef = useRef(null);
1565
+ const abortRef = useRef(null);
1566
+
1567
+ const interactive = Boolean(isRawModeSupported);
1568
+
1569
+ // useInput's handler is created before the render body computes the visible
1570
+ // slice, so the movement handlers read through a ref -- the same pattern the
1571
+ // poll loop uses for runLimitRef and activeIndexRef, and for the same reason:
1572
+ // the closure must see current values without being rebuilt on every change.
1573
+ const navRef = useRef({ items: [], key: null, bodyRows: 1, tabKey: "actions" });
1574
+ navRef.current = {
1575
+ items: data[tab.key] ?? [],
1576
+ key: selected[tab.key] ?? null,
1577
+ bodyRows,
1578
+ tabKey: tab.key,
1579
+ };
1580
+ const pageStep = Math.max(1, bodyRows - 1);
1581
+
1582
+ const abortSelectionRef = useRef(null);
1583
+
1584
+ function moveSelection(delta) {
1585
+ const { items, key: currentKey, bodyRows: rows_, tabKey } = navRef.current;
1586
+ if (items.length === 0) return;
1587
+ const current = currentKey == null ? -1 : items.findIndex((i) => itemKey(i) === currentKey);
1588
+ // From "nothing selected", down lands on the first row and up on the last,
1589
+ // which is what every list in this category does.
1590
+ const next =
1591
+ current === -1
1592
+ ? delta > 0
1593
+ ? 0
1594
+ : items.length - 1
1595
+ : Math.min(items.length - 1, Math.max(0, current + delta));
1596
+ setSelected((s) => ({ ...s, [tabKey]: itemKey(items[next]) }));
1597
+ // Keep the cursor on screen. Only the offset needed to reveal it changes,
1598
+ // so scrolling never jumps further than it has to.
1599
+ setOffset((o) => {
1600
+ const start = o[tabKey] ?? 0;
1601
+ const maxStart = Math.max(0, items.length - rows_);
1602
+ let nextStart = Math.min(start, maxStart);
1603
+ if (next < nextStart) nextStart = next;
1604
+ else if (next >= nextStart + rows_) nextStart = next - rows_ + 1;
1605
+ return nextStart === (o[tabKey] ?? 0) ? o : { ...o, [tabKey]: nextStart };
1606
+ });
1607
+ }
1608
+
1609
+ function openSelected() {
1610
+ const { items, key: currentKey, tabKey } = navRef.current;
1611
+ const item = items.find((i) => itemKey(i) === currentKey);
1612
+ if (!item) return;
1613
+ const controller = new AbortController();
1614
+ abortSelectionRef.current = controller;
1615
+ // Fire and forget: a browser launch must not block the render loop, and a
1616
+ // failure surfaces through the tab's normal error line rather than as an
1617
+ // unhandled rejection.
1618
+ openInBrowser(tabKey, item, controller.signal).catch((err) => {
1619
+ setErrors((x) => ({ ...x, [tabKey]: shortErr(err) }));
1620
+ });
1621
+ }
1622
+
1623
+ useInput(
1624
+ (input, key) => {
1625
+ if (input === "q" || key.escape) {
1626
+ exit();
1627
+ } else if (key.downArrow || input === "j") {
1628
+ moveSelection(1);
1629
+ } else if (key.upArrow || input === "k") {
1630
+ moveSelection(-1);
1631
+ } else if (key.pageDown) {
1632
+ moveSelection(pageStep);
1633
+ } else if (key.pageUp) {
1634
+ moveSelection(-pageStep);
1635
+ } else if (key.return) {
1636
+ openSelected();
1637
+ } else if (input === "r") {
1638
+ // Goes through the same per-tab in-flight guard as the poll loop, so
1639
+ // holding the key down cannot stack concurrent subprocesses.
1640
+ fetchTabRef.current?.(TABS[activeIndexRef.current].key);
1641
+ } else if (input >= "1" && input <= String(TABS.length)) {
1642
+ setActiveIndex(Number(input) - 1);
1643
+ } else if (key.tab && key.shift) {
1644
+ setActiveIndex((i) => (i - 1 + TABS.length) % TABS.length);
1645
+ } else if (key.tab || key.rightArrow) {
1646
+ setActiveIndex((i) => (i + 1) % TABS.length);
1647
+ } else if (key.leftArrow) {
1648
+ setActiveIndex((i) => (i - 1 + TABS.length) % TABS.length);
1649
+ }
1650
+ },
1651
+ // `isActive` has to be coerced: ink only skips raw mode when the flag is
1652
+ // strictly `false`, and Node reports `stdin.isTTY` as `undefined` -- not
1653
+ // `false` -- when stdin isn't a terminal. Passing the raw value through
1654
+ // would let ink call setRawMode() on a non-TTY stdin and throw on startup.
1655
+ { isActive: interactive },
1656
+ );
1657
+
1658
+ // Deliberately mount-only. Every value this closure needs is read through a
1659
+ // ref (runLimitRef, activeIndexRef, hasInProgressRef) precisely so the
1660
+ // interval is created exactly once. Adding `activeIndex`, `bodyRows` or
1661
+ // `data` to the dependency array would tear down and rebuild the poll loop on
1662
+ // every tab keypress and every terminal resize, cancelling in-flight requests
1663
+ // -- the behaviour the per-tab guard and the fetch-time limit read were
1664
+ // written to prevent. Do not "fix" this by adding them.
1665
+ //
1666
+ // exhaustive-deps is satisfied as written -- every captured value is either a
1667
+ // ref or a setState function, both of which the rule treats as stable -- so
1668
+ // this needs no suppression, and if one ever becomes necessary that is the
1669
+ // signal that a real dependency crept in.
1670
+ useEffect(() => {
1671
+ let cancelled = false;
1672
+ let ticks = 0;
1673
+ const controller = new AbortController();
1674
+ abortRef.current = controller;
1675
+
1676
+ // Each tab commits its own result the moment it lands instead of waiting on
1677
+ // a Promise.allSettled barrier. Actions is by far the slowest fetch, so
1678
+ // barrelling everything together meant the three fast tabs sat invisible
1679
+ // behind it and nothing at all appeared until the slowest call returned.
1680
+ function commit(key, run) {
1681
+ // Per-tab rather than one flag for the whole tick, so switching tabs can
1682
+ // refresh the tab you just landed on without waiting on an unrelated
1683
+ // background fetch -- and so a slow repo can't stack refreshes.
1684
+ if (inFlightRef.current[key]) return Promise.resolve();
1685
+ inFlightRef.current[key] = true;
1686
+ setLoading((l) => (l[key] ? l : { ...l, [key]: true }));
1687
+ return run()
1688
+ .then((result) => {
1689
+ if (cancelled) return;
1690
+ const { raw, parse, limit } = result;
1691
+ // Identical payload: skip the parse *and* the state update. Returning
1692
+ // the same state object makes React bail out of the re-render, so an
1693
+ // idle repo stops redrawing the pane entirely.
1694
+ //
1695
+ // The cache write used to happen *before* parse(), so a payload that
1696
+ // threw was still cached -- and on the next tick the error was cleared
1697
+ // at the top of this handler and the early return fired before parse()
1698
+ // could be retried, leaving the tab permanently showing "no runs" with
1699
+ // no error at all. Both now happen only after a successful parse.
1700
+ if (rawRef.current[key] === raw && cleanRef.current[key]) {
1701
+ setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
1702
+ return;
1703
+ }
1704
+ const value = parse();
1705
+ rawRef.current[key] = raw;
1706
+ cleanRef.current[key] = true;
1707
+ setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
1708
+ setData((d) => ({ ...d, [key]: value.alerts ?? value }));
1709
+ setMeta((m) => ({
1710
+ ...m,
1711
+ [key]: {
1712
+ at: Date.now(),
1713
+ // Compare against the limit this payload was actually fetched
1714
+ // with. Comparing against the live pane-height ref made the "n+"
1715
+ // marker lie for one cycle after every resize.
1716
+ truncated: value.truncated ?? (value.alerts ?? value).length >= limit,
1717
+ },
1718
+ }));
1719
+ if (value.notes) setSecurityNotes(value.notes);
1720
+ })
1721
+ .catch((err) => {
1722
+ if (cancelled || err?.name === "AbortError") return;
1723
+ cleanRef.current[key] = false;
1724
+ setErrors((x) => ({ ...x, [key]: shortErr(err) }));
1725
+ })
1726
+ .finally(() => {
1727
+ inFlightRef.current[key] = false;
1728
+ if (!cancelled) setLoading((l) => (l[key] ? { ...l, [key]: false } : l));
1729
+ });
1730
+ }
1731
+
1732
+ function fetchTab(key) {
1733
+ const signal = controller.signal;
1734
+ const descriptor = TABS.find((t) => t.key === key);
1735
+ // Every tab carries its own fetcher on the registry, so adding a tab is a
1736
+ // TABS entry plus a fetcher rather than an entry plus an edit to a chain
1737
+ // in a different part of the file.
1738
+ return commit(key, () => descriptor.fetch({ signal, runLimit: runLimitRef.current }));
1739
+ }
1740
+ fetchTabRef.current = fetchTab;
1741
+
1742
+ async function tick() {
1743
+ // Only the tab you're looking at needs to keep up with REFRESH_MS. The
1744
+ // other three exist to keep the tab-bar counts honest, which tolerates
1745
+ // being a minute behind -- so they refresh every BACKGROUND_EVERY ticks
1746
+ // instead, which is what keeps steady-state API usage off the rate limit.
1747
+ const active = TABS[activeIndexRef.current].key;
1748
+ const due = ticks % BACKGROUND_EVERY === 0 ? TABS.map((t) => t.key) : [active];
1749
+ ticks += 1;
1750
+ await Promise.allSettled(due.map(fetchTab));
1751
+ if (cancelled) return;
1752
+ // Advancing `now` is what forces a redraw, so only do it when it can
1753
+ // actually change what's on screen: durations of in-progress runs tick
1754
+ // every second, but every other age is minute-granular.
1755
+ setNow((prev) =>
1756
+ hasInProgressRef.current || Date.now() - prev.getTime() >= 60_000 ? new Date() : prev,
1757
+ );
1758
+ }
1759
+
1760
+ tick();
1761
+ const id = setInterval(tick, runtime.refreshMs);
1762
+ return () => {
1763
+ cancelled = true;
1764
+ clearInterval(id);
1765
+ // `cancelled` stops state updates from a promise that already resolved;
1766
+ // the signal stops the subprocess itself, so quitting doesn't orphan up to
1767
+ // six `gh` children mid-request. They cover different windows and both are
1768
+ // needed.
1769
+ controller.abort();
1770
+ };
1771
+ }, []);
1772
+
1773
+ // Background tabs can be up to BACKGROUND_EVERY ticks stale, so the tab you
1774
+ // switch to refreshes straight away rather than showing old data until its
1775
+ // slot next comes round. On mount this is a no-op: the initial tick already
1776
+ // has every tab in flight, and the per-tab guard rejects the duplicate.
1777
+ useEffect(() => {
1778
+ fetchTabRef.current?.(TABS[activeIndex].key);
1779
+ }, [activeIndex]);
1780
+
1781
+ // Animate only when something on screen is genuinely moving. `loading` flips
1782
+ // true then false on *every* tick, including one that changed nothing, so
1783
+ // driving the spinner from it meant the 100ms interval restarted every 5
1784
+ // seconds forever -- roughly 44MB of terminal writes and 27 CPU-seconds per
1785
+ // idle hour, and the exact opposite of the redraw suppression above. The
1786
+ // first load genuinely is worth animating, because an empty pane with no
1787
+ // motion reads as broken; after that, only a run actually executing is.
1788
+ const firstLoad = Object.fromEntries(TABS.map((t) => [t.key, data[t.key] == null]));
1789
+ const anyFirstLoad = Object.values(firstLoad).some(Boolean);
1790
+ const showSpinner = ANIMATE && (anyFirstLoad || hasRunningVisible);
1791
+ useEffect(() => {
1792
+ if (!showSpinner) {
1793
+ // Park on a fixed frame rather than freezing wherever the animation
1794
+ // happened to stop: the resting glyph is then the same every time, so
1795
+ // consecutive idle frames stay byte-identical and Ink writes nothing.
1796
+ setFrame(0);
1797
+ return;
1798
+ }
1799
+ const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER.length), SPINNER_MS);
1800
+ return () => clearInterval(id);
1801
+ }, [showSpinner]);
1802
+
1803
+ const items = data[tab.key];
1804
+ const error = errors[tab.key];
1805
+ const spin = SPINNER[frame % SPINNER.length];
1806
+
1807
+ const counts = Object.fromEntries(
1808
+ TABS.map((t) => {
1809
+ const list = data[t.key];
1810
+ if (list == null) return [t.key, null];
1811
+ // Every tab can be truncated, not just Actions: issues and PRs cap at
1812
+ // LIST_LIMIT and alerts at one page of 100. Reporting those as exact made
1813
+ // the count stop moving through a genuine change on any repo big enough
1814
+ // for this tool to matter.
1815
+ const suffix = meta[t.key]?.truncated ? "+" : "";
1816
+ // Newest first, so the head of the list is the run that decides whether
1817
+ // CI is currently red.
1818
+ const broken =
1819
+ t.key === "actions" &&
1820
+ list.length > 0 &&
1821
+ list[0].status === "completed" &&
1822
+ list[0].conclusion !== "success" &&
1823
+ list[0].conclusion !== "skipped";
1824
+ return [t.key, `${list.length}${suffix}${broken ? "!" : ""}`];
1825
+ }),
1826
+ );
1827
+ const failed = Object.fromEntries(TABS.map((t) => [t.key, Boolean(errors[t.key])]));
1828
+
1829
+ // Data can be arbitrarily old without anything on screen saying so: a failing
1830
+ // poll only surfaces on the tab you have selected, and after a laptop sleep
1831
+ // every pane is plausible and wrong. Threshold-gated and minute-granular on
1832
+ // purpose -- see STALE_AFTER_MS.
1833
+ const lastOk = meta[tab.key]?.at;
1834
+ const staleFor = lastOk == null ? null : now.getTime() - lastOk;
1835
+ const staleThreshold = Math.max(STALE_AFTER_MS, runtime.refreshMs * 6);
1836
+ const staleLabel =
1837
+ staleFor != null && staleFor > staleThreshold
1838
+ ? `stale ${formatDuration(Math.min(staleFor, 359_999_000))}`
1839
+ : null;
1840
+
1841
+ const allItems = items ?? [];
1842
+ const tabOffsetRaw = offset[tab.key] ?? 0;
1843
+ // Re-clamped on every render rather than only on resize: the payload can
1844
+ // shrink under us between ticks, and a stale offset would render an empty
1845
+ // body while the count in the frame said otherwise.
1846
+ const maxOffset = Math.max(0, allItems.length - bodyRows);
1847
+ const tabOffset = Math.min(tabOffsetRaw, maxOffset);
1848
+ const visibleItems = allItems.slice(tabOffset, tabOffset + bodyRows);
1849
+
1850
+ // Matched by key, never by position. If the selected item is gone -- closed,
1851
+ // merged, or aged out of the fetch window -- no row matches and nothing is
1852
+ // highlighted, which is the honest state; the next arrow key selects from the
1853
+ // top again. Resolving to a neighbouring index instead would silently move
1854
+ // the cursor onto an unrelated row.
1855
+ const selectedKey = selected[tab.key] ?? null;
1856
+ // Bottom-right of the frame, lazygit style: how much of the tab you can
1857
+ // currently see out of how much there is.
1858
+ const countLabel =
1859
+ items == null
1860
+ ? null
1861
+ : tabOffset > 0
1862
+ ? `${tabOffset + 1}-${tabOffset + visibleItems.length} of ${counts[tab.key]}`
1863
+ : `${visibleItems.length} of ${counts[tab.key]}`;
1864
+
1865
+ // Fixed columns deliberately do not shrink, so BRANCH and TIME stay readable
1866
+ // at ordinary widths. Narrow panes drop columns instead, which keeps the
1867
+ // frame, the tab bar and the quit hint on screen -- the previous behaviour
1868
+ // pushed all three off the bottom.
1869
+ const compact = cols < MIN_TABLE_WIDTH;
1870
+ const header = compact ? tab.compactHeader : tab.header;
1871
+
1872
+ return e(
1873
+ Box,
1874
+ { flexDirection: "column", width: "100%", height: Math.min(rows, usableSize(stdout?.rows, rows)) },
1875
+ e(PanelEdge, { width: cols, top: true, label: tab.label, labelColor: TITLE_COLOR }),
1876
+ e(
1877
+ Box,
1878
+ {
1879
+ flexDirection: "column",
1880
+ flexGrow: 1,
1881
+ paddingX: 1,
1882
+ // Only the verticals: the labelled edges above and below are drawn as
1883
+ // text, and a border here too would double them up.
1884
+ borderStyle: "round",
1885
+ borderColor: BORDER_COLOR,
1886
+ borderTop: false,
1887
+ borderBottom: false,
1888
+ },
1889
+ // truncate-end is what makes the one-line reservation in `extraLines`
1890
+ // true by construction. Predicting the wrapped height instead would mean
1891
+ // duplicating ink's width model, and would still be wrong on resize.
1892
+ error && e(Text, { color: ERROR_TEXT, wrap: "truncate-end" }, error),
1893
+ tab.key === "security" &&
1894
+ securityNotes.map((note, i) =>
1895
+ e(Text, { key: i, dimColor: true, wrap: "truncate-end" }, note),
1896
+ ),
1897
+ e(HeaderCells, { cells: header }),
1898
+ ...visibleItems.map((item) => {
1899
+ const key = itemKey(item);
1900
+ return e(
1901
+ RowBoundary,
1902
+ { key, resetKey: key },
1903
+ e(tab.Row, {
1904
+ item,
1905
+ now,
1906
+ compact,
1907
+ cursor: key === selectedKey,
1908
+ // Only the Actions rows read this. Passing it to the others would
1909
+ // change a prop ten times a second on every tab and defeat the
1910
+ // memoisation below for no visible effect.
1911
+ spin: tab.key === "actions" && showSpinner ? spin : null,
1912
+ }),
1913
+ );
1914
+ }),
1915
+ // Distinguishes "still fetching" from "resolved and empty" in the body,
1916
+ // which is the difference between a dashboard that looks hung and one
1917
+ // that looks correct. Driven by "never resolved" rather than by the
1918
+ // loading flag, which toggles on every tick and made a settled-empty tab
1919
+ // swap its message every five seconds.
1920
+ visibleItems.length === 0 &&
1921
+ e(
1922
+ Text,
1923
+ { dimColor: true },
1924
+ firstLoad[tab.key]
1925
+ ? `${showSpinner ? spin : SPINNER[0]} loading ${tab.label.toLowerCase()}…`
1926
+ : tab.key === "security" && securityNotes.length === ALERT_SOURCES.length
1927
+ ? "no alert sources available"
1928
+ : `no ${tab.countLabel}`,
1929
+ ),
1930
+ // Pushes the panel's bottom edge down to the foot of the pane, so the
1931
+ // frame stays put instead of closing up under the column headers on a
1932
+ // tab that only has a handful of rows.
1933
+ e(Box, { flexGrow: 1 }),
1934
+ ),
1935
+ e(PanelEdge, { width: cols, top: false, label: countLabel, labelColor: BORDER_COLOR }),
1936
+ e(TabBar, {
1937
+ activeIndex,
1938
+ counts,
1939
+ firstLoad,
1940
+ failed,
1941
+ spin,
1942
+ useShort: useShortLabels,
1943
+ }),
1944
+ e(StatusBar, {
1945
+ fetching: Object.values(loading).some(Boolean),
1946
+ spin: showSpinner ? spin : null,
1947
+ stale: staleLabel,
1948
+ interactive,
1949
+ }),
1950
+ );
1951
+ }
1952
+
1953
+ // ---------- Terminal lifecycle ----------
1954
+
1955
+ // Enter the alternate screen buffer, same as lazygit/htop/vim, so the shell
1956
+ // prompt and the command that launched this script scroll out of view instead
1957
+ // of sitting above the dashboard.
1958
+ function enterAlternateScreen() {
1959
+ process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H");
1960
+ }
1961
+
1962
+ // Idempotent by construction: writing the exit sequence while already on the
1963
+ // primary buffer is a no-op, which matters because this runs both from the
1964
+ // crash handlers and from the `exit` listener. `?25h` is here because ink only
1965
+ // restores cursor visibility through its own unmount path, which an explicit
1966
+ // process.exit() skips.
1967
+ let screenRestored = false;
1968
+ function restoreScreen() {
1969
+ if (screenRestored) return;
1970
+ screenRestored = true;
1971
+ process.stdout.write("\x1b[?25h\x1b[?1049l");
1972
+ }
1973
+
1974
+ // A crash used to be indistinguishable from a clean quit. Ink catches render
1975
+ // errors and draws them -- into the alternate screen, which the exit handler
1976
+ // then discarded -- and nothing ever set a non-zero exit code, so the dashboard
1977
+ // simply vanished and any wrapper saw success. Restore the primary buffer
1978
+ // *first*, then write, or the fix reproduces the problem it is fixing.
1979
+ function installCrashHandlers() {
1980
+ const fail = (label) => (err) => {
1981
+ restoreScreen();
1982
+ console.error(`gh-glance: ${label}`);
1983
+ console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
1984
+ process.exit(1);
1985
+ };
1986
+ process.on("uncaughtException", fail("crashed"));
1987
+ process.on("unhandledRejection", fail("unhandled promise rejection"));
1988
+ }
1989
+
1990
+ if (IS_MAIN) {
1991
+ disarmDevBuildLeak();
1992
+ installCrashHandlers();
1993
+ enterAlternateScreen();
1994
+ process.on("exit", restoreScreen);
1995
+
1996
+ // Ink's default renderer erases and rewrites the whole viewport on every
1997
+ // change; incremental mode updates only the lines that differ. Measured on a
1998
+ // settled 80x24 pane: 13,918 bytes of terminal traffic down to the figure in
1999
+ // the commit message.
2000
+ //
2001
+ // PE-M1 flagged the risk: ink's own source notes a Windows-console desync for
2002
+ // frames that exactly fill the viewport, which this app always does since the
2003
+ // root box is the terminal height. Windows is already documented as untested
2004
+ // (README Limitations), and the pty harness covers the two platforms that are
2005
+ // supported, so the flag is verified where it is claimed to work.
2006
+ const app = render(e(App), { incrementalRendering: true });
2007
+
2008
+ // 128 + signal number, so a supervisor or `timeout` can tell an interrupted
2009
+ // run from a clean one. These fire on external `kill` and when raw mode is
2010
+ // unavailable; the ordinary Ctrl+C path goes through ink's own handler.
2011
+ //
2012
+ // Unmounting before restoring is load-bearing, not tidiness. signal-exit runs
2013
+ // our own `exit` listener ahead of ink's teardown, so simply exiting here let
2014
+ // restoreScreen hand the terminal back to the primary buffer *first* and ink
2015
+ // then repainted onto it -- and because the root Box is `height: rows` the
2016
+ // frame always exactly fills the viewport, so that repaint takes ink's
2017
+ // `isUnmounting && previousOutputHeight >= viewportRows` branch and is
2018
+ // preceded by \x1b[2J\x1b[3J. \x1b[3J erases the scrollback, so a `kill` threw
2019
+ // away the user's terminal history and left a dead dashboard behind it
2020
+ // (measured: 2,728 bytes on an 80x24 pane).
2021
+ //
2022
+ // Unmounting first puts that final repaint inside the alternate screen, where
2023
+ // restoreScreen discards it -- which is the ordering the q/Esc path already
2024
+ // gets for free through ink's own handleExit. ink's final layout and render
2025
+ // are synchronous, so the repaint has landed by the time we restore, and
2026
+ // exiting immediately afterwards keeps the prompt-exit guarantee: waiting for
2027
+ // the event loop to drain would let a hung `gh` turn Ctrl+C into an apparent
2028
+ // hang.
2029
+ const bySignal = (code) => () => {
2030
+ try {
2031
+ app.unmount();
2032
+ } catch {
2033
+ // Teardown is best effort. restoreScreen below, and the `exit` listener
2034
+ // above, both still run, so the terminal is handed back either way.
2035
+ }
2036
+ restoreScreen();
2037
+ process.exit(code);
2038
+ };
2039
+ process.on("SIGINT", bySignal(130));
2040
+ process.on("SIGTERM", bySignal(143));
2041
+ process.on("SIGHUP", bySignal(129));
2042
+ }
2043
+
2044
+ // Exported for unit tests. The dashboard itself is still one file; these are
2045
+ // the pure functions worth pinning, and nothing here is part of the public API.
2046
+ export {
2047
+ parseArgs,
2048
+ validateArgs,
2049
+ REPO_PATTERN,
2050
+ MIN_REFRESH_SECONDS,
2051
+ MAX_REFRESH_SECONDS,
2052
+ TAB_KEYS,
2053
+ safe,
2054
+ shortErr,
2055
+ isUnavailable,
2056
+ isRateLimited,
2057
+ formatAge,
2058
+ formatDuration,
2059
+ usableSize,
2060
+ severityRank,
2061
+ pick,
2062
+ minimumWidthFor,
2063
+ runStatusIcon,
2064
+ RUN_STATUS_ICON,
2065
+ SEVERITY_STYLE,
2066
+ REVIEW_LABEL,
2067
+ MIN_TABLE_WIDTH,
2068
+ MIN_COMPACT_WIDTH,
2069
+ OCT_NERD,
2070
+ OCT_UNICODE,
2071
+ };