github-search-cli 0.9.0 → 9.1.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
@@ -48,7 +48,7 @@ import {
48
48
  TAB_NAMES,
49
49
  fmtStars,
50
50
  } from "./trending";
51
- import { loadConfig } from "./config";
51
+ import { loadConfig, saveConfig } from "./config";
52
52
  import { buildHelpSections, HELP_KEYS_COLUMN } from "./help";
53
53
  import {
54
54
  SearchCliError,
@@ -81,10 +81,16 @@ import { fetchDeepDive, buildDeepDiveText } from "./deepdive";
81
81
  import { buildComparisonTable } from "./compare";
82
82
  import { fetchTopics, type TopicItem } from "./explore";
83
83
  import { exportToFile, type ExportFormat } from "./output";
84
- import { loadTheme } from "./themes";
84
+ import { listThemes, loadTheme } from "./themes";
85
+ import {
86
+ createPackageSearch,
87
+ sortPackages,
88
+ PACKAGE_SORT_MODES,
89
+ type Package,
90
+ type PackageSortMode,
91
+ } from "./package";
85
92
  import { StatusManager } from "./status";
86
93
  import {
87
- addNotification,
88
94
  getNotifications,
89
95
  dismissNotification,
90
96
  dismissAll,
@@ -114,7 +120,6 @@ const colors = {
114
120
  surfaceAlt: "#525050",
115
121
  text: "#e0e4f0",
116
122
  muted: "#a8a6a6",
117
- blue: "#89b4fa",
118
123
  green: "#a6e3a1",
119
124
  yellow: "#f9e2af",
120
125
  red: "#f38ba8",
@@ -150,10 +155,20 @@ const SORT_MODES: { key: SortStrategy; label: string }[] = [
150
155
  ];
151
156
 
152
157
  // ─── Main ─────────────────────────────────────────────────────────────
153
- export async function launchBrowser(): Promise<void> {
158
+ export async function launchBrowser(theme_override?: string): Promise<void> {
154
159
  let renderer;
155
160
  try {
156
- renderer = await createCliRenderer();
161
+ const isTTY = process.stdout.isTTY && process.stdin.isTTY;
162
+ if (isTTY) {
163
+ renderer = await createCliRenderer();
164
+ } else {
165
+ renderer = await createCliRenderer({
166
+ width: 80,
167
+ height: 24,
168
+ screenMode: "main-screen",
169
+ autoFocus: false,
170
+ });
171
+ }
157
172
  } catch (err) {
158
173
  const isNode = process.versions.bun === undefined;
159
174
  if (isNode) {
@@ -176,6 +191,12 @@ export async function launchBrowser(): Promise<void> {
176
191
 
177
192
  const root = renderer.root;
178
193
  root.flexDirection = "column";
194
+ root.border = true;
195
+ root.borderTop = true;
196
+ root.borderBottom = true;
197
+ root.borderLeft = true;
198
+ root.borderRight = true;
199
+ root.borderColor = colors.borderAccent;
179
200
  root.backgroundColor = colors.bg;
180
201
 
181
202
  // ── Config ──
@@ -183,7 +204,7 @@ export async function launchBrowser(): Promise<void> {
183
204
  const githubToken = config.githubToken || process.env.GITHUB_TOKEN;
184
205
 
185
206
  // Apply theme
186
- const theme = loadTheme(config.theme);
207
+ const theme = loadTheme(theme_override || config.theme);
187
208
  Object.assign(colors, theme);
188
209
 
189
210
  // ── Session restore ──
@@ -195,7 +216,10 @@ export async function launchBrowser(): Promise<void> {
195
216
  let currentLimit = session?.limit ?? config.defaultLimit;
196
217
  let currentQueryInput = session?.query ?? "";
197
218
  let isLoading = false;
198
- let currentMode: "search" | "trending" = session?.mode ?? "search";
219
+ let currentMode: "landing" | "search" | "trending" | "packages" = session?.mode ?? "search";
220
+ let packages: Package[] = [];
221
+ let currentPackageSort: PackageSortMode = "best-match";
222
+ let packageResultsRaw: Package[] = [];
199
223
  let trendingTab: (typeof TAB_NAMES)[number] =
200
224
  (session?.trendingTab as (typeof TAB_NAMES)[number]) ?? "This Week";
201
225
  let trendingPeriod = "this week";
@@ -214,14 +238,14 @@ export async function launchBrowser(): Promise<void> {
214
238
  | "share"
215
239
  | "leader"
216
240
  | "readme"
217
- | "update" = "none";
241
+ | "update"
242
+ | "landing" = "none";
218
243
  let currentPage = 1;
219
244
  let totalCount = 0;
220
245
  let deepDiveActive = false;
221
246
  let compareList: Repo[] = [];
222
247
  let quitArmed = false;
223
248
 
224
- // ── Header bar ──────────────────────────────────────────────────────
225
249
  const header = new TextRenderable(renderer, {
226
250
  content:
227
251
  " ghfind — GitHub repo browser [/]search [Space]menu [\u2192]open [?]help [q]uit",
@@ -272,18 +296,23 @@ export async function launchBrowser(): Promise<void> {
272
296
  }
273
297
  root.add(header);
274
298
  root.add(trendingTabBox);
299
+ // ── Gap between header/tabs and search ────────────────────────────────
300
+ const gapBox = new BoxRenderable(renderer, {
301
+ height: 1,
302
+ visible: true,
303
+ });
304
+ root.add(gapBox);
275
305
 
276
306
  // ── Search input row ────────────────────────────────────────────────
277
307
  const searchBox = new BoxRenderable(renderer, {
278
308
  visible: true,
279
- borderBottom: true,
280
- borderBottomColor: colors.border,
281
- title: " SEARCH ",
309
+ bordered: true,
310
+ borderColor: colors.border,
311
+ title: "",
282
312
  titleColor: colors.blue,
283
313
  width: "100%",
284
314
  flexDirection: "row",
285
- paddingTop: 0,
286
- paddingBottom: 0,
315
+ marginTop: 1,
287
316
  backgroundColor: colors.surface,
288
317
  });
289
318
  const searchInput = new InputRenderable(renderer, {
@@ -324,7 +353,8 @@ export async function launchBrowser(): Promise<void> {
324
353
 
325
354
  // Results pane
326
355
  const resultsBox = new BoxRenderable(renderer, {
327
- borderBottom: true,
356
+ bordered: true,
357
+ borderColor: colors.border,
328
358
  borderBottomColor: colors.border,
329
359
  title: " Results ",
330
360
  titleColor: colors.blue,
@@ -355,7 +385,8 @@ export async function launchBrowser(): Promise<void> {
355
385
 
356
386
  // Detail / Graph pane
357
387
  const detailBox = new BoxRenderable(renderer, {
358
- borderBottom: true,
388
+ bordered: true,
389
+ borderColor: colors.border,
359
390
  borderBottomColor: colors.border,
360
391
  title: " Details ",
361
392
  titleColor: colors.green,
@@ -369,18 +400,171 @@ export async function launchBrowser(): Promise<void> {
369
400
  const detailText = new TextRenderable(renderer, {
370
401
  content: "",
371
402
  color: colors.text,
403
+ wrapMode: "none",
372
404
  });
373
405
  detailBox.add(detailText);
374
406
 
375
407
  body.add(resultsBox);
376
408
  body.add(detailBox);
377
409
  root.add(body);
410
+ // ── Landing screen ────────────────────────────────────────────────
411
+ const landingBox = new BoxRenderable(renderer, {
412
+ visible: false,
413
+ flexGrow: 1,
414
+ flexDirection: "column",
415
+ alignItems: "center",
416
+ justifyContent: "center",
417
+ });
418
+ root.add(landingBox);
419
+ const landingBanner = new TextRenderable(renderer, {
420
+ content: [
421
+ " ██████╗ ██╗ ██╗███████╗██╗███╗ ██╗██████╗ ",
422
+ "██╔════╝ ██║ ██║██╔════╝██║████╗ ██║██╔══██╗",
423
+ "██║ ███╗███████║█████╗ ██║██╔██╗ ██║██║ ██║",
424
+ "██║ ██║██╔══██║██╔══╝ ██║██║╚██╗██║██║ ██║",
425
+ "╚██████╔╝██║ ██║██║ ██║██║ ╚████║██████╔",
426
+ " ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═════╝",
427
+ ].join("\n"),
428
+ color: colors.accent,
429
+ backgroundColor: colors.bg,
430
+ height: 6,
431
+ });
432
+ landingBox.add(landingBanner);
433
+ // Three option cards
434
+ const landingOptions = [
435
+ {
436
+ icon: "\u{1F50D}",
437
+ title: "Repo Search",
438
+ desc: "Search GitHub repositories by query, language, stars, and more",
439
+ action: () => { currentMode = "search"; showLanding(false); showSearchMode(); },
440
+ },
441
+ {
442
+ icon: "\u{1F4E6}",
443
+ title: "Package Search",
444
+ desc: "Search npm packages by name, description, or tags",
445
+ action: () => { currentMode = "packages"; showLanding(false); showPackagesMode(); },
446
+ },
447
+ {
448
+ icon: "\u{1F525}",
449
+ title: "Trending",
450
+ desc: "Browse the hottest repos on GitHub right now",
451
+ action: () => { currentMode = "trending"; showLanding(false); loadTrending(); },
452
+ },
453
+ ];
454
+ let landingSelected = 0;
455
+
456
+ const landingCards: BoxRenderable[] = [];
457
+ for (let i = 0; i < landingOptions.length; i++) {
458
+ const opt = landingOptions[i];
459
+ const card = new BoxRenderable(renderer, {
460
+ flexDirection: "row",
461
+ height: 1,
462
+ width: "60%",
463
+ marginTop: 1,
464
+ backgroundColor: undefined,
465
+ borderColor: i === 0 ? colors.accent : colors.border,
466
+ borderWidth: 1,
467
+ paddingX: 1,
468
+ alignItems: "center",
469
+ focusable: false,
470
+ });
471
+ landingCards.push(card);
472
+ landingBox.add(card);
473
+
474
+ const titleLine = new TextRenderable(renderer, {
475
+ content: opt.title,
476
+ color: i === 0 ? colors.accent : colors.text,
477
+ backgroundColor: undefined,
478
+ height: 1,
479
+ flexGrow: 1,
480
+ });
481
+ card.add(titleLine);
482
+ }
378
483
 
379
- // ── Overlay system ──────────────────────────────────────────────────
484
+ const landingHint = new TextRenderable(renderer, {
485
+ content: " \u2191\u2193 Navigate \u21E9 Select [?]help [q]uit",
486
+ color: colors.muted,
487
+ backgroundColor: colors.bg,
488
+ height: 1,
489
+ marginTop: 1,
490
+ });
491
+ landingBox.add(landingHint);
492
+
493
+ function showLanding(visible: boolean) {
494
+ landingBox.visible = visible;
495
+ if (visible) {
496
+ currentMode = "landing";
497
+ hideMainContent();
498
+ landingSelected = 0;
499
+ updateLandingCards();
500
+ renderer.requestRender();
501
+ }
502
+ }
503
+
504
+ function updateLandingCards() {
505
+ for (let i = 0; i < landingCards.length; i++) {
506
+ const opt = landingOptions[i];
507
+ const isSel = i === landingSelected;
508
+ landingCards[i].backgroundColor = isSel ? colors.selectionBg : colors.surface;
509
+ landingCards[i].borderColor = isSel ? colors.accent : colors.border;
510
+ landingCards[i].borderWidth = isSel ? 1 : 0;
511
+ const children = landingCards[i].children ?? [];
512
+ if (children[0]) {
513
+ const iconCol = children[0] as BoxRenderable;
514
+ const iconChildren = iconCol.children ?? [];
515
+ const iconTextChild = iconChildren[0] as TextRenderable;
516
+ if (iconTextChild) {
517
+ iconTextChild.color = isSel ? colors.accent : colors.text;
518
+ iconTextChild.backgroundColor = isSel ? colors.selectionBg : colors.surface;
519
+ }
520
+ const iconSpacerChild = iconChildren[1] as TextRenderable;
521
+ if (iconSpacerChild) {
522
+ iconSpacerChild.backgroundColor = isSel ? colors.selectionBg : colors.surface;
523
+ }
524
+ }
525
+ if (children[1]) {
526
+ const contentCol = children[1] as BoxRenderable;
527
+ const contentChildren = contentCol.children ?? [];
528
+ const titleChild = contentChildren[0] as TextRenderable;
529
+ if (titleChild) {
530
+ titleChild.color = isSel ? colors.accent : colors.text;
531
+ titleChild.backgroundColor = isSel ? colors.selectionBg : colors.surface;
532
+ }
533
+ const descChild = contentChildren[1] as TextRenderable;
534
+ if (descChild) {
535
+ descChild.backgroundColor = isSel ? colors.selectionBg : colors.surface;
536
+ }
537
+ const hintChild = contentChildren[2] as TextRenderable;
538
+ }
539
+ }
540
+ }
541
+
542
+ // Landing keyboard handler
543
+ renderer.keyInput.on("keypress", (key) => {
544
+ if (currentMode === "landing") {
545
+ if (key.name === "up" || key.name === "k") {
546
+ landingSelected = Math.max(0, landingSelected - 1);
547
+ updateLandingCards();
548
+ renderer.requestRender();
549
+ } else if (key.name === "down" || key.name === "j") {
550
+ landingSelected = Math.min(landingOptions.length - 1, landingSelected + 1);
551
+ updateLandingCards();
552
+ renderer.requestRender();
553
+ } else if (key.name === "enter" || key.name === "return") {
554
+ landingOptions[landingSelected].action();
555
+ } else if (key.name === "?" || key.name === "h") {
556
+ showOverlay(currentOverlay === "help" ? "none" : "help");
557
+ } else if (key.name === "q") {
558
+ cleanup();
559
+ }
560
+ return;
561
+ }
562
+ });
380
563
 
381
564
  // Hide main content so overlays can take 100% of the content area
382
565
  function hideMainContent() {
383
566
  trendingTabBox.visible = false;
567
+ gapBox.visible = false;
384
568
  searchBox.visible = false;
385
569
  toolbarText.visible = false;
386
570
  body.visible = false;
@@ -391,6 +575,7 @@ export async function launchBrowser(): Promise<void> {
391
575
  trendingTabBox.visible = true;
392
576
  } else {
393
577
  searchBox.visible = true;
578
+ gapBox.visible = true;
394
579
  toolbarText.visible = true;
395
580
  }
396
581
  body.visible = true;
@@ -411,10 +596,18 @@ export async function launchBrowser(): Promise<void> {
411
596
  readmeBox.visible = false;
412
597
  updateBox.visible = false;
413
598
  updateDim.visible = false;
414
-
415
599
  if (type === "none") {
416
600
  currentOverlay = "none";
417
- showMainContent();
601
+ if (currentMode === "landing") {
602
+ landingBox.visible = true;
603
+ body.visible = false;
604
+ trendingTabBox.visible = false;
605
+ gapBox.visible = false;
606
+ searchBox.visible = false;
607
+ toolbarText.visible = false;
608
+ } else {
609
+ showMainContent();
610
+ }
418
611
  renderer.requestRender();
419
612
  return;
420
613
  }
@@ -1079,7 +1272,7 @@ export async function launchBrowser(): Promise<void> {
1079
1272
 
1080
1273
  const groups: MenuCategory[] = [];
1081
1274
 
1082
- // Search group
1275
+ // Search group
1083
1276
  const searchItems: MenuEntry[] = [];
1084
1277
  if (currentMode === "search") {
1085
1278
  searchItems.push(
@@ -1119,6 +1312,29 @@ export async function launchBrowser(): Promise<void> {
1119
1312
  },
1120
1313
  },
1121
1314
  );
1315
+ } else if (currentMode === "packages") {
1316
+ searchItems.push(
1317
+ {
1318
+ type: "action",
1319
+ name: "Sort",
1320
+ description: `Cycle package sort (current: ${currentPackageSort})`,
1321
+ action: () => changePackageSort(),
1322
+ },
1323
+ {
1324
+ type: "action",
1325
+ name: "Limit",
1326
+ description: `Cycle result limit (current: ${currentLimit})`,
1327
+ action: () => changeLimit(),
1328
+ },
1329
+ {
1330
+ type: "action",
1331
+ name: "Refresh",
1332
+ description: "Re-run current package search",
1333
+ action: () => {
1334
+ if (currentQueryInput) doPackageSearch(currentQueryInput);
1335
+ },
1336
+ },
1337
+ );
1122
1338
  }
1123
1339
  if (searchItems.length > 0) {
1124
1340
  groups.push({
@@ -1130,7 +1346,7 @@ export async function launchBrowser(): Promise<void> {
1130
1346
  });
1131
1347
  }
1132
1348
 
1133
- // Repo group
1349
+ // Repo group
1134
1350
  const repoItems: MenuEntry[] = [];
1135
1351
  if (hasRepo) {
1136
1352
  repoItems.push(
@@ -1197,8 +1413,17 @@ export async function launchBrowser(): Promise<void> {
1197
1413
  });
1198
1414
  }
1199
1415
 
1200
- // Navigate group
1416
+ // Navigate group
1201
1417
  const navItems: MenuEntry[] = [];
1418
+ navItems.push({
1419
+ type: "action",
1420
+ name: "Packages",
1421
+ description: "Search npm packages",
1422
+ action: () => {
1423
+ showOverlay("none");
1424
+ showPackagesMode();
1425
+ },
1426
+ });
1202
1427
  if (currentMode === "search") {
1203
1428
  navItems.push({
1204
1429
  type: "action",
@@ -1562,7 +1787,8 @@ export async function launchBrowser(): Promise<void> {
1562
1787
  }
1563
1788
 
1564
1789
  function setToolbar() {
1565
- toolbarText.content = formatToolbar(currentSort, currentLimit, totalCount);
1790
+ const sort = currentMode === "packages" ? currentPackageSort : currentSort;
1791
+ toolbarText.content = formatToolbar(sort, currentLimit, totalCount);
1566
1792
  renderer.requestRender();
1567
1793
  }
1568
1794
 
@@ -1583,7 +1809,7 @@ export async function launchBrowser(): Promise<void> {
1583
1809
  if (repo.archived) statusTags.push("archived");
1584
1810
  if (repo.isFork) statusTags.push("fork");
1585
1811
  const tagStr = statusTags.length ? ` [${statusTags.join(", ")}]` : "";
1586
-
1812
+
1587
1813
  const lines = [
1588
1814
  ` ${repo.fullName}${tagStr}`,
1589
1815
  ``,
@@ -1703,37 +1929,47 @@ export async function launchBrowser(): Promise<void> {
1703
1929
  // X-axis with week labels
1704
1930
  const xAxis = ` ┆${"─".repeat(termW)}`;
1705
1931
  const weekLabels = ` ┆ ${"1w".padEnd(Math.floor(termW/4))} ${"13w".padEnd(Math.floor(termW/4))} ${"26w".padEnd(Math.floor(termW/4))} ${"52w"}`;
1706
- return [xAxis, weekLabels];
1932
+ return [...lines, xAxis, weekLabels];
1707
1933
  }
1708
1934
 
1709
1935
  async function loadChart(repo: Repo) {
1710
1936
  detailText.content = " Loading chart...";
1711
1937
  renderer.requestRender();
1712
1938
  try {
1939
+ const headers: Record<string, string> = { "User-Agent": "ghfind/1.0" };
1940
+ if (githubToken) headers.Authorization = `Bearer ${githubToken}`;
1713
1941
  const [chartRes, repoRes] = await Promise.all([
1714
1942
  fetch(
1715
1943
  `https://api.github.com/repos/${repo.owner}/${repo.name}/stats/participation`,
1716
- { headers: { "User-Agent": "ghfind/1.0" } },
1944
+ { headers },
1717
1945
  ),
1718
1946
  fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}`, {
1719
- headers: { "User-Agent": "ghfind/1.0" },
1720
- }).then((r) => (r.ok ? r.json() : null)),
1947
+ headers,
1948
+ }).then((r) => (r.ok ? r.clone().json() : null)),
1721
1949
  ]);
1722
-
1723
1950
  let chartSection = "";
1724
1951
  if (chartRes.ok) {
1725
1952
  const data = await chartRes.json();
1726
1953
  if (data?.all && data.all.length >= 2) {
1727
1954
  chartCommitData = data.all;
1728
- const chartW = 60;
1955
+ // Chart width: leave room for y-axis labels (6 chars) and padding.
1956
+ // In fullscreen graph mode, detailBox takes 100% width.
1957
+ // In normal mode (50%), use half the terminal width.
1958
+ const termW = graphFullscreen
1959
+ ? (process.stdout.columns || 120) - 12
1960
+ : Math.floor((process.stdout.columns || 120) / 2) - 12;
1961
+ const chartW = Math.max(20, Math.min(60, termW));
1729
1962
  const chartH = 10;
1730
1963
  const chartLines = buildChartString(chartCommitData, chartW, chartH);
1731
- chartSection = ["", "Weekly commits (52 weeks)", ...chartLines].join(
1732
- "\n",
1733
- );
1964
+ chartSection = ["", "Weekly commits (52 weeks)", ...chartLines].join("\n");
1965
+ } else if (data?.message) {
1966
+ chartSection = ["", `Chart unavailable: ${data.message}`].join("\n");
1734
1967
  }
1968
+ } else {
1969
+ chartSection = ["", `Chart unavailable (API ${chartRes.status})`].join("\n");
1735
1970
  }
1736
1971
 
1972
+
1737
1973
  const desc = repo.description ?? "";
1738
1974
  const lang = repo.language ?? "—";
1739
1975
  const updated = repo.updatedAt ? repo.updatedAt.slice(0, 10) : "";
@@ -1868,14 +2104,72 @@ export async function launchBrowser(): Promise<void> {
1868
2104
  function showSearchMode() {
1869
2105
  currentMode = "search";
1870
2106
  trendingTabBox.visible = false;
2107
+ gapBox.visible = false;
1871
2108
  searchBox.visible = true;
1872
2109
  toolbarText.visible = true;
2110
+ body.visible = true;
1873
2111
  searchInput.placeholder =
1874
2112
  "Search GitHub repos (e.g. rust cli, or language:Rust stars:>100)";
1875
2113
  searchInput.focus();
1876
2114
  renderer.requestRender();
1877
2115
  }
1878
2116
 
2117
+ function showPackagesMode() {
2118
+ currentMode = "packages";
2119
+ trendingTabBox.visible = false;
2120
+ gapBox.visible = false;
2121
+ searchBox.visible = true;
2122
+ toolbarText.visible = true;
2123
+ body.visible = true;
2124
+ searchInput.placeholder = "Search npm packages (e.g. react, vue, typescript)";
2125
+ searchInput.focus();
2126
+ packages = [];
2127
+ resultsSelect.options = [
2128
+ { name: "", description: "Type a query and press Enter to search", value: null },
2129
+ ];
2130
+ detailText.content = "";
2131
+ renderer.requestRender();
2132
+ }
2133
+ async function doPackageSearch(queryText: string) {
2134
+ const q = queryText.trim();
2135
+ if (q === "" || isLoading) return;
2136
+ currentQueryInput = q;
2137
+ isLoading = true;
2138
+ resultsSelect.options = [
2139
+ { name: " Searching packages...", description: "", value: null },
2140
+ ];
2141
+ detailText.content = "";
2142
+ statusMgr.set("searching", `Searching npm for "${q}"`);
2143
+ renderer.requestRender();
2144
+ try {
2145
+ const search = createPackageSearch();
2146
+ const result = await search.searchPackage(q, currentLimit);
2147
+ totalCount = result.totalCount;
2148
+ packageResultsRaw = result.packages;
2149
+ packages = sortPackages(packageResultsRaw, currentPackageSort);
2150
+ setToolbar();
2151
+ if (packages.length > 0) {
2152
+ resultsSelect.options = packages.map((p) => ({
2153
+ name: `${p.name}@${p.version}`,
2154
+ description: p.description ?? "",
2155
+ value: p,
2156
+ }));
2157
+ resultsSelect.setSelectedIndex(0);
2158
+ statusMgr.set("success", `${packages.length} packages found`);
2159
+ } else {
2160
+ throw new NoResultsError(q);
2161
+ }
2162
+ } catch (err) {
2163
+ const msg =
2164
+ err instanceof Error ? err.message : String(err);
2165
+ resultsSelect.options = [
2166
+ { name: " (error)", description: "", value: null },
2167
+ ];
2168
+ statusMgr.set("error", msg.slice(0, 60));
2169
+ }
2170
+ isLoading = false;
2171
+ renderer.requestRender();
2172
+ }
1879
2173
  async function doSearch(queryText: string, append = false) {
1880
2174
  const q = queryText.trim();
1881
2175
  if (q === "" || isLoading) return;
@@ -1975,6 +2269,23 @@ export async function launchBrowser(): Promise<void> {
1975
2269
  }
1976
2270
  }
1977
2271
 
2272
+ function changePackageSort() {
2273
+ const idx = PACKAGE_SORT_MODES.findIndex((m) => m.key === currentPackageSort);
2274
+ const next = PACKAGE_SORT_MODES[(idx + 1) % PACKAGE_SORT_MODES.length];
2275
+ currentPackageSort = next.key;
2276
+ setToolbar();
2277
+ if (packageResultsRaw.length > 0) {
2278
+ packages = sortPackages(packageResultsRaw, currentPackageSort);
2279
+ resultsSelect.options = packages.map((p) => ({
2280
+ name: `${p.name}@${p.version}`,
2281
+ description: p.description ?? "",
2282
+ value: p,
2283
+ }));
2284
+ resultsSelect.setSelectedIndex(0);
2285
+ renderer.requestRender();
2286
+ }
2287
+ }
2288
+
1978
2289
  function changeLimit() {
1979
2290
  const limits = [10, 25, 50, 100];
1980
2291
  const idx = limits.indexOf(currentLimit);
@@ -1989,17 +2300,30 @@ export async function launchBrowser(): Promise<void> {
1989
2300
  // Enter in search input → show results
1990
2301
  searchInput.on("enter", () => {
1991
2302
  if (searchInput.value.trim() === "" || isLoading) return;
1992
- showSearchMode();
1993
- doSearch(searchInput.value);
2303
+ if (currentMode === "packages") {
2304
+ doPackageSearch(searchInput.value);
2305
+ } else {
2306
+ doSearch(searchInput.value);
2307
+ }
1994
2308
  });
1995
2309
 
1996
2310
  // Navigate results → update detail pane
1997
2311
  resultsSelect.on("selectionChanged", () => {
1998
2312
  const opt = resultsSelect.getSelectedOption();
1999
- const repo = opt?.value as Repo | undefined;
2000
- if (repo) {
2001
- if (graphFullscreen) loadChart(repo);
2002
- else updateDetail(repo);
2313
+ if (currentMode === "packages") {
2314
+ const pack = opt?.value as Package | undefined;
2315
+ if (pack) {
2316
+ detailText.content = `Package: ${pack.name}@${pack.version}
2317
+ Downloads: ${pack.downloads.toLocaleString()}
2318
+ Score: ${pack.score.toFixed(2)}
2319
+ ${pack.description ?? ""}`;
2320
+ }
2321
+ } else {
2322
+ const repo = opt?.value as Repo | undefined;
2323
+ if (repo) {
2324
+ if (graphFullscreen) loadChart(repo);
2325
+ else updateDetail(repo);
2326
+ }
2003
2327
  }
2004
2328
  renderer.requestRender();
2005
2329
  });
@@ -2007,8 +2331,13 @@ export async function launchBrowser(): Promise<void> {
2007
2331
  // Enter on a result → open URL
2008
2332
  resultsSelect.on("itemSelected", () => {
2009
2333
  const opt = resultsSelect.getSelectedOption();
2010
- const repo = opt?.value as Repo | undefined;
2011
- if (repo) openUrl(repo.url);
2334
+ if (currentMode === "packages") {
2335
+ const pack = opt?.value as Package | undefined;
2336
+ if (pack?.url) openUrl(pack.url);
2337
+ } else {
2338
+ const repo = opt?.value as Repo | undefined;
2339
+ if (repo) openUrl(repo.url);
2340
+ }
2012
2341
  });
2013
2342
 
2014
2343
  // ── Global keyboard shortcuts ──────────────────────────────────────
@@ -2342,6 +2671,19 @@ export async function launchBrowser(): Promise<void> {
2342
2671
  showLeaderMenu();
2343
2672
  }
2344
2673
  return;
2674
+ // 't' toggles theme and persists to config
2675
+ if (key.name === "t" && !searchInput.focused) {
2676
+ const current = config.theme;
2677
+ const themes = listThemes();
2678
+ const idx = themes.indexOf(current);
2679
+ const next = themes[(idx + 1) % themes.length];
2680
+ const nextTheme = loadTheme(next);
2681
+ Object.assign(colors, nextTheme);
2682
+ config.theme = next;
2683
+ saveConfig(config);
2684
+ renderer.requestRender();
2685
+ return;
2686
+ }
2345
2687
  }
2346
2688
 
2347
2689
  // '?' / Ctrl+H toggle help overlay (fast path)
@@ -2446,18 +2788,23 @@ export async function launchBrowser(): Promise<void> {
2446
2788
  }
2447
2789
  // ── Start ──────────────────────────────────────────────────────────
2448
2790
  // Check for updates (non-blocking — doesn't delay TUI startup)
2791
+ if (!session || !session.mode) {
2792
+ showLanding(true);
2793
+ }
2449
2794
  checkForUpdateAndShow();
2450
2795
  renderer.start();
2451
- if (currentMode === "trending") {
2452
- loadTrending();
2453
- } else {
2454
- searchInput.value = currentQueryInput;
2455
- if (currentQueryInput) {
2456
- doSearch(currentQueryInput);
2796
+ if (session && session.mode) {
2797
+ if (currentMode === "trending") {
2798
+ loadTrending();
2457
2799
  } else {
2458
- showSearchMode();
2800
+ searchInput.value = currentQueryInput;
2801
+ if (currentQueryInput) {
2802
+ doSearch(currentQueryInput);
2803
+ } else {
2804
+ showSearchMode();
2805
+ }
2806
+ searchInput.focus();
2459
2807
  }
2460
- searchInput.focus();
2461
2808
  }
2462
2809
  renderer.requestRender();
2463
2810
  }
@@ -2477,7 +2824,11 @@ function formatStars(n: number): string {
2477
2824
  return `★ ${n}`;
2478
2825
  }
2479
2826
 
2480
- function formatToolbar(sort: SortStrategy, limit: number, totalCount: number): string {
2827
+ function formatToolbar(
2828
+ sort: SortStrategy | PackageSortMode,
2829
+ limit: number,
2830
+ totalCount: number,
2831
+ ): string {
2481
2832
  const sortLabel = SORT_MODES.find((m) => m.key === sort)?.label ?? sort;
2482
2833
  return ` sort: ${sortLabel} limit: ${limit} results: ${totalCount.toLocaleString()}`;
2483
2834
  }
@@ -2494,6 +2845,6 @@ function cleanup(): void {
2494
2845
  // ── Auto-run ──────────────────────────────────────────────────────────
2495
2846
  // Only launch when run directly (bun run src/tui.ts), not when bundled
2496
2847
  // into cli.js or imported as a module.
2497
- if (import.meta.main && !process.env.GHFIND_BUNDLED) {
2848
+ if (import.meta.main && !process.env.GHFIND_BUNDLED && !process.env.GHFIND_CLI_RUN) {
2498
2849
  launchBrowser();
2499
2850
  }