github-search-cli 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui.ts CHANGED
@@ -1,1665 +1,1667 @@
1
- #!/usr/bin/env node
2
- /**
3
- * ghfind — interactive GitHub repository browser (OpenTUI)
4
- *
5
- * Full-screen, keyboard-driven browser. This is the only entry point.
6
- * Pipeline: user query → provider → normalizer → ranking → rendered in TUI
7
- *
8
- * Layout:
9
- * ┌─ ghfind [s]sort [l]limit [r]refresh [q]uit ────────────────┐
10
- * │ > [query input ........................................] │
11
- * │ sort: best-match limit: 50 │
12
- * ├─── Results ────────────────┬─── Details ──────────────────┤
13
- * │ owner/repo ★69k │ Name owner/repo │
14
- * │ owner/repo ★42k │ Stars 69,420 │
15
- * │ owner/repo ★12k │ Forks 1,337 │
16
- * │ owner/repo ★5k │ Language TypeScript │
17
- * │ owner/repo ★2k │ Updated 2026-07-09 │
18
- * │ owner/repo ★1k │ Topics ai, agent, tui │
19
- * │ │ │
20
- * │ │ A description... │
21
- * │ │ https://github.com/... │
22
- * ├──────────────────────────┴────────────────────────────────┤
23
- * │ 42 results of 15000 (↑↓ nav Enter open o browser) │
24
- * └───────────────────────────────────────────────────────────┘
25
- */
26
- import {
27
- createCliRenderer,
28
- BoxRenderable,
29
- TextRenderable,
30
- InputRenderable,
31
- SelectRenderable,
32
- ScrollBoxRenderable,
33
- } from "@opentui/core";
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";
37
- import { loadConfig } from "./config";
38
- 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";
43
- import { saveSession, restoreSession } from "./session";
44
- import { fetchDeepDive, buildDeepDiveText } from "./deepdive";
45
- import { buildComparisonTable } from "./compare";
46
- import { fetchTopics, type TopicItem } from "./explore";
47
- import { exportToFile, type ExportFormat } from "./export";
48
- import { loadTheme } from "./themes";
49
- import { StatusManager } from "./status";
50
- import { addNotification, getNotifications, dismissNotification, dismissAll } from "./notifications";
51
- import { formatShare, copyToClipboard, type ShareFormat } from "./share";
52
- import { nextTip } from "./tips";
53
- import { openUrl } from "./open-url";
54
-
55
- // ─── Theme ────────────────────────────────────────────────────────────
56
- const colors = {
57
- bg: "#1e1e2e",
58
- surface: "#181825",
59
- text: "#cdd6f4",
60
- muted: "#6c7086",
61
- blue: "#89b4fa",
62
- green: "#a6e3a1",
63
- yellow: "#f9e2af",
64
- red: "#f38ba8",
65
- teal: "#94e2d5",
66
- border: "#45475a",
67
- selectionBg: "#2d5bcf",
68
- selectionText: "#ffffff",
69
- };
70
-
71
- // ─── Logger ───────────────────────────────────────────────────────────
72
- const logger: Logger = {
73
- debug: () => {},
74
- info: () => {},
75
- warn: () => {},
76
- error: () => {},
77
- };
78
-
79
- // ─── Sort cycle ───────────────────────────────────────────────────────
80
- const SORT_MODES: { key: SortStrategy; label: string }[] = [
81
- { key: "best-match", label: "best-match" },
82
- { key: "stars", label: "stars" },
83
- { key: "updated", label: "updated" },
84
- { key: "forks", label: "forks" },
85
- ];
86
-
87
- // ─── Main ─────────────────────────────────────────────────────────────
88
- export async function launchBrowser(): Promise<void> {
89
- let renderer;
90
- try {
91
- renderer = await createCliRenderer();
92
- } 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
- );
98
- process.exit(1);
99
- return;
100
- }
101
-
102
- const root = renderer.root;
103
- root.flexDirection = "column";
104
- root.backgroundColor = colors.bg;
105
-
106
- // ── Config ──
107
- const config = loadConfig();
108
- const githubToken = config.githubToken || process.env.GITHUB_TOKEN;
109
-
110
- // Apply theme
111
- const theme = loadTheme(config.theme);
112
- Object.assign(colors, theme);
113
-
114
- // ── Session restore ──
115
- const session = restoreSession();
116
-
117
- // ── State ───────────────────────────────────────────────────────────
118
- let currentRepos: Repo[] = [];
119
- let currentSort: SortStrategy = session?.sort ?? config.defaultSort;
120
- let currentLimit = session?.limit ?? config.defaultLimit;
121
- let currentQueryInput = session?.query ?? "";
122
- let isLoading = false;
123
- let currentMode: "search" | "trending" = session?.mode ?? "search";
124
- let trendingTab: typeof TAB_NAMES[number] = (session?.trendingTab as typeof TAB_NAMES[number]) ?? "This Week";
125
- let trendingPeriod = "this week";
126
- let graphFullscreen = false;
127
- let chartCommitData: number[] = [];
128
- let currentOverlay: "none" | "history" | "bookmarks" | "saved" | "help" | "topics" | "export" | "compare" | "notifications" | "share" | "leader" | "readme" = "none";
129
- let currentPage = 1;
130
- let totalCount = 0;
131
- let deepDiveActive = false;
132
- let compareList: Repo[] = [];
133
- let quitArmed = false;
134
-
135
-
136
- // ── Header bar ──────────────────────────────────────────────────────
137
- const header = new TextRenderable(renderer, {
138
- content:
139
- " ghfind — GitHub repo browser [/]search [Space]menu [?]help [q]uit",
140
- backgroundColor: colors.bg,
141
- color: colors.muted,
142
- height: 1,
143
- });
144
- root.add(header);
145
-
146
- // ── Trending tab bar (hidden in search mode) ────────────────────────
147
- const trendingTabBox = new BoxRenderable(renderer, {
148
- flexDirection: "row",
149
- height: 1,
150
- backgroundColor: colors.bg,
151
- paddingX: 1,
152
- visible: false,
153
- });
154
- // Hidden by default; shown when currentMode === "trending"
155
- const trendingTabTexts: TextRenderable[] = [];
156
-
157
- function renderTrendingTabs() {
158
- for (const t of trendingTabTexts) trendingTabBox.remove(t);
159
- trendingTabTexts.length = 0;
160
- TAB_NAMES.forEach((name, i) => {
161
- const isActive = name === trendingTab;
162
- const label = ` ${name} `;
163
- const tt = new TextRenderable(renderer, {
164
- content: label,
165
- color: isActive ? colors.bg : colors.muted,
166
- backgroundColor: isActive ? colors.blue : colors.bg,
167
- height: 1,
168
- });
169
- trendingTabTexts.push(tt);
170
- trendingTabBox.add(tt);
171
- if (i < TAB_NAMES.length - 1) {
172
- const sp = new TextRenderable(renderer, {
173
- content: " ",
174
- color: colors.muted,
175
- backgroundColor: colors.bg,
176
- height: 1,
177
- });
178
- trendingTabTexts.push(sp);
179
- trendingTabBox.add(sp);
180
- }
181
- });
182
- }
183
- root.add(trendingTabBox);
184
-
185
- // ── Search input row ────────────────────────────────────────────────
186
- 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,
192
- });
193
- const searchInput = new InputRenderable(renderer, {
194
- placeholder: "Search GitHub repos (e.g. rust cli, or language:Rust stars:>100)",
195
- value: "",
196
- });
197
- searchBox.add(searchInput);
198
- root.add(searchBox);
199
-
200
- // ── Toolbar (sort + limit indicator) ────────────────────────────────
201
- const toolbarText = new TextRenderable(renderer, {
202
- content: formatToolbar(currentSort, currentLimit),
203
- visible: true, // hidden in trending mode
204
- backgroundColor: colors.bg,
205
- color: colors.muted,
206
- height: 1,
207
- paddingX: 1,
208
- });
209
- root.add(toolbarText);
210
-
211
- // ── Body: results + detail ──────────────────────────────────────────
212
- const body = new BoxRenderable(renderer, {
213
- flexDirection: "row",
214
- flexGrow: 1,
215
- gap: 1,
216
- paddingY: 1,
217
- paddingX: 1,
218
- });
219
-
220
- // Results pane
221
- const resultsBox = new BoxRenderable(renderer, {
222
- border: true,
223
- borderColor: colors.border,
224
- title: " Results ",
225
- titleColor: colors.blue,
226
- width: "50%",
227
- flexDirection: "column",
228
- paddingLeft: 0,
229
- focusable: false,
230
- });
231
- const resultsSelect = new SelectRenderable(renderer, {
232
- options: [{ name: "", description: "Type a query and press Enter to search", value: null }],
233
- showDescription: false,
234
- showSelectionIndicator: false,
235
- flexGrow: 1,
236
- backgroundColor: colors.bg,
237
- textColor: colors.text,
238
- selectedBackgroundColor: colors.selectionBg,
239
- selectedTextColor: colors.selectionText,
240
- focusedBackgroundColor: colors.bg,
241
- focusedTextColor: colors.text,
242
- itemSpacing: 0,
243
- });
244
- resultsBox.add(resultsSelect);
245
-
246
- // Detail / Graph pane
247
- const detailBox = new BoxRenderable(renderer, {
248
- border: true,
249
- borderColor: colors.border,
250
- title: " Details ",
251
- titleColor: colors.green,
252
- width: "50%",
253
- flexDirection: "column",
254
- paddingX: 1,
255
- paddingY: 1,
256
- focusable: false,
257
- });
258
- const detailText = new TextRenderable(renderer, {
259
- content: "",
260
- color: colors.text,
261
- });
262
- detailBox.add(detailText);
263
-
264
- body.add(resultsBox);
265
- body.add(detailBox);
266
- root.add(body);
267
-
268
-
269
-
270
- // ── Overlay system ──────────────────────────────────────────────────
271
-
272
- // Hide main content so overlays can take 100% of the content area
273
- function hideMainContent() {
274
- trendingTabBox.visible = false;
275
- searchBox.visible = false;
276
- toolbarText.visible = false;
277
- body.visible = false;
278
- }
279
-
280
- function showMainContent() {
281
- if (currentMode === "trending") {
282
- trendingTabBox.visible = true;
283
- } else {
284
- searchBox.visible = true;
285
- toolbarText.visible = true;
286
- }
287
- body.visible = true;
288
- }
289
-
290
- function showOverlay(type: typeof currentOverlay) {
291
- helpBox.visible = false;
292
- historyBox.visible = false;
293
- bookmarksBox.visible = false;
294
- savedBox.visible = false;
295
- topicsBox.visible = false;
296
- exportBox.visible = false;
297
- compareBox.visible = false;
298
- notifsBox.visible = false;
299
- shareBox.visible = false;
300
- leaderBox.visible = false;
301
- readmeBox.visible = false;
302
-
303
- if (type === "none") {
304
- currentOverlay = "none";
305
- showMainContent();
306
- renderer.requestRender();
307
- return;
308
- }
309
- 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; }
322
- renderer.requestRender();
323
- }
324
-
325
- // ── Help overlay (OpenTUI components) ────────────────────────
326
- const helpSections = buildHelpSections();
327
- const helpBox = new BoxRenderable(renderer, {
328
- visible: false,
329
- flexGrow: 1,
330
- backgroundColor: colors.bg,
331
- border: true,
332
- borderColor: colors.border,
333
- });
334
- const helpScroll = new ScrollBoxRenderable(renderer, {
335
- flexGrow: 1,
336
- backgroundColor: colors.bg,
337
- scrollY: true,
338
- scrollX: false,
339
- paddingX: 0,
340
- viewportOptions: { backgroundColor: colors.bg },
341
- contentOptions: { backgroundColor: colors.bg, flexDirection: "column" },
342
- scrollbarOptions: {
343
- backgroundColor: colors.bg,
344
- foregroundColor: colors.muted,
345
- width: 1,
346
- },
347
- });
348
- for (const section of helpSections) {
349
- const sectionBox = new BoxRenderable(renderer, {
350
- border: true,
351
- borderColor: colors.border,
352
- title: section.title,
353
- titleColor: (colors as Record<string, string>)[section.titleColor] ?? section.titleColor,
354
- flexDirection: "column",
355
- paddingY: 0,
356
- marginLeft: 1,
357
- marginRight: 1,
358
- marginTop: 1,
359
- });
360
- for (const row of section.rows) {
361
- const keys = row.keys.padEnd(HELP_KEYS_COLUMN);
362
- sectionBox.add(new TextRenderable(renderer, {
363
- content: ` ${keys} ${row.action}`,
364
- color: colors.text,
365
- }));
366
- }
367
- if (section.note) {
368
- sectionBox.add(new TextRenderable(renderer, {
369
- content: ` ${section.note}`,
370
- color: colors.muted,
371
- }));
372
- }
373
- helpScroll.add(sectionBox);
374
- }
375
- helpBox.add(helpScroll);
376
- root.add(helpBox);
377
-
378
- // ── History overlay ─────────────────────────────────────────
379
- const historyBox = new BoxRenderable(renderer, {
380
- visible: false,
381
- flexGrow: 1,
382
- backgroundColor: colors.bg,
383
- border: true,
384
- borderColor: colors.border,
385
- title: " Search History ",
386
- titleColor: colors.blue,
387
- });
388
- const historySelect = new SelectRenderable(renderer, {
389
- options: [{ name: "(no history)", description: "", value: null }],
390
- showDescription: false,
391
- showSelectionIndicator: true,
392
- flexGrow: 1,
393
- backgroundColor: colors.bg,
394
- textColor: colors.text,
395
- selectedBackgroundColor: colors.selectionBg,
396
- selectedTextColor: colors.selectionText,
397
- itemSpacing: 0,
398
- });
399
- historyBox.add(historySelect);
400
- root.add(historyBox);
401
-
402
- function refreshHistory() {
403
- const entries = readHistory();
404
- if (entries.length === 0) {
405
- historySelect.options = [{ name: "(no history)", description: "", value: null }];
406
- } else {
407
- historySelect.options = entries.map((e, i) => ({
408
- name: `${e.query} — ${e.mode}${e.tab ? ` (${e.tab})` : ""}`,
409
- description: `${e.resultCount} results`,
410
- value: { index: i, entry: e },
411
- }));
412
- }
413
- historySelect.setSelectedIndex(0);
414
- }
415
-
416
- // ── Bookmarks overlay ───────────────────────────────────────
417
- const bookmarksBox = new BoxRenderable(renderer, {
418
- visible: false,
419
- flexGrow: 1,
420
- backgroundColor: colors.bg,
421
- border: true,
422
- borderColor: colors.border,
423
- title: " Bookmarks ",
424
- titleColor: colors.green,
425
- });
426
- const bookmarksSelect = new SelectRenderable(renderer, {
427
- options: [{ name: "(no bookmarks)", description: "", value: null }],
428
- showDescription: false,
429
- showSelectionIndicator: true,
430
- flexGrow: 1,
431
- backgroundColor: colors.bg,
432
- textColor: colors.text,
433
- selectedBackgroundColor: colors.selectionBg,
434
- selectedTextColor: colors.selectionText,
435
- itemSpacing: 0,
436
- });
437
- bookmarksBox.add(bookmarksSelect);
438
- root.add(bookmarksBox);
439
-
440
- function refreshBookmarks() {
441
- const bookmarks = getBookmarks();
442
- if (bookmarks.length === 0) {
443
- bookmarksSelect.options = [{ name: "(no bookmarks)", description: "", value: null }];
444
- } else {
445
- bookmarksSelect.options = bookmarks.map((b) => ({
446
- name: `${b.repo.fullName} ★ ${b.repo.stars.toLocaleString()}`,
447
- description: b.repo.description ?? "",
448
- value: b,
449
- }));
450
- }
451
- bookmarksSelect.setSelectedIndex(0);
452
- }
453
-
454
- // ── Saved searches overlay ─────────────────────────────────
455
- const savedBox = new BoxRenderable(renderer, {
456
- visible: false,
457
- flexGrow: 1,
458
- backgroundColor: colors.bg,
459
- border: true,
460
- borderColor: colors.border,
461
- title: " Saved Searches ",
462
- titleColor: colors.yellow,
463
- });
464
- const savedSelect = new SelectRenderable(renderer, {
465
- options: [{ name: "(no saved searches)", description: "", value: null }],
466
- showDescription: false,
467
- showSelectionIndicator: true,
468
- flexGrow: 1,
469
- backgroundColor: colors.bg,
470
- textColor: colors.text,
471
- selectedBackgroundColor: colors.selectionBg,
472
- selectedTextColor: colors.selectionText,
473
- itemSpacing: 0,
474
- });
475
- savedBox.add(savedSelect);
476
- root.add(savedBox);
477
-
478
- // ── Topic explorer overlay ───────────────────────────────────
479
- const topicsBox = new BoxRenderable(renderer, {
480
- visible: false,
481
- flexGrow: 1,
482
- backgroundColor: colors.bg,
483
- border: true,
484
- borderColor: colors.border,
485
- title: " Topics ",
486
- titleColor: colors.purple ?? colors.blue,
487
- });
488
- const topicsSelect = new SelectRenderable(renderer, {
489
- options: [{ name: "(loading topics...)", description: "", value: null }],
490
- showDescription: true,
491
- showSelectionIndicator: true,
492
- flexGrow: 1,
493
- backgroundColor: colors.bg,
494
- textColor: colors.text,
495
- selectedBackgroundColor: colors.selectionBg,
496
- selectedTextColor: colors.selectionText,
497
- itemSpacing: 0,
498
- });
499
- topicsBox.add(topicsSelect);
500
- root.add(topicsBox);
501
-
502
- async function refreshTopics() {
503
- try {
504
- const topics = await fetchTopics();
505
- topicsSelect.options = topics.map((t) => ({
506
- name: `${t.name} (${t.repoCount.toLocaleString()} repos)`,
507
- description: t.description.slice(0, 80),
508
- value: t,
509
- }));
510
- } catch {
511
- topicsSelect.options = [{ name: "(failed to load topics)", description: "", value: null }];
512
- }
513
- topicsSelect.setSelectedIndex(0);
514
- }
515
-
516
- // ── Export overlay ───────────────────────────────────────────
517
- const exportBox = new BoxRenderable(renderer, {
518
- visible: false,
519
- flexGrow: 1,
520
- backgroundColor: colors.bg,
521
- border: true,
522
- borderColor: colors.border,
523
- title: " Export ",
524
- titleColor: colors.green,
525
- });
526
- const exportSelect = new SelectRenderable(renderer, {
527
- 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" },
531
- { name: " Plain text", description: "One repo per line", value: "text" },
532
- ],
533
- showDescription: true,
534
- showSelectionIndicator: true,
535
- flexGrow: 1,
536
- backgroundColor: colors.bg,
537
- textColor: colors.text,
538
- selectedBackgroundColor: colors.selectionBg,
539
- selectedTextColor: colors.selectionText,
540
- itemSpacing: 0,
541
- });
542
- exportBox.add(exportSelect);
543
- root.add(exportBox);
544
-
545
- // ── Compare overlay ──────────────────────────────────────────
546
- const compareBox = new BoxRenderable(renderer, {
547
- visible: false,
548
- flexGrow: 1,
549
- backgroundColor: colors.bg,
550
- border: true,
551
- borderColor: colors.border,
552
- title: " Comparison ",
553
- titleColor: colors.yellow,
554
- });
555
- const compareText = new TextRenderable(renderer, {
556
- content: "",
557
- color: colors.text,
558
- backgroundColor: colors.bg,
559
- });
560
- compareBox.add(compareText);
561
- root.add(compareBox);
562
-
563
- // ── Notifications overlay ────────────────────────────────────
564
- const notifsBox = new BoxRenderable(renderer, {
565
- visible: false,
566
- flexGrow: 1,
567
- backgroundColor: colors.bg,
568
- border: true,
569
- borderColor: colors.border,
570
- title: " Notifications ",
571
- titleColor: colors.yellow,
572
- });
573
- const notifsText = new TextRenderable(renderer, {
574
- content: "(no notifications)",
575
- color: colors.text,
576
- backgroundColor: colors.bg,
577
- });
578
- notifsBox.add(notifsText);
579
- root.add(notifsBox);
580
-
581
- function refreshNotifications() {
582
- const notifs = getNotifications();
583
- if (notifs.length === 0) {
584
- notifsText.content = " (no notifications)";
585
- } else {
586
- notifsText.content = notifs
587
- .map((n) => ` ${n.icon} ${n.message}`)
588
- .join("\n");
589
- }
590
- }
591
-
592
- // ── Share overlay ────────────────────────────────────────────
593
- const shareBox = new BoxRenderable(renderer, {
594
- visible: false,
595
- flexGrow: 1,
596
- backgroundColor: colors.bg,
597
- border: true,
598
- borderColor: colors.border,
599
- title: " Share Repo ",
600
- titleColor: colors.blue,
601
- });
602
- const shareSelect = new SelectRenderable(renderer, {
603
- 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" },
607
- { name: " Short", description: "owner/repo", value: "short" },
608
- ],
609
- showDescription: true,
610
- showSelectionIndicator: true,
611
- flexGrow: 1,
612
- backgroundColor: colors.bg,
613
- textColor: colors.text,
614
- selectedBackgroundColor: colors.selectionBg,
615
- selectedTextColor: colors.selectionText,
616
- itemSpacing: 0,
617
- });
618
- shareBox.add(shareSelect);
619
- root.add(shareBox);
620
-
621
- function refreshCompare() {
622
- if (compareList.length < 2) {
623
- compareText.content = "Select at least 2 repos to compare (press c to add).";
624
- } else {
625
- compareText.content = buildComparisonTable(compareList);
626
- }
627
- }
628
-
629
- function refreshSavedSearches() {
630
- const saved = getSavedSearches();
631
- if (saved.length === 0) {
632
- savedSelect.options = [{ name: "(no saved searches)", description: "", value: null }];
633
- } else {
634
- savedSelect.options = saved.map((s) => ({
635
- name: `${s.name}`,
636
- description: `${s.query} (${s.mode})`,
637
- value: s,
638
- }));
639
- }
640
- savedSelect.setSelectedIndex(0);
641
- }
642
-
643
- // ── Leader menu overlay (Space key) ─────────────────────────────
644
- const leaderBox = new BoxRenderable(renderer, {
645
- visible: false,
646
- flexGrow: 1,
647
- backgroundColor: colors.bg,
648
- border: true,
649
- borderColor: colors.border,
650
- title: " Menu ",
651
- titleColor: colors.blue,
652
- flexDirection: "column",
653
- });
654
- const leaderSelect = new SelectRenderable(renderer, {
655
- options: [{ name: "(empty)", description: "", value: null }],
656
- showDescription: true,
657
- showSelectionIndicator: true,
658
- flexGrow: 1,
659
- backgroundColor: colors.bg,
660
- textColor: colors.text,
661
- selectedBackgroundColor: colors.selectionBg,
662
- selectedTextColor: colors.selectionText,
663
- itemSpacing: 0,
664
- });
665
- const leaderFooter = new TextRenderable(renderer, {
666
- content: " ↑↓ select Enter run Esc/q close",
667
- color: colors.muted,
668
- backgroundColor: colors.bg,
669
- height: 1,
670
- paddingX: 1,
671
- });
672
- leaderBox.add(leaderSelect);
673
- leaderBox.add(leaderFooter);
674
- root.add(leaderBox);
675
-
676
- interface LeaderItem { name: string; description: string; action: () => void; }
677
-
678
- function showLeaderMenu() {
679
- const items: LeaderItem[] = [];
680
-
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
- }
694
-
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
- }
703
-
704
- // Common items (always available when repos exist)
705
- const hasRepo = (() => {
706
- const opt = resultsSelect.getSelectedOption();
707
- return opt?.value && typeof (opt.value as any)?.fullName === "string";
708
- })();
709
-
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") },
722
- );
723
-
724
- leaderSelect.options = items.map((it) => ({
725
- name: it.name,
726
- description: it.description,
727
- value: it,
728
- }));
729
- leaderSelect.setSelectedIndex(0);
730
- leaderSelect.focus();
731
- showOverlay("leader");
732
- }
733
-
734
- function openUrlIfSelected() {
735
- const opt = resultsSelect.getSelectedOption();
736
- const repo = opt?.value as Repo | undefined;
737
- if (repo) openUrl(repo.url);
738
- else setStatus("No repo selected");
739
- }
740
-
741
- function toggleBookmarkOnSelected() {
742
- const opt = resultsSelect.getSelectedOption();
743
- const repo = opt?.value as Repo | undefined;
744
- if (repo) {
745
- const added = toggleBookmark(repo);
746
- setStatus(added ? `Bookmarked ${repo.fullName}` : `Unbookmarked ${repo.fullName}`);
747
- }
748
- }
749
-
750
- function toggleCompareOnSelected() {
751
- const opt = resultsSelect.getSelectedOption();
752
- const repo = opt?.value as Repo | undefined;
753
- if (!repo) return;
754
- const idx = compareList.findIndex((r) => r.fullName === repo.fullName);
755
- if (idx >= 0) {
756
- compareList.splice(idx, 1);
757
- setStatus(`Removed ${repo.fullName} from comparison`);
758
- } else {
759
- compareList.push(repo);
760
- setStatus(`${repo.fullName} added to comparison (${compareList.length} selected)`);
761
- }
762
- }
763
-
764
- function showShareIfRepo() {
765
- const opt = resultsSelect.getSelectedOption();
766
- const repo = opt?.value as Repo | undefined;
767
- if (!repo) { setStatus("No repo selected to share"); return; }
768
- showOverlay("share");
769
- }
770
-
771
- function saveCurrentSearch() {
772
- 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);
777
- setStatus(`Saved as "${name}"`);
778
- } else {
779
- setStatus("No search to save");
780
- }
781
- }
782
-
783
- function triggerDeepDive() {
784
- const opt = resultsSelect.getSelectedOption();
785
- const repo = opt?.value as Repo | undefined;
786
- if (!repo) return;
787
- deepDiveActive = !deepDiveActive;
788
- if (deepDiveActive) {
789
- detailText.content = " Loading deep-dive...";
790
- renderer.requestRender();
791
- fetchDeepDive(repo, githubToken)
792
- .then((data) => {
793
- detailText.content = buildDeepDiveText(data);
794
- renderer.requestRender();
795
- })
796
- .catch(() => {
797
- detailText.content = " Failed to load deep-dive";
798
- renderer.requestRender();
799
- });
800
- } else {
801
- updateDetail(repo);
802
- renderer.requestRender();
803
- }
804
- }
805
-
806
- // ── README viewer overlay ────────────────────────────────────
807
- const readmeBox = new BoxRenderable(renderer, {
808
- visible: false,
809
- flexGrow: 1,
810
- backgroundColor: colors.bg,
811
- border: true,
812
- borderColor: colors.green,
813
- title: " README ",
814
- titleColor: colors.green,
815
- });
816
- const readmeScroll = new ScrollBoxRenderable(renderer, {
817
- flexGrow: 1,
818
- backgroundColor: colors.bg,
819
- scrollY: true,
820
- scrollX: false,
821
- paddingX: 1,
822
- viewportOptions: { backgroundColor: colors.bg },
823
- contentOptions: { backgroundColor: colors.bg, flexDirection: "column" },
824
- scrollbarOptions: {
825
- backgroundColor: colors.bg,
826
- foregroundColor: colors.muted,
827
- width: 1,
828
- },
829
- });
830
- const readmeText = new TextRenderable(renderer, {
831
- content: "",
832
- color: colors.text,
833
- backgroundColor: colors.bg,
834
- });
835
- readmeScroll.add(readmeText);
836
- readmeBox.add(readmeScroll);
837
- const readmeFooter = new TextRenderable(renderer, {
838
- content: " ↑↓/jk scroll Esc/q close",
839
- color: colors.muted,
840
- backgroundColor: colors.bg,
841
- height: 1,
842
- paddingX: 1,
843
- });
844
- readmeBox.add(readmeFooter);
845
- root.add(readmeBox);
846
-
847
- async function showReadme() {
848
- const opt = resultsSelect.getSelectedOption();
849
- const repo = opt?.value as Repo | undefined;
850
- if (!repo) {
851
- setStatus("No repo selected");
852
- return;
853
- }
854
- readmeText.content = ` Loading README for ${repo.fullName}...`;
855
- showOverlay("readme");
856
- try {
857
- const headers: Record<string, string> = { "User-Agent": "ghfind/1.0" };
858
- if (githubToken) headers.Authorization = `Bearer ${githubToken}`;
859
- let text = "";
860
- for (const path of [
861
- `${repo.owner}/${repo.name}/main/README.md`,
862
- `${repo.owner}/${repo.name}/master/README.md`,
863
- `${repo.owner}/${repo.name}/main/README.rst`,
864
- ]) {
865
- const r = await fetch(`https://raw.githubusercontent.com/${path}`, { headers });
866
- if (r.ok) { text = await r.text(); break; }
867
- }
868
- if (!text) {
869
- readmeText.content = ` (no README found for ${repo.fullName})`;
870
- } else {
871
- // ponytail: just strip markdown formatting, show full text
872
- const stripped = text
873
- .replace(/```[\s\S]*?```/g, "[code block]")
874
- .replace(/#{1,6}\s/g, "")
875
- .replace(/\*\*(.+?)\*\*/g, "$1")
876
- .replace(/\[(.+?)\]\(.+?\)/g, "$1")
877
- .replace(/`([^`]+)`/g, "$1")
878
- .split("\n")
879
- .map((l) => l.trimEnd())
880
- .join("\n");
881
- readmeText.content = stripped;
882
- }
883
- } catch {
884
- readmeText.content = " Failed to load README";
885
- }
886
- renderer.requestRender();
887
- }
888
-
889
- // ── Status bar ──────────────────────────────────────────────────────
890
- const statusBar = new TextRenderable(renderer, {
891
- content: " Ready. Press Enter to search.",
892
- backgroundColor: colors.bg,
893
- color: colors.muted,
894
- height: 1,
895
- paddingX: 1,
896
- });
897
- root.add(statusBar);
898
-
899
- const statusMgr = new StatusManager((text) => {
900
- statusBar.content = ` ${text}`;
901
- renderer.requestRender();
902
- });
903
-
904
- // ── Startup tip ──
905
- if (config.theme !== undefined || true) {
906
- // Show a tip after 2s idle
907
- setTimeout(() => {
908
- if (currentOverlay === "none") {
909
- setStatus(nextTip());
910
- }
911
- }, 2000);
912
- }
913
-
914
- // ── Helper functions ────────────────────────────────────────────────
915
-
916
- function setStatus(msg: string) {
917
- statusMgr.set("idle", msg);
918
- }
919
-
920
- function setToolbar() {
921
- toolbarText.content = formatToolbar(currentSort, currentLimit);
922
- renderer.requestRender();
923
- }
924
-
925
- function updateDetail(repo: Repo | null) {
926
- if (!repo) {
927
- detailText.content = "";
928
- return;
929
- }
930
- const topics = repo.topics.length ? repo.topics.slice(0, 8).join(", ") : "—";
931
- const desc = repo.description ?? "(no description)";
932
- 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 !== "");
946
- detailText.content = lines.join("\n");
947
- }
948
-
949
- // ── Graph / Chart ──────────────────────────────────────────────────
950
-
951
- /** Unicode blocks for 8 vertical levels within one character cell. */
952
- // Braille dot encoding: each byte is 8 dots (2 cols × 4 rows per char).
953
- // Bit layout (LSB first): 0-2=col0 rows, 3-5=col1 rows, 6=col0 row3, 7=col1 row3.
954
- // Unicode offset is 0x2800, so braille code point = 0x2800 + bitmask.
955
- function brailleChar(bits: number): string {
956
- return bits === 0 ? " " : String.fromCodePoint(0x2800 + bits);
957
- }
958
-
959
- function buildChartString(values: number[], termW: number, termH: number): string[] {
960
- if (values.length === 0) return ["(no data)"];
961
-
962
- // Braille resolution: 2 horizontal dots and 4 vertical dots per terminal cell.
963
- const dotW = termW * 2;
964
- const dotH = termH * 4;
965
-
966
- // Sample values to fit braille dot columns.
967
- const sampled: number[] = [];
968
- for (let i = 0; i < dotW; i++) {
969
- sampled.push(values[Math.floor((i / dotW) * values.length)]);
970
- }
971
- const max = Math.max(...sampled, 1);
972
- // Map each sample to a dot row (0 = bottom, dotH-1 = top).
973
- const dotRows = sampled.map(v => Math.round((v / max) * (dotH - 1)));
974
-
975
- // 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));
977
-
978
- // Draw the silhouette line: mark the dot at each column.
979
- for (let c = 0; c < dotW; c++) {
980
- dotGrid[dotH - 1 - dotRows[c]][c] = true;
981
- }
982
- // Fill gaps between adjacent columns so the line is continuous.
983
- for (let c = 0; c < dotW - 1; c++) {
984
- const r1 = dotRows[c];
985
- const r2 = dotRows[c + 1];
986
- const lo = Math.min(r1, r2);
987
- const hi = Math.max(r1, r2);
988
- for (let r = lo; r <= hi; r++) {
989
- dotGrid[dotH - 1 - r][c] = true;
990
- }
991
- }
992
-
993
- // Convert 4-row × 2-col blocks of dots into braille characters.
994
- const lines: string[] = [];
995
- for (let tr = 0; tr < termH; tr++) {
996
- const chars: string[] = [];
997
- for (let tc = 0; tc < termW; tc++) {
998
- const r0 = tr * 4;
999
- const c0 = tc * 2;
1000
- 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;
1009
- chars.push(brailleChar(bits));
1010
- }
1011
-
1012
- // 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];
1015
- const idx = labelRows.indexOf(tr);
1016
- let label = " ";
1017
- if (idx >= 0) label = String(labelVals[idx]).padStart(4) + " ";
1018
- lines.push(`${label}\u2524${chars.join("")}`);
1019
- }
1020
-
1021
- // X-axis
1022
- lines.push(` \u2514${String.fromCharCode(0x2500).repeat(termW)}`);
1023
- return lines;
1024
- }
1025
-
1026
- async function loadChart(repo: Repo) {
1027
- detailText.content = " Loading chart...";
1028
- renderer.requestRender();
1029
- try {
1030
- 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),
1035
- ]);
1036
-
1037
- let chartSection = "";
1038
- if (chartRes.ok) {
1039
- const data = await chartRes.json();
1040
- if (data?.all && data.all.length >= 2) {
1041
- chartCommitData = data.all;
1042
- const chartW = 60;
1043
- const chartH = 10;
1044
- const chartLines = buildChartString(chartCommitData, chartW, chartH);
1045
- chartSection = ["", "Weekly commits (52 weeks)", ...chartLines].join("\n");
1046
- }
1047
- }
1048
-
1049
- const desc = repo.description ?? "";
1050
- const lang = repo.language ?? "\u2014";
1051
- 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}`
1056
- : "";
1057
-
1058
- const detailLines = [
1059
- ` ${repo.fullName}`,
1060
- ` \u2606 ${repo.stars.toLocaleString()} \u2666 ${repo.forks.toLocaleString()} ${lang}`,
1061
- growthLine,
1062
- updated ? ` Updated ${updated}` : "",
1063
- topics ? ` Topics ${topics}` : "",
1064
- "",
1065
- desc,
1066
- "",
1067
- ` ${repo.url}`,
1068
- ].filter(l => l !== "");
1069
-
1070
- detailText.content = [
1071
- ...detailLines,
1072
- chartSection,
1073
- ].join("\n");
1074
- } catch {
1075
- detailText.content = " Failed to load chart";
1076
- }
1077
- renderer.requestRender();
1078
- }
1079
-
1080
- function toggleGraph() {
1081
- graphFullscreen = !graphFullscreen;
1082
- if (graphFullscreen) {
1083
- resultsBox.visible = false;
1084
- resultsBox.width = "0%";
1085
- detailBox.width = "100%";
1086
- const opt = resultsSelect.getSelectedOption();
1087
- const repo = opt?.value as Repo | undefined;
1088
- if (repo) loadChart(repo);
1089
- else detailText.content = " Select a repo to view activity";
1090
- } else {
1091
- resultsBox.visible = true;
1092
- resultsBox.width = "50%";
1093
- detailBox.width = "50%";
1094
- const opt = resultsSelect.getSelectedOption();
1095
- if (opt?.value) updateDetail(opt.value as Repo);
1096
- else detailText.content = "";
1097
- }
1098
- renderer.requestRender();
1099
- }
1100
-
1101
- // ── Trending helpers ────────────────────────────────────────────────
1102
-
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");
1105
- const name = `${r.owner}/${r.name}`.padEnd(30).slice(0, 30);
1106
- const lang = (r.language || "?").padEnd(12).slice(0, 12);
1107
- 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
- };
1134
- }
1135
-
1136
- async function loadTrending() {
1137
- currentMode = "trending";
1138
- isLoading = true;
1139
- searchBox.visible = false;
1140
- toolbarText.visible = false;
1141
- trendingTabBox.visible = true;
1142
- renderTrendingTabs();
1143
- resultsSelect.options = [{ name: " Loading trending...", description: "", value: null }];
1144
- detailText.content = "";
1145
- statusMgr.set("loading", "Loading trending repos...");
1146
- renderer.requestRender();
1147
-
1148
- try {
1149
- const fetched = await fetchTrendingRepos(tabSince(trendingTab));
1150
- 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),
1156
- }));
1157
- if (fetched.length > 0) {
1158
- resultsSelect.setSelectedIndex(0);
1159
- updateDetail(trendingRepoToSelectValue(fetched[0]));
1160
- } else {
1161
- throw new NoResultsError(trendingTab);
1162
- }
1163
- statusMgr.set("success", `${fetched.length} trending repos — ${trendingTab}`);
1164
- appendHistory({ query: `trending:${trendingTab}`, mode: "trending", tab: trendingTab, timestamp: Date.now(), resultCount: fetched.length });
1165
- } 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 }];
1168
- statusMgr.set("error", msg.slice(0, 60));
1169
- }
1170
- isLoading = false;
1171
- renderer.requestRender();
1172
- }
1173
-
1174
- function showSearchMode() {
1175
- currentMode = "search";
1176
- trendingTabBox.visible = false;
1177
- searchBox.visible = true;
1178
- toolbarText.visible = true;
1179
- searchInput.placeholder = "Search GitHub repos (e.g. rust cli, or language:Rust stars:>100)";
1180
- searchInput.focus();
1181
- renderer.requestRender();
1182
- }
1183
-
1184
- async function doSearch(queryText: string, append = false) {
1185
- const q = queryText.trim();
1186
- if (q === "" || isLoading) return;
1187
-
1188
- if (!append) currentPage = 1;
1189
- deepDiveActive = false;
1190
-
1191
- isLoading = true;
1192
- currentQueryInput = q;
1193
- if (!append) {
1194
- resultsSelect.options = [{ name: " Searching...", description: "", value: null }];
1195
- detailText.content = "";
1196
- }
1197
- statusMgr.set("searching", `Searching for "${q}"`);
1198
-
1199
- try {
1200
- const parsed = applyFlagFilters(parseQuery(q), {});
1201
- validateQuery(parsed);
1202
- const provider = createGitHubSearch(logger);
1203
- const options: SearchOptions = {
1204
- limit: currentLimit,
1205
- sort: currentSort,
1206
- json: false,
1207
- verbose: false,
1208
- token: githubToken,
1209
- page: currentPage,
1210
- };
1211
- const response = await provider.search(parsed, options);
1212
- totalCount = response.totalCount;
1213
- const newRepos = rankRepos(response.repos, options.sort);
1214
-
1215
- if (append) {
1216
- currentRepos.push(...newRepos);
1217
- } else {
1218
- currentRepos = newRepos;
1219
- }
1220
-
1221
- if (currentRepos.length === 0) {
1222
- throw new NoResultsError(q);
1223
- } else {
1224
- resultsSelect.options = currentRepos.map((r) => ({
1225
- name: formatResultLine(r),
1226
- description: "",
1227
- value: r,
1228
- }));
1229
- const idx = append ? resultsSelect.options.length - newRepos.length : 0;
1230
- updateDetail(currentRepos[idx]);
1231
- resultsSelect.setSelectedIndex(idx);
1232
- statusMgr.set("success", `Page ${currentPage} — ${currentRepos.length} of ${totalCount.toLocaleString()} results`);
1233
- if (!append) {
1234
- appendHistory({ query: q, mode: "search", timestamp: Date.now(), resultCount: currentRepos.length });
1235
- }
1236
- }
1237
- } catch (err) {
1238
- currentRepos = [];
1239
- resultsSelect.options = [{ name: " (error)", description: "", value: null }];
1240
- const msg = err instanceof SearchCliError ? err.userMessage : (err instanceof Error ? err.message : String(err));
1241
- statusMgr.set("error", msg.slice(0, 60));
1242
- }
1243
-
1244
- isLoading = false;
1245
- renderer.requestRender();
1246
- }
1247
-
1248
- function changeSort() {
1249
- const idx = SORT_MODES.findIndex((m) => m.key === currentSort);
1250
- const next = SORT_MODES[(idx + 1) % SORT_MODES.length];
1251
- currentSort = next.key;
1252
- setToolbar();
1253
- if (currentRepos.length > 0) {
1254
- currentRepos = rankRepos(currentRepos, currentSort);
1255
- resultsSelect.options = currentRepos.map((r) => ({
1256
- name: formatResultLine(r),
1257
- description: "",
1258
- value: r,
1259
- }));
1260
- resultsSelect.setSelectedIndex(0);
1261
- updateDetail(currentRepos[0]);
1262
- renderer.requestRender();
1263
- }
1264
- }
1265
-
1266
- function changeLimit() {
1267
- const limits = [10, 25, 50, 100];
1268
- const idx = limits.indexOf(currentLimit);
1269
- currentLimit = limits[(idx + 1) % limits.length];
1270
- setToolbar();
1271
- setStatus(`Limit set to ${currentLimit}. Press Enter to re-search.`);
1272
- renderer.requestRender();
1273
- }
1274
-
1275
- // ── Wire events ────────────────────────────────────────────────────
1276
-
1277
- // Enter in search input → show results
1278
- searchInput.on("enter", () => {
1279
- if (searchInput.value.trim() === "" || isLoading) return;
1280
- showSearchMode();
1281
- doSearch(searchInput.value);
1282
- });
1283
-
1284
- // Navigate results → update detail pane
1285
- resultsSelect.on("selectionChanged", () => {
1286
- const opt = resultsSelect.getSelectedOption();
1287
- const repo = opt?.value as Repo | undefined;
1288
- if (repo) {
1289
- if (graphFullscreen) loadChart(repo);
1290
- else updateDetail(repo);
1291
- }
1292
- renderer.requestRender();
1293
- });
1294
-
1295
- // Enter on a result → open URL
1296
- resultsSelect.on("itemSelected", () => {
1297
- const opt = resultsSelect.getSelectedOption();
1298
- const repo = opt?.value as Repo | undefined;
1299
- if (repo) openUrl(repo.url);
1300
- });
1301
-
1302
- // ── Global keyboard shortcuts ──────────────────────────────────────
1303
- renderer.keyInput.on("keypress", (key) => {
1304
- // ── Overlay-mode handling ────────────────────────────────────────
1305
- if (currentOverlay !== "none") {
1306
- // Escape or q closes any overlay
1307
- if (key.name === "escape" || key.name === "q") {
1308
- showOverlay("none");
1309
- renderer.requestRender();
1310
- return;
1311
- }
1312
-
1313
- // Leader menu: Enter dispatches action, up/down navigates
1314
- if (currentOverlay === "leader") {
1315
- if (key.name === "enter" || key.name === "return") {
1316
- const sel = leaderSelect.getSelectedOption();
1317
- const item = sel?.value as LeaderItem | undefined;
1318
- if (item) {
1319
- showOverlay("none");
1320
- item.action();
1321
- renderer.requestRender();
1322
- }
1323
- return;
1324
- }
1325
- // Let up/down/j/k fall through to SelectRenderable
1326
- }
1327
-
1328
- // README viewer: scrollable, Esc/q already handled above
1329
- if (currentOverlay === "readme") {
1330
- return;
1331
- }
1332
-
1333
- // History overlay
1334
- if (currentOverlay === "history") {
1335
- if (key.name === "d") {
1336
- const sel = historySelect.getSelectedOption();
1337
- if (sel?.value) {
1338
- deleteHistoryEntry((sel.value as any).index);
1339
- refreshHistory();
1340
- setStatus("Deleted history entry");
1341
- }
1342
- renderer.requestRender();
1343
- return;
1344
- }
1345
- if (key.ctrl && key.name === "x") {
1346
- clearHistory();
1347
- refreshHistory();
1348
- setStatus("History cleared");
1349
- renderer.requestRender();
1350
- return;
1351
- }
1352
- if (key.name === "enter" || key.name === "return") {
1353
- const sel = historySelect.getSelectedOption();
1354
- if (sel?.value) {
1355
- const entry = (sel.value as any).entry;
1356
- showOverlay("none");
1357
- if (entry.mode === "trending") {
1358
- if (entry.tab) trendingTab = entry.tab as typeof TAB_NAMES[number];
1359
- loadTrending();
1360
- } else {
1361
- searchInput.value = entry.query;
1362
- showSearchMode();
1363
- doSearch(entry.query);
1364
- }
1365
- }
1366
- return;
1367
- }
1368
- return;
1369
- }
1370
-
1371
- // Bookmarks overlay
1372
- if (currentOverlay === "bookmarks") {
1373
- if (key.name === "d") {
1374
- const sel = bookmarksSelect.getSelectedOption();
1375
- if (sel?.value) {
1376
- removeBookmark((sel.value as any).repo.fullName);
1377
- refreshBookmarks();
1378
- setStatus("Bookmark removed");
1379
- }
1380
- renderer.requestRender();
1381
- return;
1382
- }
1383
- if (key.name === "enter" || key.name === "return") {
1384
- const sel = bookmarksSelect.getSelectedOption();
1385
- if (sel?.value) openUrl((sel.value as any).repo.url);
1386
- return;
1387
- }
1388
- return;
1389
- }
1390
-
1391
- // Saved searches overlay
1392
- if (currentOverlay === "saved") {
1393
- if (key.name === "d") {
1394
- const sel = savedSelect.getSelectedOption();
1395
- if (sel?.value) {
1396
- deleteSavedSearch((sel.value as any).name);
1397
- refreshSavedSearches();
1398
- setStatus("Saved search deleted");
1399
- }
1400
- renderer.requestRender();
1401
- return;
1402
- }
1403
- if (key.name === "enter" || key.name === "return") {
1404
- const sel = savedSelect.getSelectedOption();
1405
- if (sel?.value) {
1406
- const s = sel.value as any;
1407
- showOverlay("none");
1408
- touchSavedSearch(s.name);
1409
- if (s.mode === "trending") {
1410
- if (s.tab) trendingTab = s.tab as typeof TAB_NAMES[number];
1411
- loadTrending();
1412
- } else {
1413
- searchInput.value = s.query;
1414
- showSearchMode();
1415
- currentSort = s.sort;
1416
- currentLimit = s.limit;
1417
- setToolbar();
1418
- doSearch(s.query);
1419
- }
1420
- }
1421
- return;
1422
- }
1423
- return;
1424
- }
1425
-
1426
- // Topics explorer overlay
1427
- if (currentOverlay === "topics") {
1428
- if (key.name === "enter" || key.name === "return") {
1429
- const sel = topicsSelect.getSelectedOption();
1430
- if (sel?.value) {
1431
- const topic = sel.value as any;
1432
- showOverlay("none");
1433
- searchInput.value = `topic:${topic.name}`;
1434
- showSearchMode();
1435
- doSearch(`topic:${topic.name}`);
1436
- }
1437
- return;
1438
- }
1439
- return;
1440
- }
1441
-
1442
- // Export overlay
1443
- if (currentOverlay === "export") {
1444
- if (key.name === "enter" || key.name === "return") {
1445
- const sel = exportSelect.getSelectedOption();
1446
- if (sel?.value && currentRepos.length > 0) {
1447
- const format = sel.value as ExportFormat;
1448
- const path = exportToFile(currentRepos, format);
1449
- showOverlay("none");
1450
- setStatus(`✓ Exported to ${path}`);
1451
- } else {
1452
- setStatus("No results to export");
1453
- showOverlay("none");
1454
- }
1455
- renderer.requestRender();
1456
- return;
1457
- }
1458
- return;
1459
- }
1460
-
1461
- // Compare overlay: Esc/q closes
1462
- if (currentOverlay === "compare") {
1463
- return;
1464
- }
1465
-
1466
- // Notifications overlay
1467
- if (currentOverlay === "notifications") {
1468
- if (key.name === "d") {
1469
- const notifs = getNotifications();
1470
- if (notifs.length > 0) {
1471
- dismissNotification(notifs[0].id);
1472
- refreshNotifications();
1473
- setStatus("Notification dismissed");
1474
- }
1475
- renderer.requestRender();
1476
- return;
1477
- }
1478
- if (key.ctrl && key.name === "c") {
1479
- dismissAll();
1480
- refreshNotifications();
1481
- renderer.requestRender();
1482
- return;
1483
- }
1484
- return;
1485
- }
1486
-
1487
- // Share overlay
1488
- if (currentOverlay === "share") {
1489
- if (key.name === "enter" || key.name === "return") {
1490
- const sel = shareSelect.getSelectedOption();
1491
- if (sel?.value) {
1492
- const opt = resultsSelect.getSelectedOption();
1493
- const repo = opt?.value as Repo | undefined;
1494
- if (repo) {
1495
- const text = formatShare(repo, sel.value as ShareFormat);
1496
- copyToClipboard(text).then((ok) => {
1497
- showOverlay("none");
1498
- setStatus(ok ? "✓ Copied!" : "Clipboard not available");
1499
- });
1500
- }
1501
- }
1502
- renderer.requestRender();
1503
- return;
1504
- }
1505
- return;
1506
- }
1507
-
1508
- // Help overlay: any key closes
1509
- if (currentOverlay === "help") {
1510
- showOverlay("none");
1511
- renderer.requestRender();
1512
- return;
1513
- }
1514
-
1515
- return;
1516
- }
1517
-
1518
- // ── Main view handling (no overlay active) ────────────────────────
1519
-
1520
- // q quits
1521
- if (key.name === "q") {
1522
- if (graphFullscreen) {
1523
- toggleGraph();
1524
- return;
1525
- }
1526
- saveSession({ mode: currentMode, query: currentQueryInput, sort: currentSort, limit: currentLimit, trendingTab });
1527
- cleanup();
1528
- return;
1529
- }
1530
-
1531
- // Esc exits graph mode
1532
- if (key.name === "escape" && graphFullscreen) {
1533
- toggleGraph();
1534
- return;
1535
- }
1536
-
1537
- // Space opens leader menu
1538
- if (key.name === "space") {
1539
- showLeaderMenu();
1540
- return;
1541
- }
1542
-
1543
- // '?' / Ctrl+H toggle help overlay (fast path)
1544
- if (key.name === "?" || (key.name === "h" && key.ctrl)) {
1545
- showOverlay("help");
1546
- return;
1547
- }
1548
-
1549
- // Tab auto-completes qualifiers in search input
1550
- if (key.name === "tab") {
1551
- if (searchBox.visible) {
1552
- const val = searchInput.value;
1553
- const lastSpace = val.lastIndexOf(" ");
1554
- const currentWord = lastSpace >= 0 ? val.slice(lastSpace + 1) : val;
1555
- const suggestions = suggestFor(currentWord);
1556
- if (suggestions.length > 0) {
1557
- const newVal = lastSpace >= 0
1558
- ? val.slice(0, lastSpace + 1) + suggestions[0]
1559
- : suggestions[0];
1560
- searchInput.value = newVal;
1561
- renderer.requestRender();
1562
- }
1563
- }
1564
- return;
1565
- }
1566
-
1567
- // '/' focuses search
1568
- if (key.name === "/") {
1569
- searchInput.focus();
1570
- showSearchMode();
1571
- renderer.requestRender();
1572
- return;
1573
- }
1574
-
1575
- // Number keys 1-5 switch trending tabs (only in trending mode)
1576
- if (/^[1-5]$/.test(key.name)) {
1577
- if (currentMode === "trending") {
1578
- const idx = parseInt(key.name) - 1;
1579
- if (idx >= 0 && idx < TAB_NAMES.length) {
1580
- trendingTab = TAB_NAMES[idx];
1581
- loadTrending();
1582
- }
1583
- }
1584
- return;
1585
- }
1586
-
1587
- // Left/right arrows (and h/l) switch trending tabs
1588
- if (key.name === "left" || key.name === "h") {
1589
- if (currentMode === "trending") {
1590
- const idx = TAB_NAMES.indexOf(trendingTab);
1591
- if (idx > 0) { trendingTab = TAB_NAMES[idx - 1]; loadTrending(); }
1592
- }
1593
- return;
1594
- }
1595
- if (key.name === "right" || key.name === "l") {
1596
- if (currentMode === "trending") {
1597
- const idx = TAB_NAMES.indexOf(trendingTab);
1598
- if (idx < TAB_NAMES.length - 1) { trendingTab = TAB_NAMES[idx + 1]; loadTrending(); }
1599
- }
1600
- return;
1601
- }
1602
-
1603
- // PageDown — next page (search mode)
1604
- if (key.name === "pagedown") {
1605
- if (currentMode === "search" && currentQueryInput) {
1606
- currentPage++;
1607
- doSearch(currentQueryInput, true);
1608
- }
1609
- return;
1610
- }
1611
-
1612
- // PageUp — scroll to top of results
1613
- if (key.name === "pageup") {
1614
- if (currentRepos.length > 0) {
1615
- resultsSelect.setSelectedIndex(0);
1616
- updateDetail(currentRepos[0]);
1617
- renderer.requestRender();
1618
- }
1619
- return;
1620
- }
1621
- });
1622
-
1623
- // ── Start ──────────────────────────────────────────────────────────
1624
- renderer.start();
1625
- if (currentMode === "trending") {
1626
- loadTrending();
1627
- } else {
1628
- searchInput.value = currentQueryInput;
1629
- if (currentQueryInput) {
1630
- doSearch(currentQueryInput);
1631
- } else {
1632
- showSearchMode();
1633
- }
1634
- searchInput.focus();
1635
- }
1636
- renderer.requestRender();
1637
- }
1638
-
1639
- // ── Utilities ─────────────────────────────────────────────────────────
1640
-
1641
- function formatResultLine(repo: Repo): string {
1642
- const stars = formatStars(repo.stars);
1643
- const lang = (repo.language ?? "?").padEnd(12).slice(0, 12);
1644
- return `${repo.fullName} ${stars}`;
1645
- }
1646
-
1647
- function formatStars(n: number): string {
1648
- if (n >= 1000) return `★ ${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
1649
- return `★ ${n}`;
1650
- }
1651
-
1652
- function formatToolbar(sort: SortStrategy, limit: number): string {
1653
- const sortLabel = SORT_MODES.find((m) => m.key === sort)?.label ?? sort;
1654
- return ` sort: ${sortLabel} limit: ${limit}`;
1655
- }
1656
-
1657
- function cleanup(): void {
1658
- try { rotateHistory(); } catch { /* non-critical */ }
1659
- process.exit(0);
1660
- }
1661
-
1662
- // ── Auto-run ──────────────────────────────────────────────────────────
1663
- if (import.meta.main) {
1664
- launchBrowser();
1665
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ghfind — interactive GitHub repository browser (OpenTUI)
4
+ *
5
+ * Full-screen, keyboard-driven browser. This is the only entry point.
6
+ * Pipeline: user query → provider → normalizer → ranking → rendered in TUI
7
+ *
8
+ * Layout:
9
+ * ┌─ ghfind [s]sort [l]limit [r]refresh [q]uit ────────────────┐
10
+ * │ > [query input ........................................] │
11
+ * │ sort: best-match limit: 50 │
12
+ * ├─── Results ────────────────┬─── Details ──────────────────┤
13
+ * │ owner/repo ★69k │ Name owner/repo │
14
+ * │ owner/repo ★42k │ Stars 69,420 │
15
+ * │ owner/repo ★12k │ Forks 1,337 │
16
+ * │ owner/repo ★5k │ Language TypeScript │
17
+ * │ owner/repo ★2k │ Updated 2026-07-09 │
18
+ * │ owner/repo ★1k │ Topics ai, agent, tui │
19
+ * │ │ │
20
+ * │ │ A description... │
21
+ * │ │ https://github.com/... │
22
+ * ├──────────────────────────┴────────────────────────────────┤
23
+ * │ 42 results of 15000 (↑↓ nav Enter open o browser) │
24
+ * └───────────────────────────────────────────────────────────┘
25
+ */
26
+ import {
27
+ createCliRenderer,
28
+ BoxRenderable,
29
+ TextRenderable,
30
+ InputRenderable,
31
+ SelectRenderable,
32
+ ScrollBoxRenderable,
33
+ } from "@opentui/core";
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";
37
+ import { loadConfig } from "./config";
38
+ 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";
43
+ import { saveSession, restoreSession } from "./session";
44
+ import { fetchDeepDive, buildDeepDiveText } from "./deepdive";
45
+ import { buildComparisonTable } from "./compare";
46
+ import { fetchTopics, type TopicItem } from "./explore";
47
+ import { exportToFile, type ExportFormat } from "./export";
48
+ import { loadTheme } from "./themes";
49
+ import { StatusManager } from "./status";
50
+ import { addNotification, getNotifications, dismissNotification, dismissAll } from "./notifications";
51
+ import { formatShare, copyToClipboard, type ShareFormat } from "./share";
52
+ import { nextTip } from "./tips";
53
+ import { openUrl } from "./open-url";
54
+
55
+ // ─── Theme ────────────────────────────────────────────────────────────
56
+ const colors = {
57
+ bg: "#1e1e2e",
58
+ surface: "#181825",
59
+ text: "#cdd6f4",
60
+ muted: "#6c7086",
61
+ blue: "#89b4fa",
62
+ green: "#a6e3a1",
63
+ yellow: "#f9e2af",
64
+ red: "#f38ba8",
65
+ teal: "#94e2d5",
66
+ border: "#45475a",
67
+ selectionBg: "#2d5bcf",
68
+ selectionText: "#ffffff",
69
+ };
70
+
71
+ // ─── Logger ───────────────────────────────────────────────────────────
72
+ const logger: Logger = {
73
+ debug: () => {},
74
+ info: () => {},
75
+ warn: () => {},
76
+ error: () => {},
77
+ };
78
+
79
+ // ─── Sort cycle ───────────────────────────────────────────────────────
80
+ const SORT_MODES: { key: SortStrategy; label: string }[] = [
81
+ { key: "best-match", label: "best-match" },
82
+ { key: "stars", label: "stars" },
83
+ { key: "updated", label: "updated" },
84
+ { key: "forks", label: "forks" },
85
+ ];
86
+
87
+ // ─── Main ─────────────────────────────────────────────────────────────
88
+ export async function launchBrowser(): Promise<void> {
89
+ let renderer;
90
+ try {
91
+ renderer = await createCliRenderer();
92
+ } 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
+ );
98
+ process.exit(1);
99
+ return;
100
+ }
101
+
102
+ const root = renderer.root;
103
+ root.flexDirection = "column";
104
+ root.backgroundColor = colors.bg;
105
+
106
+ // ── Config ──
107
+ const config = loadConfig();
108
+ const githubToken = config.githubToken || process.env.GITHUB_TOKEN;
109
+
110
+ // Apply theme
111
+ const theme = loadTheme(config.theme);
112
+ Object.assign(colors, theme);
113
+
114
+ // ── Session restore ──
115
+ const session = restoreSession();
116
+
117
+ // ── State ───────────────────────────────────────────────────────────
118
+ let currentRepos: Repo[] = [];
119
+ let currentSort: SortStrategy = session?.sort ?? config.defaultSort;
120
+ let currentLimit = session?.limit ?? config.defaultLimit;
121
+ let currentQueryInput = session?.query ?? "";
122
+ let isLoading = false;
123
+ let currentMode: "search" | "trending" = session?.mode ?? "search";
124
+ let trendingTab: typeof TAB_NAMES[number] = (session?.trendingTab as typeof TAB_NAMES[number]) ?? "This Week";
125
+ let trendingPeriod = "this week";
126
+ let graphFullscreen = false;
127
+ let chartCommitData: number[] = [];
128
+ let currentOverlay: "none" | "history" | "bookmarks" | "saved" | "help" | "topics" | "export" | "compare" | "notifications" | "share" | "leader" | "readme" = "none";
129
+ let currentPage = 1;
130
+ let totalCount = 0;
131
+ let deepDiveActive = false;
132
+ let compareList: Repo[] = [];
133
+ let quitArmed = false;
134
+
135
+
136
+ // ── Header bar ──────────────────────────────────────────────────────
137
+ const header = new TextRenderable(renderer, {
138
+ content:
139
+ " ghfind — GitHub repo browser [/]search [Space]menu [?]help [q]uit",
140
+ backgroundColor: colors.bg,
141
+ color: colors.muted,
142
+ height: 1,
143
+ });
144
+ root.add(header);
145
+
146
+ // ── Trending tab bar (hidden in search mode) ────────────────────────
147
+ const trendingTabBox = new BoxRenderable(renderer, {
148
+ flexDirection: "row",
149
+ height: 1,
150
+ backgroundColor: colors.bg,
151
+ paddingX: 1,
152
+ visible: false,
153
+ });
154
+ // Hidden by default; shown when currentMode === "trending"
155
+ const trendingTabTexts: TextRenderable[] = [];
156
+
157
+ function renderTrendingTabs() {
158
+ for (const t of trendingTabTexts) trendingTabBox.remove(t);
159
+ trendingTabTexts.length = 0;
160
+ TAB_NAMES.forEach((name, i) => {
161
+ const isActive = name === trendingTab;
162
+ const label = ` ${name} `;
163
+ const tt = new TextRenderable(renderer, {
164
+ content: label,
165
+ color: isActive ? colors.bg : colors.muted,
166
+ backgroundColor: isActive ? colors.blue : colors.bg,
167
+ height: 1,
168
+ });
169
+ trendingTabTexts.push(tt);
170
+ trendingTabBox.add(tt);
171
+ if (i < TAB_NAMES.length - 1) {
172
+ const sp = new TextRenderable(renderer, {
173
+ content: " ",
174
+ color: colors.muted,
175
+ backgroundColor: colors.bg,
176
+ height: 1,
177
+ });
178
+ trendingTabTexts.push(sp);
179
+ trendingTabBox.add(sp);
180
+ }
181
+ });
182
+ }
183
+ root.add(trendingTabBox);
184
+
185
+ // ── Search input row ────────────────────────────────────────────────
186
+ 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,
192
+ });
193
+ const searchInput = new InputRenderable(renderer, {
194
+ placeholder: "Search GitHub repos (e.g. rust cli, or language:Rust stars:>100)",
195
+ value: "",
196
+ });
197
+ searchBox.add(searchInput);
198
+ root.add(searchBox);
199
+
200
+ // ── Toolbar (sort + limit indicator) ────────────────────────────────
201
+ const toolbarText = new TextRenderable(renderer, {
202
+ content: formatToolbar(currentSort, currentLimit),
203
+ visible: true, // hidden in trending mode
204
+ backgroundColor: colors.bg,
205
+ color: colors.muted,
206
+ height: 1,
207
+ paddingX: 1,
208
+ });
209
+ root.add(toolbarText);
210
+
211
+ // ── Body: results + detail ──────────────────────────────────────────
212
+ const body = new BoxRenderable(renderer, {
213
+ flexDirection: "row",
214
+ flexGrow: 1,
215
+ gap: 1,
216
+ paddingY: 1,
217
+ paddingX: 1,
218
+ });
219
+
220
+ // Results pane
221
+ const resultsBox = new BoxRenderable(renderer, {
222
+ border: true,
223
+ borderColor: colors.border,
224
+ title: " Results ",
225
+ titleColor: colors.blue,
226
+ width: "50%",
227
+ flexDirection: "column",
228
+ paddingLeft: 0,
229
+ focusable: false,
230
+ });
231
+ const resultsSelect = new SelectRenderable(renderer, {
232
+ options: [{ name: "", description: "Type a query and press Enter to search", value: null }],
233
+ showDescription: false,
234
+ showSelectionIndicator: false,
235
+ flexGrow: 1,
236
+ backgroundColor: colors.bg,
237
+ textColor: colors.text,
238
+ selectedBackgroundColor: colors.selectionBg,
239
+ selectedTextColor: colors.selectionText,
240
+ focusedBackgroundColor: colors.bg,
241
+ focusedTextColor: colors.text,
242
+ itemSpacing: 0,
243
+ });
244
+ resultsBox.add(resultsSelect);
245
+
246
+ // Detail / Graph pane
247
+ const detailBox = new BoxRenderable(renderer, {
248
+ border: true,
249
+ borderColor: colors.border,
250
+ title: " Details ",
251
+ titleColor: colors.green,
252
+ width: "50%",
253
+ flexDirection: "column",
254
+ paddingX: 1,
255
+ paddingY: 1,
256
+ focusable: false,
257
+ });
258
+ const detailText = new TextRenderable(renderer, {
259
+ content: "",
260
+ color: colors.text,
261
+ });
262
+ detailBox.add(detailText);
263
+
264
+ body.add(resultsBox);
265
+ body.add(detailBox);
266
+ root.add(body);
267
+
268
+
269
+
270
+ // ── Overlay system ──────────────────────────────────────────────────
271
+
272
+ // Hide main content so overlays can take 100% of the content area
273
+ function hideMainContent() {
274
+ trendingTabBox.visible = false;
275
+ searchBox.visible = false;
276
+ toolbarText.visible = false;
277
+ body.visible = false;
278
+ }
279
+
280
+ function showMainContent() {
281
+ if (currentMode === "trending") {
282
+ trendingTabBox.visible = true;
283
+ } else {
284
+ searchBox.visible = true;
285
+ toolbarText.visible = true;
286
+ }
287
+ body.visible = true;
288
+ }
289
+
290
+ function showOverlay(type: typeof currentOverlay) {
291
+ helpBox.visible = false;
292
+ historyBox.visible = false;
293
+ bookmarksBox.visible = false;
294
+ savedBox.visible = false;
295
+ topicsBox.visible = false;
296
+ exportBox.visible = false;
297
+ compareBox.visible = false;
298
+ notifsBox.visible = false;
299
+ shareBox.visible = false;
300
+ leaderBox.visible = false;
301
+ readmeBox.visible = false;
302
+
303
+ if (type === "none") {
304
+ currentOverlay = "none";
305
+ showMainContent();
306
+ renderer.requestRender();
307
+ return;
308
+ }
309
+ 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; }
322
+ renderer.requestRender();
323
+ }
324
+
325
+ // ── Help overlay (OpenTUI components) ────────────────────────
326
+ const helpSections = buildHelpSections();
327
+ const helpBox = new BoxRenderable(renderer, {
328
+ visible: false,
329
+ flexGrow: 1,
330
+ backgroundColor: colors.bg,
331
+ border: true,
332
+ borderColor: colors.border,
333
+ });
334
+ const helpScroll = new ScrollBoxRenderable(renderer, {
335
+ flexGrow: 1,
336
+ backgroundColor: colors.bg,
337
+ scrollY: true,
338
+ scrollX: false,
339
+ paddingX: 0,
340
+ viewportOptions: { backgroundColor: colors.bg },
341
+ contentOptions: { backgroundColor: colors.bg, flexDirection: "column" },
342
+ scrollbarOptions: {
343
+ backgroundColor: colors.bg,
344
+ foregroundColor: colors.muted,
345
+ width: 1,
346
+ },
347
+ });
348
+ for (const section of helpSections) {
349
+ const sectionBox = new BoxRenderable(renderer, {
350
+ border: true,
351
+ borderColor: colors.border,
352
+ title: section.title,
353
+ titleColor: (colors as Record<string, string>)[section.titleColor] ?? section.titleColor,
354
+ flexDirection: "column",
355
+ paddingY: 0,
356
+ marginLeft: 1,
357
+ marginRight: 1,
358
+ marginTop: 1,
359
+ });
360
+ for (const row of section.rows) {
361
+ const keys = row.keys.padEnd(HELP_KEYS_COLUMN);
362
+ sectionBox.add(new TextRenderable(renderer, {
363
+ content: ` ${keys} ${row.action}`,
364
+ color: colors.text,
365
+ }));
366
+ }
367
+ if (section.note) {
368
+ sectionBox.add(new TextRenderable(renderer, {
369
+ content: ` ${section.note}`,
370
+ color: colors.muted,
371
+ }));
372
+ }
373
+ helpScroll.add(sectionBox);
374
+ }
375
+ helpBox.add(helpScroll);
376
+ root.add(helpBox);
377
+
378
+ // ── History overlay ─────────────────────────────────────────
379
+ const historyBox = new BoxRenderable(renderer, {
380
+ visible: false,
381
+ flexGrow: 1,
382
+ backgroundColor: colors.bg,
383
+ border: true,
384
+ borderColor: colors.border,
385
+ title: " Search History ",
386
+ titleColor: colors.blue,
387
+ });
388
+ const historySelect = new SelectRenderable(renderer, {
389
+ options: [{ name: "(no history)", description: "", value: null }],
390
+ showDescription: false,
391
+ showSelectionIndicator: true,
392
+ flexGrow: 1,
393
+ backgroundColor: colors.bg,
394
+ textColor: colors.text,
395
+ selectedBackgroundColor: colors.selectionBg,
396
+ selectedTextColor: colors.selectionText,
397
+ itemSpacing: 0,
398
+ });
399
+ historyBox.add(historySelect);
400
+ root.add(historyBox);
401
+
402
+ function refreshHistory() {
403
+ const entries = readHistory();
404
+ if (entries.length === 0) {
405
+ historySelect.options = [{ name: "(no history)", description: "", value: null }];
406
+ } else {
407
+ historySelect.options = entries.map((e, i) => ({
408
+ name: `${e.query} — ${e.mode}${e.tab ? ` (${e.tab})` : ""}`,
409
+ description: `${e.resultCount} results`,
410
+ value: { index: i, entry: e },
411
+ }));
412
+ }
413
+ historySelect.setSelectedIndex(0);
414
+ }
415
+
416
+ // ── Bookmarks overlay ───────────────────────────────────────
417
+ const bookmarksBox = new BoxRenderable(renderer, {
418
+ visible: false,
419
+ flexGrow: 1,
420
+ backgroundColor: colors.bg,
421
+ border: true,
422
+ borderColor: colors.border,
423
+ title: " Bookmarks ",
424
+ titleColor: colors.green,
425
+ });
426
+ const bookmarksSelect = new SelectRenderable(renderer, {
427
+ options: [{ name: "(no bookmarks)", description: "", value: null }],
428
+ showDescription: false,
429
+ showSelectionIndicator: true,
430
+ flexGrow: 1,
431
+ backgroundColor: colors.bg,
432
+ textColor: colors.text,
433
+ selectedBackgroundColor: colors.selectionBg,
434
+ selectedTextColor: colors.selectionText,
435
+ itemSpacing: 0,
436
+ });
437
+ bookmarksBox.add(bookmarksSelect);
438
+ root.add(bookmarksBox);
439
+
440
+ function refreshBookmarks() {
441
+ const bookmarks = getBookmarks();
442
+ if (bookmarks.length === 0) {
443
+ bookmarksSelect.options = [{ name: "(no bookmarks)", description: "", value: null }];
444
+ } else {
445
+ bookmarksSelect.options = bookmarks.map((b) => ({
446
+ name: `${b.repo.fullName} ★ ${b.repo.stars.toLocaleString()}`,
447
+ description: b.repo.description ?? "",
448
+ value: b,
449
+ }));
450
+ }
451
+ bookmarksSelect.setSelectedIndex(0);
452
+ }
453
+
454
+ // ── Saved searches overlay ─────────────────────────────────
455
+ const savedBox = new BoxRenderable(renderer, {
456
+ visible: false,
457
+ flexGrow: 1,
458
+ backgroundColor: colors.bg,
459
+ border: true,
460
+ borderColor: colors.border,
461
+ title: " Saved Searches ",
462
+ titleColor: colors.yellow,
463
+ });
464
+ const savedSelect = new SelectRenderable(renderer, {
465
+ options: [{ name: "(no saved searches)", description: "", value: null }],
466
+ showDescription: false,
467
+ showSelectionIndicator: true,
468
+ flexGrow: 1,
469
+ backgroundColor: colors.bg,
470
+ textColor: colors.text,
471
+ selectedBackgroundColor: colors.selectionBg,
472
+ selectedTextColor: colors.selectionText,
473
+ itemSpacing: 0,
474
+ });
475
+ savedBox.add(savedSelect);
476
+ root.add(savedBox);
477
+
478
+ // ── Topic explorer overlay ───────────────────────────────────
479
+ const topicsBox = new BoxRenderable(renderer, {
480
+ visible: false,
481
+ flexGrow: 1,
482
+ backgroundColor: colors.bg,
483
+ border: true,
484
+ borderColor: colors.border,
485
+ title: " Topics ",
486
+ titleColor: colors.purple ?? colors.blue,
487
+ });
488
+ const topicsSelect = new SelectRenderable(renderer, {
489
+ options: [{ name: "(loading topics...)", description: "", value: null }],
490
+ showDescription: true,
491
+ showSelectionIndicator: true,
492
+ flexGrow: 1,
493
+ backgroundColor: colors.bg,
494
+ textColor: colors.text,
495
+ selectedBackgroundColor: colors.selectionBg,
496
+ selectedTextColor: colors.selectionText,
497
+ itemSpacing: 0,
498
+ });
499
+ topicsBox.add(topicsSelect);
500
+ root.add(topicsBox);
501
+
502
+ async function refreshTopics() {
503
+ try {
504
+ const topics = await fetchTopics();
505
+ topicsSelect.options = topics.map((t) => ({
506
+ name: `${t.name} (${t.repoCount.toLocaleString()} repos)`,
507
+ description: t.description.slice(0, 80),
508
+ value: t,
509
+ }));
510
+ } catch {
511
+ topicsSelect.options = [{ name: "(failed to load topics)", description: "", value: null }];
512
+ }
513
+ topicsSelect.setSelectedIndex(0);
514
+ }
515
+
516
+ // ── Export overlay ───────────────────────────────────────────
517
+ const exportBox = new BoxRenderable(renderer, {
518
+ visible: false,
519
+ flexGrow: 1,
520
+ backgroundColor: colors.bg,
521
+ border: true,
522
+ borderColor: colors.border,
523
+ title: " Export ",
524
+ titleColor: colors.green,
525
+ });
526
+ const exportSelect = new SelectRenderable(renderer, {
527
+ 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" },
531
+ { name: " Plain text", description: "One repo per line", value: "text" },
532
+ ],
533
+ showDescription: true,
534
+ showSelectionIndicator: true,
535
+ flexGrow: 1,
536
+ backgroundColor: colors.bg,
537
+ textColor: colors.text,
538
+ selectedBackgroundColor: colors.selectionBg,
539
+ selectedTextColor: colors.selectionText,
540
+ itemSpacing: 0,
541
+ });
542
+ exportBox.add(exportSelect);
543
+ root.add(exportBox);
544
+
545
+ // ── Compare overlay ──────────────────────────────────────────
546
+ const compareBox = new BoxRenderable(renderer, {
547
+ visible: false,
548
+ flexGrow: 1,
549
+ backgroundColor: colors.bg,
550
+ border: true,
551
+ borderColor: colors.border,
552
+ title: " Comparison ",
553
+ titleColor: colors.yellow,
554
+ });
555
+ const compareText = new TextRenderable(renderer, {
556
+ content: "",
557
+ color: colors.text,
558
+ backgroundColor: colors.bg,
559
+ });
560
+ compareBox.add(compareText);
561
+ root.add(compareBox);
562
+
563
+ // ── Notifications overlay ────────────────────────────────────
564
+ const notifsBox = new BoxRenderable(renderer, {
565
+ visible: false,
566
+ flexGrow: 1,
567
+ backgroundColor: colors.bg,
568
+ border: true,
569
+ borderColor: colors.border,
570
+ title: " Notifications ",
571
+ titleColor: colors.yellow,
572
+ });
573
+ const notifsText = new TextRenderable(renderer, {
574
+ content: "(no notifications)",
575
+ color: colors.text,
576
+ backgroundColor: colors.bg,
577
+ });
578
+ notifsBox.add(notifsText);
579
+ root.add(notifsBox);
580
+
581
+ function refreshNotifications() {
582
+ const notifs = getNotifications();
583
+ if (notifs.length === 0) {
584
+ notifsText.content = " (no notifications)";
585
+ } else {
586
+ notifsText.content = notifs
587
+ .map((n) => ` ${n.icon} ${n.message}`)
588
+ .join("\n");
589
+ }
590
+ }
591
+
592
+ // ── Share overlay ────────────────────────────────────────────
593
+ const shareBox = new BoxRenderable(renderer, {
594
+ visible: false,
595
+ flexGrow: 1,
596
+ backgroundColor: colors.bg,
597
+ border: true,
598
+ borderColor: colors.border,
599
+ title: " Share Repo ",
600
+ titleColor: colors.blue,
601
+ });
602
+ const shareSelect = new SelectRenderable(renderer, {
603
+ 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" },
607
+ { name: " Short", description: "owner/repo", value: "short" },
608
+ ],
609
+ showDescription: true,
610
+ showSelectionIndicator: true,
611
+ flexGrow: 1,
612
+ backgroundColor: colors.bg,
613
+ textColor: colors.text,
614
+ selectedBackgroundColor: colors.selectionBg,
615
+ selectedTextColor: colors.selectionText,
616
+ itemSpacing: 0,
617
+ });
618
+ shareBox.add(shareSelect);
619
+ root.add(shareBox);
620
+
621
+ function refreshCompare() {
622
+ if (compareList.length < 2) {
623
+ compareText.content = "Select at least 2 repos to compare (press c to add).";
624
+ } else {
625
+ compareText.content = buildComparisonTable(compareList);
626
+ }
627
+ }
628
+
629
+ function refreshSavedSearches() {
630
+ const saved = getSavedSearches();
631
+ if (saved.length === 0) {
632
+ savedSelect.options = [{ name: "(no saved searches)", description: "", value: null }];
633
+ } else {
634
+ savedSelect.options = saved.map((s) => ({
635
+ name: `${s.name}`,
636
+ description: `${s.query} (${s.mode})`,
637
+ value: s,
638
+ }));
639
+ }
640
+ savedSelect.setSelectedIndex(0);
641
+ }
642
+
643
+ // ── Leader menu overlay (Space key) ─────────────────────────────
644
+ const leaderBox = new BoxRenderable(renderer, {
645
+ visible: false,
646
+ flexGrow: 1,
647
+ backgroundColor: colors.bg,
648
+ border: true,
649
+ borderColor: colors.border,
650
+ title: " Menu ",
651
+ titleColor: colors.blue,
652
+ flexDirection: "column",
653
+ });
654
+ const leaderSelect = new SelectRenderable(renderer, {
655
+ options: [{ name: "(empty)", description: "", value: null }],
656
+ showDescription: true,
657
+ showSelectionIndicator: true,
658
+ flexGrow: 1,
659
+ backgroundColor: colors.bg,
660
+ textColor: colors.text,
661
+ selectedBackgroundColor: colors.selectionBg,
662
+ selectedTextColor: colors.selectionText,
663
+ itemSpacing: 0,
664
+ });
665
+ const leaderFooter = new TextRenderable(renderer, {
666
+ content: " ↑↓ select Enter run Esc/q close",
667
+ color: colors.muted,
668
+ backgroundColor: colors.bg,
669
+ height: 1,
670
+ paddingX: 1,
671
+ });
672
+ leaderBox.add(leaderSelect);
673
+ leaderBox.add(leaderFooter);
674
+ root.add(leaderBox);
675
+
676
+ interface LeaderItem { name: string; description: string; action: () => void; }
677
+
678
+ function showLeaderMenu() {
679
+ const items: LeaderItem[] = [];
680
+
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
+ }
694
+
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
+ }
703
+
704
+ // Common items (always available when repos exist)
705
+ const hasRepo = (() => {
706
+ const opt = resultsSelect.getSelectedOption();
707
+ return opt?.value && typeof (opt.value as any)?.fullName === "string";
708
+ })();
709
+
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") },
722
+ );
723
+
724
+ leaderSelect.options = items.map((it) => ({
725
+ name: it.name,
726
+ description: it.description,
727
+ value: it,
728
+ }));
729
+ leaderSelect.setSelectedIndex(0);
730
+ leaderSelect.focus();
731
+ showOverlay("leader");
732
+ }
733
+
734
+ function openUrlIfSelected() {
735
+ const opt = resultsSelect.getSelectedOption();
736
+ const repo = opt?.value as Repo | undefined;
737
+ if (repo) openUrl(repo.url);
738
+ else setStatus("No repo selected");
739
+ }
740
+
741
+ function toggleBookmarkOnSelected() {
742
+ const opt = resultsSelect.getSelectedOption();
743
+ const repo = opt?.value as Repo | undefined;
744
+ if (repo) {
745
+ const added = toggleBookmark(repo);
746
+ setStatus(added ? `Bookmarked ${repo.fullName}` : `Unbookmarked ${repo.fullName}`);
747
+ }
748
+ }
749
+
750
+ function toggleCompareOnSelected() {
751
+ const opt = resultsSelect.getSelectedOption();
752
+ const repo = opt?.value as Repo | undefined;
753
+ if (!repo) return;
754
+ const idx = compareList.findIndex((r) => r.fullName === repo.fullName);
755
+ if (idx >= 0) {
756
+ compareList.splice(idx, 1);
757
+ setStatus(`Removed ${repo.fullName} from comparison`);
758
+ } else {
759
+ compareList.push(repo);
760
+ setStatus(`${repo.fullName} added to comparison (${compareList.length} selected)`);
761
+ }
762
+ }
763
+
764
+ function showShareIfRepo() {
765
+ const opt = resultsSelect.getSelectedOption();
766
+ const repo = opt?.value as Repo | undefined;
767
+ if (!repo) { setStatus("No repo selected to share"); return; }
768
+ showOverlay("share");
769
+ }
770
+
771
+ function saveCurrentSearch() {
772
+ 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);
777
+ setStatus(`Saved as "${name}"`);
778
+ } else {
779
+ setStatus("No search to save");
780
+ }
781
+ }
782
+
783
+ function triggerDeepDive() {
784
+ const opt = resultsSelect.getSelectedOption();
785
+ const repo = opt?.value as Repo | undefined;
786
+ if (!repo) return;
787
+ deepDiveActive = !deepDiveActive;
788
+ if (deepDiveActive) {
789
+ detailText.content = " Loading deep-dive...";
790
+ renderer.requestRender();
791
+ fetchDeepDive(repo, githubToken)
792
+ .then((data) => {
793
+ detailText.content = buildDeepDiveText(data);
794
+ renderer.requestRender();
795
+ })
796
+ .catch(() => {
797
+ detailText.content = " Failed to load deep-dive";
798
+ renderer.requestRender();
799
+ });
800
+ } else {
801
+ updateDetail(repo);
802
+ renderer.requestRender();
803
+ }
804
+ }
805
+
806
+ // ── README viewer overlay ────────────────────────────────────
807
+ const readmeBox = new BoxRenderable(renderer, {
808
+ visible: false,
809
+ flexGrow: 1,
810
+ backgroundColor: colors.bg,
811
+ border: true,
812
+ borderColor: colors.green,
813
+ title: " README ",
814
+ titleColor: colors.green,
815
+ });
816
+ const readmeScroll = new ScrollBoxRenderable(renderer, {
817
+ flexGrow: 1,
818
+ backgroundColor: colors.bg,
819
+ scrollY: true,
820
+ scrollX: false,
821
+ paddingX: 1,
822
+ viewportOptions: { backgroundColor: colors.bg },
823
+ contentOptions: { backgroundColor: colors.bg, flexDirection: "column" },
824
+ scrollbarOptions: {
825
+ backgroundColor: colors.bg,
826
+ foregroundColor: colors.muted,
827
+ width: 1,
828
+ },
829
+ });
830
+ const readmeText = new TextRenderable(renderer, {
831
+ content: "",
832
+ color: colors.text,
833
+ backgroundColor: colors.bg,
834
+ });
835
+ readmeScroll.add(readmeText);
836
+ readmeBox.add(readmeScroll);
837
+ const readmeFooter = new TextRenderable(renderer, {
838
+ content: " ↑↓/jk scroll Esc/q close",
839
+ color: colors.muted,
840
+ backgroundColor: colors.bg,
841
+ height: 1,
842
+ paddingX: 1,
843
+ });
844
+ readmeBox.add(readmeFooter);
845
+ root.add(readmeBox);
846
+
847
+ async function showReadme() {
848
+ const opt = resultsSelect.getSelectedOption();
849
+ const repo = opt?.value as Repo | undefined;
850
+ if (!repo) {
851
+ setStatus("No repo selected");
852
+ return;
853
+ }
854
+ readmeText.content = ` Loading README for ${repo.fullName}...`;
855
+ showOverlay("readme");
856
+ try {
857
+ const headers: Record<string, string> = { "User-Agent": "ghfind/1.0" };
858
+ if (githubToken) headers.Authorization = `Bearer ${githubToken}`;
859
+ let text = "";
860
+ for (const path of [
861
+ `${repo.owner}/${repo.name}/main/README.md`,
862
+ `${repo.owner}/${repo.name}/master/README.md`,
863
+ `${repo.owner}/${repo.name}/main/README.rst`,
864
+ ]) {
865
+ const r = await fetch(`https://raw.githubusercontent.com/${path}`, { headers });
866
+ if (r.ok) { text = await r.text(); break; }
867
+ }
868
+ if (!text) {
869
+ readmeText.content = ` (no README found for ${repo.fullName})`;
870
+ } else {
871
+ // ponytail: just strip markdown formatting, show full text
872
+ const stripped = text
873
+ .replace(/```[\s\S]*?```/g, "[code block]")
874
+ .replace(/#{1,6}\s/g, "")
875
+ .replace(/\*\*(.+?)\*\*/g, "$1")
876
+ .replace(/\[(.+?)\]\(.+?\)/g, "$1")
877
+ .replace(/`([^`]+)`/g, "$1")
878
+ .split("\n")
879
+ .map((l) => l.trimEnd())
880
+ .join("\n");
881
+ readmeText.content = stripped;
882
+ }
883
+ } catch {
884
+ readmeText.content = " Failed to load README";
885
+ }
886
+ renderer.requestRender();
887
+ }
888
+
889
+ // ── Status bar ──────────────────────────────────────────────────────
890
+ const statusBar = new TextRenderable(renderer, {
891
+ content: " Ready. Press Enter to search.",
892
+ backgroundColor: colors.bg,
893
+ color: colors.muted,
894
+ height: 1,
895
+ paddingX: 1,
896
+ });
897
+ root.add(statusBar);
898
+
899
+ const statusMgr = new StatusManager((text) => {
900
+ statusBar.content = ` ${text}`;
901
+ renderer.requestRender();
902
+ });
903
+
904
+ // ── Startup tip ──
905
+ if (config.theme !== undefined || true) {
906
+ // Show a tip after 2s idle
907
+ setTimeout(() => {
908
+ if (currentOverlay === "none") {
909
+ setStatus(nextTip());
910
+ }
911
+ }, 2000);
912
+ }
913
+
914
+ // ── Helper functions ────────────────────────────────────────────────
915
+
916
+ function setStatus(msg: string) {
917
+ statusMgr.set("idle", msg);
918
+ }
919
+
920
+ function setToolbar() {
921
+ toolbarText.content = formatToolbar(currentSort, currentLimit);
922
+ renderer.requestRender();
923
+ }
924
+
925
+ function updateDetail(repo: Repo | null) {
926
+ if (!repo) {
927
+ detailText.content = "";
928
+ return;
929
+ }
930
+ const topics = repo.topics.length ? repo.topics.slice(0, 8).join(", ") : "—";
931
+ const desc = repo.description ?? "(no description)";
932
+ 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 !== "");
946
+ detailText.content = lines.join("\n");
947
+ }
948
+
949
+ // ── Graph / Chart ──────────────────────────────────────────────────
950
+
951
+ /** Unicode blocks for 8 vertical levels within one character cell. */
952
+ // Braille dot encoding: each byte is 8 dots (2 cols × 4 rows per char).
953
+ // Bit layout (LSB first): 0-2=col0 rows, 3-5=col1 rows, 6=col0 row3, 7=col1 row3.
954
+ // Unicode offset is 0x2800, so braille code point = 0x2800 + bitmask.
955
+ function brailleChar(bits: number): string {
956
+ return bits === 0 ? " " : String.fromCodePoint(0x2800 + bits);
957
+ }
958
+
959
+ function buildChartString(values: number[], termW: number, termH: number): string[] {
960
+ if (values.length === 0) return ["(no data)"];
961
+
962
+ // Braille resolution: 2 horizontal dots and 4 vertical dots per terminal cell.
963
+ const dotW = termW * 2;
964
+ const dotH = termH * 4;
965
+
966
+ // Sample values to fit braille dot columns.
967
+ const sampled: number[] = [];
968
+ for (let i = 0; i < dotW; i++) {
969
+ sampled.push(values[Math.floor((i / dotW) * values.length)]);
970
+ }
971
+ const max = Math.max(...sampled, 1);
972
+ // Map each sample to a dot row (0 = bottom, dotH-1 = top).
973
+ const dotRows = sampled.map(v => Math.round((v / max) * (dotH - 1)));
974
+
975
+ // 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));
977
+
978
+ // Draw the silhouette line: mark the dot at each column.
979
+ for (let c = 0; c < dotW; c++) {
980
+ dotGrid[dotH - 1 - dotRows[c]][c] = true;
981
+ }
982
+ // Fill gaps between adjacent columns so the line is continuous.
983
+ for (let c = 0; c < dotW - 1; c++) {
984
+ const r1 = dotRows[c];
985
+ const r2 = dotRows[c + 1];
986
+ const lo = Math.min(r1, r2);
987
+ const hi = Math.max(r1, r2);
988
+ for (let r = lo; r <= hi; r++) {
989
+ dotGrid[dotH - 1 - r][c] = true;
990
+ }
991
+ }
992
+
993
+ // Convert 4-row × 2-col blocks of dots into braille characters.
994
+ const lines: string[] = [];
995
+ for (let tr = 0; tr < termH; tr++) {
996
+ const chars: string[] = [];
997
+ for (let tc = 0; tc < termW; tc++) {
998
+ const r0 = tr * 4;
999
+ const c0 = tc * 2;
1000
+ 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;
1009
+ chars.push(brailleChar(bits));
1010
+ }
1011
+
1012
+ // 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];
1015
+ const idx = labelRows.indexOf(tr);
1016
+ let label = " ";
1017
+ if (idx >= 0) label = String(labelVals[idx]).padStart(4) + " ";
1018
+ lines.push(`${label}\u2524${chars.join("")}`);
1019
+ }
1020
+
1021
+ // X-axis
1022
+ lines.push(` \u2514${String.fromCharCode(0x2500).repeat(termW)}`);
1023
+ return lines;
1024
+ }
1025
+
1026
+ async function loadChart(repo: Repo) {
1027
+ detailText.content = " Loading chart...";
1028
+ renderer.requestRender();
1029
+ try {
1030
+ 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),
1035
+ ]);
1036
+
1037
+ let chartSection = "";
1038
+ if (chartRes.ok) {
1039
+ const data = await chartRes.json();
1040
+ if (data?.all && data.all.length >= 2) {
1041
+ chartCommitData = data.all;
1042
+ const chartW = 60;
1043
+ const chartH = 10;
1044
+ const chartLines = buildChartString(chartCommitData, chartW, chartH);
1045
+ chartSection = ["", "Weekly commits (52 weeks)", ...chartLines].join("\n");
1046
+ }
1047
+ }
1048
+
1049
+ const desc = repo.description ?? "";
1050
+ const lang = repo.language ?? "\u2014";
1051
+ 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}`
1056
+ : "";
1057
+
1058
+ const detailLines = [
1059
+ ` ${repo.fullName}`,
1060
+ ` \u2606 ${repo.stars.toLocaleString()} \u2666 ${repo.forks.toLocaleString()} ${lang}`,
1061
+ growthLine,
1062
+ updated ? ` Updated ${updated}` : "",
1063
+ topics ? ` Topics ${topics}` : "",
1064
+ "",
1065
+ desc,
1066
+ "",
1067
+ ` ${repo.url}`,
1068
+ ].filter(l => l !== "");
1069
+
1070
+ detailText.content = [
1071
+ ...detailLines,
1072
+ chartSection,
1073
+ ].join("\n");
1074
+ } catch {
1075
+ detailText.content = " Failed to load chart";
1076
+ }
1077
+ renderer.requestRender();
1078
+ }
1079
+
1080
+ function toggleGraph() {
1081
+ graphFullscreen = !graphFullscreen;
1082
+ if (graphFullscreen) {
1083
+ resultsBox.visible = false;
1084
+ resultsBox.width = "0%";
1085
+ detailBox.width = "100%";
1086
+ const opt = resultsSelect.getSelectedOption();
1087
+ const repo = opt?.value as Repo | undefined;
1088
+ if (repo) loadChart(repo);
1089
+ else detailText.content = " Select a repo to view activity";
1090
+ } else {
1091
+ resultsBox.visible = true;
1092
+ resultsBox.width = "50%";
1093
+ detailBox.width = "50%";
1094
+ const opt = resultsSelect.getSelectedOption();
1095
+ if (opt?.value) updateDetail(opt.value as Repo);
1096
+ else detailText.content = "";
1097
+ }
1098
+ renderer.requestRender();
1099
+ }
1100
+
1101
+ // ── Trending helpers ────────────────────────────────────────────────
1102
+
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");
1105
+ const name = `${r.owner}/${r.name}`.padEnd(30).slice(0, 30);
1106
+ const lang = (r.language || "?").padEnd(12).slice(0, 12);
1107
+ 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
+ };
1134
+ }
1135
+
1136
+ async function loadTrending() {
1137
+ currentMode = "trending";
1138
+ isLoading = true;
1139
+ searchBox.visible = false;
1140
+ toolbarText.visible = false;
1141
+ trendingTabBox.visible = true;
1142
+ renderTrendingTabs();
1143
+ resultsSelect.options = [{ name: " Loading trending...", description: "", value: null }];
1144
+ detailText.content = "";
1145
+ statusMgr.set("loading", "Loading trending repos...");
1146
+ renderer.requestRender();
1147
+
1148
+ try {
1149
+ const fetched = await fetchTrendingRepos(tabSince(trendingTab));
1150
+ 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),
1156
+ }));
1157
+ if (fetched.length > 0) {
1158
+ resultsSelect.setSelectedIndex(0);
1159
+ updateDetail(trendingRepoToSelectValue(fetched[0]));
1160
+ } else {
1161
+ throw new NoResultsError(trendingTab);
1162
+ }
1163
+ statusMgr.set("success", `${fetched.length} trending repos — ${trendingTab}`);
1164
+ appendHistory({ query: `trending:${trendingTab}`, mode: "trending", tab: trendingTab, timestamp: Date.now(), resultCount: fetched.length });
1165
+ } 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 }];
1168
+ statusMgr.set("error", msg.slice(0, 60));
1169
+ }
1170
+ isLoading = false;
1171
+ renderer.requestRender();
1172
+ }
1173
+
1174
+ function showSearchMode() {
1175
+ currentMode = "search";
1176
+ trendingTabBox.visible = false;
1177
+ searchBox.visible = true;
1178
+ toolbarText.visible = true;
1179
+ searchInput.placeholder = "Search GitHub repos (e.g. rust cli, or language:Rust stars:>100)";
1180
+ searchInput.focus();
1181
+ renderer.requestRender();
1182
+ }
1183
+
1184
+ async function doSearch(queryText: string, append = false) {
1185
+ const q = queryText.trim();
1186
+ if (q === "" || isLoading) return;
1187
+
1188
+ if (!append) currentPage = 1;
1189
+ deepDiveActive = false;
1190
+
1191
+ isLoading = true;
1192
+ currentQueryInput = q;
1193
+ if (!append) {
1194
+ resultsSelect.options = [{ name: " Searching...", description: "", value: null }];
1195
+ detailText.content = "";
1196
+ }
1197
+ statusMgr.set("searching", `Searching for "${q}"`);
1198
+
1199
+ try {
1200
+ const parsed = applyFlagFilters(parseQuery(q), {});
1201
+ validateQuery(parsed);
1202
+ const provider = createGitHubSearch(logger);
1203
+ const options: SearchOptions = {
1204
+ limit: currentLimit,
1205
+ sort: currentSort,
1206
+ json: false,
1207
+ verbose: false,
1208
+ token: githubToken,
1209
+ page: currentPage,
1210
+ };
1211
+ const response = await provider.search(parsed, options);
1212
+ totalCount = response.totalCount;
1213
+ const newRepos = rankRepos(response.repos, options.sort);
1214
+
1215
+ if (append) {
1216
+ currentRepos.push(...newRepos);
1217
+ } else {
1218
+ currentRepos = newRepos;
1219
+ }
1220
+
1221
+ if (currentRepos.length === 0) {
1222
+ throw new NoResultsError(q);
1223
+ } else {
1224
+ resultsSelect.options = currentRepos.map((r) => ({
1225
+ name: formatResultLine(r),
1226
+ description: "",
1227
+ value: r,
1228
+ }));
1229
+ const idx = append ? resultsSelect.options.length - newRepos.length : 0;
1230
+ updateDetail(currentRepos[idx]);
1231
+ resultsSelect.setSelectedIndex(idx);
1232
+ statusMgr.set("success", `Page ${currentPage} — ${currentRepos.length} of ${totalCount.toLocaleString()} results`);
1233
+ if (!append) {
1234
+ appendHistory({ query: q, mode: "search", timestamp: Date.now(), resultCount: currentRepos.length });
1235
+ }
1236
+ }
1237
+ } catch (err) {
1238
+ currentRepos = [];
1239
+ resultsSelect.options = [{ name: " (error)", description: "", value: null }];
1240
+ const msg = err instanceof SearchCliError ? err.userMessage : (err instanceof Error ? err.message : String(err));
1241
+ statusMgr.set("error", msg.slice(0, 60));
1242
+ }
1243
+
1244
+ isLoading = false;
1245
+ renderer.requestRender();
1246
+ }
1247
+
1248
+ function changeSort() {
1249
+ const idx = SORT_MODES.findIndex((m) => m.key === currentSort);
1250
+ const next = SORT_MODES[(idx + 1) % SORT_MODES.length];
1251
+ currentSort = next.key;
1252
+ setToolbar();
1253
+ if (currentRepos.length > 0) {
1254
+ currentRepos = rankRepos(currentRepos, currentSort);
1255
+ resultsSelect.options = currentRepos.map((r) => ({
1256
+ name: formatResultLine(r),
1257
+ description: "",
1258
+ value: r,
1259
+ }));
1260
+ resultsSelect.setSelectedIndex(0);
1261
+ updateDetail(currentRepos[0]);
1262
+ renderer.requestRender();
1263
+ }
1264
+ }
1265
+
1266
+ function changeLimit() {
1267
+ const limits = [10, 25, 50, 100];
1268
+ const idx = limits.indexOf(currentLimit);
1269
+ currentLimit = limits[(idx + 1) % limits.length];
1270
+ setToolbar();
1271
+ setStatus(`Limit set to ${currentLimit}. Press Enter to re-search.`);
1272
+ renderer.requestRender();
1273
+ }
1274
+
1275
+ // ── Wire events ────────────────────────────────────────────────────
1276
+
1277
+ // Enter in search input → show results
1278
+ searchInput.on("enter", () => {
1279
+ if (searchInput.value.trim() === "" || isLoading) return;
1280
+ showSearchMode();
1281
+ doSearch(searchInput.value);
1282
+ });
1283
+
1284
+ // Navigate results → update detail pane
1285
+ resultsSelect.on("selectionChanged", () => {
1286
+ const opt = resultsSelect.getSelectedOption();
1287
+ const repo = opt?.value as Repo | undefined;
1288
+ if (repo) {
1289
+ if (graphFullscreen) loadChart(repo);
1290
+ else updateDetail(repo);
1291
+ }
1292
+ renderer.requestRender();
1293
+ });
1294
+
1295
+ // Enter on a result → open URL
1296
+ resultsSelect.on("itemSelected", () => {
1297
+ const opt = resultsSelect.getSelectedOption();
1298
+ const repo = opt?.value as Repo | undefined;
1299
+ if (repo) openUrl(repo.url);
1300
+ });
1301
+
1302
+ // ── Global keyboard shortcuts ──────────────────────────────────────
1303
+ renderer.keyInput.on("keypress", (key) => {
1304
+ // ── Overlay-mode handling ────────────────────────────────────────
1305
+ if (currentOverlay !== "none") {
1306
+ // Escape or q closes any overlay
1307
+ if (key.name === "escape" || key.name === "q") {
1308
+ showOverlay("none");
1309
+ renderer.requestRender();
1310
+ return;
1311
+ }
1312
+
1313
+ // Leader menu: Enter dispatches action, up/down navigates
1314
+ if (currentOverlay === "leader") {
1315
+ if (key.name === "enter" || key.name === "return") {
1316
+ const sel = leaderSelect.getSelectedOption();
1317
+ const item = sel?.value as LeaderItem | undefined;
1318
+ if (item) {
1319
+ showOverlay("none");
1320
+ item.action();
1321
+ renderer.requestRender();
1322
+ }
1323
+ return;
1324
+ }
1325
+ // Let up/down/j/k fall through to SelectRenderable
1326
+ }
1327
+
1328
+ // README viewer: scrollable, Esc/q already handled above
1329
+ if (currentOverlay === "readme") {
1330
+ return;
1331
+ }
1332
+
1333
+ // History overlay
1334
+ if (currentOverlay === "history") {
1335
+ if (key.name === "d") {
1336
+ const sel = historySelect.getSelectedOption();
1337
+ if (sel?.value) {
1338
+ deleteHistoryEntry((sel.value as any).index);
1339
+ refreshHistory();
1340
+ setStatus("Deleted history entry");
1341
+ }
1342
+ renderer.requestRender();
1343
+ return;
1344
+ }
1345
+ if (key.ctrl && key.name === "x") {
1346
+ clearHistory();
1347
+ refreshHistory();
1348
+ setStatus("History cleared");
1349
+ renderer.requestRender();
1350
+ return;
1351
+ }
1352
+ if (key.name === "enter" || key.name === "return") {
1353
+ const sel = historySelect.getSelectedOption();
1354
+ if (sel?.value) {
1355
+ const entry = (sel.value as any).entry;
1356
+ showOverlay("none");
1357
+ if (entry.mode === "trending") {
1358
+ if (entry.tab) trendingTab = entry.tab as typeof TAB_NAMES[number];
1359
+ loadTrending();
1360
+ } else {
1361
+ searchInput.value = entry.query;
1362
+ showSearchMode();
1363
+ doSearch(entry.query);
1364
+ }
1365
+ }
1366
+ return;
1367
+ }
1368
+ return;
1369
+ }
1370
+
1371
+ // Bookmarks overlay
1372
+ if (currentOverlay === "bookmarks") {
1373
+ if (key.name === "d") {
1374
+ const sel = bookmarksSelect.getSelectedOption();
1375
+ if (sel?.value) {
1376
+ removeBookmark((sel.value as any).repo.fullName);
1377
+ refreshBookmarks();
1378
+ setStatus("Bookmark removed");
1379
+ }
1380
+ renderer.requestRender();
1381
+ return;
1382
+ }
1383
+ if (key.name === "enter" || key.name === "return") {
1384
+ const sel = bookmarksSelect.getSelectedOption();
1385
+ if (sel?.value) openUrl((sel.value as any).repo.url);
1386
+ return;
1387
+ }
1388
+ return;
1389
+ }
1390
+
1391
+ // Saved searches overlay
1392
+ if (currentOverlay === "saved") {
1393
+ if (key.name === "d") {
1394
+ const sel = savedSelect.getSelectedOption();
1395
+ if (sel?.value) {
1396
+ deleteSavedSearch((sel.value as any).name);
1397
+ refreshSavedSearches();
1398
+ setStatus("Saved search deleted");
1399
+ }
1400
+ renderer.requestRender();
1401
+ return;
1402
+ }
1403
+ if (key.name === "enter" || key.name === "return") {
1404
+ const sel = savedSelect.getSelectedOption();
1405
+ if (sel?.value) {
1406
+ const s = sel.value as any;
1407
+ showOverlay("none");
1408
+ touchSavedSearch(s.name);
1409
+ if (s.mode === "trending") {
1410
+ if (s.tab) trendingTab = s.tab as typeof TAB_NAMES[number];
1411
+ loadTrending();
1412
+ } else {
1413
+ searchInput.value = s.query;
1414
+ showSearchMode();
1415
+ currentSort = s.sort;
1416
+ currentLimit = s.limit;
1417
+ setToolbar();
1418
+ doSearch(s.query);
1419
+ }
1420
+ }
1421
+ return;
1422
+ }
1423
+ return;
1424
+ }
1425
+
1426
+ // Topics explorer overlay
1427
+ if (currentOverlay === "topics") {
1428
+ if (key.name === "enter" || key.name === "return") {
1429
+ const sel = topicsSelect.getSelectedOption();
1430
+ if (sel?.value) {
1431
+ const topic = sel.value as any;
1432
+ showOverlay("none");
1433
+ searchInput.value = `topic:${topic.name}`;
1434
+ showSearchMode();
1435
+ doSearch(`topic:${topic.name}`);
1436
+ }
1437
+ return;
1438
+ }
1439
+ return;
1440
+ }
1441
+
1442
+ // Export overlay
1443
+ if (currentOverlay === "export") {
1444
+ if (key.name === "enter" || key.name === "return") {
1445
+ const sel = exportSelect.getSelectedOption();
1446
+ if (sel?.value && currentRepos.length > 0) {
1447
+ const format = sel.value as ExportFormat;
1448
+ const path = exportToFile(currentRepos, format);
1449
+ showOverlay("none");
1450
+ setStatus(`✓ Exported to ${path}`);
1451
+ } else {
1452
+ setStatus("No results to export");
1453
+ showOverlay("none");
1454
+ }
1455
+ renderer.requestRender();
1456
+ return;
1457
+ }
1458
+ return;
1459
+ }
1460
+
1461
+ // Compare overlay: Esc/q closes
1462
+ if (currentOverlay === "compare") {
1463
+ return;
1464
+ }
1465
+
1466
+ // Notifications overlay
1467
+ if (currentOverlay === "notifications") {
1468
+ if (key.name === "d") {
1469
+ const notifs = getNotifications();
1470
+ if (notifs.length > 0) {
1471
+ dismissNotification(notifs[0].id);
1472
+ refreshNotifications();
1473
+ setStatus("Notification dismissed");
1474
+ }
1475
+ renderer.requestRender();
1476
+ return;
1477
+ }
1478
+ if (key.ctrl && key.name === "c") {
1479
+ dismissAll();
1480
+ refreshNotifications();
1481
+ renderer.requestRender();
1482
+ return;
1483
+ }
1484
+ return;
1485
+ }
1486
+
1487
+ // Share overlay
1488
+ if (currentOverlay === "share") {
1489
+ if (key.name === "enter" || key.name === "return") {
1490
+ const sel = shareSelect.getSelectedOption();
1491
+ if (sel?.value) {
1492
+ const opt = resultsSelect.getSelectedOption();
1493
+ const repo = opt?.value as Repo | undefined;
1494
+ if (repo) {
1495
+ const text = formatShare(repo, sel.value as ShareFormat);
1496
+ copyToClipboard(text).then((ok) => {
1497
+ showOverlay("none");
1498
+ setStatus(ok ? "✓ Copied!" : "Clipboard not available");
1499
+ });
1500
+ }
1501
+ }
1502
+ renderer.requestRender();
1503
+ return;
1504
+ }
1505
+ return;
1506
+ }
1507
+
1508
+ // Help overlay: any key closes
1509
+ if (currentOverlay === "help") {
1510
+ showOverlay("none");
1511
+ renderer.requestRender();
1512
+ return;
1513
+ }
1514
+
1515
+ return;
1516
+ }
1517
+
1518
+ // ── Main view handling (no overlay active) ────────────────────────
1519
+
1520
+ // q quits
1521
+ if (key.name === "q") {
1522
+ if (graphFullscreen) {
1523
+ toggleGraph();
1524
+ return;
1525
+ }
1526
+ saveSession({ mode: currentMode, query: currentQueryInput, sort: currentSort, limit: currentLimit, trendingTab });
1527
+ cleanup();
1528
+ return;
1529
+ }
1530
+
1531
+ // Esc exits graph mode
1532
+ if (key.name === "escape" && graphFullscreen) {
1533
+ toggleGraph();
1534
+ return;
1535
+ }
1536
+
1537
+ // Space opens leader menu
1538
+ if (key.name === "space") {
1539
+ showLeaderMenu();
1540
+ return;
1541
+ }
1542
+
1543
+ // '?' / Ctrl+H toggle help overlay (fast path)
1544
+ if (key.name === "?" || (key.name === "h" && key.ctrl)) {
1545
+ showOverlay("help");
1546
+ return;
1547
+ }
1548
+
1549
+ // Tab auto-completes qualifiers in search input
1550
+ if (key.name === "tab") {
1551
+ if (searchBox.visible) {
1552
+ const val = searchInput.value;
1553
+ const lastSpace = val.lastIndexOf(" ");
1554
+ const currentWord = lastSpace >= 0 ? val.slice(lastSpace + 1) : val;
1555
+ const suggestions = suggestFor(currentWord);
1556
+ if (suggestions.length > 0) {
1557
+ const newVal = lastSpace >= 0
1558
+ ? val.slice(0, lastSpace + 1) + suggestions[0]
1559
+ : suggestions[0];
1560
+ searchInput.value = newVal;
1561
+ renderer.requestRender();
1562
+ }
1563
+ }
1564
+ return;
1565
+ }
1566
+
1567
+ // '/' focuses search
1568
+ if (key.name === "/") {
1569
+ searchInput.focus();
1570
+ showSearchMode();
1571
+ renderer.requestRender();
1572
+ return;
1573
+ }
1574
+
1575
+ // Number keys 1-5 switch trending tabs (only in trending mode)
1576
+ if (/^[1-5]$/.test(key.name)) {
1577
+ if (currentMode === "trending") {
1578
+ const idx = parseInt(key.name) - 1;
1579
+ if (idx >= 0 && idx < TAB_NAMES.length) {
1580
+ trendingTab = TAB_NAMES[idx];
1581
+ loadTrending();
1582
+ }
1583
+ }
1584
+ return;
1585
+ }
1586
+
1587
+ // Left/right arrows (and h/l) switch trending tabs
1588
+ if (key.name === "left" || key.name === "h") {
1589
+ if (currentMode === "trending") {
1590
+ const idx = TAB_NAMES.indexOf(trendingTab);
1591
+ if (idx > 0) { trendingTab = TAB_NAMES[idx - 1]; loadTrending(); }
1592
+ }
1593
+ return;
1594
+ }
1595
+ if (key.name === "right" || key.name === "l") {
1596
+ if (currentMode === "trending") {
1597
+ const idx = TAB_NAMES.indexOf(trendingTab);
1598
+ if (idx < TAB_NAMES.length - 1) { trendingTab = TAB_NAMES[idx + 1]; loadTrending(); }
1599
+ }
1600
+ return;
1601
+ }
1602
+
1603
+ // PageDown — next page (search mode)
1604
+ if (key.name === "pagedown") {
1605
+ if (currentMode === "search" && currentQueryInput) {
1606
+ currentPage++;
1607
+ doSearch(currentQueryInput, true);
1608
+ }
1609
+ return;
1610
+ }
1611
+
1612
+ // PageUp — scroll to top of results
1613
+ if (key.name === "pageup") {
1614
+ if (currentRepos.length > 0) {
1615
+ resultsSelect.setSelectedIndex(0);
1616
+ updateDetail(currentRepos[0]);
1617
+ renderer.requestRender();
1618
+ }
1619
+ return;
1620
+ }
1621
+ });
1622
+
1623
+ // ── Start ──────────────────────────────────────────────────────────
1624
+ renderer.start();
1625
+ if (currentMode === "trending") {
1626
+ loadTrending();
1627
+ } else {
1628
+ searchInput.value = currentQueryInput;
1629
+ if (currentQueryInput) {
1630
+ doSearch(currentQueryInput);
1631
+ } else {
1632
+ showSearchMode();
1633
+ }
1634
+ searchInput.focus();
1635
+ }
1636
+ renderer.requestRender();
1637
+ }
1638
+
1639
+ // ── Utilities ─────────────────────────────────────────────────────────
1640
+
1641
+ function formatResultLine(repo: Repo): string {
1642
+ const stars = formatStars(repo.stars);
1643
+ const lang = (repo.language ?? "?").padEnd(12).slice(0, 12);
1644
+ return `${repo.fullName} ${stars}`;
1645
+ }
1646
+
1647
+ function formatStars(n: number): string {
1648
+ if (n >= 1000) return `★ ${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
1649
+ return `★ ${n}`;
1650
+ }
1651
+
1652
+ function formatToolbar(sort: SortStrategy, limit: number): string {
1653
+ const sortLabel = SORT_MODES.find((m) => m.key === sort)?.label ?? sort;
1654
+ return ` sort: ${sortLabel} limit: ${limit}`;
1655
+ }
1656
+
1657
+ function cleanup(): void {
1658
+ try { rotateHistory(); } catch { /* non-critical */ }
1659
+ process.exit(0);
1660
+ }
1661
+
1662
+ // ── Auto-run ──────────────────────────────────────────────────────────
1663
+ // Only launch when run directly (bun run src/tui.ts), not when bundled
1664
+ // into cli.js or imported as a module.
1665
+ if (import.meta.main && !process.env.GHFIND_BUNDLED) {
1666
+ launchBrowser();
1667
+ }