From 0d3acf2933e4c9fea27a80829bf09df58900c5c6 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 05:33:56 +0000 Subject: [PATCH] gh-pulse: say when it is busy (0.35.0) A spinner wherever a fetch is in flight. In the TUI it is hqtui's new `spinner` widget (0.6.0): a busy line under the header with the live count of repos fetched, the same glyph on the range button that is loading, and a spinner in the panel when nothing is loaded yet. On the command line a range scan or a daily run shows a one-row spinner on stderr with the latest progress, when stderr is a terminal; cron and pipes still get plain lines. Scans now report progress every ten repos through the traffic and event phases instead of only at the end. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011gfb1vcQH5rf82HfbJvf1Z --- bin/gh-pulse.ts | 38 +++++++++++++++++++++++++++++++++++--- package.json | 4 ++-- pnpm-lock.yaml | 10 +++++----- pnpm-workspace.yaml | 2 +- src/gh-pulse-tui.ts | 21 +++++++++++++++------ src/gh-pulse.ts | 13 +++++++++++++ 6 files changed, 71 insertions(+), 17 deletions(-) diff --git a/bin/gh-pulse.ts b/bin/gh-pulse.ts index 50d6c13..fbaf42f 100755 --- a/bin/gh-pulse.ts +++ b/bin/gh-pulse.ts @@ -68,6 +68,28 @@ export const USAGE = `Usage: gh-pulse json [--range KEY] gh-pulse --help`; +/** + * A busy line on stderr while a scan runs, when stderr is a terminal. Progress + * lines replace each other on one row; the last one is left standing when the + * scan ends. Without a terminal (cron, a pipe) every line is printed plainly. + */ +export function ttySpinner(label: string, stream: NodeJS.WriteStream = process.stderr): { progress: (line: string) => void; done: () => void } { + if (!stream.isTTY) { + return { progress: (line) => stream.write(`gh-pulse: ${line}\n`), done: () => {} }; + } + const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let i = 0; + let line = ''; + const draw = (): void => { stream.write(`\r\x1b[K${frames[i % frames.length]} ${label}${line ? `: ${line}` : ''}`); i += 1; }; + const timer = setInterval(draw, 80); + timer.unref(); + draw(); + return { + progress: (l) => { line = l; draw(); }, + done: () => { clearInterval(timer); stream.write(`\r\x1b[K${line ? `gh-pulse: ${line}\n` : ''}`); }, + }; +} + function specFrom(rangeText: string | undefined, sinceText: string | undefined, now: Date): RangeSpec | null { if (rangeText !== undefined && sinceText !== undefined) throw new UsageError('pass --range or --since, not both'); if (sinceText !== undefined) return customRange(sinceText, now); @@ -122,7 +144,10 @@ export async function main(argv: readonly string[]): Promise { return showTui(dir, { range: startKey, loadRange: (key, progress) => loadRange(rangeSpec(key, deps.now()), progress) }); } if (verb === 'open' || verb === 'text' || verb === 'json') { - if (spec) await loadRange(spec, deps.log); + if (spec) { + const sp = ttySpinner(`gh-pulse ${spec.label}`); + try { await loadRange(spec, sp.progress); } finally { sp.done(); } + } const paths = spec ? rangeOutputPaths(dir, rangeSlug(spec, repos)) : outputPaths(dir); if (verb === 'open') { if (openInBrowser(paths.html)) return 0; @@ -143,11 +168,14 @@ export async function main(argv: readonly string[]): Promise { // A range scan prints the report; mail is opt-in, and the baseline never moves. const send = parsed.flags.has('--send'); if (send && !to) throw new UsageError('no recipient: pass --to, set GH_PULSE_TO, or configure git user.email'); + const sp = ttySpinner(`gh-pulse ${spec.label}`); try { - await rangeScan({ spec, top, repos, dataDir: dir, send, to, from }, deps); + await rangeScan({ spec, top, repos, dataDir: dir, send, to, from, progress: sp.progress }, deps); + sp.done(); process.stdout.write(readFileSync(rangeOutputPaths(dir, rangeSlug(spec, repos)).text, 'utf8')); return 0; } catch (error) { + sp.done(); process.stderr.write(`gh-pulse: ${(error as Error).stack ?? String(error)}\n`); return 1; } @@ -155,10 +183,14 @@ export async function main(argv: readonly string[]): Promise { if (!to) throw new UsageError('no recipient: pass --to, set GH_PULSE_TO, or configure git user.email'); const dryRun = parsed.flags.has('--dry-run'); + const sp = ttySpinner(dryRun ? 'gh-pulse dry run' : 'gh-pulse daily run'); try { - await run({ dryRun, to, from, top, hours, repos, dataDir: dir }, deps); + // On a terminal the coarse log lines ride the spinner too; in cron they print as before. + await run({ dryRun, to, from, top, hours, repos, dataDir: dir, progress: sp.progress }, process.stderr.isTTY ? { ...deps, log: sp.progress } : deps); + sp.done(); return 0; } catch (error) { + sp.done(); process.stderr.write(`gh-pulse: ${(error as Error).stack ?? String(error)}\n`); if (!dryRun) await sendFailure(error, to, from, deps.resendKey(), deps.fetch); return 1; diff --git a/package.json b/package.json index 995b3e9..4e84bd5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/cli-tools", - "version": "0.34.0", + "version": "0.35.0", "private": true, "description": "Local command-line tools, in TypeScript, exposed on PATH.", "type": "module", @@ -32,7 +32,7 @@ "sharp": "^0.35.3" }, "dependencies": { - "@profullstack/hqtui": "^0.5.1", + "@profullstack/hqtui": "^0.6.0", "@resvg/resvg-js": "^2.6.2", "axe-core": "^4.13.0", "imapflow": "^1.7.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb5bc45..ec52986 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@profullstack/hqtui': - specifier: ^0.5.1 - version: 0.5.1 + specifier: ^0.6.0 + version: 0.6.0 '@resvg/resvg-js': specifier: ^2.6.2 version: 2.6.2 @@ -382,8 +382,8 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@profullstack/hqtui@0.5.1': - resolution: {integrity: sha512-TRj2CKCUhpXhhmsQ1DMIZb9OcU+XLm90BqhPPo0zk2OwyXNcQhlqBeNdxLqwTY/BlIWZpcTyQto458AQ7iCTFg==} + '@profullstack/hqtui@0.6.0': + resolution: {integrity: sha512-jUFockH6yWeE48xD4gOKINPGzUmShn5k8c6Wqv/apkxI8CIJW4ieGZx5Co+ltD/CGqnGEVnXUbUu5SlNpfBdfQ==} engines: {bun: '>=1.1', node: '>=22.6'} hasBin: true @@ -1274,7 +1274,7 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@profullstack/hqtui@0.5.1': {} + '@profullstack/hqtui@0.6.0': {} '@resvg/resvg-js-android-arm-eabi@2.6.2': optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index df8d7d3..312eb36 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,4 +8,4 @@ allowBuilds: esbuild: true minimumReleaseAgeExclude: - - '@profullstack/hqtui@0.5.1' + - '@profullstack/hqtui@0.5.1 || 0.6.0' diff --git a/src/gh-pulse-tui.ts b/src/gh-pulse-tui.ts index 8084ce5..b11f18e 100644 --- a/src/gh-pulse-tui.ts +++ b/src/gh-pulse-tui.ts @@ -19,7 +19,7 @@ * flips private, r rescans the current range, o opens the HTML, q quits. */ -import { createApp } from '@profullstack/hqtui'; +import { createApp, widgets } from '@profullstack/hqtui'; import { RANGE_KEYS, @@ -213,9 +213,11 @@ export async function showTui(dataDir: string, opts: ShowOptions): Promise { + app.render(({ ui, theme, width, height, elapsed }) => { const rows = visible(); - const bodyRows = Math.max(3, height - 13); + const bodyRows = Math.max(3, height - 14); + // The same glyph the spinner line shows, for the range button that is loading. + const glyph = widgets.spinnerFrame(elapsed, widgets.SPINNER_FRAMES.dots); if (selected >= rows.length) selected = Math.max(0, rows.length - 1); if (selected < offset) offset = selected; if (selected >= offset + bodyRows) offset = selected - bodyRows + 1; @@ -228,7 +230,7 @@ export async function showTui(dataDir: string, opts: ShowOptions): Promise pickRange('latest') }, ...RANGE_KEYS.map((k) => ({ - label: `${RANGE_LABEL[k].replace('last ', '')}${scanning === k ? ' …' : ''}`, + label: `${RANGE_LABEL[k].replace('last ', '')}${scanning === k ? ` ${glyph}` : ''}`, variant: (current === k ? 'primary' : 'ghost') as 'primary' | 'ghost', onPress: () => pickRange(k), })), @@ -247,8 +249,15 @@ export async function showTui(dataDir: string, opts: ShowOptions): Promise { p.text(scanLine || notice || 'nothing loaded yet', { fg: theme.muted }); }); + col.panel({ title: scanning ? `Fetching ${RANGE_LABEL[scanning]}` : 'No report' }, (p) => { + if (scanning) p.spinner({ label: `fetching ${RANGE_LABEL[scanning]} from GitHub`, text: scanLine }); + else p.text(notice || 'nothing loaded yet', { fg: theme.muted }); + }); return; } const rep = report; @@ -264,7 +273,7 @@ export async function showTui(dataDir: string, opts: ShowOptions): Promise= 0 ? '+' : ''}${rep.totals.starsDelta})`}`, color: theme.accent }, { label: 'Views', value: `${rep.totals.views} / ${rep.totals.uniques} unique` }, { label: 'Clones', value: `${rep.totals.clones} / ${rep.totals.cloners} unique` }, - { label: scanning ? 'Scanning' : 'Status', value: scanning ? `${RANGE_LABEL[scanning]}: ${scanLine}` : (notice || 'ready'), color: scanning ? theme.warning : theme.muted }, + { label: 'Newest traffic day', value: rep.newestTrafficDay }, ], { width: '1fr' }); }); diff --git a/src/gh-pulse.ts b/src/gh-pulse.ts index c1a972b..fc42ed2 100644 --- a/src/gh-pulse.ts +++ b/src/gh-pulse.ts @@ -199,6 +199,8 @@ export interface RunOptions { hours: number; dataDir: string; from: string; + /** Fine-grained progress (every few repos) for a spinner; the coarse lines still go to `deps.log`. */ + progress?: (line: string) => void; } export interface RunDeps { @@ -1087,13 +1089,17 @@ export async function run(opt: RunOptions, deps: RunDeps = defaultDeps()): Promi deps.log(`${repos.length} repos`); // 2. traffic for all of them: the bulk of the calls + const tick = opt.progress ?? (() => {}); + const counter = (what: string, total: number) => { let done = 0; return () => { done += 1; if (done % 10 === 0 || done === total) tick(`${what} ${done}/${total}`); }; }; const R = new Map(); + const trafficDone = counter('traffic', repos.length); await Promise.all(repos.map((r) => gh.limit(async () => { const [v, c] = await Promise.all([ gh.get<{ views: Bucket[] }>(`/repos/${r.full_name}/traffic/views`, { allow: [403, 404] }), gh.get<{ clones: Bucket[] }>(`/repos/${r.full_name}/traffic/clones`, { allow: [403, 404] }), ]); R.set(r.full_name, { repo: r, views: v.data?.views ?? null, clones: c.data?.clones ?? null, trafficOk: !!(v.data && c.data), m: emptyMovement() }); + trafficDone(); }))); // 3. movement per repo @@ -1118,10 +1124,12 @@ export async function run(opt: RunOptions, deps: RunDeps = defaultDeps()): Promi // 4. detail for candidates const budget = pageBudget('daily'); + const detailDone = counter('events', R.size); await Promise.all([...R.values()].map((x) => gh.limit(async () => { const n = x.repo.full_name; const m = x.m; const hasBaseline = prevRepos.has(n); + detailDone(); if (needStars.has(n)) { const { logins } = await collectStars(gh, n, cutoffMs, budget.stars); m.newStargazers = logins; @@ -1274,6 +1282,8 @@ export async function rangeScan(opt: RangeOptions, deps: RunDeps = defaultDeps() // The ledger: every snapshot, plus today's live fourteen days on top. const ledger = buildLedger(listSnapshots(opt.dataDir).flatMap((f) => { try { return [readSnapshot(f)]; } catch { return []; } })); const R = new Map(); + const counter = (what: string, total: number) => { let done = 0; return () => { done += 1; if (done % 10 === 0 || done === total) say(`${what} ${done}/${total}`); }; }; + const trafficDone = counter('traffic', repos.length); await Promise.all(repos.map((r) => gh.limit(async () => { const [v, c] = await Promise.all([ gh.get<{ views: Bucket[] }>(`/repos/${r.full_name}/traffic/views`, { allow: [403, 404] }), @@ -1283,6 +1293,7 @@ export async function rangeScan(opt: RangeOptions, deps: RunDeps = defaultDeps() const clones = c.data?.clones ?? null; mergeBuckets(ledger, r.full_name, views, clones); R.set(r.full_name, { repo: r, views, clones, trafficOk: !!(v.data && c.data), m: emptyMovement() }); + trafficDone(); }))); say(`${repos.length} repos, traffic merged into the ledger`); @@ -1299,10 +1310,12 @@ export async function rangeScan(opt: RangeOptions, deps: RunDeps = defaultDeps() const cap = commitCapFor(days); let partialRepos = 0; + const eventsDone = counter('events', R.size); await Promise.all([...R.values()].map((x) => gh.limit(async () => { const r = x.repo; const n = r.full_name; const m = x.m; + eventsDone(); const dv = ledgerSum(ledger.views.get(n), fromDay, toDay); const dc = ledgerSum(ledger.clones.get(n), fromDay, toDay); m.views = dv.count; m.uniques = dv.uniques; m.clones = dc.count; m.cloners = dc.uniques;