From b3c66906fae12fc299b3ca8b0acda62c65bc660e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 07:15:06 +0000 Subject: [PATCH] gh-pulse: quiet cron, a clamped spinner, a rescan that rescans (0.36.1) Follow-ups from a review of the busy indicator. The daily run handed its per-ten progress counts to the plain logger when stderr was not a terminal, so every cron.log grew ~70 lines it never had; the counts now ride the spinner only on a terminal and cron prints exactly what it printed before. The command-line spinner clamps its row to the terminal width (an overlong row walked down the screen a row per tick) and stops exactly once (the range path could stop it twice and print the last line twice). In the TUI, `r` finally forces a rescan (force was dropped at the ShowOptions boundary and the hour-fresh cache came back), a "still scanning" notice no longer outlives the scan, and the loading range button uses the ascii frames on a terminal without Unicode, like the spinner line does. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011gfb1vcQH5rf82HfbJvf1Z --- bin/gh-pulse.ts | 30 +++++++++++++++++++++--------- package.json | 2 +- src/gh-pulse-tui.ts | 12 ++++++------ test/gh-pulse.test.ts | 31 +++++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 16 deletions(-) diff --git a/bin/gh-pulse.ts b/bin/gh-pulse.ts index bfa0743..4ce3e58 100755 --- a/bin/gh-pulse.ts +++ b/bin/gh-pulse.ts @@ -88,13 +88,22 @@ export function ttySpinner(label: string, stream: NodeJS.WriteStream = process.s const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; let i = 0; let line = ''; - const draw = (): void => { stream.write(`\r\x1b[K${frames[i % frames.length]} ${label}${line ? `: ${line}` : ''}`); i += 1; }; + let ended = false; + // Clamped to the terminal width: `\r\x1b[K` only clears the last physical row + // of a wrapped line, so an overlong row would walk down the screen a row per tick. + const draw = (): void => { + const cols = stream.columns ?? 80; + const text = `${frames[i % frames.length]} ${label}${line ? `: ${line}` : ''}`; + stream.write(`\r\x1b[K${text.length < cols ? text : `${text.slice(0, Math.max(0, cols - 2))}…`}`); + 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` : ''}`); }, + progress: (l) => { line = l; if (!ended) draw(); }, + // Idempotent: a caller may stop it in a finally block after stopping it on success. + done: () => { if (ended) return; ended = true; clearInterval(timer); stream.write(`\r\x1b[K${line ? `gh-pulse: ${line}\n` : ''}`); }, }; } @@ -149,7 +158,7 @@ export async function main(argv: readonly string[]): Promise { if (verb === 'show') { const { showTui } = await import('../src/gh-pulse-tui.ts'); const startKey: RangeKey | undefined = spec && spec.key !== 'custom' ? spec.key : undefined; - const code = await showTui(dir, { range: startKey, loadRange: (key, progress) => loadRange(rangeSpec(key, deps.now()), progress) }); + const code = await showTui(dir, { range: startKey, loadRange: (key, progress, force) => loadRange(rangeSpec(key, deps.now()), progress, force) }); // A range scan still in flight would keep the process alive after the screen is gone, spending the hour on a report nobody reads. process.exit(code); } @@ -181,22 +190,25 @@ export async function main(argv: readonly string[]): Promise { const sp = ttySpinner(`gh-pulse ${spec.label}`); try { 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; + } finally { + sp.done(); } + process.stdout.write(readFileSync(rangeOutputPaths(dir, rangeSlug(spec, repos)).text, 'utf8')); + return 0; } 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 { - // 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); + // On a terminal the coarse log lines and the per-ten counts ride the spinner; + // in cron only the coarse lines print, exactly as before the spinner existed. + const tty = process.stderr.isTTY === true; + await run({ dryRun, to, from, top, hours, repos, dataDir: dir, ...(tty ? { progress: sp.progress } : {}) }, tty ? { ...deps, log: sp.progress } : deps); sp.done(); return 0; } catch (error) { diff --git a/package.json b/package.json index 578c388..02a7043 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/cli-tools", - "version": "0.36.0", + "version": "0.36.1", "private": true, "description": "Local command-line tools, in TypeScript, exposed on PATH.", "type": "module", diff --git a/src/gh-pulse-tui.ts b/src/gh-pulse-tui.ts index b11f18e..efcaf66 100644 --- a/src/gh-pulse-tui.ts +++ b/src/gh-pulse-tui.ts @@ -128,7 +128,7 @@ export interface ShowOptions { /** Start on this range instead of the latest daily report. */ range?: RangeKey | undefined; /** Produce (or fetch from cache) the report for a range. Called off the render loop. */ - loadRange: (key: RangeKey, progress: (line: string) => void) => Promise; + loadRange: (key: RangeKey, progress: (line: string) => void, force?: boolean) => Promise; } export async function showTui(dataDir: string, opts: ShowOptions): Promise { @@ -175,8 +175,8 @@ export async function showTui(dataDir: string, opts: ShowOptions): Promise { scanLine = line; app.invalidate(); }).then( - (r) => { report = r; current = key; selected = 0; offset = 0; scanning = null; scanLine = ''; app.invalidate(); }, + opts.loadRange(key, (line) => { scanLine = line; app.invalidate(); }, force).then( + (r) => { report = r; current = key; selected = 0; offset = 0; scanning = null; scanLine = ''; notice = ''; app.invalidate(); }, (error: unknown) => { scanning = null; scanLine = ''; notice = `scan failed: ${(error as Error).message}`; app.invalidate(); }, ); }; @@ -213,11 +213,11 @@ export async function showTui(dataDir: string, opts: ShowOptions): Promise { + app.render(({ ui, theme, width, height, elapsed, capabilities }) => { const rows = visible(); 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); + // The same glyph the spinner line shows, for the range button that is loading; ascii where the terminal has no Unicode. + const glyph = widgets.spinnerFrame(elapsed, capabilities.unicode ? widgets.SPINNER_FRAMES.dots : widgets.SPINNER_FRAMES.ascii); if (selected >= rows.length) selected = Math.max(0, rows.length - 1); if (selected < offset) offset = selected; if (selected >= offset + bodyRows) offset = selected - bodyRows + 1; diff --git a/test/gh-pulse.test.ts b/test/gh-pulse.test.ts index a3f58bd..a24c1b7 100644 --- a/test/gh-pulse.test.ts +++ b/test/gh-pulse.test.ts @@ -23,6 +23,7 @@ import { type Snapshot, } from '../src/gh-pulse.ts'; import { historyRows } from '../src/gh-pulse-tui.ts'; +import { ttySpinner } from '../bin/gh-pulse.ts'; const b = (day: string, count: number, uniques = count): Bucket => ({ timestamp: `${day}T00:00:00Z`, count, uniques }); const moved = (patch: Partial): Movement => ({ ...emptyMovement(), ...patch }); @@ -196,3 +197,33 @@ describe('historyRows', () => { expect(historyRows([snap])).toEqual([{ at: snap.at, repos: 2, stars: 12, followers: 2, movers: 1, views: 3, clones: 4 }]); }); }); + +describe('ttySpinner', () => { + const fake = (isTTY: boolean, columns = 40) => { + const out: string[] = []; + const stream = { isTTY, columns, write: (s: string) => { out.push(s); return true; } } as unknown as NodeJS.WriteStream; + return { stream, out }; + }; + + it('prints plain lines and nothing else when there is no terminal', () => { + const { stream, out } = fake(false); + const sp = ttySpinner('gh-pulse daily run', stream); + sp.progress('349 repos'); + sp.done(); + sp.done(); + expect(out).toEqual(['gh-pulse: 349 repos\n']); + }); + + it('clamps the busy row to the terminal width and stops exactly once', () => { + const { stream, out } = fake(true, 30); + const sp = ttySpinner('gh-pulse last 7 days', stream); + sp.progress('a progress line that is far wider than thirty columns'); + sp.done(); + sp.done(); + const rows = out.filter((s) => s.startsWith('\r\x1b[K') && !s.endsWith('\n')).map((s) => s.slice(4)); + expect(rows.length).toBeGreaterThan(0); + expect(rows.every((r) => r.length <= 30)).toBe(true); + expect(rows[rows.length - 1]!.endsWith('…')).toBe(true); + expect(out.filter((s) => s.endsWith('\n'))).toEqual(['\r\x1b[Kgh-pulse: a progress line that is far wider than thirty columns\n']); + }); +});