github-search-cli 9.4.2 → 9.4.4

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +38 -4
  2. package/README.md +8 -3
  3. package/bin/ghfind.js +98 -89
  4. package/completions/ghfind.bash +1 -1
  5. package/completions/ghfind.fish +3 -1
  6. package/completions/ghfind.zsh +6 -0
  7. package/dist/{chunk-IVUU2KGR.js → chunk-GYIL4S5X.js} +4 -1
  8. package/dist/cli.js +761 -342
  9. package/dist/{config-GNZOQRTH.js → config-OCIPF2IF.js} +1 -1
  10. package/package.json +6 -3
  11. package/release-notes/9.3.0.md +32 -29
  12. package/scripts/build.ts +34 -19
  13. package/scripts/generate-svg-demo.js +218 -168
  14. package/scripts/postinstall.js +176 -176
  15. package/src/bookmarks.ts +69 -69
  16. package/src/cli.ts +17 -1
  17. package/src/compare.ts +69 -47
  18. package/src/config.ts +70 -65
  19. package/src/deepdive.ts +149 -118
  20. package/src/doctor.ts +287 -287
  21. package/src/errors.ts +62 -59
  22. package/src/explore.ts +34 -29
  23. package/src/export.ts +73 -63
  24. package/src/help.ts +157 -157
  25. package/src/history.ts +69 -55
  26. package/src/init.ts +60 -55
  27. package/src/landing.ts +106 -104
  28. package/src/login.ts +186 -0
  29. package/src/markdown-render.ts +1 -6
  30. package/src/notifications.ts +75 -59
  31. package/src/open-url.ts +16 -16
  32. package/src/org.ts +43 -15
  33. package/src/output.ts +153 -137
  34. package/src/package-browser.ts +317 -305
  35. package/src/package.ts +149 -137
  36. package/src/pipe.ts +60 -54
  37. package/src/releases.ts +182 -144
  38. package/src/saved-searches.ts +72 -50
  39. package/src/search-state.ts +283 -229
  40. package/src/search.ts +678 -653
  41. package/src/session.ts +26 -24
  42. package/src/share.ts +61 -54
  43. package/src/status.ts +68 -61
  44. package/src/storage.ts +136 -106
  45. package/src/themes.ts +259 -116
  46. package/src/tips.ts +42 -42
  47. package/src/toast.ts +70 -69
  48. package/src/trending-parser.ts +104 -0
  49. package/src/trending.ts +481 -462
  50. package/src/tui.ts +149 -153
  51. package/src/types.ts +160 -158
  52. package/src/watch.ts +95 -74
package/src/trending.ts CHANGED
@@ -1,462 +1,481 @@
1
- #!/usr/bin/env node
2
- /** trending.ts — "Trending Repositories" view for the GitHub repo browser CLI. */
3
- import {
4
- createCliRenderer,
5
- BoxRenderable,
6
- TextRenderable,
7
- ScrollBoxRenderable,
8
- StyledText,
9
- bold,
10
- fg,
11
- dim,
12
- t,
13
- } from "@opentui/core";
14
- import type { Repo, ParsedQuery } from "./types";
15
- import { openUrl } from "./open-url";
16
- import { SearchModule, TrendingAdapter } from "./search";
17
- // ─── Tokyo Night palette ──────────────────────────────────────────────
18
- const C = {
19
- bg: "#1a1b26",
20
- surface: "#16161e",
21
- text: "#a9b1d6",
22
- muted: "#565f89",
23
- cyan: "#7dcfff",
24
- purple: "#bb9af7",
25
- green: "#9ece6a",
26
- gold: "#e0af68",
27
- orange: "#ff9e64",
28
- red: "#f7768e",
29
- blue: "#7aa2f7",
30
- lightBlue: "#b4f9f8",
31
- selectionBg: "#3b6ce8",
32
- selectionText: "#c0caf5",
33
- border: "#363b54",
34
- descText: "#6c7086", // lighter muted for descriptions
35
- rankBg: "#2a2e42",
36
- rankTop1: "#e0af68", // gold
37
- rankTop2: "#9ece6a", // silver-ish green
38
- rankTop3: "#ff9e64", // bronze-ish orange
39
- rankRest: "#565f89", // muted gray
40
- };
41
- // ─── Language → color mapping ─────────────────────────────────────────
42
- const LANG_COLORS: Record<string, string> = {
43
- Rust: "#e0af68",
44
- TypeScript: "#7dcfff",
45
- JavaScript: "#e0d068",
46
- Python: "#7aa2f7",
47
- Go: "#73daca",
48
- Java: "#f7768e",
49
- Ruby: "#f7768e",
50
- C: "#f7768e",
51
- "C++": "#f7768e",
52
- "C#": "#f7768e",
53
- Shell: "#9ece6a",
54
- Zig: "#e0af68",
55
- Lua: "#7dcfff",
56
- Haskell: "#bb9af7",
57
- Elixir: "#bb9af7",
58
- Dart: "#73daca",
59
- Swift: "#f7768e",
60
- Kotlin: "#7aa2f7",
61
- };
62
- function langColor(lang: string): string {
63
- return LANG_COLORS[lang] ?? C.muted;
64
- }
65
- function rankColor(rank: number): string {
66
- if (rank === 1) return C.rankTop1;
67
- if (rank === 2) return C.rankTop2;
68
- if (rank === 3) return C.rankTop3;
69
- return C.rankRest;
70
- }
71
- // ─── Tab definitions ──────────────────────────────────────────────────
72
- export const TAB_NAMES = ["Today", "This Week", "This Month"] as const;
73
- export type TabName = (typeof TAB_NAMES)[number];
74
- /** Map TabName to github.com/trending?since= param */
75
- export function tabSince(tab: TabName): "daily" | "weekly" | "monthly" {
76
- switch (tab) {
77
- case "Today": return "daily";
78
- case "This Week": return "weekly";
79
- default: return "monthly";
80
- }
81
- }
82
- // ─── Number formatting ────────────────────────────────────────────────
83
- /** 1234 → "1.2k", 1234567 → "1.2M", 999999 → "1M" */
84
- export function fmtStars(n: number): string {
85
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
86
- if (n >= 999_500) return "1M"; // ponytail: round 999500+ up before showing 1000k
87
- if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
88
- return String(n);
89
- }
90
- /** Format signed number: 1204 → "+1.2k" */
91
- export function fmtSigned(n: number): string {
92
- const abs = fmtStars(Math.abs(n));
93
- return n >= 0 ? `+${abs}` : `-${abs}`;
94
- }
95
- // ─── Row formatting ───────────────────────────────────────────────────
96
- /**
97
- * Format one repo as a StyledText for per-chunk coloring.
98
- *
99
- * Layout (two lines):
100
- * "[01] owner/name ● Rust ★ 89.2k ▲ +235 today"
101
- * " Build smaller, faster desktop apps."
102
- *
103
- * Uses OpenTUI's StyledText + bold/fg/dim helpers for per-element colors:
104
- * - Rank badge: colored background via bracket style, rank color
105
- * - owner/name: bold white
106
- * - Language dot: language-specific color
107
- * - Stars: gold
108
- * - Growth: green bold
109
- * - Description: dim muted gray
110
- */
111
- export function formatRepoLine(repo: Repo, rank: number): StyledText {
112
- const rankStr = String(rank).padStart(2, "0");
113
- const full = `${repo.owner}/${repo.name}`;
114
- const lc = langColor(repo.language ?? "");
115
- const rc = rankColor(rank);
116
- const star = "★";
117
- const arrow = repo.score > 0 ? "▲" : repo.score < 0 ? "▼" : "—";
118
- const growth = `${arrow} ${fmtSigned(repo.score)} today`;
119
- const desc = (repo.description ?? "").length > 60
120
- ? (repo.description ?? "").slice(0, 57) + "..."
121
- : (repo.description ?? "");
122
- const line1 = t`${dim(fg(C.rankBg)(`[${rankStr}]`))} ${bold(fg("#c0caf5")(full))} ${fg(lc)(`● ${repo.language}`)} ${fg(C.gold)(`${star} ${fmtStars(repo.stars)}`)} ${bold(fg(C.green)(growth))}`;
123
- const line2 = t` ${dim(fg(C.muted)(desc))}`;
124
- const chunks = [
125
- ...line1.chunks,
126
- { text: "\n", __isChunk: true as const, fg: C.text },
127
- ...line2.chunks,
128
- ];
129
- return new StyledText(chunks);
130
- }
131
- // ─── Tab query builder ────────────────────────────────────────────────
132
- /**
133
- * Build a GitHub search query string for the given time range tab.
134
- *
135
- * Examples:
136
- * "Today" → "created:>2026-07-09 sort:stars"
137
- * "This Week" → "created:>2026-07-03 sort:stars"
138
- * "All Time" → "sort:stars"
139
- */
140
- export function getTrendingQuery(tab: TabName): string {
141
- const now = Date.now();
142
- const msPerDay = 86_400_000;
143
- const ranges: Record<TabName, number> = {
144
- Today: 1,
145
- "This Week": 7,
146
- "This Month": 30,
147
- };
148
- const days = ranges[tab];
149
- if (days === 0) return "sort:stars";
150
- const d = new Date(now - days * msPerDay);
151
- return `created:>${d.toISOString().slice(0, 10)} sort:stars`;
152
- }
153
- // ─── Main TUI entry ───────────────────────────────────────────────────
154
- export async function launchTrending(): Promise<void> {
155
- let renderer;
156
- try {
157
- renderer = await createCliRenderer();
158
- } catch (err) {
159
- console.error(
160
- "Failed to start the interactive browser.\n",
161
- `Reason: ${err instanceof Error ? err.message : String(err)}\n`,
162
- );
163
- process.exit(1);
164
- return;
165
- }
166
- const root = renderer.root;
167
- root.flexDirection = "column";
168
- root.backgroundColor = C.bg;
169
- // ── State ──
170
- let selectedTab = 1; // "This Week" default
171
- let currentPeriod = "this week";
172
- let repos: Repo[] = [];
173
- let selectedRepoIdx = 0;
174
- let isLoading = false;
175
- // ── Fetch from github.com/trending ──
176
- async function loadTab(index: number) {
177
- if (index < 0 || index >= TAB_NAMES.length) return;
178
- selectedTab = index;
179
- const since = tabSince(TAB_NAMES[index]);
180
- currentPeriod = since === "daily" ? "today" : since === "weekly" ? "this week" : "this month";
181
- isLoading = true;
182
- for (const rb of rowBoxes) scrollBox.remove(rb);
183
- rowBoxes.length = 0;
184
- rowTexts.length = 0;
185
- const loadingText = new TextRenderable(renderer, {
186
- content: t`${dim(fg(C.muted)(` Loading trending repos...`))}`,
187
- backgroundColor: C.bg,
188
- height: 1,
189
- });
190
- scrollBox.add(loadingText);
191
- renderer.requestRender();
192
- try {
193
- const searchModule = new SearchModule(new TrendingAdapter());
194
- const parsed: ParsedQuery = { keywords: [], qualifiers: [], raw: "trending" };
195
- const response = await searchModule.search(parsed, {
196
- limit: 25,
197
- sort: "stars",
198
- json: false,
199
- verbose: false,
200
- trendingSince: tabSince(TAB_NAMES[index]),
201
- });
202
- scrollBox.remove(loadingText);
203
- repos = response.repos;
204
- selectedRepoIdx = 0;
205
- rebuildList();
206
- } catch (err) {
207
- scrollBox.remove(loadingText);
208
- const msg = err instanceof Error ? err.message : String(err);
209
- const errText = new TextRenderable(renderer, {
210
- content: t`${fg(C.red)(` Error: ${msg.slice(0, 70)}`)}`,
211
- backgroundColor: C.bg,
212
- height: 1,
213
- });
214
- scrollBox.add(errText);
215
- }
216
- isLoading = false;
217
- renderTabBar();
218
- renderer.requestRender();
219
- }
220
- // ── Switch tab ──
221
- function switchTab(index: number) {
222
- if (index < 0 || index >= TAB_NAMES.length || index === selectedTab) return;
223
- loadTab(index);
224
- }
225
- // ── Outer frame ──
226
- const outerBox = new BoxRenderable(renderer, {
227
- flexGrow: 1,
228
- flexDirection: "column",
229
- backgroundColor: C.bg,
230
- border: true,
231
- borderColor: C.border,
232
- borderStyle: "rounded",
233
- paddingX: 0,
234
- paddingY: 0,
235
- });
236
- // ── Header bar ──
237
- const headerBox = new BoxRenderable(renderer, {
238
- height: 1,
239
- backgroundColor: C.bg,
240
- paddingX: 1,
241
- });
242
- const headerText = new TextRenderable(renderer, {
243
- content: t`${bold(fg(C.purple)("⟠ tulipsearch"))}${fg(C.muted)(" — Trending")}`,
244
- height: 1,
245
- });
246
- headerBox.add(headerText);
247
- outerBox.add(headerBox);
248
- // ── Tab bar ──
249
- const tabBox = new BoxRenderable(renderer, {
250
- flexDirection: "row",
251
- height: 1,
252
- backgroundColor: C.bg,
253
- paddingX: 1,
254
- });
255
- const tabTexts: TextRenderable[] = [];
256
- const underlineBox = new BoxRenderable(renderer, {
257
- height: 1,
258
- backgroundColor: C.bg,
259
- paddingX: 1,
260
- });
261
- function tabWidth(name: string): number {
262
- return name.length + 2; // " name " padding
263
- }
264
- function renderTabBar() {
265
- for (const t of tabTexts) tabBox.remove(t);
266
- tabTexts.length = 0;
267
- TAB_NAMES.forEach((name, i) => {
268
- const isActive = i === selectedTab;
269
- const label = ` ${name} `;
270
- const tt = new TextRenderable(renderer, {
271
- content: isActive ? t`${bold(fg(C.bg)(label))}` : t`${fg(C.muted)(label)}`,
272
- fg: isActive ? C.bg : C.muted,
273
- bg: isActive ? C.cyan : C.bg,
274
- height: 1,
275
- });
276
- tabTexts.push(tt);
277
- tabBox.add(tt);
278
- if (i < TAB_NAMES.length - 1) {
279
- const sp = new TextRenderable(renderer, {
280
- content: " ",
281
- fg: C.muted,
282
- bg: C.bg,
283
- height: 1,
284
- });
285
- tabTexts.push(sp);
286
- tabBox.add(sp);
287
- }
288
- });
289
- // ── cyan underline bar under active tab ──
290
- let offset = 1; // paddingX
291
- for (let i = 0; i < selectedTab; i++) {
292
- offset += tabWidth(TAB_NAMES[i]) + 2; // tab + separator
293
- }
294
- const w = tabWidth(TAB_NAMES[selectedTab]);
295
- const dots = "┉".repeat(w);
296
- underlineBox.remove(underlineText);
297
- underlineText.content = t`${fg(C.cyan)(dots)}`;
298
- underlineText.left = offset;
299
- }
300
- const underlineText = new TextRenderable(renderer, {
301
- content: "",
302
- fg: C.cyan,
303
- bg: C.bg,
304
- height: 1,
305
- });
306
- underlineBox.add(underlineText);
307
- renderTabBar();
308
- outerBox.add(tabBox);
309
- outerBox.add(underlineBox);
310
- const divider = new TextRenderable(renderer, {
311
- content: "─".repeat(80),
312
- color: C.border,
313
- height: 1,
314
- backgroundColor: C.bg,
315
- });
316
- outerBox.add(divider);
317
- // ── Repo list (scrollable) ──
318
- const scrollBox = new ScrollBoxRenderable(renderer, {
319
- flexGrow: 1,
320
- backgroundColor: C.bg,
321
- scrollY: true,
322
- scrollX: false,
323
- viewportCulling: true,
324
- });
325
- const rowBoxes: BoxRenderable[] = [];
326
- const rowTexts: TextRenderable[] = [];
327
- function rebuildList() {
328
- for (const rb of rowBoxes) scrollBox.remove(rb);
329
- rowBoxes.length = 0;
330
- rowTexts.length = 0;
331
- repos.forEach((r, i) => {
332
- const isEven = i % 2 === 0;
333
- const isSelected = i === selectedRepoIdx;
334
- const rowBg = isSelected ? C.selectionBg : (isEven ? C.bg : C.surface);
335
- const rank = i + 1;
336
- const rc = rankColor(rank);
337
- const lc = langColor(r.language ?? "");
338
- const desc = (r.description ?? "").length > 55
339
- ? (r.description ?? "").slice(0, 52) + "..."
340
- : (r.description ?? "");
341
- const arrow = r.score > 0 ? "▲" : r.score < 0 ? "▼" : "—";
342
- const growthStr = `${arrow} ${fmtStars(Math.abs(r.score))} ${currentPeriod}`;
343
- const rankStr = `[${String(rank).padStart(2, "0")}]`;
344
- const nameStr = `${r.owner}/${r.name}`.padEnd(30).slice(0, 30);
345
- const langStr = `● ${r.language || "—"}`.padEnd(14).slice(0, 14);
346
- const starsStr = `★ ${fmtStars(r.stars)}`.padEnd(12).slice(0, 12);
347
- const rankText = new TextRenderable(renderer, {
348
- content: t`${bold(fg(rc)(rankStr))}`,
349
- backgroundColor: C.rankBg,
350
- color: rc,
351
- height: 1,
352
- });
353
- const descStr = desc.length > 60 ? desc.slice(0, 57) + "..." : desc;
354
- const pad = " ";
355
- const line1 = t`${bold(fg("#c0caf5")(nameStr))}${fg(lc)(langStr)}${fg(C.muted)(starsStr)}${bold(fg(C.green)(growthStr))}`;
356
- const line2 = t`${fg(C.descText)(`${pad}${descStr}`)}`;
357
- const chunks = [
358
- ...line1.chunks,
359
- { text: "\n", __isChunk: true as const, fg: C.text },
360
- ...line2.chunks,
361
- ];
362
- const rowText = new TextRenderable(renderer, {
363
- content: new StyledText(chunks),
364
- backgroundColor: rowBg,
365
- height: 2,
366
- });
367
- const rowBox = new BoxRenderable(renderer, {
368
- flexDirection: "row",
369
- backgroundColor: rowBg,
370
- });
371
- rowBox.add(rankText);
372
- rowBox.add(rowText);
373
- rowBoxes.push(rowBox);
374
- rowTexts.push(rowText);
375
- scrollBox.add(rowBox);
376
- });
377
- }
378
- outerBox.add(scrollBox);
379
- // ── Footer bar ──
380
- const footerBox = new BoxRenderable(renderer, {
381
- height: 1,
382
- backgroundColor: C.bg,
383
- paddingX: 1,
384
- });
385
- const footerText = new TextRenderable(renderer, {
386
- content: " ↑↓ navigate ↵ open / search 1-3 tab q quit r refresh",
387
- color: C.muted,
388
- height: 1,
389
- });
390
- footerBox.add(footerText);
391
- outerBox.add(footerBox);
392
- root.add(outerBox);
393
- // ── Helpers ──
394
- function openSelectedRepo() {
395
- if (selectedRepoIdx < 0 || selectedRepoIdx >= repos.length) return;
396
- const r = repos[selectedRepoIdx];
397
- openUrl(`https://github.com/${r.owner}/${r.name}`);
398
- }
399
- function moveSelection(delta: number) {
400
- const oldIdx = selectedRepoIdx;
401
- const newIdx = Math.max(0, Math.min(repos.length - 1, oldIdx + delta));
402
- if (newIdx === oldIdx) return;
403
- selectedRepoIdx = newIdx;
404
- const setBg = (idx: number, selected: boolean) => {
405
- if (idx < 0 || idx >= rowBoxes.length) return;
406
- const bg = selected ? C.selectionBg : (idx % 2 === 0 ? C.bg : C.surface);
407
- rowBoxes[idx].backgroundColor = bg;
408
- if (rowTexts[idx]) rowTexts[idx].backgroundColor = bg;
409
- };
410
- setBg(oldIdx, false);
411
- setBg(newIdx, true);
412
- renderer.requestRender();
413
- }
414
- // ── Global key events ──
415
- renderer.keyInput.on("keypress", (key) => {
416
- const name = key.name || "";
417
- if (name === "q") {
418
- process.exit(0);
419
- return;
420
- }
421
- if (/^[1-3]$/.test(name)) {
422
- switchTab(parseInt(name, 10) - 1);
423
- return;
424
- }
425
- if (name === "left" || name === "h") {
426
- switchTab(selectedTab - 1);
427
- return;
428
- }
429
- if (name === "right" || name === "l") {
430
- switchTab(selectedTab + 1);
431
- return;
432
- }
433
- if (name === "up" || name === "k") {
434
- moveSelection(-1);
435
- return;
436
- }
437
- if (name === "down" || name === "j") {
438
- moveSelection(1);
439
- return;
440
- }
441
- if (name === "enter" || name === "return" || name === "o") {
442
- openSelectedRepo();
443
- return;
444
- }
445
- if (name === "/") {
446
- moveSelection(-selectedRepoIdx);
447
- return;
448
- }
449
- if (name === "r") {
450
- loadTab(selectedTab);
451
- return;
452
- }
453
- });
454
- // ── Start ──
455
- renderer.start();
456
- renderer.requestRender();
457
- loadTab(selectedTab);
458
- }
459
- // ── Auto-run ──────────────────────────────────────────────────────────
460
- if (import.meta.main && !process.env.GHFIND_BUNDLED) {
461
- launchTrending();
462
- }
1
+ #!/usr/bin/env node
2
+ /** trending.ts — "Trending Repositories" view for the GitHub repo browser CLI. */
3
+ import {
4
+ createCliRenderer,
5
+ BoxRenderable,
6
+ TextRenderable,
7
+ ScrollBoxRenderable,
8
+ StyledText,
9
+ bold,
10
+ fg,
11
+ dim,
12
+ t,
13
+ } from "@opentui/core";
14
+ import type { CliRenderer } from "@opentui/core";
15
+ import type { Repo, ParsedQuery } from "./types";
16
+ import { openUrl } from "./open-url";
17
+ import { SearchModule, TrendingAdapter } from "./search";
18
+ import { detectTerminalBackground, deriveSurfaceLayers } from "./themes";
19
+ // ─── Tokyo Night palette ──────────────────────────────────────────────
20
+ const C = {
21
+ bg: "#1a1b26",
22
+ surface: "#16161e",
23
+ text: "#a9b1d6",
24
+ muted: "#565f89",
25
+ cyan: "#7dcfff",
26
+ purple: "#bb9af7",
27
+ green: "#9ece6a",
28
+ gold: "#e0af68",
29
+ orange: "#ff9e64",
30
+ red: "#f7768e",
31
+ blue: "#7aa2f7",
32
+ lightBlue: "#b4f9f8",
33
+ selectionBg: "#3b6ce8",
34
+ selectionText: "#c0caf5",
35
+ border: "#363b54",
36
+ descText: "#6c7086", // lighter muted for descriptions
37
+ rankBg: "#2a2e42",
38
+ rankTop1: "#e0af68", // gold
39
+ rankTop2: "#9ece6a", // silver-ish green
40
+ rankTop3: "#ff9e64", // bronze-ish orange
41
+ rankRest: "#565f89", // muted gray
42
+ };
43
+ // ─── Language → color mapping ─────────────────────────────────────────
44
+ const LANG_COLORS: Record<string, string> = {
45
+ Rust: "#e0af68",
46
+ TypeScript: "#7dcfff",
47
+ JavaScript: "#e0d068",
48
+ Python: "#7aa2f7",
49
+ Go: "#73daca",
50
+ Java: "#f7768e",
51
+ Ruby: "#f7768e",
52
+ C: "#f7768e",
53
+ "C++": "#f7768e",
54
+ "C#": "#f7768e",
55
+ Shell: "#9ece6a",
56
+ Zig: "#e0af68",
57
+ Lua: "#7dcfff",
58
+ Haskell: "#bb9af7",
59
+ Elixir: "#bb9af7",
60
+ Dart: "#73daca",
61
+ Swift: "#f7768e",
62
+ Kotlin: "#7aa2f7",
63
+ };
64
+ function langColor(lang: string): string {
65
+ return LANG_COLORS[lang] ?? C.muted;
66
+ }
67
+ function rankColor(rank: number): string {
68
+ if (rank === 1) return C.rankTop1;
69
+ if (rank === 2) return C.rankTop2;
70
+ if (rank === 3) return C.rankTop3;
71
+ return C.rankRest;
72
+ }
73
+ // ─── Tab definitions ──────────────────────────────────────────────────
74
+ export const TAB_NAMES = ["Today", "This Week", "This Month"] as const;
75
+ export type TabName = (typeof TAB_NAMES)[number];
76
+ /** Map TabName to github.com/trending?since= param */
77
+ export function tabSince(tab: TabName): "daily" | "weekly" | "monthly" {
78
+ switch (tab) {
79
+ case "Today":
80
+ return "daily";
81
+ case "This Week":
82
+ return "weekly";
83
+ default:
84
+ return "monthly";
85
+ }
86
+ }
87
+ // ─── Number formatting ────────────────────────────────────────────────
88
+ /** 1234 → "1.2k", 1234567 → "1.2M", 999999 → "1M" */
89
+ export function fmtStars(n: number): string {
90
+ if (n >= 1_000_000)
91
+ return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
92
+ if (n >= 999_500) return "1M"; // ponytail: round 999500+ up before showing 1000k
93
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
94
+ return String(n);
95
+ }
96
+ /** Format signed number: 1204 → "+1.2k" */
97
+ export function fmtSigned(n: number): string {
98
+ const abs = fmtStars(Math.abs(n));
99
+ return n >= 0 ? `+${abs}` : `-${abs}`;
100
+ }
101
+ // ─── Row formatting ───────────────────────────────────────────────────
102
+ /**
103
+ * Format one repo as a StyledText for per-chunk coloring.
104
+ *
105
+ * Layout (two lines):
106
+ * "[01] owner/name ● Rust ★ 89.2k ▲ +235 today"
107
+ * " Build smaller, faster desktop apps."
108
+ *
109
+ * Uses OpenTUI's StyledText + bold/fg/dim helpers for per-element colors:
110
+ * - Rank badge: colored background via bracket style, rank color
111
+ * - owner/name: bold white
112
+ * - Language dot: language-specific color
113
+ * - Stars: gold
114
+ * - Growth: green bold
115
+ * - Description: dim muted gray
116
+ */
117
+ export function formatRepoLine(repo: Repo, rank: number): StyledText {
118
+ const rankStr = String(rank).padStart(2, "0");
119
+ const full = `${repo.owner}/${repo.name}`;
120
+ const lc = langColor(repo.language ?? "");
121
+ const rc = rankColor(rank);
122
+ const star = "★";
123
+ const arrow = repo.score > 0 ? "▲" : repo.score < 0 ? "▼" : "—";
124
+ const growth = `${arrow} ${fmtSigned(repo.score)} today`;
125
+ const desc =
126
+ (repo.description ?? "").length > 60
127
+ ? (repo.description ?? "").slice(0, 57) + "..."
128
+ : (repo.description ?? "");
129
+ const line1 = t`${dim(fg(C.rankBg)(`[${rankStr}]`))} ${bold(fg("#c0caf5")(full))} ${fg(lc)(`● ${repo.language}`)} ${fg(C.gold)(`${star} ${fmtStars(repo.stars)}`)} ${bold(fg(C.green)(growth))}`;
130
+ const line2 = t` ${dim(fg(C.muted)(desc))}`;
131
+ const chunks = [...line1.chunks, fg(C.text)("\n"), ...line2.chunks];
132
+ return new StyledText(chunks);
133
+ }
134
+ // ─── Tab query builder ────────────────────────────────────────────────
135
+ /**
136
+ * Build a GitHub search query string for the given time range tab.
137
+ *
138
+ * Examples:
139
+ * "Today" → "created:>2026-07-09 sort:stars"
140
+ * "This Week" → "created:>2026-07-03 sort:stars"
141
+ * "All Time" → "sort:stars"
142
+ */
143
+ export function getTrendingQuery(tab: TabName): string {
144
+ const now = Date.now();
145
+ const msPerDay = 86_400_000;
146
+ const ranges: Record<TabName, number> = {
147
+ Today: 1,
148
+ "This Week": 7,
149
+ "This Month": 30,
150
+ };
151
+ const days = ranges[tab];
152
+ if (days === 0) return "sort:stars";
153
+ const d = new Date(now - days * msPerDay);
154
+ return `created:>${d.toISOString().slice(0, 10)} sort:stars`;
155
+ }
156
+ // ─── Main TUI entry ───────────────────────────────────────────────────
157
+ export async function launchTrending(): Promise<void> {
158
+ // Match the app backdrop to the terminal's own background (OSC 11), before
159
+ // the renderer takes over stdin. Falls back to the Tokyo Night bg. Surface
160
+ // and border layers are blended toward that background for a seamless look.
161
+ const terminalBg = await detectTerminalBackground();
162
+ if (terminalBg) {
163
+ C.bg = terminalBg;
164
+ Object.assign(C, deriveSurfaceLayers(terminalBg));
165
+ }
166
+ let renderer: CliRenderer;
167
+ try {
168
+ renderer = await createCliRenderer();
169
+ } catch (err) {
170
+ console.error(
171
+ "Failed to start the interactive browser.\n",
172
+ `Reason: ${err instanceof Error ? err.message : String(err)}\n`,
173
+ );
174
+ process.exit(1);
175
+ return;
176
+ }
177
+ const root = renderer.root;
178
+ root.flexDirection = "column";
179
+ renderer.setBackgroundColor(C.bg);
180
+ // ── State ──
181
+ let selectedTab = 1; // "This Week" default
182
+ let currentPeriod = "this week";
183
+ let repos: Repo[] = [];
184
+ let selectedRepoIdx = 0;
185
+ let isLoading = false;
186
+ // ── Fetch from github.com/trending ──
187
+ async function loadTab(index: number) {
188
+ if (index < 0 || index >= TAB_NAMES.length) return;
189
+ selectedTab = index;
190
+ const since = tabSince(TAB_NAMES[index]);
191
+ currentPeriod =
192
+ since === "daily"
193
+ ? "today"
194
+ : since === "weekly"
195
+ ? "this week"
196
+ : "this month";
197
+ isLoading = true;
198
+ for (const rb of rowBoxes) scrollBox.remove(rb);
199
+ rowBoxes.length = 0;
200
+ rowTexts.length = 0;
201
+ const loadingText = new TextRenderable(renderer, {
202
+ content: t`${dim(fg(C.muted)(` Loading trending repos...`))}`,
203
+ bg: C.bg,
204
+ height: 1,
205
+ });
206
+ scrollBox.add(loadingText);
207
+ renderer.requestRender();
208
+ try {
209
+ const searchModule = new SearchModule(new TrendingAdapter());
210
+ const parsed: ParsedQuery = {
211
+ keywords: [],
212
+ qualifiers: [],
213
+ raw: "trending",
214
+ };
215
+ const response = await searchModule.search(parsed, {
216
+ limit: 25,
217
+ sort: "stars",
218
+ json: false,
219
+ verbose: false,
220
+ trendingSince: tabSince(TAB_NAMES[index]),
221
+ });
222
+ scrollBox.remove(loadingText);
223
+ repos = response.repos;
224
+ selectedRepoIdx = 0;
225
+ rebuildList();
226
+ } catch (err) {
227
+ scrollBox.remove(loadingText);
228
+ const msg = err instanceof Error ? err.message : String(err);
229
+ const errText = new TextRenderable(renderer, {
230
+ content: t`${fg(C.red)(` Error: ${msg.slice(0, 70)}`)}`,
231
+ bg: C.bg,
232
+ height: 1,
233
+ });
234
+ scrollBox.add(errText);
235
+ }
236
+ isLoading = false;
237
+ renderTabBar();
238
+ renderer.requestRender();
239
+ }
240
+ // ── Switch tab ──
241
+ function switchTab(index: number) {
242
+ if (index < 0 || index >= TAB_NAMES.length || index === selectedTab) return;
243
+ loadTab(index);
244
+ }
245
+ // ── Outer frame ──
246
+ const outerBox = new BoxRenderable(renderer, {
247
+ flexGrow: 1,
248
+ flexDirection: "column",
249
+ backgroundColor: C.bg,
250
+ border: true,
251
+ borderColor: C.border,
252
+ borderStyle: "rounded",
253
+ paddingX: 0,
254
+ paddingY: 0,
255
+ });
256
+ // ── Header bar ──
257
+ const headerBox = new BoxRenderable(renderer, {
258
+ height: 1,
259
+ backgroundColor: C.bg,
260
+ paddingX: 1,
261
+ });
262
+ const headerText = new TextRenderable(renderer, {
263
+ content: t`${bold(fg(C.purple)("⟠ tulipsearch"))}${fg(C.muted)(" — Trending")}`,
264
+ height: 1,
265
+ });
266
+ headerBox.add(headerText);
267
+ outerBox.add(headerBox);
268
+ // ── Tab bar ──
269
+ const tabBox = new BoxRenderable(renderer, {
270
+ flexDirection: "row",
271
+ height: 1,
272
+ backgroundColor: C.bg,
273
+ paddingX: 1,
274
+ });
275
+ const tabTexts: TextRenderable[] = [];
276
+ const underlineBox = new BoxRenderable(renderer, {
277
+ height: 1,
278
+ backgroundColor: C.bg,
279
+ paddingX: 1,
280
+ });
281
+ function tabWidth(name: string): number {
282
+ return name.length + 2; // " name " padding
283
+ }
284
+ function renderTabBar() {
285
+ for (const t of tabTexts) tabBox.remove(t);
286
+ tabTexts.length = 0;
287
+ TAB_NAMES.forEach((name, i) => {
288
+ const isActive = i === selectedTab;
289
+ const label = ` ${name} `;
290
+ const tt = new TextRenderable(renderer, {
291
+ content: isActive
292
+ ? t`${bold(fg(C.bg)(label))}`
293
+ : t`${fg(C.muted)(label)}`,
294
+ fg: isActive ? C.bg : C.muted,
295
+ bg: isActive ? C.cyan : C.bg,
296
+ height: 1,
297
+ });
298
+ tabTexts.push(tt);
299
+ tabBox.add(tt);
300
+ if (i < TAB_NAMES.length - 1) {
301
+ const sp = new TextRenderable(renderer, {
302
+ content: " ",
303
+ fg: C.muted,
304
+ bg: C.bg,
305
+ height: 1,
306
+ });
307
+ tabTexts.push(sp);
308
+ tabBox.add(sp);
309
+ }
310
+ });
311
+ // ── cyan underline bar under active tab ──
312
+ let offset = 1; // paddingX
313
+ for (let i = 0; i < selectedTab; i++) {
314
+ offset += tabWidth(TAB_NAMES[i]) + 2; // tab + separator
315
+ }
316
+ const w = tabWidth(TAB_NAMES[selectedTab]);
317
+ const dots = "┉".repeat(w);
318
+ underlineBox.remove(underlineText);
319
+ underlineText.content = t`${fg(C.cyan)(dots)}`;
320
+ underlineText.left = offset;
321
+ }
322
+ const underlineText = new TextRenderable(renderer, {
323
+ content: "",
324
+ fg: C.cyan,
325
+ bg: C.bg,
326
+ height: 1,
327
+ });
328
+ underlineBox.add(underlineText);
329
+ renderTabBar();
330
+ outerBox.add(tabBox);
331
+ outerBox.add(underlineBox);
332
+ const divider = new TextRenderable(renderer, {
333
+ content: "─".repeat(80),
334
+ fg: C.border,
335
+ height: 1,
336
+ bg: C.bg,
337
+ });
338
+ outerBox.add(divider);
339
+ // ── Repo list (scrollable) ──
340
+ const scrollBox = new ScrollBoxRenderable(renderer, {
341
+ flexGrow: 1,
342
+ backgroundColor: C.bg,
343
+ scrollY: true,
344
+ scrollX: false,
345
+ viewportCulling: true,
346
+ });
347
+ const rowBoxes: BoxRenderable[] = [];
348
+ const rowTexts: TextRenderable[] = [];
349
+ function rebuildList() {
350
+ for (const rb of rowBoxes) scrollBox.remove(rb);
351
+ rowBoxes.length = 0;
352
+ rowTexts.length = 0;
353
+ repos.forEach((r, i) => {
354
+ const isEven = i % 2 === 0;
355
+ const isSelected = i === selectedRepoIdx;
356
+ const rowBg = isSelected ? C.selectionBg : isEven ? C.bg : C.surface;
357
+ const rank = i + 1;
358
+ const rc = rankColor(rank);
359
+ const lc = langColor(r.language ?? "");
360
+ const desc =
361
+ (r.description ?? "").length > 55
362
+ ? (r.description ?? "").slice(0, 52) + "..."
363
+ : (r.description ?? "");
364
+ const arrow = r.score > 0 ? "▲" : r.score < 0 ? "▼" : "—";
365
+ const growthStr = `${arrow} ${fmtStars(Math.abs(r.score))} ${currentPeriod}`;
366
+ const rankStr = `[${String(rank).padStart(2, "0")}]`;
367
+ const nameStr = `${r.owner}/${r.name}`.padEnd(30).slice(0, 30);
368
+ const langStr = `● ${r.language || "—"}`.padEnd(14).slice(0, 14);
369
+ const starsStr = `★ ${fmtStars(r.stars)}`.padEnd(12).slice(0, 12);
370
+ const rankText = new TextRenderable(renderer, {
371
+ content: t`${bold(fg(rc)(rankStr))}`,
372
+ bg: C.rankBg,
373
+ fg: rc,
374
+ height: 1,
375
+ });
376
+ const descStr = desc.length > 60 ? desc.slice(0, 57) + "..." : desc;
377
+ const pad = " ";
378
+ const line1 = t`${bold(fg("#c0caf5")(nameStr))}${fg(lc)(langStr)}${fg(C.muted)(starsStr)}${bold(fg(C.green)(growthStr))}`;
379
+ const line2 = t`${fg(C.descText)(`${pad}${descStr}`)}`;
380
+ const chunks = [...line1.chunks, fg(C.text)("\n"), ...line2.chunks];
381
+ const rowText = new TextRenderable(renderer, {
382
+ content: new StyledText(chunks),
383
+ bg: rowBg,
384
+ height: 2,
385
+ });
386
+ const rowBox = new BoxRenderable(renderer, {
387
+ flexDirection: "row",
388
+ backgroundColor: rowBg,
389
+ });
390
+ rowBox.add(rankText);
391
+ rowBox.add(rowText);
392
+ rowBoxes.push(rowBox);
393
+ rowTexts.push(rowText);
394
+ scrollBox.add(rowBox);
395
+ });
396
+ }
397
+ outerBox.add(scrollBox);
398
+ // ── Footer bar ──
399
+ const footerBox = new BoxRenderable(renderer, {
400
+ height: 1,
401
+ backgroundColor: C.bg,
402
+ paddingX: 1,
403
+ });
404
+ const footerText = new TextRenderable(renderer, {
405
+ content: " ↑↓ navigate ↵ open / search 1-3 tab q quit r refresh",
406
+ fg: C.muted,
407
+ height: 1,
408
+ });
409
+ footerBox.add(footerText);
410
+ outerBox.add(footerBox);
411
+ root.add(outerBox);
412
+ // ── Helpers ──
413
+ function openSelectedRepo() {
414
+ if (selectedRepoIdx < 0 || selectedRepoIdx >= repos.length) return;
415
+ const r = repos[selectedRepoIdx];
416
+ openUrl(`https://github.com/${r.owner}/${r.name}`);
417
+ }
418
+ function moveSelection(delta: number) {
419
+ const oldIdx = selectedRepoIdx;
420
+ const newIdx = Math.max(0, Math.min(repos.length - 1, oldIdx + delta));
421
+ if (newIdx === oldIdx) return;
422
+ selectedRepoIdx = newIdx;
423
+ const setBg = (idx: number, selected: boolean) => {
424
+ if (idx < 0 || idx >= rowBoxes.length) return;
425
+ const bg = selected ? C.selectionBg : idx % 2 === 0 ? C.bg : C.surface;
426
+ rowBoxes[idx].backgroundColor = bg;
427
+ if (rowTexts[idx]) rowTexts[idx].bg = bg;
428
+ };
429
+ setBg(oldIdx, false);
430
+ setBg(newIdx, true);
431
+ renderer.requestRender();
432
+ }
433
+ // ── Global key events ──
434
+ renderer.keyInput.on("keypress", (key) => {
435
+ const name = key.name || "";
436
+ if (name === "q") {
437
+ process.exit(0);
438
+ return;
439
+ }
440
+ if (/^[1-3]$/.test(name)) {
441
+ switchTab(parseInt(name, 10) - 1);
442
+ return;
443
+ }
444
+ if (name === "left" || name === "h") {
445
+ switchTab(selectedTab - 1);
446
+ return;
447
+ }
448
+ if (name === "right" || name === "l") {
449
+ switchTab(selectedTab + 1);
450
+ return;
451
+ }
452
+ if (name === "up" || name === "k") {
453
+ moveSelection(-1);
454
+ return;
455
+ }
456
+ if (name === "down" || name === "j") {
457
+ moveSelection(1);
458
+ return;
459
+ }
460
+ if (name === "enter" || name === "return" || name === "o") {
461
+ openSelectedRepo();
462
+ return;
463
+ }
464
+ if (name === "/") {
465
+ moveSelection(-selectedRepoIdx);
466
+ return;
467
+ }
468
+ if (name === "r") {
469
+ loadTab(selectedTab);
470
+ return;
471
+ }
472
+ });
473
+ // ── Start ──
474
+ renderer.start();
475
+ renderer.requestRender();
476
+ loadTab(selectedTab);
477
+ }
478
+ // ── Auto-run ──────────────────────────────────────────────────────────
479
+ if (import.meta.main && !process.env.GHFIND_BUNDLED) {
480
+ launchTrending();
481
+ }