github-search-cli 0.8.1 → 0.9.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.
package/src/tui.ts CHANGED
@@ -32,40 +32,105 @@ import {
32
32
  ScrollBoxRenderable,
33
33
  } from "@opentui/core";
34
34
  import type { Repo, SearchOptions, SortStrategy } from "./types";
35
- import { parseQuery, applyFlagFilters, validateQuery, suggestFor, rankRepos, createGitHubSearch, type Logger } from "./search";
36
- import { fetchTrendingRepos, tabSince, TAB_NAMES, fmtStars, type TrendingRepo } from "./trending";
35
+ import {
36
+ parseQuery,
37
+ applyFlagFilters,
38
+ validateQuery,
39
+ suggestFor,
40
+ rankRepos,
41
+ createGitHubSearch,
42
+ SearchModule,
43
+ TrendingAdapter,
44
+ type Logger,
45
+ } from "./search";
46
+ import {
47
+ tabSince,
48
+ TAB_NAMES,
49
+ fmtStars,
50
+ } from "./trending";
37
51
  import { loadConfig } from "./config";
38
52
  import { buildHelpSections, HELP_KEYS_COLUMN } from "./help";
39
- import { SearchCliError, NetworkError, RateLimitError, BadQueryError, NoResultsError } from "./errors";
40
- import { appendHistory, readHistory, deleteHistoryEntry, clearHistory, rotateHistory } from "./history";
41
- import { getBookmarks, isBookmarked, toggleBookmark, removeBookmark } from "./bookmarks";
42
- import { getSavedSearches, saveSearch, deleteSavedSearch, touchSavedSearch } from "./saved-searches";
53
+ import {
54
+ SearchCliError,
55
+ NetworkError,
56
+ RateLimitError,
57
+ BadQueryError,
58
+ NoResultsError,
59
+ } from "./errors";
60
+ import {
61
+ appendHistory,
62
+ readHistory,
63
+ deleteHistoryEntry,
64
+ clearHistory,
65
+ rotateHistory,
66
+ } from "./history";
67
+ import {
68
+ getBookmarks,
69
+ isBookmarked,
70
+ toggleBookmark,
71
+ removeBookmark,
72
+ } from "./bookmarks";
73
+ import {
74
+ getSavedSearches,
75
+ saveSearch,
76
+ deleteSavedSearch,
77
+ touchSavedSearch,
78
+ } from "./saved-searches";
43
79
  import { saveSession, restoreSession } from "./session";
44
80
  import { fetchDeepDive, buildDeepDiveText } from "./deepdive";
45
81
  import { buildComparisonTable } from "./compare";
46
82
  import { fetchTopics, type TopicItem } from "./explore";
47
- import { exportToFile, type ExportFormat } from "./export";
83
+ import { exportToFile, type ExportFormat } from "./output";
48
84
  import { loadTheme } from "./themes";
49
85
  import { StatusManager } from "./status";
50
- import { addNotification, getNotifications, dismissNotification, dismissAll } from "./notifications";
86
+ import {
87
+ addNotification,
88
+ getNotifications,
89
+ dismissNotification,
90
+ dismissAll,
91
+ } from "./notifications";
51
92
  import { formatShare, copyToClipboard, type ShareFormat } from "./share";
52
93
  import { nextTip } from "./tips";
53
94
  import { openUrl } from "./open-url";
95
+ import { readFileSync } from "fs";
96
+ import { join, dirname } from "path";
97
+ import { fileURLToPath } from "url";
98
+ const __dirname = dirname(fileURLToPath(import.meta.url));
99
+ const PKG_DIR = join(__dirname, "..");
54
100
 
55
- // ─── Theme ────────────────────────────────────────────────────────────
101
+ let cachedVersion: string | null = null;
102
+ import {
103
+ checkForUpdate,
104
+ shouldCheckUpdate,
105
+ markUpdateChecked,
106
+ snoozeUpdateNotices,
107
+ suppressUpdateNotices,
108
+ performUpdate,
109
+ } from "./update-check";
110
+ import { debugLog } from "./storage";
56
111
  const colors = {
57
- bg: "#1e1e2e",
58
- surface: "#181825",
59
- text: "#cdd6f4",
60
- muted: "#6c7086",
112
+ bg: "#3D3B3B",
113
+ surface: "#4a4848",
114
+ surfaceAlt: "#525050",
115
+ text: "#e0e4f0",
116
+ muted: "#a8a6a6",
61
117
  blue: "#89b4fa",
62
118
  green: "#a6e3a1",
63
119
  yellow: "#f9e2af",
64
120
  red: "#f38ba8",
65
121
  teal: "#94e2d5",
66
- border: "#45475a",
67
- selectionBg: "#2d5bcf",
122
+ purple: "#cba6f7",
123
+ orange: "#fab387",
124
+ border: "#1C1C1C",
125
+ borderAlt: "#2a2a2a",
126
+ separator: "#1C1C1C", // Dark thin colorblock - thicker than border, visible separation
127
+ selectionBg: "#2A2A9C",
68
128
  selectionText: "#ffffff",
129
+ // Premium accents for the command menu
130
+ accent: "#89b4fa",
131
+ accentDim: "#587cf5",
132
+ surfaceDim: "#1a1919",
133
+ borderAccent: "#1a1919",
69
134
  };
70
135
 
71
136
  // ─── Logger ───────────────────────────────────────────────────────────
@@ -90,11 +155,21 @@ export async function launchBrowser(): Promise<void> {
90
155
  try {
91
156
  renderer = await createCliRenderer();
92
157
  } catch (err) {
93
- console.error(
94
- "Failed to start the interactive browser. OpenTUI native backend unavailable.\n",
95
- `Reason: ${err instanceof Error ? err.message : String(err)}\n`,
96
- "Install Zig or use a platform with native OpenTUI support.",
97
- );
158
+ const isNode = process.versions.bun === undefined;
159
+ if (isNode) {
160
+ console.error("\n❌ The interactive TUI requires Bun runtime.\n");
161
+ console.error(" Install Bun: curl -fsSL https://bun.sh/install | bash");
162
+ console.error(" Then run: bunx ghfind");
163
+ console.error("\n Or use non-interactive mode (works on Node.js):");
164
+ console.error(" ghfind \"language:Rust\" --json | jq '.[].fullName'");
165
+ console.error(" ghfind --trending --count");
166
+ } else {
167
+ console.error(
168
+ "Failed to start the interactive browser. OpenTUI native backend unavailable.\n",
169
+ `Reason: ${err instanceof Error ? err.message : String(err)}\n`,
170
+ "Install Zig or use a platform with native OpenTUI support.",
171
+ );
172
+ }
98
173
  process.exit(1);
99
174
  return;
100
175
  }
@@ -121,33 +196,48 @@ export async function launchBrowser(): Promise<void> {
121
196
  let currentQueryInput = session?.query ?? "";
122
197
  let isLoading = false;
123
198
  let currentMode: "search" | "trending" = session?.mode ?? "search";
124
- let trendingTab: typeof TAB_NAMES[number] = (session?.trendingTab as typeof TAB_NAMES[number]) ?? "This Week";
199
+ let trendingTab: (typeof TAB_NAMES)[number] =
200
+ (session?.trendingTab as (typeof TAB_NAMES)[number]) ?? "This Week";
125
201
  let trendingPeriod = "this week";
126
202
  let graphFullscreen = false;
127
203
  let chartCommitData: number[] = [];
128
- let currentOverlay: "none" | "history" | "bookmarks" | "saved" | "help" | "topics" | "export" | "compare" | "notifications" | "share" | "leader" | "readme" = "none";
204
+ let currentOverlay:
205
+ | "none"
206
+ | "history"
207
+ | "bookmarks"
208
+ | "saved"
209
+ | "help"
210
+ | "topics"
211
+ | "export"
212
+ | "compare"
213
+ | "notifications"
214
+ | "share"
215
+ | "leader"
216
+ | "readme"
217
+ | "update" = "none";
129
218
  let currentPage = 1;
130
219
  let totalCount = 0;
131
220
  let deepDiveActive = false;
132
221
  let compareList: Repo[] = [];
133
222
  let quitArmed = false;
134
223
 
135
-
136
224
  // ── Header bar ──────────────────────────────────────────────────────
137
225
  const header = new TextRenderable(renderer, {
138
226
  content:
139
- " ghfind — GitHub repo browser [/]search [Space]menu [?]help [q]uit",
227
+ " ghfind — GitHub repo browser [/]search [Space]menu [\u2192]open [?]help [q]uit",
140
228
  backgroundColor: colors.bg,
141
229
  color: colors.muted,
142
230
  height: 1,
231
+ borderBottom: true,
232
+ borderBottomColor: colors.border,
143
233
  });
144
- root.add(header);
145
234
 
146
235
  // ── Trending tab bar (hidden in search mode) ────────────────────────
147
236
  const trendingTabBox = new BoxRenderable(renderer, {
148
237
  flexDirection: "row",
149
238
  height: 1,
150
- backgroundColor: colors.bg,
239
+ backgroundColor: colors.surface,
240
+ // No border — pure contract-edge separation via text contrast
151
241
  paddingX: 1,
152
242
  visible: false,
153
243
  });
@@ -180,30 +270,45 @@ export async function launchBrowser(): Promise<void> {
180
270
  }
181
271
  });
182
272
  }
273
+ root.add(header);
183
274
  root.add(trendingTabBox);
184
275
 
185
276
  // ── Search input row ────────────────────────────────────────────────
186
277
  const searchBox = new BoxRenderable(renderer, {
187
- visible: true, // hidden in trending mode
188
- border: true,
189
- borderColor: colors.blue,
190
- paddingX: 1,
191
- height: 3,
278
+ visible: true,
279
+ borderBottom: true,
280
+ borderBottomColor: colors.border,
281
+ title: " SEARCH ",
282
+ titleColor: colors.blue,
283
+ width: "100%",
284
+ flexDirection: "row",
285
+ paddingTop: 0,
286
+ paddingBottom: 0,
287
+ backgroundColor: colors.surface,
192
288
  });
193
289
  const searchInput = new InputRenderable(renderer, {
194
- placeholder: "Search GitHub repos (e.g. rust cli, or language:Rust stars:>100)",
290
+ placeholder:
291
+ "Search GitHub repos (e.g. rust cli, or language:Rust stars:>100)",
195
292
  value: "",
293
+ backgroundColor: colors.surface,
294
+ textColor: colors.text,
295
+ borderColor: colors.blue,
296
+ selectedBorderColor: colors.blue,
297
+ paddingX: 0,
298
+ flexGrow: 1,
196
299
  });
197
300
  searchBox.add(searchInput);
198
301
  root.add(searchBox);
199
302
 
200
303
  // ── Toolbar (sort + limit indicator) ────────────────────────────────
201
304
  const toolbarText = new TextRenderable(renderer, {
202
- content: formatToolbar(currentSort, currentLimit),
305
+ content: formatToolbar(currentSort, currentLimit, totalCount),
203
306
  visible: true, // hidden in trending mode
204
- backgroundColor: colors.bg,
307
+ backgroundColor: colors.surface, // Surface panel
205
308
  color: colors.muted,
206
309
  height: 1,
310
+ borderBottom: true,
311
+ borderBottomColor: colors.border,
207
312
  paddingX: 1,
208
313
  });
209
314
  root.add(toolbarText);
@@ -219,8 +324,8 @@ export async function launchBrowser(): Promise<void> {
219
324
 
220
325
  // Results pane
221
326
  const resultsBox = new BoxRenderable(renderer, {
222
- border: true,
223
- borderColor: colors.border,
327
+ borderBottom: true,
328
+ borderBottomColor: colors.border,
224
329
  title: " Results ",
225
330
  titleColor: colors.blue,
226
331
  width: "50%",
@@ -229,11 +334,16 @@ export async function launchBrowser(): Promise<void> {
229
334
  focusable: false,
230
335
  });
231
336
  const resultsSelect = new SelectRenderable(renderer, {
232
- options: [{ name: "", description: "Type a query and press Enter to search", value: null }],
337
+ options: [
338
+ {
339
+ name: "",
340
+ description: "Type a query and press Enter to search",
341
+ value: null,
342
+ },
343
+ ],
233
344
  showDescription: false,
234
345
  showSelectionIndicator: false,
235
346
  flexGrow: 1,
236
- backgroundColor: colors.bg,
237
347
  textColor: colors.text,
238
348
  selectedBackgroundColor: colors.selectionBg,
239
349
  selectedTextColor: colors.selectionText,
@@ -245,8 +355,8 @@ export async function launchBrowser(): Promise<void> {
245
355
 
246
356
  // Detail / Graph pane
247
357
  const detailBox = new BoxRenderable(renderer, {
248
- border: true,
249
- borderColor: colors.border,
358
+ borderBottom: true,
359
+ borderBottomColor: colors.border,
250
360
  title: " Details ",
251
361
  titleColor: colors.green,
252
362
  width: "50%",
@@ -254,6 +364,7 @@ export async function launchBrowser(): Promise<void> {
254
364
  paddingX: 1,
255
365
  paddingY: 1,
256
366
  focusable: false,
367
+ backgroundColor: colors.surface,
257
368
  });
258
369
  const detailText = new TextRenderable(renderer, {
259
370
  content: "",
@@ -265,8 +376,6 @@ export async function launchBrowser(): Promise<void> {
265
376
  body.add(detailBox);
266
377
  root.add(body);
267
378
 
268
-
269
-
270
379
  // ── Overlay system ──────────────────────────────────────────────────
271
380
 
272
381
  // Hide main content so overlays can take 100% of the content area
@@ -298,7 +407,10 @@ export async function launchBrowser(): Promise<void> {
298
407
  notifsBox.visible = false;
299
408
  shareBox.visible = false;
300
409
  leaderBox.visible = false;
410
+ leaderDim.visible = false;
301
411
  readmeBox.visible = false;
412
+ updateBox.visible = false;
413
+ updateDim.visible = false;
302
414
 
303
415
  if (type === "none") {
304
416
  currentOverlay = "none";
@@ -307,18 +419,38 @@ export async function launchBrowser(): Promise<void> {
307
419
  return;
308
420
  }
309
421
  currentOverlay = type;
310
- hideMainContent();
311
- if (type === "help") { helpBox.visible = true; }
312
- else if (type === "history") { historyBox.visible = true; }
313
- else if (type === "bookmarks") { bookmarksBox.visible = true; }
314
- else if (type === "saved") { savedBox.visible = true; }
315
- else if (type === "topics") { topicsBox.visible = true; }
316
- else if (type === "export") { exportBox.visible = true; }
317
- else if (type === "compare") { compareBox.visible = true; }
318
- else if (type === "notifications") { notifsBox.visible = true; }
319
- else if (type === "share") { shareBox.visible = true; }
320
- else if (type === "leader") { leaderBox.visible = true; }
321
- else if (type === "readme") { readmeBox.visible = true; }
422
+ // Leader menu is a floating overlay — main content stays visible behind it
423
+ if (type !== "leader") {
424
+ hideMainContent();
425
+ }
426
+ if (type === "help") {
427
+ helpBox.visible = true;
428
+ } else if (type === "history") {
429
+ historyBox.visible = true;
430
+ } else if (type === "bookmarks") {
431
+ bookmarksBox.visible = true;
432
+ } else if (type === "saved") {
433
+ savedBox.visible = true;
434
+ } else if (type === "topics") {
435
+ topicsBox.visible = true;
436
+ } else if (type === "export") {
437
+ exportBox.visible = true;
438
+ } else if (type === "compare") {
439
+ compareBox.visible = true;
440
+ } else if (type === "notifications") {
441
+ notifsBox.visible = true;
442
+ } else if (type === "share") {
443
+ shareBox.visible = true;
444
+ } else if (type === "leader") {
445
+ leaderDim.visible = true;
446
+ leaderBox.visible = true;
447
+ } else if (type === "readme") {
448
+ readmeBox.visible = true;
449
+ } else if (type === "update") {
450
+ updateDim.visible = true;
451
+ updateBox.visible = true;
452
+ updateSelect.focus();
453
+ }
322
454
  renderer.requestRender();
323
455
  }
324
456
 
@@ -328,8 +460,8 @@ export async function launchBrowser(): Promise<void> {
328
460
  visible: false,
329
461
  flexGrow: 1,
330
462
  backgroundColor: colors.bg,
331
- border: true,
332
- borderColor: colors.border,
463
+ borderBottom: true,
464
+ borderBottomColor: colors.border,
333
465
  });
334
466
  const helpScroll = new ScrollBoxRenderable(renderer, {
335
467
  flexGrow: 1,
@@ -347,10 +479,12 @@ export async function launchBrowser(): Promise<void> {
347
479
  });
348
480
  for (const section of helpSections) {
349
481
  const sectionBox = new BoxRenderable(renderer, {
350
- border: true,
351
- borderColor: colors.border,
482
+ borderBottom: true,
483
+ borderBottomColor: colors.border,
352
484
  title: section.title,
353
- titleColor: (colors as Record<string, string>)[section.titleColor] ?? section.titleColor,
485
+ titleColor:
486
+ (colors as Record<string, string>)[section.titleColor] ??
487
+ section.titleColor,
354
488
  flexDirection: "column",
355
489
  paddingY: 0,
356
490
  marginLeft: 1,
@@ -359,16 +493,20 @@ export async function launchBrowser(): Promise<void> {
359
493
  });
360
494
  for (const row of section.rows) {
361
495
  const keys = row.keys.padEnd(HELP_KEYS_COLUMN);
362
- sectionBox.add(new TextRenderable(renderer, {
363
- content: ` ${keys} ${row.action}`,
364
- color: colors.text,
365
- }));
496
+ sectionBox.add(
497
+ new TextRenderable(renderer, {
498
+ content: ` ${keys} ${row.action}`,
499
+ color: colors.text,
500
+ }),
501
+ );
366
502
  }
367
503
  if (section.note) {
368
- sectionBox.add(new TextRenderable(renderer, {
369
- content: ` ${section.note}`,
370
- color: colors.muted,
371
- }));
504
+ sectionBox.add(
505
+ new TextRenderable(renderer, {
506
+ content: ` ${section.note}`,
507
+ color: colors.muted,
508
+ }),
509
+ );
372
510
  }
373
511
  helpScroll.add(sectionBox);
374
512
  }
@@ -380,8 +518,8 @@ export async function launchBrowser(): Promise<void> {
380
518
  visible: false,
381
519
  flexGrow: 1,
382
520
  backgroundColor: colors.bg,
383
- border: true,
384
- borderColor: colors.border,
521
+ borderBottom: true,
522
+ borderBottomColor: colors.border,
385
523
  title: " Search History ",
386
524
  titleColor: colors.blue,
387
525
  });
@@ -402,7 +540,9 @@ export async function launchBrowser(): Promise<void> {
402
540
  function refreshHistory() {
403
541
  const entries = readHistory();
404
542
  if (entries.length === 0) {
405
- historySelect.options = [{ name: "(no history)", description: "", value: null }];
543
+ historySelect.options = [
544
+ { name: "(no history)", description: "", value: null },
545
+ ];
406
546
  } else {
407
547
  historySelect.options = entries.map((e, i) => ({
408
548
  name: `${e.query} — ${e.mode}${e.tab ? ` (${e.tab})` : ""}`,
@@ -418,8 +558,8 @@ export async function launchBrowser(): Promise<void> {
418
558
  visible: false,
419
559
  flexGrow: 1,
420
560
  backgroundColor: colors.bg,
421
- border: true,
422
- borderColor: colors.border,
561
+ borderBottom: true,
562
+ borderBottomColor: colors.border,
423
563
  title: " Bookmarks ",
424
564
  titleColor: colors.green,
425
565
  });
@@ -440,7 +580,9 @@ export async function launchBrowser(): Promise<void> {
440
580
  function refreshBookmarks() {
441
581
  const bookmarks = getBookmarks();
442
582
  if (bookmarks.length === 0) {
443
- bookmarksSelect.options = [{ name: "(no bookmarks)", description: "", value: null }];
583
+ bookmarksSelect.options = [
584
+ { name: "(no bookmarks)", description: "", value: null },
585
+ ];
444
586
  } else {
445
587
  bookmarksSelect.options = bookmarks.map((b) => ({
446
588
  name: `${b.repo.fullName} ★ ${b.repo.stars.toLocaleString()}`,
@@ -456,8 +598,8 @@ export async function launchBrowser(): Promise<void> {
456
598
  visible: false,
457
599
  flexGrow: 1,
458
600
  backgroundColor: colors.bg,
459
- border: true,
460
- borderColor: colors.border,
601
+ borderBottom: true,
602
+ borderBottomColor: colors.border,
461
603
  title: " Saved Searches ",
462
604
  titleColor: colors.yellow,
463
605
  });
@@ -480,8 +622,8 @@ export async function launchBrowser(): Promise<void> {
480
622
  visible: false,
481
623
  flexGrow: 1,
482
624
  backgroundColor: colors.bg,
483
- border: true,
484
- borderColor: colors.border,
625
+ borderBottom: true,
626
+ borderBottomColor: colors.border,
485
627
  title: " Topics ",
486
628
  titleColor: colors.purple ?? colors.blue,
487
629
  });
@@ -508,7 +650,9 @@ export async function launchBrowser(): Promise<void> {
508
650
  value: t,
509
651
  }));
510
652
  } catch {
511
- topicsSelect.options = [{ name: "(failed to load topics)", description: "", value: null }];
653
+ topicsSelect.options = [
654
+ { name: "(failed to load topics)", description: "", value: null },
655
+ ];
512
656
  }
513
657
  topicsSelect.setSelectedIndex(0);
514
658
  }
@@ -518,16 +662,28 @@ export async function launchBrowser(): Promise<void> {
518
662
  visible: false,
519
663
  flexGrow: 1,
520
664
  backgroundColor: colors.bg,
521
- border: true,
522
- borderColor: colors.border,
665
+ borderBottom: true,
666
+ borderBottomColor: colors.border,
523
667
  title: " Export ",
524
668
  titleColor: colors.green,
525
669
  });
526
670
  const exportSelect = new SelectRenderable(renderer, {
527
671
  options: [
528
- { name: " JSON", description: "Full repo data as JSON array", value: "json" },
529
- { name: " CSV", description: "Rank, name, stars, forks, language, URL", value: "csv" },
530
- { name: " Markdown", description: "Pretty table with links (paste into README)", value: "markdown" },
672
+ {
673
+ name: " JSON",
674
+ description: "Full repo data as JSON array",
675
+ value: "json",
676
+ },
677
+ {
678
+ name: " CSV",
679
+ description: "Rank, name, stars, forks, language, URL",
680
+ value: "csv",
681
+ },
682
+ {
683
+ name: " Markdown",
684
+ description: "Pretty table with links (paste into README)",
685
+ value: "markdown",
686
+ },
531
687
  { name: " Plain text", description: "One repo per line", value: "text" },
532
688
  ],
533
689
  showDescription: true,
@@ -547,8 +703,8 @@ export async function launchBrowser(): Promise<void> {
547
703
  visible: false,
548
704
  flexGrow: 1,
549
705
  backgroundColor: colors.bg,
550
- border: true,
551
- borderColor: colors.border,
706
+ borderBottom: true,
707
+ borderBottomColor: colors.border,
552
708
  title: " Comparison ",
553
709
  titleColor: colors.yellow,
554
710
  });
@@ -565,8 +721,8 @@ export async function launchBrowser(): Promise<void> {
565
721
  visible: false,
566
722
  flexGrow: 1,
567
723
  backgroundColor: colors.bg,
568
- border: true,
569
- borderColor: colors.border,
724
+ borderBottom: true,
725
+ borderBottomColor: colors.border,
570
726
  title: " Notifications ",
571
727
  titleColor: colors.yellow,
572
728
  });
@@ -594,16 +750,28 @@ export async function launchBrowser(): Promise<void> {
594
750
  visible: false,
595
751
  flexGrow: 1,
596
752
  backgroundColor: colors.bg,
597
- border: true,
598
- borderColor: colors.border,
753
+ borderBottom: true,
754
+ borderBottomColor: colors.border,
599
755
  title: " Share Repo ",
600
756
  titleColor: colors.blue,
601
757
  });
602
758
  const shareSelect = new SelectRenderable(renderer, {
603
759
  options: [
604
- { name: " Markdown", description: "[owner/repo](url) — description", value: "markdown" },
605
- { name: " Plain", description: "owner/repo — description — url", value: "plain" },
606
- { name: " GitHub CLI", description: "gh repo view owner/repo", value: "gh-cli" },
760
+ {
761
+ name: " Markdown",
762
+ description: "[owner/repo](url) — description",
763
+ value: "markdown",
764
+ },
765
+ {
766
+ name: " Plain",
767
+ description: "owner/repo — description — url",
768
+ value: "plain",
769
+ },
770
+ {
771
+ name: " GitHub CLI",
772
+ description: "gh repo view owner/repo",
773
+ value: "gh-cli",
774
+ },
607
775
  { name: " Short", description: "owner/repo", value: "short" },
608
776
  ],
609
777
  showDescription: true,
@@ -620,7 +788,8 @@ export async function launchBrowser(): Promise<void> {
620
788
 
621
789
  function refreshCompare() {
622
790
  if (compareList.length < 2) {
623
- compareText.content = "Select at least 2 repos to compare (press c to add).";
791
+ compareText.content =
792
+ "Select at least 2 repos to compare (press c to add).";
624
793
  } else {
625
794
  compareText.content = buildComparisonTable(compareList);
626
795
  }
@@ -629,7 +798,9 @@ export async function launchBrowser(): Promise<void> {
629
798
  function refreshSavedSearches() {
630
799
  const saved = getSavedSearches();
631
800
  if (saved.length === 0) {
632
- savedSelect.options = [{ name: "(no saved searches)", description: "", value: null }];
801
+ savedSelect.options = [
802
+ { name: "(no saved searches)", description: "", value: null },
803
+ ];
633
804
  } else {
634
805
  savedSelect.options = saved.map((s) => ({
635
806
  name: `${s.name}`,
@@ -641,96 +812,547 @@ export async function launchBrowser(): Promise<void> {
641
812
  }
642
813
 
643
814
  // ── Leader menu overlay (Space key) ─────────────────────────────
815
+ // Dim overlay — sits behind the menu, above main content
816
+ const leaderDim = new BoxRenderable(renderer, {
817
+ visible: false,
818
+ position: "absolute",
819
+ top: 0,
820
+ left: 0,
821
+ width: "100%",
822
+ height: "100%",
823
+ backgroundColor: "#00000099", // 60% opacity black (hex alpha: 0x99 ≈ 153/255)
824
+ });
825
+ root.add(leaderDim);
826
+
644
827
  const leaderBox = new BoxRenderable(renderer, {
645
828
  visible: false,
646
- flexGrow: 1,
647
- backgroundColor: colors.bg,
829
+ position: "absolute",
830
+ width: "80%",
831
+ height: 18,
832
+ left: "10%",
833
+ top: "30%",
834
+ backgroundColor: colors.surfaceDim,
648
835
  border: true,
649
- borderColor: colors.border,
650
- title: " Menu ",
651
- titleColor: colors.blue,
836
+ borderStyle: "rounded",
837
+ borderColor: colors.borderAccent,
838
+ title: " ⚙ Menu ",
839
+ titleColor: colors.accent,
840
+ titleAlignment: "left",
652
841
  flexDirection: "column",
842
+ paddingX: 1,
653
843
  });
654
844
  const leaderSelect = new SelectRenderable(renderer, {
655
845
  options: [{ name: "(empty)", description: "", value: null }],
656
846
  showDescription: true,
657
847
  showSelectionIndicator: true,
658
848
  flexGrow: 1,
659
- backgroundColor: colors.bg,
849
+ backgroundColor: colors.surfaceDim,
660
850
  textColor: colors.text,
661
- selectedBackgroundColor: colors.selectionBg,
851
+ focusedBackgroundColor: colors.surfaceDim,
852
+ focusedTextColor: colors.text,
853
+ selectedBackgroundColor: colors.accent,
662
854
  selectedTextColor: colors.selectionText,
855
+ descriptionColor: colors.muted,
856
+ selectedDescriptionColor: colors.selectionText,
663
857
  itemSpacing: 0,
664
858
  });
665
859
  const leaderFooter = new TextRenderable(renderer, {
666
- content: " ↑↓ select Enter run Esc/q close",
860
+ content: " ↑↓ select Enter run Esc/q close ",
667
861
  color: colors.muted,
668
- backgroundColor: colors.bg,
862
+ backgroundColor: colors.surfaceDim,
669
863
  height: 1,
670
- paddingX: 1,
864
+ borderTop: true,
865
+ borderTopColor: colors.border,
866
+ paddingX: 2,
671
867
  });
672
868
  leaderBox.add(leaderSelect);
673
869
  leaderBox.add(leaderFooter);
870
+ root.add(leaderDim);
674
871
  root.add(leaderBox);
675
872
 
676
- interface LeaderItem { name: string; description: string; action: () => void; }
873
+ // ── Update panel overlay ────────────────────────────────────────
874
+ const updateDim = new BoxRenderable(renderer, {
875
+ visible: false,
876
+ position: "absolute",
877
+ top: 0,
878
+ left: 0,
879
+ width: "100%",
880
+ height: "100%",
881
+ backgroundColor: "#00000088",
882
+ });
677
883
 
678
- function showLeaderMenu() {
679
- const items: LeaderItem[] = [];
884
+ root.add(updateDim);
680
885
 
681
- if (currentMode === "search") {
682
- items.push(
683
- { name: " Sort", description: `Cycle sort (current: ${currentSort})`, action: () => changeSort() },
684
- { name: " Limit", description: `Cycle result limit (current: ${currentLimit})`, action: () => changeLimit() },
685
- { name: " Refresh", description: "Re-run current search", action: () => { if (currentQueryInput) doSearch(currentQueryInput); } },
686
- { name: " Toggle trending", description: "Browse GitHub trending repos", action: () => loadTrending() },
687
- { name: " Deep-dive", description: "Languages, contributors, README excerpt", action: () => { showOverlay("none"); triggerDeepDive(); } },
688
- { name: " Readme", description: "Full README viewer", action: () => { showReadme(); } },
689
- { name: " Activity graph", description: "Toggle commit chart fullscreen", action: () => toggleGraph() },
690
- { name: " Bookmark", description: "Save / unsave selected repo", action: () => toggleBookmarkOnSelected() },
691
- { name: " Compare", description: "Add/remove repo to comparison", action: () => toggleCompareOnSelected() },
692
- );
693
- }
886
+ const updateBox = new BoxRenderable(renderer, {
887
+ visible: false,
888
+ position: "absolute",
889
+ width: "55%",
890
+ height: 16,
891
+ left: "22.5%",
892
+ top: "25%",
893
+ backgroundColor: colors.surfaceDim,
894
+ border: true,
895
+ borderStyle: "rounded",
896
+ borderColor: colors.border,
897
+ title: " ghfind ",
898
+ titleColor: colors.accent,
899
+ titleAlignment: "left",
900
+ flexDirection: "column",
901
+ paddingX: 2,
902
+ paddingLeft: 2,
903
+ paddingRight: 2,
904
+ });
905
+ const updateText = new TextRenderable(renderer, {
906
+ content: "",
907
+ color: colors.accent,
908
+ backgroundColor: colors.surfaceDim,
909
+ });
694
910
 
695
- if (currentMode === "trending") {
696
- items.push(
697
- { name: " Refresh", description: "Reload trending repos", action: () => loadTrending() },
698
- { name: " Search mode", description: "Switch to query search", action: () => showSearchMode() },
699
- { name: " Readme", description: "Full README viewer", action: () => { showReadme(); } },
700
- { name: " Bookmark", description: "Save / unsave selected repo", action: () => toggleBookmarkOnSelected() },
701
- );
702
- }
911
+ // Horizontal option boxes
912
+ let updateSelectedOption = 0;
913
+
914
+ const updateOptionsRow = new BoxRenderable(renderer, {
915
+ flexDirection: "row",
916
+ gap: 1,
917
+ backgroundColor: colors.surfaceDim,
918
+ height: 3,
919
+ width: "100%",
920
+ });
921
+
922
+ const updateNowBox = new BoxRenderable(renderer, {
923
+ border: true,
924
+ borderColor: updateSelectedOption === 0 ? colors.yellow : colors.border,
925
+ backgroundColor: colors.surfaceDim,
926
+ height: 3,
927
+ width: "50%",
928
+ paddingLeft: 1,
929
+ paddingRight: 1,
930
+ });
931
+ const updateNowText = new TextRenderable(renderer, {
932
+ content: " Update Now ",
933
+ color: updateSelectedOption === 0 ? colors.yellow : colors.text,
934
+ backgroundColor: colors.surfaceDim,
935
+ });
936
+ updateNowBox.add(updateNowText);
937
+
938
+ const laterBox = new BoxRenderable(renderer, {
939
+ border: true,
940
+ borderColor: updateSelectedOption === 1 ? colors.yellow : colors.border,
941
+ backgroundColor: colors.surfaceDim,
942
+ height: 3,
943
+ width: "50%",
944
+ paddingLeft: 1,
945
+ paddingRight: 1,
946
+ });
947
+ const laterText = new TextRenderable(renderer, {
948
+ content: " Later",
949
+ color: updateSelectedOption === 1 ? colors.yellow : colors.text,
950
+ backgroundColor: colors.surfaceDim,
951
+ });
952
+ laterBox.add(laterText);
953
+
954
+ const neverBox = new BoxRenderable(renderer, {
955
+ border: true,
956
+ borderColor: updateSelectedOption === 2 ? colors.yellow : colors.border,
957
+ backgroundColor: colors.surfaceDim,
958
+ height: 3,
959
+ width: "50%",
960
+ paddingLeft: 1,
961
+ paddingRight: 1,
962
+ });
963
+ const neverText = new TextRenderable(renderer, {
964
+ content: " Don't show again",
965
+ color: updateSelectedOption === 2 ? colors.yellow : colors.text,
966
+ backgroundColor: colors.surfaceDim,
967
+ });
968
+ neverBox.add(neverText);
969
+
970
+ function renderUpdateOptions() {
971
+ updateNowBox.borderColor = updateSelectedOption === 0 ? colors.yellow : colors.border;
972
+ laterBox.borderColor = updateSelectedOption === 1 ? colors.yellow : colors.border;
973
+ neverBox.borderColor = updateSelectedOption === 2 ? colors.yellow : colors.border;
974
+ updateNowText.color = updateSelectedOption === 0 ? colors.yellow : colors.text;
975
+ laterText.color = updateSelectedOption === 1 ? colors.yellow : colors.text;
976
+ neverText.color = updateSelectedOption === 2 ? colors.yellow : colors.text;
977
+ renderer.requestRender();
978
+ }
979
+
980
+ updateOptionsRow.add(updateNowBox);
981
+ updateOptionsRow.add(laterBox);
982
+
983
+ const updateFooter = new TextRenderable(renderer, {
984
+ content: " ←→ select Enter Esc/q close",
985
+ color: colors.muted,
986
+ backgroundColor: colors.surfaceDim,
987
+ height: 1,
988
+ paddingX: 1,
989
+ });
990
+ updateBox.add(updateText);
991
+ updateBox.add(updateOptionsRow);
992
+ updateBox.add(updateFooter);
993
+ root.add(updateDim);
994
+ root.add(updateBox);
995
+
996
+ // ── Hierarchical menu types ──────────────────────────────────────
997
+ interface MenuAction {
998
+ type: "action";
999
+ name: string;
1000
+ description: string;
1001
+ action: () => void;
1002
+ }
1003
+
1004
+ interface MenuCategory {
1005
+ type: "category";
1006
+ name: string;
1007
+ description: string;
1008
+ icon: string;
1009
+ children: MenuEntry[];
1010
+ }
1011
+
1012
+ type MenuEntry = MenuAction | MenuCategory;
1013
+
1014
+ // ── Submenu navigation ──────────────────────────────────────────
1015
+ interface MenuLevel {
1016
+ title: string;
1017
+ parentTitle: string;
1018
+ entries: MenuEntry[];
1019
+ selectedIndex: number;
1020
+ }
1021
+
1022
+ const menuStack: MenuLevel[] = [];
1023
+
1024
+ function pushMenuLevel(
1025
+ title: string,
1026
+ parentTitle: string,
1027
+ entries: MenuEntry[],
1028
+ selectIndex = 0,
1029
+ ) {
1030
+ menuStack.push({
1031
+ title,
1032
+ parentTitle,
1033
+ entries,
1034
+ selectedIndex: leaderSelect.getSelectedIndex?.(),
1035
+ });
1036
+ leaderSelect.options = entries.map((e) => ({
1037
+ name:
1038
+ e.type === "category" ? ` ${e.icon} ${e.name} \u2192` : ` ${e.name}`,
1039
+ description: e.description,
1040
+ value: e,
1041
+ }));
1042
+ leaderSelect.setSelectedIndex(selectIndex);
1043
+ leaderBox.title = title;
1044
+ const isTop = menuStack.length === 1;
1045
+ leaderFooter.content = isTop
1046
+ ? " \u2191\u2193 select \u2192 open category Enter run Esc/q close"
1047
+ : " Esc back \u2191\u2193 select Enter run";
1048
+ }
1049
+
1050
+ function popMenuLevel(): boolean {
1051
+ if (menuStack.length <= 1) return false;
1052
+ const prev = menuStack[menuStack.length - 2];
1053
+ const current = menuStack.pop()!;
1054
+ leaderSelect.options = prev.entries.map((e) => ({
1055
+ name:
1056
+ e.type === "category" ? ` ${e.icon} ${e.name} \u2192` : ` ${e.name}`,
1057
+ description: e.description,
1058
+ value: e,
1059
+ }));
1060
+ leaderSelect.setSelectedIndex(current.selectedIndex);
1061
+ leaderBox.title = prev.title;
1062
+ const isTop = menuStack.length === 1;
1063
+ leaderFooter.content = isTop
1064
+ ? " \u2191\u2193 select \u2192 open category Enter run Esc/q close"
1065
+ : " Esc back \u2191\u2193 select Enter run";
1066
+ return true;
1067
+ }
1068
+
1069
+ function resetMenu() {
1070
+ menuStack.length = 0;
1071
+ }
703
1072
 
704
- // Common items (always available when repos exist)
1073
+ // ── Build hierarchical menu ─────────────────────────────────────
1074
+ function buildMenuHierarchy(): MenuCategory[] {
705
1075
  const hasRepo = (() => {
706
1076
  const opt = resultsSelect.getSelectedOption();
707
1077
  return opt?.value && typeof (opt.value as any)?.fullName === "string";
708
1078
  })();
709
1079
 
710
- items.push(
711
- { name: " Open in browser", description: "Open selected repo in browser", action: () => openUrlIfSelected() },
712
- { name: " History", description: "Search history", action: () => { refreshHistory(); showOverlay("history"); } },
713
- { name: " Saved searches", description: "Load a saved search", action: () => { refreshSavedSearches(); showOverlay("saved"); } },
714
- { name: " Export", description: "Export results to JSON/CSV/Markdown", action: () => showOverlay("export") },
715
- { name: " Share repo", description: "Copy repo link to clipboard", action: () => showShareIfRepo() },
716
- { name: " Notifications", description: "View alerts", action: () => { refreshNotifications(); showOverlay("notifications"); } },
717
- { name: " Topics", description: "Browse popular GitHub topics", action: () => { refreshTopics(); showOverlay("topics"); } },
718
- { name: " Compare view", description: "Show side-by-side comparison", action: () => { refreshCompare(); showOverlay("compare"); } },
719
- { name: " Bookmarks panel", description: "Browse saved repos", action: () => { refreshBookmarks(); showOverlay("bookmarks"); } },
720
- { name: " Save search", description: "Save current query", action: () => saveCurrentSearch() },
721
- { name: " Help", description: "Keybindings reference", action: () => showOverlay("help") },
1080
+ const groups: MenuCategory[] = [];
1081
+
1082
+ // Search group
1083
+ const searchItems: MenuEntry[] = [];
1084
+ if (currentMode === "search") {
1085
+ searchItems.push(
1086
+ {
1087
+ type: "action",
1088
+ name: "Sort",
1089
+ description: `Cycle sort (current: ${currentSort})`,
1090
+ action: () => changeSort(),
1091
+ },
1092
+ {
1093
+ type: "action",
1094
+ name: "Limit",
1095
+ description: `Cycle result limit (current: ${currentLimit})`,
1096
+ action: () => changeLimit(),
1097
+ },
1098
+ {
1099
+ type: "action",
1100
+ name: "Refresh",
1101
+ description: "Re-run current search",
1102
+ action: () => {
1103
+ if (currentQueryInput) doSearch(currentQueryInput);
1104
+ },
1105
+ },
1106
+ {
1107
+ type: "action",
1108
+ name: "Save search",
1109
+ description: "Save current query as a named search",
1110
+ action: () => saveCurrentSearch(),
1111
+ },
1112
+ {
1113
+ type: "action",
1114
+ name: "Saved searches",
1115
+ description: "Load a saved search",
1116
+ action: () => {
1117
+ refreshSavedSearches();
1118
+ showOverlay("saved");
1119
+ },
1120
+ },
1121
+ );
1122
+ }
1123
+ if (searchItems.length > 0) {
1124
+ groups.push({
1125
+ type: "category",
1126
+ name: "Search",
1127
+ description: "Sort, limit, refresh, save searches",
1128
+ icon: "\uD83D\uDD0D",
1129
+ children: searchItems,
1130
+ });
1131
+ }
1132
+
1133
+ // Repo group
1134
+ const repoItems: MenuEntry[] = [];
1135
+ if (hasRepo) {
1136
+ repoItems.push(
1137
+ {
1138
+ type: "action",
1139
+ name: "Open in browser",
1140
+ description: "Open selected repo in browser",
1141
+ action: () => openUrlIfSelected(),
1142
+ },
1143
+ {
1144
+ type: "action",
1145
+ name: "Readme",
1146
+ description: "Full README viewer",
1147
+ action: () => {
1148
+ showReadme();
1149
+ },
1150
+ },
1151
+ {
1152
+ type: "action",
1153
+ name: "Activity graph",
1154
+ description: "Toggle commit chart fullscreen",
1155
+ action: () => toggleGraph(),
1156
+ },
1157
+ );
1158
+ if (currentMode === "search") {
1159
+ repoItems.push({
1160
+ type: "action",
1161
+ name: "Deep-dive",
1162
+ description: "Languages, contributors, README excerpt",
1163
+ action: () => {
1164
+ showOverlay("none");
1165
+ triggerDeepDive();
1166
+ },
1167
+ });
1168
+ }
1169
+ repoItems.push(
1170
+ {
1171
+ type: "action",
1172
+ name: "Bookmark",
1173
+ description: "Save / unsave selected repo",
1174
+ action: () => toggleBookmarkOnSelected(),
1175
+ },
1176
+ {
1177
+ type: "action",
1178
+ name: "Compare",
1179
+ description: "Add/remove repo to comparison",
1180
+ action: () => toggleCompareOnSelected(),
1181
+ },
1182
+ {
1183
+ type: "action",
1184
+ name: "Share",
1185
+ description: "Copy repo link to clipboard",
1186
+ action: () => showShareIfRepo(),
1187
+ },
1188
+ );
1189
+ }
1190
+ if (repoItems.length > 0) {
1191
+ groups.push({
1192
+ type: "category",
1193
+ name: "Repo",
1194
+ description: "Open, readme, bookmark, share, compare",
1195
+ icon: "\uD83D\uDCCB",
1196
+ children: repoItems,
1197
+ });
1198
+ }
1199
+
1200
+ // Navigate group
1201
+ const navItems: MenuEntry[] = [];
1202
+ if (currentMode === "search") {
1203
+ navItems.push({
1204
+ type: "action",
1205
+ name: "Trending",
1206
+ description: "Browse GitHub trending repos",
1207
+ action: () => loadTrending(),
1208
+ });
1209
+ } else {
1210
+ navItems.push({
1211
+ type: "action",
1212
+ name: "Search mode",
1213
+ description: "Switch to query search",
1214
+ action: () => showSearchMode(),
1215
+ });
1216
+ }
1217
+ navItems.push(
1218
+ {
1219
+ type: "action",
1220
+ name: "History",
1221
+ description: "Search history",
1222
+ action: () => {
1223
+ refreshHistory();
1224
+ showOverlay("history");
1225
+ },
1226
+ },
1227
+ {
1228
+ type: "action",
1229
+ name: "Bookmarks",
1230
+ description: "Browse saved repos",
1231
+ action: () => {
1232
+ refreshBookmarks();
1233
+ showOverlay("bookmarks");
1234
+ },
1235
+ },
1236
+ {
1237
+ type: "action",
1238
+ name: "Topics",
1239
+ description: "Browse popular GitHub topics",
1240
+ action: () => {
1241
+ refreshTopics();
1242
+ showOverlay("topics");
1243
+ },
1244
+ },
1245
+ {
1246
+ type: "action",
1247
+ name: "Export",
1248
+ description: "Export results to JSON/CSV/Markdown",
1249
+ action: () => showOverlay("export"),
1250
+ },
722
1251
  );
1252
+ groups.push({
1253
+ type: "category",
1254
+ name: "Navigate",
1255
+ description: "Trending, history, bookmarks, topics, export",
1256
+ icon: "\uD83E\uDDED",
1257
+ children: navItems,
1258
+ });
723
1259
 
724
- leaderSelect.options = items.map((it) => ({
725
- name: it.name,
726
- description: it.description,
727
- value: it,
728
- }));
729
- leaderSelect.setSelectedIndex(0);
1260
+ // ⚙️ System group
1261
+ const sysItems: MenuEntry[] = [
1262
+ {
1263
+ type: "action",
1264
+ name: "Check for updates",
1265
+ description: "Check npm for a newer version (debug)",
1266
+ action: () => {
1267
+ showOverlay("none");
1268
+ checkForUpdateAndShow(true);
1269
+ },
1270
+ },
1271
+ {
1272
+ type: "action",
1273
+ name: "Help",
1274
+ description: "Keybindings reference",
1275
+ action: () => showOverlay("help"),
1276
+ },
1277
+ {
1278
+ type: "action",
1279
+ name: "Notifications",
1280
+ description: "View alerts",
1281
+ action: () => {
1282
+ refreshNotifications();
1283
+ showOverlay("notifications");
1284
+ },
1285
+ },
1286
+ {
1287
+ type: "action",
1288
+ name: "Compare view",
1289
+ description: "Show side-by-side comparison",
1290
+ action: () => {
1291
+ refreshCompare();
1292
+ showOverlay("compare");
1293
+ },
1294
+ },
1295
+ {
1296
+ type: "action",
1297
+ name: "Quit",
1298
+ description: "Exit ghfind",
1299
+ action: () => {
1300
+ saveSession({
1301
+ mode: currentMode,
1302
+ query: currentQueryInput,
1303
+ sort: currentSort,
1304
+ limit: currentLimit,
1305
+ trendingTab,
1306
+ });
1307
+ cleanup();
1308
+ },
1309
+ },
1310
+ ];
1311
+ groups.push({
1312
+ type: "category",
1313
+ name: "System",
1314
+ description: "Help, notifications, compare, quit",
1315
+ icon: "\u2699\uFE0F",
1316
+ children: sysItems,
1317
+ });
1318
+
1319
+ return groups;
1320
+ }
1321
+
1322
+ function showLeaderMenu() {
1323
+ resetMenu();
1324
+ const topLevel = buildMenuHierarchy();
1325
+ pushMenuLevel(" ⚙ Menu ", "", topLevel, 0);
730
1326
  leaderSelect.focus();
731
1327
  showOverlay("leader");
732
1328
  }
733
1329
 
1330
+ async function checkForUpdateAndShow(force: boolean = false) {
1331
+ if (!force && !shouldCheckUpdate()) return;
1332
+ if (cachedVersion === null) {
1333
+ try {
1334
+ const pkg = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf-8"));
1335
+ cachedVersion = pkg.version;
1336
+ } catch {
1337
+ debugLog("Failed to read package.json");
1338
+ return;
1339
+ }
1340
+ }
1341
+ const currentVersion = cachedVersion;
1342
+ // ponytail: DEBUG_FORCE_UPDATE lets you test the panel without a real newer version
1343
+ const latest = process.env.DEBUG_FORCE_UPDATE || await checkForUpdate(currentVersion);
1344
+ markUpdateChecked(latest ?? undefined);
1345
+ if (latest) {
1346
+ updateSelectedOption = 0;
1347
+ updateText.content = ` A new version is available\n\n Current: ${currentVersion}\n Latest: ${latest}\n\n Run "npm install -g ghfind"`;
1348
+ renderUpdateOptions();
1349
+ showOverlay("update");
1350
+ } else {
1351
+ // ponytail: debug to file so it's not swallowed by TUI renderer
1352
+ debugLog(`No update available (current: ${currentVersion})`);
1353
+ }
1354
+ }
1355
+
734
1356
  function openUrlIfSelected() {
735
1357
  const opt = resultsSelect.getSelectedOption();
736
1358
  const repo = opt?.value as Repo | undefined;
@@ -743,7 +1365,9 @@ export async function launchBrowser(): Promise<void> {
743
1365
  const repo = opt?.value as Repo | undefined;
744
1366
  if (repo) {
745
1367
  const added = toggleBookmark(repo);
746
- setStatus(added ? `Bookmarked ${repo.fullName}` : `Unbookmarked ${repo.fullName}`);
1368
+ setStatus(
1369
+ added ? `Bookmarked ${repo.fullName}` : `Unbookmarked ${repo.fullName}`,
1370
+ );
747
1371
  }
748
1372
  }
749
1373
 
@@ -757,23 +1381,36 @@ export async function launchBrowser(): Promise<void> {
757
1381
  setStatus(`Removed ${repo.fullName} from comparison`);
758
1382
  } else {
759
1383
  compareList.push(repo);
760
- setStatus(`${repo.fullName} added to comparison (${compareList.length} selected)`);
1384
+ setStatus(
1385
+ `${repo.fullName} added to comparison (${compareList.length} selected)`,
1386
+ );
761
1387
  }
762
1388
  }
763
1389
 
764
1390
  function showShareIfRepo() {
765
1391
  const opt = resultsSelect.getSelectedOption();
766
1392
  const repo = opt?.value as Repo | undefined;
767
- if (!repo) { setStatus("No repo selected to share"); return; }
1393
+ if (!repo) {
1394
+ setStatus("No repo selected to share");
1395
+ return;
1396
+ }
768
1397
  showOverlay("share");
769
1398
  }
770
1399
 
771
1400
  function saveCurrentSearch() {
772
1401
  if (currentQueryInput) {
773
- const name = currentQueryInput.length > 40
774
- ? currentQueryInput.slice(0, 37) + "..."
775
- : currentQueryInput;
776
- saveSearch(name, currentQueryInput, currentMode, currentSort, currentLimit, currentMode === "trending" ? trendingTab : undefined);
1402
+ const name =
1403
+ currentQueryInput.length > 40
1404
+ ? currentQueryInput.slice(0, 37) + "..."
1405
+ : currentQueryInput;
1406
+ saveSearch(
1407
+ name,
1408
+ currentQueryInput,
1409
+ currentMode,
1410
+ currentSort,
1411
+ currentLimit,
1412
+ currentMode === "trending" ? trendingTab : undefined,
1413
+ );
777
1414
  setStatus(`Saved as "${name}"`);
778
1415
  } else {
779
1416
  setStatus("No search to save");
@@ -807,9 +1444,9 @@ export async function launchBrowser(): Promise<void> {
807
1444
  const readmeBox = new BoxRenderable(renderer, {
808
1445
  visible: false,
809
1446
  flexGrow: 1,
810
- backgroundColor: colors.bg,
811
- border: true,
812
- borderColor: colors.green,
1447
+ backgroundColor: colors.surface,
1448
+ borderBottom: true,
1449
+ borderBottomColor: colors.border,
813
1450
  title: " README ",
814
1451
  titleColor: colors.green,
815
1452
  });
@@ -862,8 +1499,13 @@ export async function launchBrowser(): Promise<void> {
862
1499
  `${repo.owner}/${repo.name}/master/README.md`,
863
1500
  `${repo.owner}/${repo.name}/main/README.rst`,
864
1501
  ]) {
865
- const r = await fetch(`https://raw.githubusercontent.com/${path}`, { headers });
866
- if (r.ok) { text = await r.text(); break; }
1502
+ const r = await fetch(`https://raw.githubusercontent.com/${path}`, {
1503
+ headers,
1504
+ });
1505
+ if (r.ok) {
1506
+ text = await r.text();
1507
+ break;
1508
+ }
867
1509
  }
868
1510
  if (!text) {
869
1511
  readmeText.content = ` (no README found for ${repo.fullName})`;
@@ -889,10 +1531,12 @@ export async function launchBrowser(): Promise<void> {
889
1531
  // ── Status bar ──────────────────────────────────────────────────────
890
1532
  const statusBar = new TextRenderable(renderer, {
891
1533
  content: " Ready. Press Enter to search.",
892
- backgroundColor: colors.bg,
1534
+ backgroundColor: colors.surface,
893
1535
  color: colors.muted,
894
1536
  height: 1,
895
1537
  paddingX: 1,
1538
+ borderTop: true,
1539
+ borderTopColor: colors.border,
896
1540
  });
897
1541
  root.add(statusBar);
898
1542
 
@@ -918,7 +1562,7 @@ export async function launchBrowser(): Promise<void> {
918
1562
  }
919
1563
 
920
1564
  function setToolbar() {
921
- toolbarText.content = formatToolbar(currentSort, currentLimit);
1565
+ toolbarText.content = formatToolbar(currentSort, currentLimit, totalCount);
922
1566
  renderer.requestRender();
923
1567
  }
924
1568
 
@@ -927,22 +1571,30 @@ export async function launchBrowser(): Promise<void> {
927
1571
  detailText.content = "";
928
1572
  return;
929
1573
  }
930
- const topics = repo.topics.length ? repo.topics.slice(0, 8).join(", ") : "—";
1574
+ const topics = repo.topics.length
1575
+ ? repo.topics.slice(0, 8).join(", ")
1576
+ : "—";
931
1577
  const desc = repo.description ?? "(no description)";
1578
+ const stars = repo.stars.toLocaleString();
1579
+ const forks = repo.forks.toLocaleString();
1580
+ const lang = repo.language ?? "—";
1581
+ const updated = repo.updatedAt.slice(0, 10);
1582
+ const statusTags: string[] = [];
1583
+ if (repo.archived) statusTags.push("archived");
1584
+ if (repo.isFork) statusTags.push("fork");
1585
+ const tagStr = statusTags.length ? ` [${statusTags.join(", ")}]` : "";
1586
+
932
1587
  const lines = [
933
- `Name ${repo.fullName}`,
934
- `Stars ${repo.stars.toLocaleString()}`,
935
- `Forks ${repo.forks.toLocaleString()}`,
936
- `Lang ${repo.language ?? "—"}`,
937
- `Updated ${repo.updatedAt.slice(0, 10)}`,
938
- repo.archived ? "Status archived" : "",
939
- repo.isFork ? "Status fork" : "",
940
- `Topics ${topics}`,
941
- "",
942
- desc,
943
- "",
944
- repo.url,
945
- ].filter((l) => l !== "");
1588
+ ` ${repo.fullName}${tagStr}`,
1589
+ ``,
1590
+ ` ★ ${stars} ♡ ${forks} ${lang}`,
1591
+ ` Updated ${updated}`,
1592
+ ` Topics ${topics}`,
1593
+ ``,
1594
+ ` ${desc}`,
1595
+ ``,
1596
+ ` ${repo.url}`,
1597
+ ];
946
1598
  detailText.content = lines.join("\n");
947
1599
  }
948
1600
 
@@ -956,7 +1608,11 @@ export async function launchBrowser(): Promise<void> {
956
1608
  return bits === 0 ? " " : String.fromCodePoint(0x2800 + bits);
957
1609
  }
958
1610
 
959
- function buildChartString(values: number[], termW: number, termH: number): string[] {
1611
+ function buildChartString(
1612
+ values: number[],
1613
+ termW: number,
1614
+ termH: number,
1615
+ ): string[] {
960
1616
  if (values.length === 0) return ["(no data)"];
961
1617
 
962
1618
  // Braille resolution: 2 horizontal dots and 4 vertical dots per terminal cell.
@@ -970,10 +1626,12 @@ export async function launchBrowser(): Promise<void> {
970
1626
  }
971
1627
  const max = Math.max(...sampled, 1);
972
1628
  // Map each sample to a dot row (0 = bottom, dotH-1 = top).
973
- const dotRows = sampled.map(v => Math.round((v / max) * (dotH - 1)));
1629
+ const dotRows = sampled.map((v) => Math.round((v / max) * (dotH - 1)));
974
1630
 
975
1631
  // Build a mask grid: dotGrid[row][col] = true if the silhouette passes through.
976
- const dotGrid: boolean[][] = Array.from({ length: dotH }, () => Array(dotW).fill(false));
1632
+ const dotGrid: boolean[][] = Array.from({ length: dotH }, () =>
1633
+ Array(dotW).fill(false),
1634
+ );
977
1635
 
978
1636
  // Draw the silhouette line: mark the dot at each column.
979
1637
  for (let c = 0; c < dotW; c++) {
@@ -998,29 +1656,54 @@ export async function launchBrowser(): Promise<void> {
998
1656
  const r0 = tr * 4;
999
1657
  const c0 = tc * 2;
1000
1658
  let bits = 0;
1001
- if (r0 + 0 < dotH && c0 + 0 < dotW && dotGrid[r0 + 0][c0 + 0]) bits |= 0x01;
1002
- if (r0 + 1 < dotH && c0 + 0 < dotW && dotGrid[r0 + 1][c0 + 0]) bits |= 0x02;
1003
- if (r0 + 2 < dotH && c0 + 0 < dotW && dotGrid[r0 + 2][c0 + 0]) bits |= 0x04;
1004
- if (r0 + 0 < dotH && c0 + 1 < dotW && dotGrid[r0 + 0][c0 + 1]) bits |= 0x08;
1005
- if (r0 + 1 < dotH && c0 + 1 < dotW && dotGrid[r0 + 1][c0 + 1]) bits |= 0x10;
1006
- if (r0 + 2 < dotH && c0 + 1 < dotW && dotGrid[r0 + 2][c0 + 1]) bits |= 0x20;
1007
- if (r0 + 3 < dotH && c0 + 0 < dotW && dotGrid[r0 + 3][c0 + 0]) bits |= 0x40;
1008
- if (r0 + 3 < dotH && c0 + 1 < dotW && dotGrid[r0 + 3][c0 + 1]) bits |= 0x80;
1659
+ if (r0 + 0 < dotH && c0 + 0 < dotW && dotGrid[r0 + 0][c0 + 0])
1660
+ bits |= 0x01;
1661
+ if (r0 + 1 < dotH && c0 + 0 < dotW && dotGrid[r0 + 1][c0 + 0])
1662
+ bits |= 0x02;
1663
+ if (r0 + 2 < dotH && c0 + 0 < dotW && dotGrid[r0 + 2][c0 + 0])
1664
+ bits |= 0x04;
1665
+ if (r0 + 0 < dotH && c0 + 1 < dotW && dotGrid[r0 + 0][c0 + 1])
1666
+ bits |= 0x08;
1667
+ if (r0 + 1 < dotH && c0 + 1 < dotW && dotGrid[r0 + 1][c0 + 1])
1668
+ bits |= 0x10;
1669
+ if (r0 + 2 < dotH && c0 + 1 < dotW && dotGrid[r0 + 2][c0 + 1])
1670
+ bits |= 0x20;
1671
+ if (r0 + 3 < dotH && c0 + 0 < dotW && dotGrid[r0 + 3][c0 + 0])
1672
+ bits |= 0x40;
1673
+ if (r0 + 3 < dotH && c0 + 1 < dotW && dotGrid[r0 + 3][c0 + 1])
1674
+ bits |= 0x80;
1009
1675
  chars.push(brailleChar(bits));
1010
1676
  }
1011
1677
 
1012
1678
  // Y-axis labels
1013
- const labelRows = [0, Math.floor(termH / 4), Math.floor(termH / 2), Math.floor(3 * termH / 4), termH - 1];
1014
- const labelVals = [max, Math.round(max * 0.75), Math.round(max / 2), Math.round(max / 4), 0];
1679
+ const labelRows = [
1680
+ 0,
1681
+ Math.floor(termH / 4),
1682
+ Math.floor(termH / 2),
1683
+ Math.floor((3 * termH) / 4),
1684
+ termH - 1,
1685
+ ];
1686
+ const labelVals = [
1687
+ max,
1688
+ Math.round(max * 0.75),
1689
+ Math.round(max / 2),
1690
+ Math.round(max / 4),
1691
+ 0,
1692
+ ];
1015
1693
  const idx = labelRows.indexOf(tr);
1016
1694
  let label = " ";
1017
- if (idx >= 0) label = String(labelVals[idx]).padStart(4) + " ";
1018
- lines.push(`${label}\u2524${chars.join("")}`);
1695
+ if (idx >= 0) {
1696
+ const val = labelVals[idx];
1697
+ label = val >= 1000 ? `${(val/1000).toFixed(1).replace(/\.0$/, "")}k` : String(val);
1698
+ label = label.padStart(5) + " ";
1699
+ }
1700
+ lines.push(`${label}┆${chars.join("")}`);
1019
1701
  }
1020
1702
 
1021
- // X-axis
1022
- lines.push(` \u2514${String.fromCharCode(0x2500).repeat(termW)}`);
1023
- return lines;
1703
+ // X-axis with week labels
1704
+ const xAxis = ` ┆${"─".repeat(termW)}`;
1705
+ const weekLabels = ` ┆ ${"1w".padEnd(Math.floor(termW/4))} ${"13w".padEnd(Math.floor(termW/4))} ${"26w".padEnd(Math.floor(termW/4))} ${"52w"}`;
1706
+ return [xAxis, weekLabels];
1024
1707
  }
1025
1708
 
1026
1709
  async function loadChart(repo: Repo) {
@@ -1028,10 +1711,13 @@ export async function launchBrowser(): Promise<void> {
1028
1711
  renderer.requestRender();
1029
1712
  try {
1030
1713
  const [chartRes, repoRes] = await Promise.all([
1031
- fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/stats/participation`,
1032
- { headers: { "User-Agent": "ghfind/1.0" } }),
1033
- fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}`,
1034
- { headers: { "User-Agent": "ghfind/1.0" } }).then(r => r.ok ? r.json() : null),
1714
+ fetch(
1715
+ `https://api.github.com/repos/${repo.owner}/${repo.name}/stats/participation`,
1716
+ { headers: { "User-Agent": "ghfind/1.0" } },
1717
+ ),
1718
+ fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}`, {
1719
+ headers: { "User-Agent": "ghfind/1.0" },
1720
+ }).then((r) => (r.ok ? r.json() : null)),
1035
1721
  ]);
1036
1722
 
1037
1723
  let chartSection = "";
@@ -1042,22 +1728,28 @@ export async function launchBrowser(): Promise<void> {
1042
1728
  const chartW = 60;
1043
1729
  const chartH = 10;
1044
1730
  const chartLines = buildChartString(chartCommitData, chartW, chartH);
1045
- chartSection = ["", "Weekly commits (52 weeks)", ...chartLines].join("\n");
1731
+ chartSection = ["", "Weekly commits (52 weeks)", ...chartLines].join(
1732
+ "\n",
1733
+ );
1046
1734
  }
1047
1735
  }
1048
1736
 
1049
1737
  const desc = repo.description ?? "";
1050
- const lang = repo.language ?? "\u2014";
1738
+ const lang = repo.language ?? "—";
1051
1739
  const updated = repo.updatedAt ? repo.updatedAt.slice(0, 10) : "";
1052
- const topics = repoRes?.topics?.length ? repoRes.topics.slice(0, 8).join(", ") : "";
1053
-
1054
- const growthLine = currentMode === "trending" && repo.score > 0
1055
- ? ` Growth \u25b2 ${fmtStars(repo.score)} ${trendingPeriod}`
1740
+ const topics = repoRes?.topics?.length
1741
+ ? repoRes.topics.slice(0, 8).join(", ")
1056
1742
  : "";
1057
1743
 
1744
+ const growthLine =
1745
+ currentMode === "trending" && repo.score > 0
1746
+ ? ` Growth \u25b2 ${fmtStars(repo.score)} ${trendingPeriod}`
1747
+ : "";
1748
+
1058
1749
  const detailLines = [
1059
- ` ${repo.fullName}`,
1060
- ` \u2606 ${repo.stars.toLocaleString()} \u2666 ${repo.forks.toLocaleString()} ${lang}`,
1750
+ ` ${repo.fullName}`,
1751
+ ``,
1752
+ ` ★ ${repo.stars.toLocaleString()} ♡ ${repo.forks.toLocaleString()} ${lang}`,
1061
1753
  growthLine,
1062
1754
  updated ? ` Updated ${updated}` : "",
1063
1755
  topics ? ` Topics ${topics}` : "",
@@ -1065,12 +1757,9 @@ export async function launchBrowser(): Promise<void> {
1065
1757
  desc,
1066
1758
  "",
1067
1759
  ` ${repo.url}`,
1068
- ].filter(l => l !== "");
1760
+ ].filter((l) => l !== "");
1069
1761
 
1070
- detailText.content = [
1071
- ...detailLines,
1072
- chartSection,
1073
- ].join("\n");
1762
+ detailText.content = [...detailLines, chartSection].join("\n");
1074
1763
  } catch {
1075
1764
  detailText.content = " Failed to load chart";
1076
1765
  }
@@ -1100,37 +1789,14 @@ export async function launchBrowser(): Promise<void> {
1100
1789
 
1101
1790
  // ── Trending helpers ────────────────────────────────────────────────
1102
1791
 
1103
- function formatTrendingLine(r: { rank: number; owner: string; name: string; stars: number; starsToday: number; language: string }): string {
1104
- const rank = String(r.rank).padStart(2, "0");
1792
+ function formatTrendingLine(r: Repo, rank: number): string {
1793
+ const rankStr = String(rank).padStart(2, "0");
1105
1794
  const name = `${r.owner}/${r.name}`.padEnd(30).slice(0, 30);
1106
1795
  const lang = (r.language || "?").padEnd(12).slice(0, 12);
1107
1796
  const stars = `★ ${fmtStars(r.stars)}`.padEnd(9).slice(0, 9);
1108
- const arrow = r.starsToday > 0 ? "▲" : r.starsToday < 0 ? "▼" : "—";
1109
- const growth = `${arrow} ${fmtStars(r.starsToday)} ${trendingPeriod}`;
1110
- return `[${rank}] ${name} ${lang} ${stars} ${growth}`;
1111
- }
1112
-
1113
- function trendingRepoToSelectValue(r: TrendingRepo): Repo {
1114
- return {
1115
- id: 0,
1116
- fullName: `${r.owner}/${r.name}`,
1117
- name: r.name,
1118
- owner: r.owner,
1119
- description: r.description,
1120
- url: `https://github.com/${r.owner}/${r.name}`,
1121
- stars: r.stars,
1122
- forks: 0,
1123
- watchers: 0,
1124
- language: r.language,
1125
- topics: [],
1126
- archived: false,
1127
- isFork: false,
1128
- private: false,
1129
- createdAt: "",
1130
- updatedAt: "",
1131
- pushedAt: "",
1132
- score: r.starsToday,
1133
- };
1797
+ const arrow = r.score > 0 ? "▲" : r.score < 0 ? "▼" : "—";
1798
+ const growth = `${arrow} ${fmtStars(r.score)} ${trendingPeriod}`;
1799
+ return `[${rankStr}] ${name} ${lang} ${stars} ${growth}`;
1134
1800
  }
1135
1801
 
1136
1802
  async function loadTrending() {
@@ -1140,31 +1806,59 @@ export async function launchBrowser(): Promise<void> {
1140
1806
  toolbarText.visible = false;
1141
1807
  trendingTabBox.visible = true;
1142
1808
  renderTrendingTabs();
1143
- resultsSelect.options = [{ name: " Loading trending...", description: "", value: null }];
1809
+ resultsSelect.options = [
1810
+ { name: " Loading trending...", description: "", value: null },
1811
+ ];
1144
1812
  detailText.content = "";
1145
1813
  statusMgr.set("loading", "Loading trending repos...");
1146
1814
  renderer.requestRender();
1147
1815
 
1148
1816
  try {
1149
- const fetched = await fetchTrendingRepos(tabSince(trendingTab));
1817
+ const searchModule = new SearchModule(new TrendingAdapter());
1818
+ const response = await searchModule.search(
1819
+ { keywords: [], qualifiers: [], raw: "trending" },
1820
+ { limit: 25, sort: "stars", json: false, verbose: false, trendingSince: tabSince(trendingTab) },
1821
+ );
1822
+ const fetched = response.repos;
1150
1823
  const since = tabSince(trendingTab);
1151
- trendingPeriod = since === "daily" ? "today" : since === "weekly" ? "this week" : "this month";
1152
- resultsSelect.options = fetched.map(r => ({
1153
- name: formatTrendingLine(r),
1154
- description: r.description,
1155
- value: trendingRepoToSelectValue(r),
1824
+ trendingPeriod =
1825
+ since === "daily"
1826
+ ? "today"
1827
+ : since === "weekly"
1828
+ ? "this week"
1829
+ : "this month";
1830
+ resultsSelect.options = fetched.map((r, i) => ({
1831
+ name: formatTrendingLine(r, i + 1),
1832
+ description: r.description ?? "",
1833
+ value: r,
1156
1834
  }));
1157
1835
  if (fetched.length > 0) {
1158
1836
  resultsSelect.setSelectedIndex(0);
1159
- updateDetail(trendingRepoToSelectValue(fetched[0]));
1837
+ updateDetail(fetched[0]);
1160
1838
  } else {
1161
1839
  throw new NoResultsError(trendingTab);
1162
1840
  }
1163
- statusMgr.set("success", `${fetched.length} trending repos — ${trendingTab}`);
1164
- appendHistory({ query: `trending:${trendingTab}`, mode: "trending", tab: trendingTab, timestamp: Date.now(), resultCount: fetched.length });
1841
+ statusMgr.set(
1842
+ "success",
1843
+ `${fetched.length} trending repos — ${trendingTab}`,
1844
+ );
1845
+ appendHistory({
1846
+ query: `trending:${trendingTab}`,
1847
+ mode: "trending",
1848
+ tab: trendingTab,
1849
+ timestamp: Date.now(),
1850
+ resultCount: fetched.length,
1851
+ });
1165
1852
  } catch (err) {
1166
- const msg = err instanceof SearchCliError ? err.userMessage : (err instanceof Error ? err.message : String(err));
1167
- resultsSelect.options = [{ name: " (error)", description: "", value: null }];
1853
+ const msg =
1854
+ err instanceof SearchCliError
1855
+ ? err.userMessage
1856
+ : err instanceof Error
1857
+ ? err.message
1858
+ : String(err);
1859
+ resultsSelect.options = [
1860
+ { name: " (error)", description: "", value: null },
1861
+ ];
1168
1862
  statusMgr.set("error", msg.slice(0, 60));
1169
1863
  }
1170
1864
  isLoading = false;
@@ -1176,7 +1870,8 @@ export async function launchBrowser(): Promise<void> {
1176
1870
  trendingTabBox.visible = false;
1177
1871
  searchBox.visible = true;
1178
1872
  toolbarText.visible = true;
1179
- searchInput.placeholder = "Search GitHub repos (e.g. rust cli, or language:Rust stars:>100)";
1873
+ searchInput.placeholder =
1874
+ "Search GitHub repos (e.g. rust cli, or language:Rust stars:>100)";
1180
1875
  searchInput.focus();
1181
1876
  renderer.requestRender();
1182
1877
  }
@@ -1191,7 +1886,9 @@ export async function launchBrowser(): Promise<void> {
1191
1886
  isLoading = true;
1192
1887
  currentQueryInput = q;
1193
1888
  if (!append) {
1194
- resultsSelect.options = [{ name: " Searching...", description: "", value: null }];
1889
+ resultsSelect.options = [
1890
+ { name: " Searching...", description: "", value: null },
1891
+ ];
1195
1892
  detailText.content = "";
1196
1893
  }
1197
1894
  statusMgr.set("searching", `Searching for "${q}"`);
@@ -1229,15 +1926,30 @@ export async function launchBrowser(): Promise<void> {
1229
1926
  const idx = append ? resultsSelect.options.length - newRepos.length : 0;
1230
1927
  updateDetail(currentRepos[idx]);
1231
1928
  resultsSelect.setSelectedIndex(idx);
1232
- statusMgr.set("success", `Page ${currentPage} — ${currentRepos.length} of ${totalCount.toLocaleString()} results`);
1929
+ statusMgr.set(
1930
+ "success",
1931
+ `Page ${currentPage} — ${currentRepos.length} of ${totalCount.toLocaleString()} results`,
1932
+ );
1233
1933
  if (!append) {
1234
- appendHistory({ query: q, mode: "search", timestamp: Date.now(), resultCount: currentRepos.length });
1934
+ appendHistory({
1935
+ query: q,
1936
+ mode: "search",
1937
+ timestamp: Date.now(),
1938
+ resultCount: currentRepos.length,
1939
+ });
1235
1940
  }
1236
1941
  }
1237
1942
  } catch (err) {
1238
1943
  currentRepos = [];
1239
- resultsSelect.options = [{ name: " (error)", description: "", value: null }];
1240
- const msg = err instanceof SearchCliError ? err.userMessage : (err instanceof Error ? err.message : String(err));
1944
+ resultsSelect.options = [
1945
+ { name: " (error)", description: "", value: null },
1946
+ ];
1947
+ const msg =
1948
+ err instanceof SearchCliError
1949
+ ? err.userMessage
1950
+ : err instanceof Error
1951
+ ? err.message
1952
+ : String(err);
1241
1953
  statusMgr.set("error", msg.slice(0, 60));
1242
1954
  }
1243
1955
 
@@ -1303,21 +2015,56 @@ export async function launchBrowser(): Promise<void> {
1303
2015
  renderer.keyInput.on("keypress", (key) => {
1304
2016
  // ── Overlay-mode handling ────────────────────────────────────────
1305
2017
  if (currentOverlay !== "none") {
1306
- // Escape or q closes any overlay
1307
- if (key.name === "escape" || key.name === "q") {
2018
+ // Escape or q closes any overlay (except: leader uses Esc for back-nav, q to close)
2019
+ if (key.name === "escape") {
2020
+ if (currentOverlay === "leader") {
2021
+ if (!popMenuLevel()) {
2022
+ showOverlay("none");
2023
+ }
2024
+ setToolbar();
2025
+ renderer.requestRender();
2026
+ } else {
2027
+ showOverlay("none");
2028
+ renderer.requestRender();
2029
+ }
2030
+ return;
2031
+ }
2032
+ if (key.name === "q") {
1308
2033
  showOverlay("none");
1309
2034
  renderer.requestRender();
1310
2035
  return;
1311
2036
  }
1312
2037
 
1313
- // Leader menu: Enter dispatches action, up/down navigates
2038
+ // Leader menu: hierarchical submenu navigation
1314
2039
  if (currentOverlay === "leader") {
1315
- if (key.name === "enter" || key.name === "return") {
2040
+ if (
2041
+ key.name === "enter" ||
2042
+ key.name === "return" ||
2043
+ key.name === "right" ||
2044
+ key.name === "l"
2045
+ ) {
1316
2046
  const sel = leaderSelect.getSelectedOption();
1317
- const item = sel?.value as LeaderItem | undefined;
1318
- if (item) {
2047
+ const entry = sel?.value as MenuEntry | undefined;
2048
+ if (!entry) return;
2049
+ if (entry.type === "category") {
2050
+ pushMenuLevel(
2051
+ ` Menu > ${entry.name}`,
2052
+ entry.name,
2053
+ entry.children,
2054
+ 0,
2055
+ );
2056
+ renderer.requestRender();
2057
+ } else {
2058
+ resetMenu();
1319
2059
  showOverlay("none");
1320
- item.action();
2060
+ entry.action();
2061
+ renderer.requestRender();
2062
+ }
2063
+ return;
2064
+ }
2065
+ if (key.name === "left" || key.name === "h") {
2066
+ if (popMenuLevel()) {
2067
+ setToolbar();
1321
2068
  renderer.requestRender();
1322
2069
  }
1323
2070
  return;
@@ -1355,7 +2102,8 @@ export async function launchBrowser(): Promise<void> {
1355
2102
  const entry = (sel.value as any).entry;
1356
2103
  showOverlay("none");
1357
2104
  if (entry.mode === "trending") {
1358
- if (entry.tab) trendingTab = entry.tab as typeof TAB_NAMES[number];
2105
+ if (entry.tab)
2106
+ trendingTab = entry.tab as (typeof TAB_NAMES)[number];
1359
2107
  loadTrending();
1360
2108
  } else {
1361
2109
  searchInput.value = entry.query;
@@ -1407,7 +2155,7 @@ export async function launchBrowser(): Promise<void> {
1407
2155
  showOverlay("none");
1408
2156
  touchSavedSearch(s.name);
1409
2157
  if (s.mode === "trending") {
1410
- if (s.tab) trendingTab = s.tab as typeof TAB_NAMES[number];
2158
+ if (s.tab) trendingTab = s.tab as (typeof TAB_NAMES)[number];
1411
2159
  loadTrending();
1412
2160
  } else {
1413
2161
  searchInput.value = s.query;
@@ -1512,6 +2260,51 @@ export async function launchBrowser(): Promise<void> {
1512
2260
  return;
1513
2261
  }
1514
2262
 
2263
+ // Update panel
2264
+ if (currentOverlay === "update") {
2265
+ if (key.name === "escape" || key.name === "q") {
2266
+ showOverlay("none");
2267
+ renderer.requestRender();
2268
+ return;
2269
+ }
2270
+ if (key.name === "left" || key.name === "h") {
2271
+ updateSelectedOption = Math.max(0, updateSelectedOption - 1);
2272
+ renderUpdateOptions();
2273
+ return;
2274
+ }
2275
+ if (key.name === "right" || key.name === "l") {
2276
+ updateSelectedOption = Math.min(2, updateSelectedOption + 1);
2277
+ renderUpdateOptions();
2278
+ return;
2279
+ }
2280
+ if (key.name === "enter" || key.name === "return") {
2281
+ const action = ["now", "later", "never"][updateSelectedOption] as "now" | "later" | "never" | undefined;
2282
+ if (action === "now") {
2283
+ showOverlay("none");
2284
+ performUpdate().then((ok) => {
2285
+ if (ok) {
2286
+ setStatus("✓ Update complete — restart ghfind");
2287
+ } else {
2288
+ setStatus("✗ Update failed — check terminal output");
2289
+ }
2290
+ }).catch(() => {
2291
+ setStatus("✗ Update failed — check terminal output");
2292
+ });
2293
+ } else if (action === "never") {
2294
+ suppressUpdateNotices();
2295
+ showOverlay("none");
2296
+ setStatus("Update notices suppressed");
2297
+ } else {
2298
+ snoozeUpdateNotices(3);
2299
+ showOverlay("none");
2300
+ setStatus("Update reminder snoozed for 3 days");
2301
+ }
2302
+ renderer.requestRender();
2303
+ return;
2304
+ }
2305
+ return;
2306
+ }
2307
+
1515
2308
  return;
1516
2309
  }
1517
2310
 
@@ -1523,7 +2316,13 @@ export async function launchBrowser(): Promise<void> {
1523
2316
  toggleGraph();
1524
2317
  return;
1525
2318
  }
1526
- saveSession({ mode: currentMode, query: currentQueryInput, sort: currentSort, limit: currentLimit, trendingTab });
2319
+ saveSession({
2320
+ mode: currentMode,
2321
+ query: currentQueryInput,
2322
+ sort: currentSort,
2323
+ limit: currentLimit,
2324
+ trendingTab,
2325
+ });
1527
2326
  cleanup();
1528
2327
  return;
1529
2328
  }
@@ -1534,9 +2333,14 @@ export async function launchBrowser(): Promise<void> {
1534
2333
  return;
1535
2334
  }
1536
2335
 
1537
- // Space opens leader menu
1538
- if (key.name === "space") {
1539
- showLeaderMenu();
2336
+ // Space toggles leader menu (open if closed, close if open)
2337
+ // Skip when the search input is focused so spaces can be typed in queries
2338
+ if (key.name === "space" && !searchInput.focused) {
2339
+ if (currentOverlay === "leader") {
2340
+ showOverlay("none");
2341
+ } else {
2342
+ showLeaderMenu();
2343
+ }
1540
2344
  return;
1541
2345
  }
1542
2346
 
@@ -1554,9 +2358,10 @@ export async function launchBrowser(): Promise<void> {
1554
2358
  const currentWord = lastSpace >= 0 ? val.slice(lastSpace + 1) : val;
1555
2359
  const suggestions = suggestFor(currentWord);
1556
2360
  if (suggestions.length > 0) {
1557
- const newVal = lastSpace >= 0
1558
- ? val.slice(0, lastSpace + 1) + suggestions[0]
1559
- : suggestions[0];
2361
+ const newVal =
2362
+ lastSpace >= 0
2363
+ ? val.slice(0, lastSpace + 1) + suggestions[0]
2364
+ : suggestions[0];
1560
2365
  searchInput.value = newVal;
1561
2366
  renderer.requestRender();
1562
2367
  }
@@ -1588,14 +2393,20 @@ export async function launchBrowser(): Promise<void> {
1588
2393
  if (key.name === "left" || key.name === "h") {
1589
2394
  if (currentMode === "trending") {
1590
2395
  const idx = TAB_NAMES.indexOf(trendingTab);
1591
- if (idx > 0) { trendingTab = TAB_NAMES[idx - 1]; loadTrending(); }
2396
+ if (idx > 0) {
2397
+ trendingTab = TAB_NAMES[idx - 1];
2398
+ loadTrending();
2399
+ }
1592
2400
  }
1593
2401
  return;
1594
2402
  }
1595
2403
  if (key.name === "right" || key.name === "l") {
1596
2404
  if (currentMode === "trending") {
1597
2405
  const idx = TAB_NAMES.indexOf(trendingTab);
1598
- if (idx < TAB_NAMES.length - 1) { trendingTab = TAB_NAMES[idx + 1]; loadTrending(); }
2406
+ if (idx < TAB_NAMES.length - 1) {
2407
+ trendingTab = TAB_NAMES[idx + 1];
2408
+ loadTrending();
2409
+ }
1599
2410
  }
1600
2411
  return;
1601
2412
  }
@@ -1620,7 +2431,22 @@ export async function launchBrowser(): Promise<void> {
1620
2431
  }
1621
2432
  });
1622
2433
 
2434
+ // ── Update check (before start) ──────────────────────────────────────
2435
+ const pkgPath = join(
2436
+ dirname(fileURLToPath(import.meta.url)),
2437
+ "..",
2438
+ "package.json",
2439
+ );
2440
+ let currentVersion = "0.0.0";
2441
+ try {
2442
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
2443
+ currentVersion = pkg.version;
2444
+ } catch {
2445
+ // non-critical
2446
+ }
1623
2447
  // ── Start ──────────────────────────────────────────────────────────
2448
+ // Check for updates (non-blocking — doesn't delay TUI startup)
2449
+ checkForUpdateAndShow();
1624
2450
  renderer.start();
1625
2451
  if (currentMode === "trending") {
1626
2452
  loadTrending();
@@ -1641,21 +2467,27 @@ export async function launchBrowser(): Promise<void> {
1641
2467
  function formatResultLine(repo: Repo): string {
1642
2468
  const stars = formatStars(repo.stars);
1643
2469
  const lang = (repo.language ?? "?").padEnd(12).slice(0, 12);
1644
- return `${repo.fullName} ${stars}`;
2470
+ const name = repo.fullName.padEnd(40).slice(0, 40);
2471
+ return `${name} ${lang} ${stars}`;
1645
2472
  }
1646
2473
 
1647
2474
  function formatStars(n: number): string {
2475
+ if (n >= 1000000) return `★ ${(n / 1000000).toFixed(1).replace(/\.0$/, "")}M`;
1648
2476
  if (n >= 1000) return `★ ${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
1649
2477
  return `★ ${n}`;
1650
2478
  }
1651
2479
 
1652
- function formatToolbar(sort: SortStrategy, limit: number): string {
2480
+ function formatToolbar(sort: SortStrategy, limit: number, totalCount: number): string {
1653
2481
  const sortLabel = SORT_MODES.find((m) => m.key === sort)?.label ?? sort;
1654
- return ` sort: ${sortLabel} limit: ${limit}`;
2482
+ return ` sort: ${sortLabel} limit: ${limit} results: ${totalCount.toLocaleString()}`;
1655
2483
  }
1656
2484
 
1657
2485
  function cleanup(): void {
1658
- try { rotateHistory(); } catch { /* non-critical */ }
2486
+ try {
2487
+ rotateHistory();
2488
+ } catch {
2489
+ /* non-critical */
2490
+ }
1659
2491
  process.exit(0);
1660
2492
  }
1661
2493