github-search-cli 0.7.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,12 +1,12 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env node
2
2
  /**
3
- * search-cli — interactive GitHub repository browser (OpenTUI)
3
+ * ghfind — interactive GitHub repository browser (OpenTUI)
4
4
  *
5
5
  * Full-screen, keyboard-driven browser. This is the only entry point.
6
6
  * Pipeline: user query → provider → normalizer → ranking → rendered in TUI
7
7
  *
8
8
  * Layout:
9
- * ┌─ search-cli [s]sort [l]limit [r]refresh [q]quit ─────┐
9
+ * ┌─ ghfind [s]sort [l]limit [r]refresh [q]uit ────────────────┐
10
10
  * │ > [query input ........................................] │
11
11
  * │ sort: best-match limit: 50 │
12
12
  * ├─── Results ────────────────┬─── Details ──────────────────┤
@@ -31,28 +31,26 @@ import {
31
31
  SelectRenderable,
32
32
  ScrollBoxRenderable,
33
33
  } from "@opentui/core";
34
- import type { Repo, SearchOptions, SortStrategy } from "./types.ts";
35
- import { parseQuery, applyFlagFilters, validateQuery, suggestFor } from "./query.ts";
36
- import { GitHubSearchProvider, type Logger } from "./provider.ts";
37
- import { rankRepos } from "./ranking.ts";
38
- import { fetchTrendingRepos, tabSince, TAB_NAMES, fmtStars, type TrendingRepo } from "./trending.ts";
39
- import { loadConfig } from "./config.ts";
40
- import { buildHelpSections, HELP_KEYS_COLUMN } from "./help.ts";
41
- import { SearchCliError, NetworkError, RateLimitError, BadQueryError, NoResultsError } from "./errors.ts";
42
- import { appendHistory, readHistory, deleteHistoryEntry, clearHistory, rotateHistory } from "./history.ts";
43
- import { getBookmarks, isBookmarked, toggleBookmark, removeBookmark } from "./bookmarks.ts";
44
- import { getSavedSearches, saveSearch, deleteSavedSearch, touchSavedSearch } from "./saved-searches.ts";
45
- import { saveSession, restoreSession } from "./session.ts";
46
- import { fetchDeepDive, buildDeepDiveText } from "./deepdive.ts";
47
- import { buildComparisonTable } from "./compare.ts";
48
- import { fetchTopics, type TopicItem } from "./explore.ts";
49
- import { exportToFile, type ExportFormat } from "./export.ts";
50
- import { loadTheme } from "./themes.ts";
51
- import { StatusManager } from "./status.ts";
52
- import { addNotification, getNotifications, dismissNotification, dismissAll } from "./notifications.ts";
53
- import { formatShare, copyToClipboard, type ShareFormat } from "./share.ts";
54
- import { nextTip } from "./tips.ts";
55
- import { openUrl } from "./open-url.ts";
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";
56
54
 
57
55
  // ─── Theme ────────────────────────────────────────────────────────────
58
56
  const colors = {
@@ -127,18 +125,19 @@ export async function launchBrowser(): Promise<void> {
127
125
  let trendingPeriod = "this week";
128
126
  let graphFullscreen = false;
129
127
  let chartCommitData: number[] = [];
130
- let currentOverlay: "none" | "history" | "bookmarks" | "saved" | "help" | "topics" | "export" | "compare" | "notifications" | "share" = "none";
128
+ let currentOverlay: "none" | "history" | "bookmarks" | "saved" | "help" | "topics" | "export" | "compare" | "notifications" | "share" | "leader" | "readme" = "none";
131
129
  let currentPage = 1;
132
130
  let totalCount = 0;
133
131
  let deepDiveActive = false;
134
132
  let compareList: Repo[] = [];
135
133
  let quitArmed = false;
136
134
 
135
+
137
136
  // ── Header bar ──────────────────────────────────────────────────────
138
137
  const header = new TextRenderable(renderer, {
139
138
  content:
140
- " search-cli — GitHub repo browser [t]rending [/]search [g]raph [r]efresh [q]uit",
141
- backgroundColor: colors.surface,
139
+ " ghfind — GitHub repo browser [/]search [Space]menu [?]help [q]uit",
140
+ backgroundColor: colors.bg,
142
141
  color: colors.muted,
143
142
  height: 1,
144
143
  });
@@ -266,6 +265,8 @@ export async function launchBrowser(): Promise<void> {
266
265
  body.add(detailBox);
267
266
  root.add(body);
268
267
 
268
+
269
+
269
270
  // ── Overlay system ──────────────────────────────────────────────────
270
271
 
271
272
  // Hide main content so overlays can take 100% of the content area
@@ -296,6 +297,8 @@ export async function launchBrowser(): Promise<void> {
296
297
  compareBox.visible = false;
297
298
  notifsBox.visible = false;
298
299
  shareBox.visible = false;
300
+ leaderBox.visible = false;
301
+ readmeBox.visible = false;
299
302
 
300
303
  if (type === "none") {
301
304
  currentOverlay = "none";
@@ -314,6 +317,8 @@ export async function launchBrowser(): Promise<void> {
314
317
  else if (type === "compare") { compareBox.visible = true; }
315
318
  else if (type === "notifications") { notifsBox.visible = true; }
316
319
  else if (type === "share") { shareBox.visible = true; }
320
+ else if (type === "leader") { leaderBox.visible = true; }
321
+ else if (type === "readme") { readmeBox.visible = true; }
317
322
  renderer.requestRender();
318
323
  }
319
324
 
@@ -635,10 +640,256 @@ export async function launchBrowser(): Promise<void> {
635
640
  savedSelect.setSelectedIndex(0);
636
641
  }
637
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
+
638
889
  // ── Status bar ──────────────────────────────────────────────────────
639
890
  const statusBar = new TextRenderable(renderer, {
640
891
  content: " Ready. Press Enter to search.",
641
- backgroundColor: colors.surface,
892
+ backgroundColor: colors.bg,
642
893
  color: colors.muted,
643
894
  height: 1,
644
895
  paddingX: 1,
@@ -698,50 +949,77 @@ export async function launchBrowser(): Promise<void> {
698
949
  // ── Graph / Chart ──────────────────────────────────────────────────
699
950
 
700
951
  /** Unicode blocks for 8 vertical levels within one character cell. */
701
- const BLOCKS = [" ", "\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
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
+ }
702
958
 
703
- function buildChartString(values: number[], width: number, height: number): string[] {
959
+ function buildChartString(values: number[], termW: number, termH: number): string[] {
704
960
  if (values.length === 0) return ["(no data)"];
705
- // Sample to fit width
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.
706
967
  const sampled: number[] = [];
707
- for (let i = 0; i < width; i++) {
708
- sampled.push(values[Math.floor((i / width) * values.length)]);
968
+ for (let i = 0; i < dotW; i++) {
969
+ sampled.push(values[Math.floor((i / dotW) * values.length)]);
709
970
  }
710
971
  const max = Math.max(...sampled, 1);
711
- const norm = sampled.map(v => Math.round((v / max) * (8 * height - 1)));
712
-
713
- // Build char grid from bottom
714
- const grid: string[][] = Array.from({ length: height }, () => Array(width).fill(" "));
715
- for (let c = 0; c < width; c++) {
716
- const p = norm[c];
717
- const full = Math.floor(p / 8);
718
- const part = p % 8;
719
- for (let r = 0; r < full && r < height; r++) grid[height - 1 - r][c] = "\u2588";
720
- if (full < height && part > 0) grid[height - 1 - full][c] = BLOCKS[part];
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;
721
981
  }
722
- // Fill vertical gaps between adjacent columns
723
- for (let c = 0; c < width - 1; c++) {
724
- const r1 = Math.floor(norm[c] / 8);
725
- const r2 = Math.floor(norm[c + 1] / 8);
726
- const lo = Math.max(0, Math.min(r1, r2));
727
- const hi = Math.min(height - 1, Math.max(r1, r2));
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);
728
988
  for (let r = lo; r <= hi; r++) {
729
- if (grid[height - 1 - r][c] === " ") grid[height - 1 - r][c] = "\u2588";
989
+ dotGrid[dotH - 1 - r][c] = true;
730
990
  }
731
991
  }
732
992
 
733
- // Build rows with Y-axis labels
993
+ // Convert 4-row × 2-col blocks of dots into braille characters.
734
994
  const lines: string[] = [];
735
- const labelRows = [0, Math.floor(height / 4), Math.floor(height / 2), Math.floor(3 * height / 4), height - 1];
736
- const labelVals = [max, Math.round(max * 0.75), Math.round(max / 2), Math.round(max / 4), 0];
737
- for (let r = 0; r < height; r++) {
738
- const idx = labelRows.indexOf(r);
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);
739
1016
  let label = " ";
740
1017
  if (idx >= 0) label = String(labelVals[idx]).padStart(4) + " ";
741
- lines.push(`${label}\u2524${grid[r].join("")}`);
1018
+ lines.push(`${label}\u2524${chars.join("")}`);
742
1019
  }
1020
+
743
1021
  // X-axis
744
- lines.push(` \u2514${String.fromCharCode(0x2500).repeat(width)}`);
1022
+ lines.push(` \u2514${String.fromCharCode(0x2500).repeat(termW)}`);
745
1023
  return lines;
746
1024
  }
747
1025
 
@@ -751,9 +1029,9 @@ export async function launchBrowser(): Promise<void> {
751
1029
  try {
752
1030
  const [chartRes, repoRes] = await Promise.all([
753
1031
  fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/stats/participation`,
754
- { headers: { "User-Agent": "search-cli/1.0" } }),
1032
+ { headers: { "User-Agent": "ghfind/1.0" } }),
755
1033
  fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}`,
756
- { headers: { "User-Agent": "search-cli/1.0" } }).then(r => r.ok ? r.json() : null),
1034
+ { headers: { "User-Agent": "ghfind/1.0" } }).then(r => r.ok ? r.json() : null),
757
1035
  ]);
758
1036
 
759
1037
  let chartSection = "";
@@ -761,7 +1039,7 @@ export async function launchBrowser(): Promise<void> {
761
1039
  const data = await chartRes.json();
762
1040
  if (data?.all && data.all.length >= 2) {
763
1041
  chartCommitData = data.all;
764
- const chartW = Math.min(55, chartCommitData.length);
1042
+ const chartW = 60;
765
1043
  const chartH = 10;
766
1044
  const chartLines = buildChartString(chartCommitData, chartW, chartH);
767
1045
  chartSection = ["", "Weekly commits (52 weeks)", ...chartLines].join("\n");
@@ -921,7 +1199,7 @@ export async function launchBrowser(): Promise<void> {
921
1199
  try {
922
1200
  const parsed = applyFlagFilters(parseQuery(q), {});
923
1201
  validateQuery(parsed);
924
- const provider = new GitHubSearchProvider(logger);
1202
+ const provider = createGitHubSearch(logger);
925
1203
  const options: SearchOptions = {
926
1204
  limit: currentLimit,
927
1205
  sort: currentSort,
@@ -996,8 +1274,9 @@ export async function launchBrowser(): Promise<void> {
996
1274
 
997
1275
  // ── Wire events ────────────────────────────────────────────────────
998
1276
 
999
- // Enter in search input → search (resets to search mode)
1277
+ // Enter in search input → show results
1000
1278
  searchInput.on("enter", () => {
1279
+ if (searchInput.value.trim() === "" || isLoading) return;
1001
1280
  showSearchMode();
1002
1281
  doSearch(searchInput.value);
1003
1282
  });
@@ -1031,9 +1310,29 @@ export async function launchBrowser(): Promise<void> {
1031
1310
  return;
1032
1311
  }
1033
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
+
1034
1333
  // History overlay
1035
1334
  if (currentOverlay === "history") {
1036
- if (key.ctrl && key.name === "d") {
1335
+ if (key.name === "d") {
1037
1336
  const sel = historySelect.getSelectedOption();
1038
1337
  if (sel?.value) {
1039
1338
  deleteHistoryEntry((sel.value as any).index);
@@ -1050,7 +1349,6 @@ export async function launchBrowser(): Promise<void> {
1050
1349
  renderer.requestRender();
1051
1350
  return;
1052
1351
  }
1053
- // SelectRenderable handles ↑↓; Enter runs the search
1054
1352
  if (key.name === "enter" || key.name === "return") {
1055
1353
  const sel = historySelect.getSelectedOption();
1056
1354
  if (sel?.value) {
@@ -1067,7 +1365,7 @@ export async function launchBrowser(): Promise<void> {
1067
1365
  }
1068
1366
  return;
1069
1367
  }
1070
- return; // all other keys ignored in history overlay
1368
+ return;
1071
1369
  }
1072
1370
 
1073
1371
  // Bookmarks overlay
@@ -1160,19 +1458,13 @@ export async function launchBrowser(): Promise<void> {
1160
1458
  return;
1161
1459
  }
1162
1460
 
1163
- // Compare overlay: Esc closes
1461
+ // Compare overlay: Esc/q closes
1164
1462
  if (currentOverlay === "compare") {
1165
1463
  return;
1166
1464
  }
1167
1465
 
1168
1466
  // Notifications overlay
1169
1467
  if (currentOverlay === "notifications") {
1170
- if (key.ctrl && key.name === "c") {
1171
- dismissAll();
1172
- refreshNotifications();
1173
- renderer.requestRender();
1174
- return;
1175
- }
1176
1468
  if (key.name === "d") {
1177
1469
  const notifs = getNotifications();
1178
1470
  if (notifs.length > 0) {
@@ -1183,6 +1475,12 @@ export async function launchBrowser(): Promise<void> {
1183
1475
  renderer.requestRender();
1184
1476
  return;
1185
1477
  }
1478
+ if (key.ctrl && key.name === "c") {
1479
+ dismissAll();
1480
+ refreshNotifications();
1481
+ renderer.requestRender();
1482
+ return;
1483
+ }
1186
1484
  return;
1187
1485
  }
1188
1486
 
@@ -1213,89 +1511,38 @@ export async function launchBrowser(): Promise<void> {
1213
1511
  renderer.requestRender();
1214
1512
  return;
1215
1513
  }
1514
+
1515
+ return;
1216
1516
  }
1217
1517
 
1218
1518
  // ── Main view handling (no overlay active) ────────────────────────
1219
1519
 
1220
- // Always allow q to quit
1520
+ // q quits
1221
1521
  if (key.name === "q") {
1522
+ if (graphFullscreen) {
1523
+ toggleGraph();
1524
+ return;
1525
+ }
1222
1526
  saveSession({ mode: currentMode, query: currentQueryInput, sort: currentSort, limit: currentLimit, trendingTab });
1223
1527
  cleanup();
1224
1528
  return;
1225
1529
  }
1226
1530
 
1227
- // '?' / Ctrl+H toggle help overlay
1228
- if (key.name === "?" || (key.name === "h" && key.ctrl)) {
1229
- refreshHistory(); // refresh in background
1230
- showOverlay("help");
1231
- return;
1232
- }
1233
-
1234
- // Ctrl+R — search history overlay
1235
- if (key.ctrl && key.name === "r") {
1236
- refreshHistory();
1237
- showOverlay("history");
1238
- return;
1239
- }
1240
-
1241
- // Ctrl+S — save current search
1242
- if (key.ctrl && key.name === "s") {
1243
- if (currentQueryInput) {
1244
- const name = currentQueryInput.length > 40
1245
- ? currentQueryInput.slice(0, 37) + "..."
1246
- : currentQueryInput;
1247
- saveSearch(name, currentQueryInput, currentMode, currentSort, currentLimit, currentMode === "trending" ? trendingTab : undefined);
1248
- setStatus(`✓ Saved as "${name}"`);
1249
- } else {
1250
- setStatus("No search to save");
1251
- }
1252
- renderer.requestRender();
1253
- return;
1254
- }
1255
-
1256
- // Ctrl+O — saved searches panel
1257
- if (key.ctrl && key.name === "o") {
1258
- refreshSavedSearches();
1259
- showOverlay("saved");
1260
- return;
1261
- }
1262
-
1263
- // Ctrl+N — notifications panel
1264
- if (key.ctrl && key.name === "n") {
1265
- refreshNotifications();
1266
- showOverlay("notifications");
1267
- return;
1268
- }
1269
-
1270
- // Ctrl+P — share repo
1271
- if (key.ctrl && key.name === "p") {
1272
- const opt = resultsSelect.getSelectedOption();
1273
- const repo = opt?.value as Repo | undefined;
1274
- if (!repo) {
1275
- setStatus("No repo selected to share");
1276
- renderer.requestRender();
1277
- return;
1278
- }
1279
- showOverlay("share");
1531
+ // Esc exits graph mode
1532
+ if (key.name === "escape" && graphFullscreen) {
1533
+ toggleGraph();
1280
1534
  return;
1281
1535
  }
1282
1536
 
1283
- // 'b' — toggle bookmark on selected repo
1284
- if (key.name === "b" && !key.shift) {
1285
- const opt = resultsSelect.getSelectedOption();
1286
- const repo = opt?.value as Repo | undefined;
1287
- if (repo) {
1288
- const added = toggleBookmark(repo);
1289
- setStatus(added ? `Bookmarked ${repo.fullName}` : `Unbookmarked ${repo.fullName}`);
1290
- renderer.requestRender();
1291
- }
1537
+ // Space opens leader menu
1538
+ if (key.name === "space") {
1539
+ showLeaderMenu();
1292
1540
  return;
1293
1541
  }
1294
1542
 
1295
- // 'B' (shift+b) — open bookmarks panel
1296
- if (key.name === "b" && key.shift) {
1297
- refreshBookmarks();
1298
- showOverlay("bookmarks");
1543
+ // '?' / Ctrl+H toggle help overlay (fast path)
1544
+ if (key.name === "?" || (key.name === "h" && key.ctrl)) {
1545
+ showOverlay("help");
1299
1546
  return;
1300
1547
  }
1301
1548
 
@@ -1317,27 +1564,11 @@ export async function launchBrowser(): Promise<void> {
1317
1564
  return;
1318
1565
  }
1319
1566
 
1320
- // '/' focuses search, switches to search mode
1567
+ // '/' focuses search
1321
1568
  if (key.name === "/") {
1569
+ searchInput.focus();
1322
1570
  showSearchMode();
1323
- return;
1324
- }
1325
-
1326
- // ponytail: guard single-letter bindings behind !searchInput.focused
1327
- // so typing a repo name containing t/g/r/s/l/o/d/c doesn't trigger actions
1328
- if (!searchInput.focused) {
1329
-
1330
- if (key.name === "g") {
1331
- toggleGraph();
1332
- return;
1333
- }
1334
-
1335
- if (key.name === "t") {
1336
- if (currentMode === "trending") {
1337
- showSearchMode();
1338
- } else {
1339
- loadTrending();
1340
- }
1571
+ renderer.requestRender();
1341
1572
  return;
1342
1573
  }
1343
1574
 
@@ -1353,38 +1584,7 @@ export async function launchBrowser(): Promise<void> {
1353
1584
  return;
1354
1585
  }
1355
1586
 
1356
- if (key.name === "r") {
1357
- if (currentMode === "trending") {
1358
- loadTrending();
1359
- } else {
1360
- doSearch(searchInput.value || currentQueryInput);
1361
- }
1362
- return;
1363
- }
1364
-
1365
- if (key.name === "s") {
1366
- if (currentMode === "search") changeSort();
1367
- return;
1368
- }
1369
-
1370
- if (key.name === "l") {
1371
- if (currentMode === "search") {
1372
- changeLimit();
1373
- } else if (currentMode === "trending") {
1374
- const idx = TAB_NAMES.indexOf(trendingTab);
1375
- if (idx < TAB_NAMES.length - 1) { trendingTab = TAB_NAMES[idx + 1]; loadTrending(); }
1376
- }
1377
- return;
1378
- }
1379
-
1380
- if (key.name === "o") {
1381
- const opt = resultsSelect.getSelectedOption();
1382
- const repo = opt?.value as Repo | undefined;
1383
- if (repo) openUrl(repo.url);
1384
- return;
1385
- }
1386
-
1387
- // Left/right/h arrows switch trending tabs
1587
+ // Left/right arrows (and h/l) switch trending tabs
1388
1588
  if (key.name === "left" || key.name === "h") {
1389
1589
  if (currentMode === "trending") {
1390
1590
  const idx = TAB_NAMES.indexOf(trendingTab);
@@ -1392,75 +1592,16 @@ export async function launchBrowser(): Promise<void> {
1392
1592
  }
1393
1593
  return;
1394
1594
  }
1395
-
1396
- // ── P2: Power Tool keys ───────────────────────────────────────────
1397
-
1398
- // 'd' — toggle deep-dive on selected repo
1399
- if (key.name === "d") {
1400
- const opt = resultsSelect.getSelectedOption();
1401
- const repo = opt?.value as Repo | undefined;
1402
- if (!repo) return;
1403
- deepDiveActive = !deepDiveActive;
1404
- if (deepDiveActive) {
1405
- detailText.content = " Loading deep-dive...";
1406
- renderer.requestRender();
1407
- fetchDeepDive(repo, githubToken)
1408
- .then((data) => {
1409
- detailText.content = buildDeepDiveText(data);
1410
- renderer.requestRender();
1411
- })
1412
- .catch(() => {
1413
- detailText.content = " Failed to load deep-dive";
1414
- renderer.requestRender();
1415
- });
1416
- } else {
1417
- updateDetail(repo);
1418
- renderer.requestRender();
1419
- }
1420
- return;
1421
- }
1422
-
1423
- // 'c' — toggle compare list for selected repo
1424
- if (key.name === "c" && !key.shift) {
1425
- const opt = resultsSelect.getSelectedOption();
1426
- const repo = opt?.value as Repo | undefined;
1427
- if (!repo) return;
1428
- const idx = compareList.findIndex((r) => r.fullName === repo.fullName);
1429
- if (idx >= 0) {
1430
- compareList.splice(idx, 1);
1431
- setStatus(`Removed ${repo.fullName} from comparison`);
1432
- } else {
1433
- compareList.push(repo);
1434
- setStatus(`${repo.fullName} added to comparison (${compareList.length} selected)`);
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(); }
1435
1599
  }
1436
- renderer.requestRender();
1437
- return;
1438
- }
1439
-
1440
- // 'C' (Shift+c) — show comparison view
1441
- if (key.name === "c" && key.shift) {
1442
- refreshCompare();
1443
- showOverlay("compare");
1444
- return;
1445
- }
1446
-
1447
- // 'E' (Shift+e) — topic explorer
1448
- if (key.name === "e" && key.shift) {
1449
- refreshTopics();
1450
- showOverlay("topics");
1451
1600
  return;
1452
1601
  }
1453
1602
 
1454
- }
1455
-
1456
- // Ctrl+E — export
1457
- if (key.ctrl && key.name === "e") {
1458
- showOverlay("export");
1459
- return;
1460
- }
1461
-
1462
- // PageDown / Ctrl+F — next page
1463
- if (key.name === "pagedown" || (key.ctrl && key.name === "f")) {
1603
+ // PageDown — next page (search mode)
1604
+ if (key.name === "pagedown") {
1464
1605
  if (currentMode === "search" && currentQueryInput) {
1465
1606
  currentPage++;
1466
1607
  doSearch(currentQueryInput, true);
@@ -1468,8 +1609,8 @@ export async function launchBrowser(): Promise<void> {
1468
1609
  return;
1469
1610
  }
1470
1611
 
1471
- // PageUp / Ctrl+B — scroll to top of results
1472
- if (key.name === "pageup" || (key.ctrl && key.name === "b")) {
1612
+ // PageUp — scroll to top of results
1613
+ if (key.name === "pageup") {
1473
1614
  if (currentRepos.length > 0) {
1474
1615
  resultsSelect.setSelectedIndex(0);
1475
1616
  updateDetail(currentRepos[0]);
@@ -1485,8 +1626,12 @@ export async function launchBrowser(): Promise<void> {
1485
1626
  loadTrending();
1486
1627
  } else {
1487
1628
  searchInput.value = currentQueryInput;
1629
+ if (currentQueryInput) {
1630
+ doSearch(currentQueryInput);
1631
+ } else {
1632
+ showSearchMode();
1633
+ }
1488
1634
  searchInput.focus();
1489
- if (currentQueryInput) doSearch(currentQueryInput);
1490
1635
  }
1491
1636
  renderer.requestRender();
1492
1637
  }
@@ -1506,7 +1651,7 @@ function formatStars(n: number): string {
1506
1651
 
1507
1652
  function formatToolbar(sort: SortStrategy, limit: number): string {
1508
1653
  const sortLabel = SORT_MODES.find((m) => m.key === sort)?.label ?? sort;
1509
- return ` sort: ${sortLabel} limit: ${limit} (s=cycle sort l=cycle limit)`;
1654
+ return ` sort: ${sortLabel} limit: ${limit}`;
1510
1655
  }
1511
1656
 
1512
1657
  function cleanup(): void {
@@ -1515,6 +1660,8 @@ function cleanup(): void {
1515
1660
  }
1516
1661
 
1517
1662
  // ── Auto-run ──────────────────────────────────────────────────────────
1518
- if (import.meta.main) {
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) {
1519
1666
  launchBrowser();
1520
1667
  }