github-search-cli 0.8.2 → 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 ───────────────────────────────────────────────────────────
@@ -131,33 +196,48 @@ export async function launchBrowser(): Promise<void> {
131
196
  let currentQueryInput = session?.query ?? "";
132
197
  let isLoading = false;
133
198
  let currentMode: "search" | "trending" = session?.mode ?? "search";
134
- 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";
135
201
  let trendingPeriod = "this week";
136
202
  let graphFullscreen = false;
137
203
  let chartCommitData: number[] = [];
138
- 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";
139
218
  let currentPage = 1;
140
219
  let totalCount = 0;
141
220
  let deepDiveActive = false;
142
221
  let compareList: Repo[] = [];
143
222
  let quitArmed = false;
144
223
 
145
-
146
224
  // ── Header bar ──────────────────────────────────────────────────────
147
225
  const header = new TextRenderable(renderer, {
148
226
  content:
149
- " ghfind — GitHub repo browser [/]search [Space]menu [?]help [q]uit",
227
+ " ghfind — GitHub repo browser [/]search [Space]menu [\u2192]open [?]help [q]uit",
150
228
  backgroundColor: colors.bg,
151
229
  color: colors.muted,
152
230
  height: 1,
231
+ borderBottom: true,
232
+ borderBottomColor: colors.border,
153
233
  });
154
- root.add(header);
155
234
 
156
235
  // ── Trending tab bar (hidden in search mode) ────────────────────────
157
236
  const trendingTabBox = new BoxRenderable(renderer, {
158
237
  flexDirection: "row",
159
238
  height: 1,
160
- backgroundColor: colors.bg,
239
+ backgroundColor: colors.surface,
240
+ // No border — pure contract-edge separation via text contrast
161
241
  paddingX: 1,
162
242
  visible: false,
163
243
  });
@@ -190,30 +270,45 @@ export async function launchBrowser(): Promise<void> {
190
270
  }
191
271
  });
192
272
  }
273
+ root.add(header);
193
274
  root.add(trendingTabBox);
194
275
 
195
276
  // ── Search input row ────────────────────────────────────────────────
196
277
  const searchBox = new BoxRenderable(renderer, {
197
- visible: true, // hidden in trending mode
198
- border: true,
199
- borderColor: colors.blue,
200
- paddingX: 1,
201
- 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,
202
288
  });
203
289
  const searchInput = new InputRenderable(renderer, {
204
- 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)",
205
292
  value: "",
293
+ backgroundColor: colors.surface,
294
+ textColor: colors.text,
295
+ borderColor: colors.blue,
296
+ selectedBorderColor: colors.blue,
297
+ paddingX: 0,
298
+ flexGrow: 1,
206
299
  });
207
300
  searchBox.add(searchInput);
208
301
  root.add(searchBox);
209
302
 
210
303
  // ── Toolbar (sort + limit indicator) ────────────────────────────────
211
304
  const toolbarText = new TextRenderable(renderer, {
212
- content: formatToolbar(currentSort, currentLimit),
305
+ content: formatToolbar(currentSort, currentLimit, totalCount),
213
306
  visible: true, // hidden in trending mode
214
- backgroundColor: colors.bg,
307
+ backgroundColor: colors.surface, // Surface panel
215
308
  color: colors.muted,
216
309
  height: 1,
310
+ borderBottom: true,
311
+ borderBottomColor: colors.border,
217
312
  paddingX: 1,
218
313
  });
219
314
  root.add(toolbarText);
@@ -229,8 +324,8 @@ export async function launchBrowser(): Promise<void> {
229
324
 
230
325
  // Results pane
231
326
  const resultsBox = new BoxRenderable(renderer, {
232
- border: true,
233
- borderColor: colors.border,
327
+ borderBottom: true,
328
+ borderBottomColor: colors.border,
234
329
  title: " Results ",
235
330
  titleColor: colors.blue,
236
331
  width: "50%",
@@ -239,11 +334,16 @@ export async function launchBrowser(): Promise<void> {
239
334
  focusable: false,
240
335
  });
241
336
  const resultsSelect = new SelectRenderable(renderer, {
242
- 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
+ ],
243
344
  showDescription: false,
244
345
  showSelectionIndicator: false,
245
346
  flexGrow: 1,
246
- backgroundColor: colors.bg,
247
347
  textColor: colors.text,
248
348
  selectedBackgroundColor: colors.selectionBg,
249
349
  selectedTextColor: colors.selectionText,
@@ -255,8 +355,8 @@ export async function launchBrowser(): Promise<void> {
255
355
 
256
356
  // Detail / Graph pane
257
357
  const detailBox = new BoxRenderable(renderer, {
258
- border: true,
259
- borderColor: colors.border,
358
+ borderBottom: true,
359
+ borderBottomColor: colors.border,
260
360
  title: " Details ",
261
361
  titleColor: colors.green,
262
362
  width: "50%",
@@ -264,6 +364,7 @@ export async function launchBrowser(): Promise<void> {
264
364
  paddingX: 1,
265
365
  paddingY: 1,
266
366
  focusable: false,
367
+ backgroundColor: colors.surface,
267
368
  });
268
369
  const detailText = new TextRenderable(renderer, {
269
370
  content: "",
@@ -275,8 +376,6 @@ export async function launchBrowser(): Promise<void> {
275
376
  body.add(detailBox);
276
377
  root.add(body);
277
378
 
278
-
279
-
280
379
  // ── Overlay system ──────────────────────────────────────────────────
281
380
 
282
381
  // Hide main content so overlays can take 100% of the content area
@@ -308,7 +407,10 @@ export async function launchBrowser(): Promise<void> {
308
407
  notifsBox.visible = false;
309
408
  shareBox.visible = false;
310
409
  leaderBox.visible = false;
410
+ leaderDim.visible = false;
311
411
  readmeBox.visible = false;
412
+ updateBox.visible = false;
413
+ updateDim.visible = false;
312
414
 
313
415
  if (type === "none") {
314
416
  currentOverlay = "none";
@@ -317,18 +419,38 @@ export async function launchBrowser(): Promise<void> {
317
419
  return;
318
420
  }
319
421
  currentOverlay = type;
320
- hideMainContent();
321
- if (type === "help") { helpBox.visible = true; }
322
- else if (type === "history") { historyBox.visible = true; }
323
- else if (type === "bookmarks") { bookmarksBox.visible = true; }
324
- else if (type === "saved") { savedBox.visible = true; }
325
- else if (type === "topics") { topicsBox.visible = true; }
326
- else if (type === "export") { exportBox.visible = true; }
327
- else if (type === "compare") { compareBox.visible = true; }
328
- else if (type === "notifications") { notifsBox.visible = true; }
329
- else if (type === "share") { shareBox.visible = true; }
330
- else if (type === "leader") { leaderBox.visible = true; }
331
- 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
+ }
332
454
  renderer.requestRender();
333
455
  }
334
456
 
@@ -338,8 +460,8 @@ export async function launchBrowser(): Promise<void> {
338
460
  visible: false,
339
461
  flexGrow: 1,
340
462
  backgroundColor: colors.bg,
341
- border: true,
342
- borderColor: colors.border,
463
+ borderBottom: true,
464
+ borderBottomColor: colors.border,
343
465
  });
344
466
  const helpScroll = new ScrollBoxRenderable(renderer, {
345
467
  flexGrow: 1,
@@ -357,10 +479,12 @@ export async function launchBrowser(): Promise<void> {
357
479
  });
358
480
  for (const section of helpSections) {
359
481
  const sectionBox = new BoxRenderable(renderer, {
360
- border: true,
361
- borderColor: colors.border,
482
+ borderBottom: true,
483
+ borderBottomColor: colors.border,
362
484
  title: section.title,
363
- titleColor: (colors as Record<string, string>)[section.titleColor] ?? section.titleColor,
485
+ titleColor:
486
+ (colors as Record<string, string>)[section.titleColor] ??
487
+ section.titleColor,
364
488
  flexDirection: "column",
365
489
  paddingY: 0,
366
490
  marginLeft: 1,
@@ -369,16 +493,20 @@ export async function launchBrowser(): Promise<void> {
369
493
  });
370
494
  for (const row of section.rows) {
371
495
  const keys = row.keys.padEnd(HELP_KEYS_COLUMN);
372
- sectionBox.add(new TextRenderable(renderer, {
373
- content: ` ${keys} ${row.action}`,
374
- color: colors.text,
375
- }));
496
+ sectionBox.add(
497
+ new TextRenderable(renderer, {
498
+ content: ` ${keys} ${row.action}`,
499
+ color: colors.text,
500
+ }),
501
+ );
376
502
  }
377
503
  if (section.note) {
378
- sectionBox.add(new TextRenderable(renderer, {
379
- content: ` ${section.note}`,
380
- color: colors.muted,
381
- }));
504
+ sectionBox.add(
505
+ new TextRenderable(renderer, {
506
+ content: ` ${section.note}`,
507
+ color: colors.muted,
508
+ }),
509
+ );
382
510
  }
383
511
  helpScroll.add(sectionBox);
384
512
  }
@@ -390,8 +518,8 @@ export async function launchBrowser(): Promise<void> {
390
518
  visible: false,
391
519
  flexGrow: 1,
392
520
  backgroundColor: colors.bg,
393
- border: true,
394
- borderColor: colors.border,
521
+ borderBottom: true,
522
+ borderBottomColor: colors.border,
395
523
  title: " Search History ",
396
524
  titleColor: colors.blue,
397
525
  });
@@ -412,7 +540,9 @@ export async function launchBrowser(): Promise<void> {
412
540
  function refreshHistory() {
413
541
  const entries = readHistory();
414
542
  if (entries.length === 0) {
415
- historySelect.options = [{ name: "(no history)", description: "", value: null }];
543
+ historySelect.options = [
544
+ { name: "(no history)", description: "", value: null },
545
+ ];
416
546
  } else {
417
547
  historySelect.options = entries.map((e, i) => ({
418
548
  name: `${e.query} — ${e.mode}${e.tab ? ` (${e.tab})` : ""}`,
@@ -428,8 +558,8 @@ export async function launchBrowser(): Promise<void> {
428
558
  visible: false,
429
559
  flexGrow: 1,
430
560
  backgroundColor: colors.bg,
431
- border: true,
432
- borderColor: colors.border,
561
+ borderBottom: true,
562
+ borderBottomColor: colors.border,
433
563
  title: " Bookmarks ",
434
564
  titleColor: colors.green,
435
565
  });
@@ -450,7 +580,9 @@ export async function launchBrowser(): Promise<void> {
450
580
  function refreshBookmarks() {
451
581
  const bookmarks = getBookmarks();
452
582
  if (bookmarks.length === 0) {
453
- bookmarksSelect.options = [{ name: "(no bookmarks)", description: "", value: null }];
583
+ bookmarksSelect.options = [
584
+ { name: "(no bookmarks)", description: "", value: null },
585
+ ];
454
586
  } else {
455
587
  bookmarksSelect.options = bookmarks.map((b) => ({
456
588
  name: `${b.repo.fullName} ★ ${b.repo.stars.toLocaleString()}`,
@@ -466,8 +598,8 @@ export async function launchBrowser(): Promise<void> {
466
598
  visible: false,
467
599
  flexGrow: 1,
468
600
  backgroundColor: colors.bg,
469
- border: true,
470
- borderColor: colors.border,
601
+ borderBottom: true,
602
+ borderBottomColor: colors.border,
471
603
  title: " Saved Searches ",
472
604
  titleColor: colors.yellow,
473
605
  });
@@ -490,8 +622,8 @@ export async function launchBrowser(): Promise<void> {
490
622
  visible: false,
491
623
  flexGrow: 1,
492
624
  backgroundColor: colors.bg,
493
- border: true,
494
- borderColor: colors.border,
625
+ borderBottom: true,
626
+ borderBottomColor: colors.border,
495
627
  title: " Topics ",
496
628
  titleColor: colors.purple ?? colors.blue,
497
629
  });
@@ -518,7 +650,9 @@ export async function launchBrowser(): Promise<void> {
518
650
  value: t,
519
651
  }));
520
652
  } catch {
521
- topicsSelect.options = [{ name: "(failed to load topics)", description: "", value: null }];
653
+ topicsSelect.options = [
654
+ { name: "(failed to load topics)", description: "", value: null },
655
+ ];
522
656
  }
523
657
  topicsSelect.setSelectedIndex(0);
524
658
  }
@@ -528,16 +662,28 @@ export async function launchBrowser(): Promise<void> {
528
662
  visible: false,
529
663
  flexGrow: 1,
530
664
  backgroundColor: colors.bg,
531
- border: true,
532
- borderColor: colors.border,
665
+ borderBottom: true,
666
+ borderBottomColor: colors.border,
533
667
  title: " Export ",
534
668
  titleColor: colors.green,
535
669
  });
536
670
  const exportSelect = new SelectRenderable(renderer, {
537
671
  options: [
538
- { name: " JSON", description: "Full repo data as JSON array", value: "json" },
539
- { name: " CSV", description: "Rank, name, stars, forks, language, URL", value: "csv" },
540
- { 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
+ },
541
687
  { name: " Plain text", description: "One repo per line", value: "text" },
542
688
  ],
543
689
  showDescription: true,
@@ -557,8 +703,8 @@ export async function launchBrowser(): Promise<void> {
557
703
  visible: false,
558
704
  flexGrow: 1,
559
705
  backgroundColor: colors.bg,
560
- border: true,
561
- borderColor: colors.border,
706
+ borderBottom: true,
707
+ borderBottomColor: colors.border,
562
708
  title: " Comparison ",
563
709
  titleColor: colors.yellow,
564
710
  });
@@ -575,8 +721,8 @@ export async function launchBrowser(): Promise<void> {
575
721
  visible: false,
576
722
  flexGrow: 1,
577
723
  backgroundColor: colors.bg,
578
- border: true,
579
- borderColor: colors.border,
724
+ borderBottom: true,
725
+ borderBottomColor: colors.border,
580
726
  title: " Notifications ",
581
727
  titleColor: colors.yellow,
582
728
  });
@@ -604,16 +750,28 @@ export async function launchBrowser(): Promise<void> {
604
750
  visible: false,
605
751
  flexGrow: 1,
606
752
  backgroundColor: colors.bg,
607
- border: true,
608
- borderColor: colors.border,
753
+ borderBottom: true,
754
+ borderBottomColor: colors.border,
609
755
  title: " Share Repo ",
610
756
  titleColor: colors.blue,
611
757
  });
612
758
  const shareSelect = new SelectRenderable(renderer, {
613
759
  options: [
614
- { name: " Markdown", description: "[owner/repo](url) — description", value: "markdown" },
615
- { name: " Plain", description: "owner/repo — description — url", value: "plain" },
616
- { 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
+ },
617
775
  { name: " Short", description: "owner/repo", value: "short" },
618
776
  ],
619
777
  showDescription: true,
@@ -630,7 +788,8 @@ export async function launchBrowser(): Promise<void> {
630
788
 
631
789
  function refreshCompare() {
632
790
  if (compareList.length < 2) {
633
- 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).";
634
793
  } else {
635
794
  compareText.content = buildComparisonTable(compareList);
636
795
  }
@@ -639,7 +798,9 @@ export async function launchBrowser(): Promise<void> {
639
798
  function refreshSavedSearches() {
640
799
  const saved = getSavedSearches();
641
800
  if (saved.length === 0) {
642
- savedSelect.options = [{ name: "(no saved searches)", description: "", value: null }];
801
+ savedSelect.options = [
802
+ { name: "(no saved searches)", description: "", value: null },
803
+ ];
643
804
  } else {
644
805
  savedSelect.options = saved.map((s) => ({
645
806
  name: `${s.name}`,
@@ -651,96 +812,547 @@ export async function launchBrowser(): Promise<void> {
651
812
  }
652
813
 
653
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
+
654
827
  const leaderBox = new BoxRenderable(renderer, {
655
828
  visible: false,
656
- flexGrow: 1,
657
- backgroundColor: colors.bg,
829
+ position: "absolute",
830
+ width: "80%",
831
+ height: 18,
832
+ left: "10%",
833
+ top: "30%",
834
+ backgroundColor: colors.surfaceDim,
658
835
  border: true,
659
- borderColor: colors.border,
660
- title: " Menu ",
661
- titleColor: colors.blue,
836
+ borderStyle: "rounded",
837
+ borderColor: colors.borderAccent,
838
+ title: " ⚙ Menu ",
839
+ titleColor: colors.accent,
840
+ titleAlignment: "left",
662
841
  flexDirection: "column",
842
+ paddingX: 1,
663
843
  });
664
844
  const leaderSelect = new SelectRenderable(renderer, {
665
845
  options: [{ name: "(empty)", description: "", value: null }],
666
846
  showDescription: true,
667
847
  showSelectionIndicator: true,
668
848
  flexGrow: 1,
669
- backgroundColor: colors.bg,
849
+ backgroundColor: colors.surfaceDim,
670
850
  textColor: colors.text,
671
- selectedBackgroundColor: colors.selectionBg,
851
+ focusedBackgroundColor: colors.surfaceDim,
852
+ focusedTextColor: colors.text,
853
+ selectedBackgroundColor: colors.accent,
672
854
  selectedTextColor: colors.selectionText,
855
+ descriptionColor: colors.muted,
856
+ selectedDescriptionColor: colors.selectionText,
673
857
  itemSpacing: 0,
674
858
  });
675
859
  const leaderFooter = new TextRenderable(renderer, {
676
- content: " ↑↓ select Enter run Esc/q close",
860
+ content: " ↑↓ select Enter run Esc/q close ",
677
861
  color: colors.muted,
678
- backgroundColor: colors.bg,
862
+ backgroundColor: colors.surfaceDim,
679
863
  height: 1,
680
- paddingX: 1,
864
+ borderTop: true,
865
+ borderTopColor: colors.border,
866
+ paddingX: 2,
681
867
  });
682
868
  leaderBox.add(leaderSelect);
683
869
  leaderBox.add(leaderFooter);
870
+ root.add(leaderDim);
684
871
  root.add(leaderBox);
685
872
 
686
- 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
+ });
687
883
 
688
- function showLeaderMenu() {
689
- const items: LeaderItem[] = [];
884
+ root.add(updateDim);
690
885
 
691
- if (currentMode === "search") {
692
- items.push(
693
- { name: " Sort", description: `Cycle sort (current: ${currentSort})`, action: () => changeSort() },
694
- { name: " Limit", description: `Cycle result limit (current: ${currentLimit})`, action: () => changeLimit() },
695
- { name: " Refresh", description: "Re-run current search", action: () => { if (currentQueryInput) doSearch(currentQueryInput); } },
696
- { name: " Toggle trending", description: "Browse GitHub trending repos", action: () => loadTrending() },
697
- { name: " Deep-dive", description: "Languages, contributors, README excerpt", action: () => { showOverlay("none"); triggerDeepDive(); } },
698
- { name: " Readme", description: "Full README viewer", action: () => { showReadme(); } },
699
- { name: " Activity graph", description: "Toggle commit chart fullscreen", action: () => toggleGraph() },
700
- { name: " Bookmark", description: "Save / unsave selected repo", action: () => toggleBookmarkOnSelected() },
701
- { name: " Compare", description: "Add/remove repo to comparison", action: () => toggleCompareOnSelected() },
702
- );
703
- }
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
+ });
704
910
 
705
- if (currentMode === "trending") {
706
- items.push(
707
- { name: " Refresh", description: "Reload trending repos", action: () => loadTrending() },
708
- { name: " Search mode", description: "Switch to query search", action: () => showSearchMode() },
709
- { name: " Readme", description: "Full README viewer", action: () => { showReadme(); } },
710
- { name: " Bookmark", description: "Save / unsave selected repo", action: () => toggleBookmarkOnSelected() },
711
- );
712
- }
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
+ }
713
1072
 
714
- // Common items (always available when repos exist)
1073
+ // ── Build hierarchical menu ─────────────────────────────────────
1074
+ function buildMenuHierarchy(): MenuCategory[] {
715
1075
  const hasRepo = (() => {
716
1076
  const opt = resultsSelect.getSelectedOption();
717
1077
  return opt?.value && typeof (opt.value as any)?.fullName === "string";
718
1078
  })();
719
1079
 
720
- items.push(
721
- { name: " Open in browser", description: "Open selected repo in browser", action: () => openUrlIfSelected() },
722
- { name: " History", description: "Search history", action: () => { refreshHistory(); showOverlay("history"); } },
723
- { name: " Saved searches", description: "Load a saved search", action: () => { refreshSavedSearches(); showOverlay("saved"); } },
724
- { name: " Export", description: "Export results to JSON/CSV/Markdown", action: () => showOverlay("export") },
725
- { name: " Share repo", description: "Copy repo link to clipboard", action: () => showShareIfRepo() },
726
- { name: " Notifications", description: "View alerts", action: () => { refreshNotifications(); showOverlay("notifications"); } },
727
- { name: " Topics", description: "Browse popular GitHub topics", action: () => { refreshTopics(); showOverlay("topics"); } },
728
- { name: " Compare view", description: "Show side-by-side comparison", action: () => { refreshCompare(); showOverlay("compare"); } },
729
- { name: " Bookmarks panel", description: "Browse saved repos", action: () => { refreshBookmarks(); showOverlay("bookmarks"); } },
730
- { name: " Save search", description: "Save current query", action: () => saveCurrentSearch() },
731
- { 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
+ },
732
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
+ });
733
1259
 
734
- leaderSelect.options = items.map((it) => ({
735
- name: it.name,
736
- description: it.description,
737
- value: it,
738
- }));
739
- 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);
740
1326
  leaderSelect.focus();
741
1327
  showOverlay("leader");
742
1328
  }
743
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
+
744
1356
  function openUrlIfSelected() {
745
1357
  const opt = resultsSelect.getSelectedOption();
746
1358
  const repo = opt?.value as Repo | undefined;
@@ -753,7 +1365,9 @@ export async function launchBrowser(): Promise<void> {
753
1365
  const repo = opt?.value as Repo | undefined;
754
1366
  if (repo) {
755
1367
  const added = toggleBookmark(repo);
756
- setStatus(added ? `Bookmarked ${repo.fullName}` : `Unbookmarked ${repo.fullName}`);
1368
+ setStatus(
1369
+ added ? `Bookmarked ${repo.fullName}` : `Unbookmarked ${repo.fullName}`,
1370
+ );
757
1371
  }
758
1372
  }
759
1373
 
@@ -767,23 +1381,36 @@ export async function launchBrowser(): Promise<void> {
767
1381
  setStatus(`Removed ${repo.fullName} from comparison`);
768
1382
  } else {
769
1383
  compareList.push(repo);
770
- setStatus(`${repo.fullName} added to comparison (${compareList.length} selected)`);
1384
+ setStatus(
1385
+ `${repo.fullName} added to comparison (${compareList.length} selected)`,
1386
+ );
771
1387
  }
772
1388
  }
773
1389
 
774
1390
  function showShareIfRepo() {
775
1391
  const opt = resultsSelect.getSelectedOption();
776
1392
  const repo = opt?.value as Repo | undefined;
777
- if (!repo) { setStatus("No repo selected to share"); return; }
1393
+ if (!repo) {
1394
+ setStatus("No repo selected to share");
1395
+ return;
1396
+ }
778
1397
  showOverlay("share");
779
1398
  }
780
1399
 
781
1400
  function saveCurrentSearch() {
782
1401
  if (currentQueryInput) {
783
- const name = currentQueryInput.length > 40
784
- ? currentQueryInput.slice(0, 37) + "..."
785
- : currentQueryInput;
786
- 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
+ );
787
1414
  setStatus(`Saved as "${name}"`);
788
1415
  } else {
789
1416
  setStatus("No search to save");
@@ -817,9 +1444,9 @@ export async function launchBrowser(): Promise<void> {
817
1444
  const readmeBox = new BoxRenderable(renderer, {
818
1445
  visible: false,
819
1446
  flexGrow: 1,
820
- backgroundColor: colors.bg,
821
- border: true,
822
- borderColor: colors.green,
1447
+ backgroundColor: colors.surface,
1448
+ borderBottom: true,
1449
+ borderBottomColor: colors.border,
823
1450
  title: " README ",
824
1451
  titleColor: colors.green,
825
1452
  });
@@ -872,8 +1499,13 @@ export async function launchBrowser(): Promise<void> {
872
1499
  `${repo.owner}/${repo.name}/master/README.md`,
873
1500
  `${repo.owner}/${repo.name}/main/README.rst`,
874
1501
  ]) {
875
- const r = await fetch(`https://raw.githubusercontent.com/${path}`, { headers });
876
- 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
+ }
877
1509
  }
878
1510
  if (!text) {
879
1511
  readmeText.content = ` (no README found for ${repo.fullName})`;
@@ -899,10 +1531,12 @@ export async function launchBrowser(): Promise<void> {
899
1531
  // ── Status bar ──────────────────────────────────────────────────────
900
1532
  const statusBar = new TextRenderable(renderer, {
901
1533
  content: " Ready. Press Enter to search.",
902
- backgroundColor: colors.bg,
1534
+ backgroundColor: colors.surface,
903
1535
  color: colors.muted,
904
1536
  height: 1,
905
1537
  paddingX: 1,
1538
+ borderTop: true,
1539
+ borderTopColor: colors.border,
906
1540
  });
907
1541
  root.add(statusBar);
908
1542
 
@@ -928,7 +1562,7 @@ export async function launchBrowser(): Promise<void> {
928
1562
  }
929
1563
 
930
1564
  function setToolbar() {
931
- toolbarText.content = formatToolbar(currentSort, currentLimit);
1565
+ toolbarText.content = formatToolbar(currentSort, currentLimit, totalCount);
932
1566
  renderer.requestRender();
933
1567
  }
934
1568
 
@@ -937,22 +1571,30 @@ export async function launchBrowser(): Promise<void> {
937
1571
  detailText.content = "";
938
1572
  return;
939
1573
  }
940
- 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
+ : "—";
941
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
+
942
1587
  const lines = [
943
- `Name ${repo.fullName}`,
944
- `Stars ${repo.stars.toLocaleString()}`,
945
- `Forks ${repo.forks.toLocaleString()}`,
946
- `Lang ${repo.language ?? "—"}`,
947
- `Updated ${repo.updatedAt.slice(0, 10)}`,
948
- repo.archived ? "Status archived" : "",
949
- repo.isFork ? "Status fork" : "",
950
- `Topics ${topics}`,
951
- "",
952
- desc,
953
- "",
954
- repo.url,
955
- ].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
+ ];
956
1598
  detailText.content = lines.join("\n");
957
1599
  }
958
1600
 
@@ -966,7 +1608,11 @@ export async function launchBrowser(): Promise<void> {
966
1608
  return bits === 0 ? " " : String.fromCodePoint(0x2800 + bits);
967
1609
  }
968
1610
 
969
- function buildChartString(values: number[], termW: number, termH: number): string[] {
1611
+ function buildChartString(
1612
+ values: number[],
1613
+ termW: number,
1614
+ termH: number,
1615
+ ): string[] {
970
1616
  if (values.length === 0) return ["(no data)"];
971
1617
 
972
1618
  // Braille resolution: 2 horizontal dots and 4 vertical dots per terminal cell.
@@ -980,10 +1626,12 @@ export async function launchBrowser(): Promise<void> {
980
1626
  }
981
1627
  const max = Math.max(...sampled, 1);
982
1628
  // Map each sample to a dot row (0 = bottom, dotH-1 = top).
983
- const dotRows = sampled.map(v => Math.round((v / max) * (dotH - 1)));
1629
+ const dotRows = sampled.map((v) => Math.round((v / max) * (dotH - 1)));
984
1630
 
985
1631
  // Build a mask grid: dotGrid[row][col] = true if the silhouette passes through.
986
- 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
+ );
987
1635
 
988
1636
  // Draw the silhouette line: mark the dot at each column.
989
1637
  for (let c = 0; c < dotW; c++) {
@@ -1008,29 +1656,54 @@ export async function launchBrowser(): Promise<void> {
1008
1656
  const r0 = tr * 4;
1009
1657
  const c0 = tc * 2;
1010
1658
  let bits = 0;
1011
- if (r0 + 0 < dotH && c0 + 0 < dotW && dotGrid[r0 + 0][c0 + 0]) bits |= 0x01;
1012
- if (r0 + 1 < dotH && c0 + 0 < dotW && dotGrid[r0 + 1][c0 + 0]) bits |= 0x02;
1013
- if (r0 + 2 < dotH && c0 + 0 < dotW && dotGrid[r0 + 2][c0 + 0]) bits |= 0x04;
1014
- if (r0 + 0 < dotH && c0 + 1 < dotW && dotGrid[r0 + 0][c0 + 1]) bits |= 0x08;
1015
- if (r0 + 1 < dotH && c0 + 1 < dotW && dotGrid[r0 + 1][c0 + 1]) bits |= 0x10;
1016
- if (r0 + 2 < dotH && c0 + 1 < dotW && dotGrid[r0 + 2][c0 + 1]) bits |= 0x20;
1017
- if (r0 + 3 < dotH && c0 + 0 < dotW && dotGrid[r0 + 3][c0 + 0]) bits |= 0x40;
1018
- 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;
1019
1675
  chars.push(brailleChar(bits));
1020
1676
  }
1021
1677
 
1022
1678
  // Y-axis labels
1023
- const labelRows = [0, Math.floor(termH / 4), Math.floor(termH / 2), Math.floor(3 * termH / 4), termH - 1];
1024
- 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
+ ];
1025
1693
  const idx = labelRows.indexOf(tr);
1026
1694
  let label = " ";
1027
- if (idx >= 0) label = String(labelVals[idx]).padStart(4) + " ";
1028
- 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("")}`);
1029
1701
  }
1030
1702
 
1031
- // X-axis
1032
- lines.push(` \u2514${String.fromCharCode(0x2500).repeat(termW)}`);
1033
- 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];
1034
1707
  }
1035
1708
 
1036
1709
  async function loadChart(repo: Repo) {
@@ -1038,10 +1711,13 @@ export async function launchBrowser(): Promise<void> {
1038
1711
  renderer.requestRender();
1039
1712
  try {
1040
1713
  const [chartRes, repoRes] = await Promise.all([
1041
- fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/stats/participation`,
1042
- { headers: { "User-Agent": "ghfind/1.0" } }),
1043
- fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}`,
1044
- { 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)),
1045
1721
  ]);
1046
1722
 
1047
1723
  let chartSection = "";
@@ -1052,22 +1728,28 @@ export async function launchBrowser(): Promise<void> {
1052
1728
  const chartW = 60;
1053
1729
  const chartH = 10;
1054
1730
  const chartLines = buildChartString(chartCommitData, chartW, chartH);
1055
- chartSection = ["", "Weekly commits (52 weeks)", ...chartLines].join("\n");
1731
+ chartSection = ["", "Weekly commits (52 weeks)", ...chartLines].join(
1732
+ "\n",
1733
+ );
1056
1734
  }
1057
1735
  }
1058
1736
 
1059
1737
  const desc = repo.description ?? "";
1060
- const lang = repo.language ?? "\u2014";
1738
+ const lang = repo.language ?? "—";
1061
1739
  const updated = repo.updatedAt ? repo.updatedAt.slice(0, 10) : "";
1062
- const topics = repoRes?.topics?.length ? repoRes.topics.slice(0, 8).join(", ") : "";
1063
-
1064
- const growthLine = currentMode === "trending" && repo.score > 0
1065
- ? ` Growth \u25b2 ${fmtStars(repo.score)} ${trendingPeriod}`
1740
+ const topics = repoRes?.topics?.length
1741
+ ? repoRes.topics.slice(0, 8).join(", ")
1066
1742
  : "";
1067
1743
 
1744
+ const growthLine =
1745
+ currentMode === "trending" && repo.score > 0
1746
+ ? ` Growth \u25b2 ${fmtStars(repo.score)} ${trendingPeriod}`
1747
+ : "";
1748
+
1068
1749
  const detailLines = [
1069
- ` ${repo.fullName}`,
1070
- ` \u2606 ${repo.stars.toLocaleString()} \u2666 ${repo.forks.toLocaleString()} ${lang}`,
1750
+ ` ${repo.fullName}`,
1751
+ ``,
1752
+ ` ★ ${repo.stars.toLocaleString()} ♡ ${repo.forks.toLocaleString()} ${lang}`,
1071
1753
  growthLine,
1072
1754
  updated ? ` Updated ${updated}` : "",
1073
1755
  topics ? ` Topics ${topics}` : "",
@@ -1075,12 +1757,9 @@ export async function launchBrowser(): Promise<void> {
1075
1757
  desc,
1076
1758
  "",
1077
1759
  ` ${repo.url}`,
1078
- ].filter(l => l !== "");
1760
+ ].filter((l) => l !== "");
1079
1761
 
1080
- detailText.content = [
1081
- ...detailLines,
1082
- chartSection,
1083
- ].join("\n");
1762
+ detailText.content = [...detailLines, chartSection].join("\n");
1084
1763
  } catch {
1085
1764
  detailText.content = " Failed to load chart";
1086
1765
  }
@@ -1110,37 +1789,14 @@ export async function launchBrowser(): Promise<void> {
1110
1789
 
1111
1790
  // ── Trending helpers ────────────────────────────────────────────────
1112
1791
 
1113
- function formatTrendingLine(r: { rank: number; owner: string; name: string; stars: number; starsToday: number; language: string }): string {
1114
- const rank = String(r.rank).padStart(2, "0");
1792
+ function formatTrendingLine(r: Repo, rank: number): string {
1793
+ const rankStr = String(rank).padStart(2, "0");
1115
1794
  const name = `${r.owner}/${r.name}`.padEnd(30).slice(0, 30);
1116
1795
  const lang = (r.language || "?").padEnd(12).slice(0, 12);
1117
1796
  const stars = `★ ${fmtStars(r.stars)}`.padEnd(9).slice(0, 9);
1118
- const arrow = r.starsToday > 0 ? "▲" : r.starsToday < 0 ? "▼" : "—";
1119
- const growth = `${arrow} ${fmtStars(r.starsToday)} ${trendingPeriod}`;
1120
- return `[${rank}] ${name} ${lang} ${stars} ${growth}`;
1121
- }
1122
-
1123
- function trendingRepoToSelectValue(r: TrendingRepo): Repo {
1124
- return {
1125
- id: 0,
1126
- fullName: `${r.owner}/${r.name}`,
1127
- name: r.name,
1128
- owner: r.owner,
1129
- description: r.description,
1130
- url: `https://github.com/${r.owner}/${r.name}`,
1131
- stars: r.stars,
1132
- forks: 0,
1133
- watchers: 0,
1134
- language: r.language,
1135
- topics: [],
1136
- archived: false,
1137
- isFork: false,
1138
- private: false,
1139
- createdAt: "",
1140
- updatedAt: "",
1141
- pushedAt: "",
1142
- score: r.starsToday,
1143
- };
1797
+ const arrow = r.score > 0 ? "▲" : r.score < 0 ? "▼" : "—";
1798
+ const growth = `${arrow} ${fmtStars(r.score)} ${trendingPeriod}`;
1799
+ return `[${rankStr}] ${name} ${lang} ${stars} ${growth}`;
1144
1800
  }
1145
1801
 
1146
1802
  async function loadTrending() {
@@ -1150,31 +1806,59 @@ export async function launchBrowser(): Promise<void> {
1150
1806
  toolbarText.visible = false;
1151
1807
  trendingTabBox.visible = true;
1152
1808
  renderTrendingTabs();
1153
- resultsSelect.options = [{ name: " Loading trending...", description: "", value: null }];
1809
+ resultsSelect.options = [
1810
+ { name: " Loading trending...", description: "", value: null },
1811
+ ];
1154
1812
  detailText.content = "";
1155
1813
  statusMgr.set("loading", "Loading trending repos...");
1156
1814
  renderer.requestRender();
1157
1815
 
1158
1816
  try {
1159
- 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;
1160
1823
  const since = tabSince(trendingTab);
1161
- trendingPeriod = since === "daily" ? "today" : since === "weekly" ? "this week" : "this month";
1162
- resultsSelect.options = fetched.map(r => ({
1163
- name: formatTrendingLine(r),
1164
- description: r.description,
1165
- 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,
1166
1834
  }));
1167
1835
  if (fetched.length > 0) {
1168
1836
  resultsSelect.setSelectedIndex(0);
1169
- updateDetail(trendingRepoToSelectValue(fetched[0]));
1837
+ updateDetail(fetched[0]);
1170
1838
  } else {
1171
1839
  throw new NoResultsError(trendingTab);
1172
1840
  }
1173
- statusMgr.set("success", `${fetched.length} trending repos — ${trendingTab}`);
1174
- 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
+ });
1175
1852
  } catch (err) {
1176
- const msg = err instanceof SearchCliError ? err.userMessage : (err instanceof Error ? err.message : String(err));
1177
- 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
+ ];
1178
1862
  statusMgr.set("error", msg.slice(0, 60));
1179
1863
  }
1180
1864
  isLoading = false;
@@ -1186,7 +1870,8 @@ export async function launchBrowser(): Promise<void> {
1186
1870
  trendingTabBox.visible = false;
1187
1871
  searchBox.visible = true;
1188
1872
  toolbarText.visible = true;
1189
- 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)";
1190
1875
  searchInput.focus();
1191
1876
  renderer.requestRender();
1192
1877
  }
@@ -1201,7 +1886,9 @@ export async function launchBrowser(): Promise<void> {
1201
1886
  isLoading = true;
1202
1887
  currentQueryInput = q;
1203
1888
  if (!append) {
1204
- resultsSelect.options = [{ name: " Searching...", description: "", value: null }];
1889
+ resultsSelect.options = [
1890
+ { name: " Searching...", description: "", value: null },
1891
+ ];
1205
1892
  detailText.content = "";
1206
1893
  }
1207
1894
  statusMgr.set("searching", `Searching for "${q}"`);
@@ -1239,15 +1926,30 @@ export async function launchBrowser(): Promise<void> {
1239
1926
  const idx = append ? resultsSelect.options.length - newRepos.length : 0;
1240
1927
  updateDetail(currentRepos[idx]);
1241
1928
  resultsSelect.setSelectedIndex(idx);
1242
- 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
+ );
1243
1933
  if (!append) {
1244
- 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
+ });
1245
1940
  }
1246
1941
  }
1247
1942
  } catch (err) {
1248
1943
  currentRepos = [];
1249
- resultsSelect.options = [{ name: " (error)", description: "", value: null }];
1250
- 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);
1251
1953
  statusMgr.set("error", msg.slice(0, 60));
1252
1954
  }
1253
1955
 
@@ -1313,21 +2015,56 @@ export async function launchBrowser(): Promise<void> {
1313
2015
  renderer.keyInput.on("keypress", (key) => {
1314
2016
  // ── Overlay-mode handling ────────────────────────────────────────
1315
2017
  if (currentOverlay !== "none") {
1316
- // Escape or q closes any overlay
1317
- 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") {
1318
2033
  showOverlay("none");
1319
2034
  renderer.requestRender();
1320
2035
  return;
1321
2036
  }
1322
2037
 
1323
- // Leader menu: Enter dispatches action, up/down navigates
2038
+ // Leader menu: hierarchical submenu navigation
1324
2039
  if (currentOverlay === "leader") {
1325
- 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
+ ) {
1326
2046
  const sel = leaderSelect.getSelectedOption();
1327
- const item = sel?.value as LeaderItem | undefined;
1328
- 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();
1329
2059
  showOverlay("none");
1330
- 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();
1331
2068
  renderer.requestRender();
1332
2069
  }
1333
2070
  return;
@@ -1365,7 +2102,8 @@ export async function launchBrowser(): Promise<void> {
1365
2102
  const entry = (sel.value as any).entry;
1366
2103
  showOverlay("none");
1367
2104
  if (entry.mode === "trending") {
1368
- if (entry.tab) trendingTab = entry.tab as typeof TAB_NAMES[number];
2105
+ if (entry.tab)
2106
+ trendingTab = entry.tab as (typeof TAB_NAMES)[number];
1369
2107
  loadTrending();
1370
2108
  } else {
1371
2109
  searchInput.value = entry.query;
@@ -1417,7 +2155,7 @@ export async function launchBrowser(): Promise<void> {
1417
2155
  showOverlay("none");
1418
2156
  touchSavedSearch(s.name);
1419
2157
  if (s.mode === "trending") {
1420
- if (s.tab) trendingTab = s.tab as typeof TAB_NAMES[number];
2158
+ if (s.tab) trendingTab = s.tab as (typeof TAB_NAMES)[number];
1421
2159
  loadTrending();
1422
2160
  } else {
1423
2161
  searchInput.value = s.query;
@@ -1522,6 +2260,51 @@ export async function launchBrowser(): Promise<void> {
1522
2260
  return;
1523
2261
  }
1524
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
+
1525
2308
  return;
1526
2309
  }
1527
2310
 
@@ -1533,7 +2316,13 @@ export async function launchBrowser(): Promise<void> {
1533
2316
  toggleGraph();
1534
2317
  return;
1535
2318
  }
1536
- 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
+ });
1537
2326
  cleanup();
1538
2327
  return;
1539
2328
  }
@@ -1569,9 +2358,10 @@ export async function launchBrowser(): Promise<void> {
1569
2358
  const currentWord = lastSpace >= 0 ? val.slice(lastSpace + 1) : val;
1570
2359
  const suggestions = suggestFor(currentWord);
1571
2360
  if (suggestions.length > 0) {
1572
- const newVal = lastSpace >= 0
1573
- ? val.slice(0, lastSpace + 1) + suggestions[0]
1574
- : suggestions[0];
2361
+ const newVal =
2362
+ lastSpace >= 0
2363
+ ? val.slice(0, lastSpace + 1) + suggestions[0]
2364
+ : suggestions[0];
1575
2365
  searchInput.value = newVal;
1576
2366
  renderer.requestRender();
1577
2367
  }
@@ -1603,14 +2393,20 @@ export async function launchBrowser(): Promise<void> {
1603
2393
  if (key.name === "left" || key.name === "h") {
1604
2394
  if (currentMode === "trending") {
1605
2395
  const idx = TAB_NAMES.indexOf(trendingTab);
1606
- if (idx > 0) { trendingTab = TAB_NAMES[idx - 1]; loadTrending(); }
2396
+ if (idx > 0) {
2397
+ trendingTab = TAB_NAMES[idx - 1];
2398
+ loadTrending();
2399
+ }
1607
2400
  }
1608
2401
  return;
1609
2402
  }
1610
2403
  if (key.name === "right" || key.name === "l") {
1611
2404
  if (currentMode === "trending") {
1612
2405
  const idx = TAB_NAMES.indexOf(trendingTab);
1613
- 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
+ }
1614
2410
  }
1615
2411
  return;
1616
2412
  }
@@ -1635,7 +2431,22 @@ export async function launchBrowser(): Promise<void> {
1635
2431
  }
1636
2432
  });
1637
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
+ }
1638
2447
  // ── Start ──────────────────────────────────────────────────────────
2448
+ // Check for updates (non-blocking — doesn't delay TUI startup)
2449
+ checkForUpdateAndShow();
1639
2450
  renderer.start();
1640
2451
  if (currentMode === "trending") {
1641
2452
  loadTrending();
@@ -1656,21 +2467,27 @@ export async function launchBrowser(): Promise<void> {
1656
2467
  function formatResultLine(repo: Repo): string {
1657
2468
  const stars = formatStars(repo.stars);
1658
2469
  const lang = (repo.language ?? "?").padEnd(12).slice(0, 12);
1659
- return `${repo.fullName} ${stars}`;
2470
+ const name = repo.fullName.padEnd(40).slice(0, 40);
2471
+ return `${name} ${lang} ${stars}`;
1660
2472
  }
1661
2473
 
1662
2474
  function formatStars(n: number): string {
2475
+ if (n >= 1000000) return `★ ${(n / 1000000).toFixed(1).replace(/\.0$/, "")}M`;
1663
2476
  if (n >= 1000) return `★ ${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
1664
2477
  return `★ ${n}`;
1665
2478
  }
1666
2479
 
1667
- function formatToolbar(sort: SortStrategy, limit: number): string {
2480
+ function formatToolbar(sort: SortStrategy, limit: number, totalCount: number): string {
1668
2481
  const sortLabel = SORT_MODES.find((m) => m.key === sort)?.label ?? sort;
1669
- return ` sort: ${sortLabel} limit: ${limit}`;
2482
+ return ` sort: ${sortLabel} limit: ${limit} results: ${totalCount.toLocaleString()}`;
1670
2483
  }
1671
2484
 
1672
2485
  function cleanup(): void {
1673
- try { rotateHistory(); } catch { /* non-critical */ }
2486
+ try {
2487
+ rotateHistory();
2488
+ } catch {
2489
+ /* non-critical */
2490
+ }
1674
2491
  process.exit(0);
1675
2492
  }
1676
2493