github-search-cli 9.9.0 → 9.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/HOW-TO.md CHANGED
@@ -141,8 +141,9 @@ ghfind "cli" --sort forks
141
141
  ghfind "cli" --sort best-match # GitHub's own relevance order
142
142
  ```
143
143
 
144
- In the TUI, `Space` opens the leader menu, where **Sort** cycles the same
145
- strategies without leaving the results.
144
+ In the TUI, `Esc` opens the leader menu (or click the <kbd>☰ menu</kbd> button in
145
+ the bottom-right corner), where **Sort** cycles the same strategies without
146
+ leaving the results.
146
147
 
147
148
  ---
148
149
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "github-search-cli",
3
- "version": "9.9.0",
4
- "description": "Search GitHub from your terminal — repo search, trending repos, npm package search, bookmarks, deep-dive, compare, export. Keyboard-driven TUI + pipeable CLI.",
3
+ "version": "9.10.1",
4
+ "description": "Search GitHub from your terminal — repo search, trending repos, npm package search, bookmarks, deep-dive, compare, export. Keyboard + mouse TUI + pipeable CLI.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1",
7
7
  "volta": {
@@ -0,0 +1,137 @@
1
+ # v9.10.0
2
+
3
+ ghfind's TUI has always been keyboard-only — and, quietly, half-broken for the
4
+ mouse. OpenTUI was already emitting SGR mouse tracking and hit-testing every
5
+ event into a target renderable. Nothing in ghfind consumed any of it. The
6
+ events were parsed, dispatched, and thrown away. This release makes the TUI
7
+ clickable, and fixes the two keyboard rough edges that were hiding behind it.
8
+
9
+ ## The TUI is now fully clickable
10
+
11
+ Click a result and the detail pane updates. Click it again and your browser
12
+ opens. Scroll to move through results. Click outside an overlay to close it.
13
+
14
+ Every list, card, tab and button responds: the results list, the landing mode
15
+ cards, trending tabs, the update dialog's buttons, and every overlay list
16
+ (history, bookmarks, saved searches, topics, export, share, and the command
17
+ menu itself).
18
+
19
+ The important detail is that clicks do not reimplement anything. A click calls
20
+ `setSelectedIndex`, which emits the same `selectionChanged` event `↑`/`↓`
21
+ already emitted, so the detail pane, the detail graph and the packages view all
22
+ work identically without a parallel code path. A double-click calls the same
23
+ activation path as `Enter`. There is no mouse branch to keep in sync — the
24
+ keyboard and the mouse converge on one implementation, which is also why they
25
+ cannot drift.
26
+
27
+ ## The wheel scrolled the wrong pane
28
+
29
+ Worth calling out, because it predates this release and was a real bug rather
30
+ than a missing feature.
31
+
32
+ When a scroll event hit nothing, OpenTUI falls back to delivering it to
33
+ whatever renderable currently holds focus. In ghfind that was almost always the
34
+ results list. So opening the README viewer and scrolling would move the
35
+ _result selection_ instead of the README — the one thing you were trying to do.
36
+
37
+ Every scrollable region now claims the wheel explicitly, and the list's wheel
38
+ handler steps the selection through `moveUp`/`moveDown`, so wheel navigation
39
+ and `↑`/`↓` navigation cannot diverge either.
40
+
41
+ ## Text that swallowed clicks
42
+
43
+ `TextRenderable` defaults to OpenTUI's `selectable: true`, and a left-press on
44
+ a selectable renderable is consumed as a text-selection drag _before_ the app's
45
+ own handlers run. The detail pane, comparison table, tab bar and other read-only
46
+ text are all `TextRenderable`s, so clicks landing on them did nothing at all.
47
+
48
+ They now opt out of text selection. The query input deliberately keeps it, so
49
+ clicking into the input still places your cursor.
50
+
51
+ ## A hover highlight that painted the list magenta
52
+
53
+ Small one, but it would have been immediately obvious to anyone using it.
54
+ OpenTUI's colour parser accepts `"transparent"`, CSS colour names, and 3/4/6/8
55
+ digit hex — nothing else. An `rgba(255,255,255,0.06)` hover tint failed that
56
+ check and fell through to its magenta fallback, so hovering a list would have
57
+ flashed it magenta. Now written as `#ffffff0f`.
58
+
59
+ Found because the test harness prints that warning. Worth noting the class of
60
+ bug: OpenTUI degrades an invalid colour to magenta rather than throwing, so this
61
+ fails silently and visibly at the same time.
62
+
63
+ ## `Space` opened the menu only if you had clicked something first
64
+
65
+ The subtlest problem in this release, and it was not caused by mouse support —
66
+ it was exposed by it.
67
+
68
+ The query input is focused by default. The command menu was bound to `Space`,
69
+ gated on the input _not_ being focused, so that spaces could still be typed into
70
+ a query. That gate meant `Space` opened the menu only when focus had moved
71
+ somewhere else. The one way focus moved was clicking a result row: OpenTUI's
72
+ auto-focus-on-press walks up from the pressed renderable to the nearest
73
+ focusable ancestor, finds the results list, and focuses it — blurring the input
74
+ as a side effect.
75
+
76
+ So on a fresh launch, `Space` typed a space. Click any row, and `Space` opened
77
+ the menu. Same key, same screen, different behaviour depending on invisible
78
+ state.
79
+
80
+ A regression test now pins this down from both directions: with `keepFocus` a row
81
+ click does not move focus, and without it one does.
82
+
83
+ The resolution is that **`Esc` opens the command menu**, and `Space` always
84
+ types. `Esc` already closed every overlay, so this adds a meaning rather than
85
+ replacing one. For anyone who prefers not to reach for `Esc`, there is now a
86
+ <kbd>☰ menu</kbd> button in the bottom-right corner — clickable, it floats above
87
+ the layout, and it hides while an overlay is open so it never sits on top of one.
88
+
89
+ ## Added
90
+
91
+ - Full mouse support: click to select, double-click to open, wheel to scroll,
92
+ click-outside to dismiss, hover feedback with a pointer cursor.
93
+ - <kbd>☰ menu</kbd> button in the bottom-right corner to open the command menu.
94
+ - A "Mouse" section in the in-app help, documenting every binding.
95
+ - README keybinding tables gained a mouse table; the command-menu references
96
+ across the README and `docs/HOW-TO.md` now say `Esc` instead of `Space`.
97
+
98
+ ## Fixed
99
+
100
+ - The scroll wheel scrolled the focused renderable rather than the pane under
101
+ the pointer, so long-pane scrolling moved the result selection instead.
102
+ - Non-interactive text consumed left-presses as text-selection drags, so clicks
103
+ over the detail pane and comparison table did nothing.
104
+ - The hover tint used an `rgba()` string that OpenTUI's colour parser rejects,
105
+ rendering the list magenta.
106
+ - `Space` could not open the command menu on a fresh launch, because it was
107
+ gated on focus that only moved after a mouse click.
108
+
109
+ ## Verification
110
+
111
+ The suite grew by 33 tests, to 657.
112
+
113
+ - **15 unit tests** on the click→row geometry: scroll offsets, multi-line rows
114
+ (overlay lists render a description line, so each option is two lines tall),
115
+ `itemSpacing`, clicks past the last row, unmeasured renderables, and empty
116
+ lists. OpenTUI keeps `scrollOffset` and `linesPerItem` private, so this
117
+ arithmetic is read through a single narrow cast — the tests are what keep that
118
+ cast honest.
119
+ - **12 integration tests** driving the real handlers with OpenTUI's mock mouse
120
+ against a live renderer: click-selects, double-click-opens, the double-click
121
+ window expiring, click-outside dismissal, row clicks _not_ leaking to the
122
+ enclosing panel, focus behaviour with and without `keepFocus`, and wheel
123
+ stepping including both ends of the list.
124
+ - **4 button tests** asserting the corner button actually lands in the
125
+ bottom-right, that hidden buttons neither render nor take clicks, and that its
126
+ click does not bubble.
127
+
128
+ The first two geometry tests failed on the run that wrote them, and caught a
129
+ real off-by-one: the empty area below a short list returned "no row" instead of
130
+ resolving to the last row, which would have dismissed an overlay instead of
131
+ selecting its final entry. That is the exact class of bug that would have been
132
+ very annoying and very hard to report.
133
+
134
+ `test`, `format:check`, `lint` (Biome) and `typecheck` (tsc) are all green, and
135
+ `bun run build` succeeds. The TUI was also driven end-to-end in a pty to confirm
136
+ the menu opens from both `Esc` and the corner button, since the mock mouse does
137
+ not exercise a real terminal's mouse reporting.
@@ -0,0 +1,59 @@
1
+ # v9.10.1
2
+
3
+ A bug-fix release, found by crawling the whole source against its own docs.
4
+ Three of the eight issues were the kind that quietly cost you time: a flag
5
+ that was documented everywhere but never parsed, a typo that opened a GitHub
6
+ issue tab instead of printing an error, and an empty query that came back as
7
+ a network problem you didn't have.
8
+
9
+ ## `--interval` was never parsed
10
+
11
+ `--interval` appeared in `--help`, the README, the how-to guide and all
12
+ three shell completions. `parseArgs` had no case for it. So
13
+ `ghfind --watch --interval 60` searched for the literal string
14
+ `"--interval 60"` and polled at the default 300s anyway. It now parses a
15
+ positive whole number of seconds — and rejects anything else with a message
16
+ instead of a mangled query.
17
+
18
+ ## A typo used to open a GitHub issue tab
19
+
20
+ `--format jsonn` threw `Unknown format` from inside the run, which the top
21
+ level error handler classified as unexpected: it printed "ghfind encountered
22
+ an unexpected error" and opened a prefilled GitHub issue page in your
23
+ browser. `--pipe clonee` was the opposite failure — it did nothing at all
24
+ and exited 0.
25
+
26
+ Both flags now validate up front, print the list of valid values, and exit 1
27
+ before any search happens.
28
+
29
+ ## An empty query was a network error
30
+
31
+ `ghfind --count` with no query sent `q=" "` to GitHub, got a 422 back, and
32
+ surfaced it as `⚠ Network error — check your connection. Retrying in 5s...`
33
+ — a connectivity problem you didn't have, on a loop. The search, format,
34
+ pipe and watch handlers now fail fast with a usage hint instead.
35
+
36
+ ## The rest
37
+
38
+ - **Bare `ghfind --trending` opens the trending view.** It used to fall
39
+ through to the landing menu, contradicting TRACKER.md.
40
+ - **`--since` validates its value** (`daily|weekly|monthly`) instead of
41
+ silently falling back to daily — the same treatment `--sort` already had.
42
+ - **Watch mode honors `--since`.** The trending period was derived from the
43
+ query string, so `--watch --trending --since weekly` polled daily. An
44
+ explicit `--since` wins; the query-string convention still works when the
45
+ flag is absent.
46
+ - **The MCP `ghfind_user_profile` tool returns formatted text**, matching
47
+ `ghfind_org_profile` and the CLI's default `ghfind user` output — the raw
48
+ JSON still rides along in `data`.
49
+ - **The trending footer matches its `/` key** (`/ top` — it scrolls to the
50
+ top, it never opened search), and the quit paths of the main TUI, trending
51
+ and package browser now call `renderer.destroy()` before exiting, so the
52
+ terminal is released properly.
53
+
54
+ ## Removed
55
+
56
+ Dead code with no importers: `src/search-state.ts` (still referenced the
57
+ pre-unification `config.githubTokens`), `src/export.ts` and `src/pipe.ts`
58
+ (byte-for-byte duplicates of `src/output.ts`, which is what everything
59
+ imports), and the unused `historyCount()` export.
package/src/cli.ts CHANGED
@@ -25,6 +25,8 @@ import { reportError } from "./error-report";
25
25
  import {
26
26
  format as formatOutput,
27
27
  pipeExec,
28
+ FORMAT_VALUES,
29
+ PIPE_TARGETS,
28
30
  type Format,
29
31
  type ExportFormat,
30
32
  } from "./output";
@@ -104,6 +106,8 @@ interface CLIFlags {
104
106
  token?: string;
105
107
  trending: boolean;
106
108
  since: string;
109
+ /** True when --since was passed (watch falls back to the query-string convention otherwise). */
110
+ sinceExplicit: boolean;
107
111
  pipe?: string;
108
112
  format?: string;
109
113
  watch: boolean;
@@ -156,6 +160,9 @@ const SORT_VALUES = [
156
160
  "name",
157
161
  ];
158
162
 
163
+ /** Valid `--since` values — the periods github.com/trending accepts. */
164
+ const SINCE_VALUES: readonly string[] = ["daily", "weekly", "monthly"];
165
+
159
166
  function parseArgs(args: string[]): CLIFlags {
160
167
  const flags: CLIFlags = {
161
168
  query: "",
@@ -168,6 +175,7 @@ function parseArgs(args: string[]): CLIFlags {
168
175
  token: process.env.GITHUB_TOKEN,
169
176
  trending: false,
170
177
  since: "daily",
178
+ sinceExplicit: false,
171
179
  watch: false,
172
180
  releases: false,
173
181
  interval: 300,
@@ -276,15 +284,51 @@ function parseArgs(args: string[]): CLIFlags {
276
284
  case "--token":
277
285
  flags.token = args[++i];
278
286
  break;
279
- case "--since":
280
- flags.since = args[++i];
287
+ case "--interval": {
288
+ const parsed = parseInt(args[++i], 10);
289
+ if (Number.isNaN(parsed) || parsed < 1) {
290
+ console.error(
291
+ `Invalid interval: ${args[i] ?? "(missing)"} (must be a whole number of seconds, e.g. 300).`,
292
+ );
293
+ process.exit(1);
294
+ }
295
+ flags.interval = parsed;
281
296
  break;
282
- case "--pipe":
283
- flags.pipe = args[++i];
297
+ }
298
+ case "--since": {
299
+ const since = args[++i];
300
+ if (!since || !SINCE_VALUES.includes(since)) {
301
+ console.error(
302
+ `Invalid since: ${since ?? "(missing)"} (use daily|weekly|monthly).`,
303
+ );
304
+ process.exit(1);
305
+ }
306
+ flags.since = since;
307
+ flags.sinceExplicit = true;
284
308
  break;
285
- case "--format":
286
- flags.format = args[++i];
309
+ }
310
+ case "--pipe": {
311
+ const target = args[++i];
312
+ if (!target || !PIPE_TARGETS.includes(target)) {
313
+ console.error(
314
+ `Invalid pipe target: ${target ?? "(missing)"} (use open|clone).`,
315
+ );
316
+ process.exit(1);
317
+ }
318
+ flags.pipe = target;
319
+ break;
320
+ }
321
+ case "--format": {
322
+ const fmt = args[++i];
323
+ if (!fmt || !FORMAT_VALUES.includes(fmt)) {
324
+ console.error(
325
+ `Invalid format: ${fmt ?? "(missing)"} (use ${FORMAT_VALUES.join("|")}).`,
326
+ );
327
+ process.exit(1);
328
+ }
329
+ flags.format = fmt;
287
330
  break;
331
+ }
288
332
  case "--registry":
289
333
  flags.registry = args[++i] ?? "npm";
290
334
  break;
@@ -636,7 +680,13 @@ Options:
636
680
  return;
637
681
  }
638
682
 
639
- await launchBrowser(flags.theme);
683
+ await launchBrowser(
684
+ flags.theme,
685
+ // `ghfind --trending` with no query/format promises the trending view
686
+ // (TRACKER.md), but the bare flag used to fall through to the landing
687
+ // menu. An explicit flag is explicit intent — skip the menu.
688
+ flags.trending ? "trending" : undefined,
689
+ );
640
690
  }
641
691
 
642
692
  interface SearchContext {
@@ -1197,6 +1247,7 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
1197
1247
  token: flags.token,
1198
1248
  intervalMs: flags.interval * 1000,
1199
1249
  trending: flags.trending,
1250
+ since: flags.sinceExplicit ? flags.since : undefined,
1200
1251
  },
1201
1252
  (repos, delta) => {
1202
1253
  tick++;
@@ -1298,6 +1349,25 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
1298
1349
  ? "format"
1299
1350
  : "search";
1300
1351
 
1352
+ // The search/format/pipe/watch handlers all need a query. Without this
1353
+ // gate an empty query was sent to GitHub as `q=" "`, which returns 422 and
1354
+ // surfaced to the user as a misleading "Network error — check your
1355
+ // connection" retry loop.
1356
+ const needsQuery =
1357
+ key === "search" ||
1358
+ key === "format" ||
1359
+ key === "pipe" ||
1360
+ (key === "watch" && !flags.trending);
1361
+ if (needsQuery && flags.query.trim() === "") {
1362
+ console.error("⚠ No search query given.");
1363
+ console.error(
1364
+ ' Usage: ghfind "<query>" [--count|--format <fmt>|--pipe <tgt>|--watch]',
1365
+ );
1366
+ console.error(' Example: ghfind "language:Rust stars:>500" --count');
1367
+ console.error(" Run `ghfind --help` for all options.");
1368
+ process.exit(1);
1369
+ }
1370
+
1301
1371
  const ctx = buildSearchContext(flags);
1302
1372
  await handlers[key](ctx);
1303
1373
  }
package/src/help.ts CHANGED
@@ -30,7 +30,7 @@ export function buildHelpSections(): HelpSection[] {
30
30
  { keys: "↑ ↓ / j k", action: "Move selection" },
31
31
  { keys: "Enter", action: "Open repo in browser" },
32
32
  { keys: "/", action: "Focus search input" },
33
- { keys: "Space", action: "Open command menu" },
33
+ { keys: "Esc", action: "Open command menu" },
34
34
  { keys: "? / Ctrl+H", action: "Toggle this help screen" },
35
35
  { keys: "q", action: "Quit" },
36
36
  ],
@@ -65,6 +65,7 @@ export function buildHelpSections(): HelpSection[] {
65
65
  { keys: "1-5", action: "Switch tab: Today → Week → Month → …" },
66
66
  { keys: "← → / h l", action: "Navigate tabs" },
67
67
  ],
68
+ note: "Tabs are also clickable.",
68
69
  },
69
70
  {
70
71
  title: " Packages Mode ",
@@ -73,12 +74,12 @@ export function buildHelpSections(): HelpSection[] {
73
74
  { keys: "/", action: "Search npm packages" },
74
75
  { keys: "↑ ↓ / j k", action: "Move selection" },
75
76
  { keys: "Enter", action: "View package details" },
76
- { keys: "Space", action: "Open command menu" },
77
+ { keys: "Esc", action: "Open command menu" },
77
78
  ],
78
79
  note: "Search by name, description, or tags. Sort by downloads or score.",
79
80
  },
80
81
  {
81
- title: " Command Menu (Space)",
82
+ title: " Command Menu (Esc)",
82
83
  titleColor: "purple",
83
84
  rows: [
84
85
  { keys: "Sort", action: "Cycle sort: best-match → stars → …" },
@@ -103,7 +104,7 @@ export function buildHelpSections(): HelpSection[] {
103
104
  { keys: "History", action: "View search history" },
104
105
  { keys: "Help", action: "Show this help screen" },
105
106
  ],
106
- note: "Navigate menu with ↑ ↓, select with Enter, back with ← / Esc",
107
+ note: "Navigate menu with ↑ ↓, select with Enter, back with ← / Esc. Clicking a row runs it directly. Esc (or the ☰ menu button) opens this menu; Space types a space in the query.",
107
108
  },
108
109
  {
109
110
  title: " Bookmarks Page ",
@@ -116,6 +117,22 @@ export function buildHelpSections(): HelpSection[] {
116
117
  ],
117
118
  note: "A full page, not an overlay — Esc returns to the mode menu.",
118
119
  },
120
+ {
121
+ title: " Mouse ",
122
+ titleColor: "green",
123
+ rows: [
124
+ { keys: "Click", action: "Select a row / card / tab" },
125
+ { keys: "Double-click", action: "Open the repo or run the action" },
126
+ { keys: "Scroll wheel", action: "Move the selection / scroll a pane" },
127
+ { keys: "Click outside", action: "Dismiss an open overlay" },
128
+ { keys: "Click query box", action: "Focus the search input" },
129
+ {
130
+ keys: "☰ menu button",
131
+ action: "Open the command menu (bottom-right)",
132
+ },
133
+ ],
134
+ note: "Every list is fully clickable; keyboard and mouse are interchangeable.",
135
+ },
119
136
  {
120
137
  title: " Overlay Keys ",
121
138
  titleColor: "teal",
package/src/history.ts CHANGED
@@ -29,6 +29,10 @@ export function readHistory(): HistoryEntry[] {
29
29
 
30
30
  /** Delete a single history entry by index (0 = newest). */
31
31
  export function deleteHistoryEntry(index: number): void {
32
+ // The index comes from the overlay, which lists readHistory()'s deduplicated
33
+ // newest-first view — so delete against that same view. Raw lines can't be
34
+ // addressed by this index: consecutive duplicate queries are collapsed in
35
+ // the view, which shifts every raw-line position after them.
32
36
  const entries = readHistory();
33
37
  if (index < 0 || index >= entries.length) return;
34
38
  entries.splice(index, 1);
@@ -59,8 +63,3 @@ export function rotateHistory(): void {
59
63
  .join("\n")}\n`,
60
64
  );
61
65
  }
62
-
63
- /** Count entries in history file. */
64
- export function historyCount(): number {
65
- return readJSONL<HistoryEntry>(HISTORY_FILE).length;
66
- }
package/src/mcp-server.ts CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  type PackageSortMode,
39
39
  } from "./package";
40
40
  import { fetchOrgProfile, formatOrgText } from "./org";
41
- import { fetchUserProfile, formatUserJson } from "./user";
41
+ import { fetchUserProfile, formatUserText } from "./user";
42
42
  import {
43
43
  fetchDeepDive,
44
44
  buildDeepDiveText,
@@ -511,7 +511,9 @@ const TOOLS: ToolDefinition[] = [
511
511
  token: resolveToken(state, args.token),
512
512
  limit,
513
513
  });
514
- return { text: formatUserJson(profile), data: { ...profile } };
514
+ // Human-readable text like ghfind_org_profile (the structured data
515
+ // still rides along in `data` for callers that want the raw numbers).
516
+ return { text: formatUserText(profile), data: { ...profile } };
515
517
  },
516
518
  },
517
519
  {
package/src/output.ts CHANGED
@@ -22,8 +22,11 @@ export type Format =
22
22
 
23
23
  export type FormatFn = (repos: Repo[]) => string;
24
24
 
25
+ /** Accepted `--pipe` targets — the cases `pipeExec` actually handles. */
26
+ export const PIPE_TARGETS: readonly string[] = ["open", "clone"];
27
+
25
28
  /** Registry of all format functions. Adding a format = 1 line here. */
26
- const FORMATS: Map<Format, FormatFn> = new Map([
29
+ const FORMATS: Map<Format, FormatFn> = new Map<Format, FormatFn>([
27
30
  // Export formats
28
31
  ["json", formatJson],
29
32
  ["csv", formatCsv],
@@ -37,6 +40,13 @@ const FORMATS: Map<Format, FormatFn> = new Map([
37
40
  ["ids", formatIds],
38
41
  ]);
39
42
 
43
+ /**
44
+ * Every accepted `--format` value, derived from the registry above so the CLI
45
+ * can validate up front instead of throwing `Unknown format` mid-run (a plain
46
+ * typo used to route into the crash-report flow).
47
+ */
48
+ export const FORMAT_VALUES: readonly string[] = [...FORMATS.keys()];
49
+
40
50
  // ── Export format functions ────────────────────────────────────────────
41
51
 
42
52
  export function formatJson(repos: Repo[]): string {
@@ -265,6 +265,8 @@ export async function launchPackageBrowser(): Promise<void> {
265
265
  renderer.keyInput.on("keypress", (key: import("@opentui/core").KeyEvent) => {
266
266
  const name = key.name || "";
267
267
  if (name === "q" || name === "escape") {
268
+ // Release the terminal before the hard exit, same as the main TUI.
269
+ renderer.destroy();
268
270
  process.exit(0);
269
271
  return;
270
272
  }
package/src/status.ts CHANGED
@@ -12,12 +12,16 @@ export class StatusManager {
12
12
  private type: StatusType = "idle";
13
13
  private message = "";
14
14
  private onUpdate: (text: string) => void;
15
+ private disposed = false;
15
16
 
16
17
  constructor(onUpdate: (text: string) => void) {
17
18
  this.onUpdate = onUpdate;
18
19
  }
19
20
 
20
21
  set(type: StatusType, message: string): void {
22
+ // Late async continuations (a search resolving after quit) can call
23
+ // set() long after the UI is gone; a disposed manager is inert.
24
+ if (this.disposed) return;
21
25
  this.stopSpinner();
22
26
  this.type = type;
23
27
  this.message = message;
@@ -32,6 +36,7 @@ export class StatusManager {
32
36
  }
33
37
 
34
38
  private startSpinner(frames: string[]): void {
39
+ if (this.disposed) return;
35
40
  this.spinnerIndex = 0;
36
41
  this.emit(frames[0]);
37
42
  this.spinnerInterval = setInterval(() => {
@@ -48,6 +53,8 @@ export class StatusManager {
48
53
  }
49
54
 
50
55
  private emit(spinner?: string): void {
56
+ // A tick can already be queued when dispose() clears the interval.
57
+ if (this.disposed) return;
51
58
  const icon =
52
59
  this.type === "searching"
53
60
  ? "🔍"
@@ -63,6 +70,39 @@ export class StatusManager {
63
70
  }
64
71
 
65
72
  dispose(): void {
73
+ this.disposed = true;
66
74
  this.stopSpinner();
67
75
  }
68
76
  }
77
+
78
+ /**
79
+ * The status-bar renderable, narrowed to what the manager needs. TextRenderable
80
+ * satisfies this structurally (isDestroyed is a public getter on Renderable).
81
+ */
82
+ export interface StatusBarTarget {
83
+ readonly isDestroyed: boolean;
84
+ // Accessor pair, not `content: string`: TextRenderable's getter widens to
85
+ // StyledText while its setter accepts a plain string. `unknown` on the read
86
+ // side keeps the renderable assignable; we only ever write strings.
87
+ get content(): unknown;
88
+ set content(value: string);
89
+ }
90
+
91
+ /**
92
+ * Build a StatusManager wired to a status bar, plus a hook to request a frame.
93
+ *
94
+ * Writes are dropped once the renderable is destroyed. The renderer tears its
95
+ * tree down on Ctrl+C / SIGTERM / terminal close but leaves the event loop
96
+ * running, so an unguarded spinner tick would assign to a destroyed TextBuffer
97
+ * and throw out of a timer callback, taking the whole process with it.
98
+ */
99
+ export function createStatusManager(
100
+ bar: StatusBarTarget,
101
+ onFrame: () => void,
102
+ ): StatusManager {
103
+ return new StatusManager((text) => {
104
+ if (bar.isDestroyed) return;
105
+ bar.content = ` ${text}`;
106
+ onFrame();
107
+ });
108
+ }
package/src/trending.ts CHANGED
@@ -398,7 +398,7 @@ export async function launchTrending(): Promise<void> {
398
398
  paddingX: 1,
399
399
  });
400
400
  const footerText = new TextRenderable(renderer, {
401
- content: " ↑↓ navigate ↵ open / search 1-3 tab q quit r refresh",
401
+ content: " ↑↓ navigate ↵ open / top 1-3 tab q quit r refresh",
402
402
  fg: C.muted,
403
403
  height: 1,
404
404
  });
@@ -430,6 +430,8 @@ export async function launchTrending(): Promise<void> {
430
430
  renderer.keyInput.on("keypress", (key) => {
431
431
  const name = key.name || "";
432
432
  if (name === "q") {
433
+ // Release the terminal before the hard exit, same as the main TUI.
434
+ renderer.destroy();
433
435
  process.exit(0);
434
436
  return;
435
437
  }