github-search-cli 9.9.0 → 9.10.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
@@ -57,6 +57,13 @@ import {
57
57
  type Logger,
58
58
  } from "./search";
59
59
  import { tabSince, TAB_NAMES, fmtStars } from "./trending";
60
+ import {
61
+ attachClickToSelect,
62
+ attachClickOutside,
63
+ attachClickableBox,
64
+ NON_INTERACTIVE_TEXT,
65
+ DOUBLE_CLICK_MS,
66
+ } from "./tui/mouse";
60
67
  import { loadConfig, saveConfig } from "./config";
61
68
  import { buildHelpSections, HELP_KEYS_COLUMN } from "./help";
62
69
  import {
@@ -101,7 +108,7 @@ import {
101
108
  PACKAGE_SORT_MODES,
102
109
  type PackageSortMode,
103
110
  } from "./package";
104
- import { StatusManager } from "./status";
111
+ import { type StatusManager, createStatusManager } from "./status";
105
112
  import {
106
113
  getNotifications,
107
114
  dismissNotification,
@@ -192,6 +199,15 @@ function createTuiLogger(): Logger {
192
199
 
193
200
  const logger: Logger = createTuiLogger();
194
201
 
202
+ // The live StatusManager for the current renderer. Kept at module scope so the
203
+ // hoisted cleanup() can dispose the spinner interval on the quit path; a stale
204
+ // interval would otherwise outlive the destroyed renderable it writes to.
205
+ let activeStatusMgr: StatusManager | null = null;
206
+ // The live renderer, so the hoisted cleanup() can release the terminal
207
+ // (raw mode, alternate screen) before process.exit() — same reasoning as
208
+ // activeStatusMgr above.
209
+ let activeRenderer: CliRenderer | null = null;
210
+
195
211
  // ─── Sort cycle ───────────────────────────────────────────────────────
196
212
  const SORT_MODES: { key: SortStrategy; label: string }[] = [
197
213
  { key: "best-match", label: "best-match" },
@@ -201,7 +217,10 @@ const SORT_MODES: { key: SortStrategy; label: string }[] = [
201
217
  ];
202
218
 
203
219
  // ─── Main ─────────────────────────────────────────────────────────────
204
- export async function launchBrowser(theme_override?: string): Promise<void> {
220
+ export async function launchBrowser(
221
+ theme_override?: string,
222
+ startMode?: "trending",
223
+ ): Promise<void> {
205
224
  // Ask the terminal for its real background color (OSC 11) BEFORE the
206
225
  // renderer takes over stdin — after that, reading the reply gets messy.
207
226
  // Used below so the app backdrop matches the user's terminal theme.
@@ -240,6 +259,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
240
259
  }
241
260
 
242
261
  const root = renderer.root;
262
+ activeRenderer = renderer;
243
263
  root.flexDirection = "column";
244
264
 
245
265
  // ── Config ──
@@ -310,7 +330,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
310
330
 
311
331
  const header = new TextRenderable(renderer, {
312
332
  content:
313
- " ghfind — GitHub repo browser [/]search [Space]menu [\u2192]open [?]help [q]uit",
333
+ " ghfind — GitHub repo browser [/]search [Esc]menu [\u2192]open [?]help [q]uit · click select · dbl-click open",
314
334
  bg: colors.bg,
315
335
  fg: colors.muted,
316
336
  height: 1,
@@ -335,15 +355,26 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
335
355
  const isActive = name === trendingTab;
336
356
  const label = ` ${name} `;
337
357
  const tt = new TextRenderable(renderer, {
358
+ ...NON_INTERACTIVE_TEXT,
338
359
  content: label,
339
360
  fg: isActive ? colors.bg : colors.muted,
340
361
  bg: isActive ? colors.blue : colors.bg,
341
362
  height: 1,
342
363
  });
364
+ // Click a tab to switch to it — same path as the 1-5 number keys.
365
+ tt.onMouseDown = (event) => {
366
+ event.stopPropagation();
367
+ if (name === trendingTab) return;
368
+ trendingTab = name;
369
+ loadTrending();
370
+ };
371
+ tt.onMouseOver = () => renderer.setMousePointer("pointer");
372
+ tt.onMouseOut = () => renderer.setMousePointer("default");
343
373
  trendingTabTexts.push(tt);
344
374
  trendingTabBox.add(tt);
345
375
  if (i < TAB_NAMES.length - 1) {
346
376
  const sp = new TextRenderable(renderer, {
377
+ ...NON_INTERACTIVE_TEXT,
347
378
  content: " ",
348
379
  fg: colors.muted,
349
380
  bg: colors.bg,
@@ -442,6 +473,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
442
473
  content: "",
443
474
  fg: colors.text,
444
475
  wrapMode: "none",
476
+ // Non-interactive: a left-press here would otherwise be swallowed as a
477
+ // text-selection drag instead of reaching the click handlers.
478
+ selectable: false,
445
479
  });
446
480
  detailBox.add(detailText);
447
481
 
@@ -458,6 +492,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
458
492
  });
459
493
  root.add(landingBox);
460
494
  const landingBanner = new TextRenderable(renderer, {
495
+ ...NON_INTERACTIVE_TEXT,
461
496
  content: [
462
497
  " ██████╗ ██╗ ██╗███████╗██╗███╗ ██╗██████╗ ",
463
498
  "██╔════╝ ██║ ██║██╔════╝██║████╗ ██║██╔══██╗",
@@ -540,8 +575,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
540
575
  }
541
576
 
542
577
  const landingHint = new TextRenderable(renderer, {
578
+ ...NON_INTERACTIVE_TEXT,
543
579
  content:
544
- " \u2191\u2193 Navigate \u21E9 Select [b]ookmarks [?]help [q]uit",
580
+ " \u2191\u2193 Navigate \u21E9 Select [b]ookmarks [?]help [q]uit · click to pick a mode",
545
581
  fg: colors.muted,
546
582
  bg: colors.bg,
547
583
  height: 1,
@@ -572,6 +608,34 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
572
608
  }
573
609
  }
574
610
 
611
+ // ── Landing mouse ───────────────────────────────────────────
612
+ // Click selects, double-click runs the card's action (same as Enter), hover
613
+ // shows a pointer cursor. `updateLandingCards` is the single source of truth
614
+ // for the card colours, so hover and selection cannot drift.
615
+ landingCards.forEach((card, i) => {
616
+ let lastClickAt = 0;
617
+ card.onMouseDown = (event) => {
618
+ event.stopPropagation();
619
+ const now = Date.now();
620
+ const isDouble =
621
+ landingSelected === i && now - lastClickAt < DOUBLE_CLICK_MS;
622
+ lastClickAt = now;
623
+ if (isDouble) {
624
+ landingOptions[i].action();
625
+ return;
626
+ }
627
+ landingSelected = i;
628
+ updateLandingCards();
629
+ renderer.requestRender();
630
+ };
631
+ card.onMouseOver = () => {
632
+ renderer.setMousePointer("pointer");
633
+ };
634
+ card.onMouseOut = () => {
635
+ renderer.setMousePointer("default");
636
+ };
637
+ });
638
+
575
639
  // Landing keyboard handler. Registered before the global handler below,
576
640
  // and both listeners receive the same key event — so keys the landing
577
641
  // screen consumes must be claimed with stopPropagation(), or the global
@@ -641,10 +705,15 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
641
705
  leaderBox.visible = false;
642
706
  leaderDim.visible = false;
643
707
  readmeBox.visible = false;
708
+ // The org panel is a flexGrow sibling of `body` just like every other
709
+ // overlay — omitting it here meant it stayed visible after Esc, permanently
710
+ // stealing half the content height and every click in the lower half.
711
+ orgBox.visible = false;
644
712
  updateBox.visible = false;
645
713
  updateDim.visible = false;
646
714
  if (type === "none") {
647
715
  currentOverlay = "none";
716
+ menuButton.visible = true;
648
717
  if (currentMode === "landing") {
649
718
  landingBox.visible = true;
650
719
  body.visible = false;
@@ -658,6 +727,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
658
727
  return;
659
728
  }
660
729
  currentOverlay = type;
730
+ // The button floats above the layout, so it would sit on top of every
731
+ // full-screen overlay (and stay clickable through the dim layer).
732
+ menuButton.visible = false;
661
733
  // An overlay opened from the landing screen must hide the menu too
662
734
  // (hideMainContent only covers the search/trending/toolbar boxes).
663
735
  landingBox.visible = false;
@@ -945,6 +1017,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
945
1017
  content: "",
946
1018
  fg: colors.text,
947
1019
  bg: colors.bg,
1020
+ selectable: false,
948
1021
  });
949
1022
  compareBox.add(compareText);
950
1023
  root.add(compareBox);
@@ -1040,7 +1113,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1040
1113
  savedSelect.setSelectedIndex(0);
1041
1114
  }
1042
1115
 
1043
- // ── Leader menu overlay (Space key) ─────────────────────────────
1116
+ // ── Leader menu overlay (Esc key) ─────────────────────────────
1044
1117
  // Dim overlay — sits behind the menu, above main content
1045
1118
  const leaderDim = new BoxRenderable(renderer, {
1046
1119
  visible: false,
@@ -1086,7 +1159,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1086
1159
  itemSpacing: 0,
1087
1160
  });
1088
1161
  const leaderFooter = new TextRenderable(renderer, {
1089
- content: " ↑↓ select Enter run Esc/q close ",
1162
+ ...NON_INTERACTIVE_TEXT,
1163
+ content:
1164
+ " ↑↓ select Enter run Esc/q close · click to run click outside to close ",
1090
1165
  fg: colors.muted,
1091
1166
  bg: colors.surfaceDim,
1092
1167
  height: 1,
@@ -1236,11 +1311,77 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1236
1311
  renderer.requestRender();
1237
1312
  }
1238
1313
 
1314
+ // ── Update panel mouse ────────────────────────────────────────────
1315
+ // Click a card to select *and* run it in one gesture: these three options are
1316
+ // terminal actions (install now / snooze / suppress), and a modal that needs
1317
+ // select-then-Enter would be two clicks for the common case. `renderUpdateOptions`
1318
+ // stays the single source of truth for the colours.
1319
+ function runUpdateOption(index: number) {
1320
+ updateSelectedOption = index;
1321
+ renderUpdateOptions();
1322
+ const action = ["now", "later", "never"][index] as
1323
+ "now" | "later" | "never" | undefined;
1324
+ if (action === "now") {
1325
+ showOverlay("none");
1326
+ // ponytail: record the running version so the next launch detects the upgrade
1327
+ recordPreUpdateState(cachedVersion ?? "");
1328
+ performUpdate()
1329
+ .then((ok) => {
1330
+ if (ok) {
1331
+ setStatus("✓ Update complete — restart ghfind");
1332
+ } else {
1333
+ setStatus("✗ Update failed — check terminal output");
1334
+ }
1335
+ })
1336
+ .catch(() => {
1337
+ setStatus("✗ Update failed — check terminal output");
1338
+ });
1339
+ } else if (action === "never") {
1340
+ suppressUpdateNotices();
1341
+ showOverlay("none");
1342
+ setStatus("Update notices suppressed");
1343
+ } else {
1344
+ snoozeUpdateNotices(3);
1345
+ showOverlay("none");
1346
+ setStatus("Update reminder snoozed for 3 days");
1347
+ }
1348
+ renderer.requestRender();
1349
+ }
1350
+
1351
+ const updateCards: [BoxRenderable, number][] = [
1352
+ [updateNowBox, 0],
1353
+ [laterBox, 1],
1354
+ [neverBox, 2],
1355
+ ];
1356
+ for (const [box, index] of updateCards) {
1357
+ attachClickableBox(renderer, box, {
1358
+ onActivate: () => runUpdateOption(index),
1359
+ idleBorder:
1360
+ updateSelectedOption === index ? colors.yellow : colors.border,
1361
+ activeBorder: colors.yellow,
1362
+ // Hovering must not fight the selection highlight, so it only moves the
1363
+ // pointer cursor; the border stays owned by renderUpdateOptions().
1364
+ onStateChange: () => {
1365
+ box.borderColor =
1366
+ updateSelectedOption === index ? colors.yellow : colors.border;
1367
+ },
1368
+ });
1369
+ }
1370
+
1371
+ // Clicking the dim layer behind the panel dismisses it without applying
1372
+ // anything (equivalent to Esc).
1373
+ attachClickOutside(updateDim, () => {
1374
+ showOverlay("none");
1375
+ renderer.requestRender();
1376
+ });
1377
+
1239
1378
  updateOptionsRow.add(updateNowBox);
1240
1379
  updateOptionsRow.add(laterBox);
1241
1380
 
1242
1381
  const updateFooter = new TextRenderable(renderer, {
1243
- content: " ↑↓/jk/PgUp/PgDn scroll ←→ select Enter Esc/q close",
1382
+ ...NON_INTERACTIVE_TEXT,
1383
+ content:
1384
+ " ↑↓/jk/PgUp/PgDn scroll ←→ select Enter Esc/q close · click a button click outside to close",
1244
1385
  fg: colors.muted,
1245
1386
  bg: colors.surfaceDim,
1246
1387
  height: 1,
@@ -1805,6 +1946,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1805
1946
  },
1806
1947
  });
1807
1948
  const orgText = new TextRenderable(renderer, {
1949
+ ...NON_INTERACTIVE_TEXT,
1808
1950
  content: "",
1809
1951
  fg: colors.text,
1810
1952
  bg: colors.bg,
@@ -1812,6 +1954,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1812
1954
  orgScroll.add(orgText);
1813
1955
  orgBox.add(orgScroll);
1814
1956
  const orgFooter = new TextRenderable(renderer, {
1957
+ ...NON_INTERACTIVE_TEXT,
1815
1958
  content: " ↑↓/jk scroll Esc/q close",
1816
1959
  fg: colors.muted,
1817
1960
  bg: colors.bg,
@@ -1964,10 +2107,58 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1964
2107
  });
1965
2108
  root.add(statusBar);
1966
2109
 
1967
- const statusMgr = new StatusManager((text) => {
1968
- statusBar.content = ` ${text}`;
1969
- renderer.requestRender();
2110
+ // ── Menu button (bottom-right corner) ─────────────────────────────
2111
+ // Mouse-only affordance: Space types a space in the query, so a mouse user
2112
+ // needs a target to reach the command menu. Absolutely positioned so it
2113
+ // floats over the results pane instead of stealing a flex row, and added
2114
+ // after every other root child so it wins the hit test on overlap.
2115
+ const menuButton = new BoxRenderable(renderer, {
2116
+ position: "absolute",
2117
+ bottom: 1,
2118
+ right: 1,
2119
+ height: 1,
2120
+ paddingX: 1,
2121
+ backgroundColor: colors.surface,
2122
+ focusable: false,
2123
+ });
2124
+ const menuButtonLabel = new TextRenderable(renderer, {
2125
+ ...NON_INTERACTIVE_TEXT,
2126
+ content: "☰ menu",
2127
+ fg: colors.muted,
2128
+ bg: colors.surface,
2129
+ height: 1,
1970
2130
  });
2131
+ menuButton.add(menuButtonLabel);
2132
+ root.add(menuButton);
2133
+
2134
+ menuButton.onMouseDown = (event) => {
2135
+ event.stopPropagation();
2136
+ // Toggle, so clicking the button twice returns to where you were.
2137
+ if (currentOverlay === "leader") showOverlay("none");
2138
+ else showLeaderMenu();
2139
+ renderer.requestRender();
2140
+ };
2141
+ menuButton.onMouseOver = () => {
2142
+ renderer.setMousePointer("pointer");
2143
+ menuButtonLabel.fg = colors.accent;
2144
+ renderer.requestRender();
2145
+ };
2146
+ menuButton.onMouseOut = () => {
2147
+ renderer.setMousePointer("default");
2148
+ menuButtonLabel.fg = colors.muted;
2149
+ renderer.requestRender();
2150
+ };
2151
+
2152
+ // Held at module scope so cleanup() (a hoisted function declaration, called
2153
+ // from key handlers defined above the manager's creation) can still reach it.
2154
+ activeStatusMgr = createStatusManager(statusBar, () =>
2155
+ renderer.requestRender(),
2156
+ );
2157
+ const statusMgr = activeStatusMgr;
2158
+ // The renderer tears down its renderables on Ctrl+C / SIGTERM / terminal
2159
+ // close, but the 120ms spinner interval is ours. Without this, the next tick
2160
+ // assigns to a destroyed TextBuffer and throws out of a timer callback.
2161
+ renderer.on("destroy", () => statusMgr.dispose());
1971
2162
  setTimeout(() => {
1972
2163
  if (currentOverlay === "none") {
1973
2164
  setStatus(nextTip());
@@ -2713,6 +2904,192 @@ ${pack.description ?? ""}`;
2713
2904
  }
2714
2905
  });
2715
2906
 
2907
+ // ── Mouse on the results list ───────────────────────────────────────
2908
+ // Click selects (the `selectionChanged` handler above refreshes the detail
2909
+ // pane), double-click opens via the same path as Enter, and the wheel steps
2910
+ // the selection. `keepFocus` stops OpenTUI from auto-focusing the list away
2911
+ // from the query input, so typing after a click still edits the query.
2912
+ attachClickToSelect(renderer, resultsSelect, {
2913
+ keepFocus: true,
2914
+ onOpen: () => resultsSelect.selectCurrent(),
2915
+ });
2916
+
2917
+ /**
2918
+ * Enter-equivalent for every list overlay. Single source of truth for both
2919
+ * the keyboard handler below and the overlay mouse wiring, so a click can
2920
+ * never drift from pressing Enter.
2921
+ */
2922
+ function activateOverlaySelection() {
2923
+ switch (currentOverlay) {
2924
+ case "history": {
2925
+ const sel = historySelect.getSelectedOption();
2926
+ if (sel?.value) {
2927
+ const entry = (sel.value as { entry?: HistoryEntry }).entry;
2928
+ showOverlay("none");
2929
+ if (!entry) return;
2930
+ if (entry.mode === "trending") {
2931
+ if (entry.tab)
2932
+ trendingTab = entry.tab as (typeof TAB_NAMES)[number];
2933
+ loadTrending();
2934
+ } else {
2935
+ searchInput.value = entry.query;
2936
+ showSearchMode();
2937
+ doSearch(entry.query);
2938
+ }
2939
+ }
2940
+ return;
2941
+ }
2942
+ case "bookmarks": {
2943
+ const sel = bookmarksSelect.getSelectedOption();
2944
+ if (sel?.value)
2945
+ openUrl((sel.value as { repo?: { url?: string } }).repo?.url ?? "");
2946
+ return;
2947
+ }
2948
+ case "saved": {
2949
+ const sel = savedSelect.getSelectedOption();
2950
+ if (sel?.value) {
2951
+ const s = sel.value as SavedSearch;
2952
+ showOverlay("none");
2953
+ touchSavedSearch(s.name);
2954
+ if (s.mode === "trending") {
2955
+ if (s.tab) trendingTab = s.tab as (typeof TAB_NAMES)[number];
2956
+ loadTrending();
2957
+ } else {
2958
+ searchInput.value = s.query;
2959
+ showSearchMode();
2960
+ currentSort = s.sort;
2961
+ currentLimit = s.limit;
2962
+ setToolbar();
2963
+ doSearch(s.query);
2964
+ }
2965
+ }
2966
+ return;
2967
+ }
2968
+ case "topics": {
2969
+ const sel = topicsSelect.getSelectedOption();
2970
+ if (sel?.value) {
2971
+ const topic = sel.value as { name?: string };
2972
+ showOverlay("none");
2973
+ searchInput.value = `topic:${topic.name ?? ""}`;
2974
+ showSearchMode();
2975
+ doSearch(`topic:${topic.name}`);
2976
+ }
2977
+ return;
2978
+ }
2979
+ case "export": {
2980
+ const sel = exportSelect.getSelectedOption();
2981
+ if (sel?.value && currentRepos.length > 0) {
2982
+ const format = sel.value as ExportFormat;
2983
+ const path = exportToFile(currentRepos, format);
2984
+ showOverlay("none");
2985
+ setStatus(`✓ Exported to ${path}`);
2986
+ } else {
2987
+ setStatus("No results to export");
2988
+ showOverlay("none");
2989
+ }
2990
+ renderer.requestRender();
2991
+ return;
2992
+ }
2993
+ case "share": {
2994
+ const sel = shareSelect.getSelectedOption();
2995
+ if (sel?.value) {
2996
+ const opt = resultsSelect.getSelectedOption();
2997
+ const repo = opt?.value as Repo | undefined;
2998
+ if (repo) {
2999
+ const text = formatShare(repo, sel.value as ShareFormat);
3000
+ copyToClipboard(text).then((ok) => {
3001
+ showOverlay("none");
3002
+ setStatus(ok ? "✓ Copied!" : "Clipboard not available");
3003
+ });
3004
+ }
3005
+ }
3006
+ renderer.requestRender();
3007
+ return;
3008
+ }
3009
+ case "help": {
3010
+ showOverlay("none");
3011
+ renderer.requestRender();
3012
+ return;
3013
+ }
3014
+ default:
3015
+ // Leader / update / readme / org / compare / notifications have their
3016
+ // own activation paths (see the key handler) or are not list-driven.
3017
+ return;
3018
+ }
3019
+ }
3020
+
3021
+ /**
3022
+ * Wire the overlay lists. A click selects, a double-click activates (the
3023
+ * overlay's Enter-equivalent), the wheel scrolls, and a click on the panel
3024
+ * outside the rows dismisses the overlay like Esc.
3025
+ */
3026
+ function attachOverlayMouse(
3027
+ box: Parameters<typeof attachClickOutside>[0],
3028
+ select: Parameters<typeof attachClickToSelect>[1],
3029
+ ) {
3030
+ attachClickToSelect(renderer, select, {
3031
+ onOpen: () => activateOverlaySelection(),
3032
+ onClickMiss: () => {
3033
+ showOverlay("none");
3034
+ renderer.requestRender();
3035
+ },
3036
+ });
3037
+ attachClickOutside(box, () => {
3038
+ showOverlay("none");
3039
+ renderer.requestRender();
3040
+ });
3041
+ }
3042
+
3043
+ attachOverlayMouse(historyBox, historySelect);
3044
+ attachOverlayMouse(bookmarksBox, bookmarksSelect);
3045
+ attachOverlayMouse(savedBox, savedSelect);
3046
+ attachOverlayMouse(topicsBox, topicsSelect);
3047
+ attachOverlayMouse(exportBox, exportSelect);
3048
+ attachOverlayMouse(shareBox, shareSelect);
3049
+
3050
+ // Read-only overlays have no rows to click, so the panel itself is the
3051
+ // dismiss target (same as Esc). Their ScrollBox children handle the wheel
3052
+ // natively; claiming it here stops the renderer from also feeding the
3053
+ // focused-renderable fallback.
3054
+ for (const panel of [helpBox, orgBox, readmeBox, compareBox, notifsBox]) {
3055
+ attachClickOutside(panel, () => {
3056
+ showOverlay("none");
3057
+ renderer.requestRender();
3058
+ });
3059
+ }
3060
+ for (const scroller of [helpScroll, orgScroll, readmeScroll, updateScroll]) {
3061
+ scroller.onMouseScroll = (event) => {
3062
+ event.stopPropagation();
3063
+ };
3064
+ }
3065
+
3066
+ // Leader menu: the list activates like Enter, but the dim layer behind the
3067
+ // floating menu is the click-outside target (the menu box itself is nested
3068
+ // inside it and stops propagation).
3069
+ attachClickToSelect(renderer, leaderSelect, {
3070
+ onOpen: () => {
3071
+ const entry = leaderSelect.getSelectedOption()?.value as
3072
+ MenuEntry | undefined;
3073
+ if (!entry) return;
3074
+ if (entry.type === "category") {
3075
+ pushMenuLevel(` Menu > ${entry.name}`, entry.name, entry.children, 0);
3076
+ } else {
3077
+ resetMenu();
3078
+ showOverlay("none");
3079
+ entry.action();
3080
+ }
3081
+ renderer.requestRender();
3082
+ },
3083
+ onClickMiss: () => {
3084
+ showOverlay("none");
3085
+ renderer.requestRender();
3086
+ },
3087
+ });
3088
+ attachClickOutside(leaderDim, () => {
3089
+ showOverlay("none");
3090
+ renderer.requestRender();
3091
+ });
3092
+
2716
3093
  // ── Global keyboard shortcuts ──────────────────────────────────────
2717
3094
  renderer.keyInput.on("keypress", (key) => {
2718
3095
  // ── Token prompt (own keyboard scope) ──────────────────────────
@@ -2825,21 +3202,7 @@ ${pack.description ?? ""}`;
2825
3202
  return;
2826
3203
  }
2827
3204
  if (key.name === "enter" || key.name === "return") {
2828
- const sel = historySelect.getSelectedOption();
2829
- if (sel?.value) {
2830
- const entry = (sel.value as { entry?: HistoryEntry }).entry;
2831
- showOverlay("none");
2832
- if (!entry) return;
2833
- if (entry.mode === "trending") {
2834
- if (entry.tab)
2835
- trendingTab = entry.tab as (typeof TAB_NAMES)[number];
2836
- loadTrending();
2837
- } else {
2838
- searchInput.value = entry.query;
2839
- showSearchMode();
2840
- doSearch(entry.query);
2841
- }
2842
- }
3205
+ activateOverlaySelection();
2843
3206
  return;
2844
3207
  }
2845
3208
  return;
@@ -2861,9 +3224,7 @@ ${pack.description ?? ""}`;
2861
3224
  return;
2862
3225
  }
2863
3226
  if (key.name === "enter" || key.name === "return") {
2864
- const sel = bookmarksSelect.getSelectedOption();
2865
- if (sel?.value)
2866
- openUrl((sel.value as { repo?: { url?: string } }).repo?.url ?? "");
3227
+ activateOverlaySelection();
2867
3228
  return;
2868
3229
  }
2869
3230
  return;
@@ -2882,23 +3243,7 @@ ${pack.description ?? ""}`;
2882
3243
  return;
2883
3244
  }
2884
3245
  if (key.name === "enter" || key.name === "return") {
2885
- const sel = savedSelect.getSelectedOption();
2886
- if (sel?.value) {
2887
- const s = sel.value as SavedSearch;
2888
- showOverlay("none");
2889
- touchSavedSearch(s.name);
2890
- if (s.mode === "trending") {
2891
- if (s.tab) trendingTab = s.tab as (typeof TAB_NAMES)[number];
2892
- loadTrending();
2893
- } else {
2894
- searchInput.value = s.query;
2895
- showSearchMode();
2896
- currentSort = s.sort;
2897
- currentLimit = s.limit;
2898
- setToolbar();
2899
- doSearch(s.query);
2900
- }
2901
- }
3246
+ activateOverlaySelection();
2902
3247
  return;
2903
3248
  }
2904
3249
  return;
@@ -2907,14 +3252,7 @@ ${pack.description ?? ""}`;
2907
3252
  // Topics explorer overlay
2908
3253
  if (currentOverlay === "topics") {
2909
3254
  if (key.name === "enter" || key.name === "return") {
2910
- const sel = topicsSelect.getSelectedOption();
2911
- if (sel?.value) {
2912
- const topic = sel.value as { name?: string };
2913
- showOverlay("none");
2914
- searchInput.value = `topic:${topic.name ?? ""}`;
2915
- showSearchMode();
2916
- doSearch(`topic:${topic.name}`);
2917
- }
3255
+ activateOverlaySelection();
2918
3256
  return;
2919
3257
  }
2920
3258
  return;
@@ -2923,17 +3261,7 @@ ${pack.description ?? ""}`;
2923
3261
  // Export overlay
2924
3262
  if (currentOverlay === "export") {
2925
3263
  if (key.name === "enter" || key.name === "return") {
2926
- const sel = exportSelect.getSelectedOption();
2927
- if (sel?.value && currentRepos.length > 0) {
2928
- const format = sel.value as ExportFormat;
2929
- const path = exportToFile(currentRepos, format);
2930
- showOverlay("none");
2931
- setStatus(`✓ Exported to ${path}`);
2932
- } else {
2933
- setStatus("No results to export");
2934
- showOverlay("none");
2935
- }
2936
- renderer.requestRender();
3264
+ activateOverlaySelection();
2937
3265
  return;
2938
3266
  }
2939
3267
  return;
@@ -2968,19 +3296,7 @@ ${pack.description ?? ""}`;
2968
3296
  // Share overlay
2969
3297
  if (currentOverlay === "share") {
2970
3298
  if (key.name === "enter" || key.name === "return") {
2971
- const sel = shareSelect.getSelectedOption();
2972
- if (sel?.value) {
2973
- const opt = resultsSelect.getSelectedOption();
2974
- const repo = opt?.value as Repo | undefined;
2975
- if (repo) {
2976
- const text = formatShare(repo, sel.value as ShareFormat);
2977
- copyToClipboard(text).then((ok) => {
2978
- showOverlay("none");
2979
- setStatus(ok ? "✓ Copied!" : "Clipboard not available");
2980
- });
2981
- }
2982
- }
2983
- renderer.requestRender();
3299
+ activateOverlaySelection();
2984
3300
  return;
2985
3301
  }
2986
3302
  return;
@@ -2988,8 +3304,7 @@ ${pack.description ?? ""}`;
2988
3304
 
2989
3305
  // Help overlay: any key closes
2990
3306
  if (currentOverlay === "help") {
2991
- showOverlay("none");
2992
- renderer.requestRender();
3307
+ activateOverlaySelection();
2993
3308
  return;
2994
3309
  }
2995
3310
 
@@ -3029,33 +3344,7 @@ ${pack.description ?? ""}`;
3029
3344
  return;
3030
3345
  }
3031
3346
  if (key.name === "enter" || key.name === "return") {
3032
- const action = ["now", "later", "never"][updateSelectedOption] as
3033
- "now" | "later" | "never" | undefined;
3034
- if (action === "now") {
3035
- showOverlay("none");
3036
- // ponytail: record the running version so the next launch detects the upgrade
3037
- recordPreUpdateState(cachedVersion ?? "");
3038
- performUpdate()
3039
- .then((ok) => {
3040
- if (ok) {
3041
- setStatus("✓ Update complete — restart ghfind");
3042
- } else {
3043
- setStatus("✗ Update failed — check terminal output");
3044
- }
3045
- })
3046
- .catch(() => {
3047
- setStatus("✗ Update failed — check terminal output");
3048
- });
3049
- } else if (action === "never") {
3050
- suppressUpdateNotices();
3051
- showOverlay("none");
3052
- setStatus("Update notices suppressed");
3053
- } else {
3054
- snoozeUpdateNotices(3);
3055
- showOverlay("none");
3056
- setStatus("Update reminder snoozed for 3 days");
3057
- }
3058
- renderer.requestRender();
3347
+ runUpdateOption(updateSelectedOption);
3059
3348
  return;
3060
3349
  }
3061
3350
  return;
@@ -3091,11 +3380,19 @@ ${pack.description ?? ""}`;
3091
3380
  return;
3092
3381
  }
3093
3382
 
3094
- // Space toggles leader menu (open if closed, close if open)
3095
- // Skip when the search input is focused so spaces can be typed in queries
3096
- // (overlays were already handled above, so this is always "open" here)
3097
- if (key.name === "space" && !searchInput.focused) {
3098
- showLeaderMenu();
3383
+ // Esc toggles the leader menu — the only binding for it. Space used to open it
3384
+ // when the query input was not focused, which made the same key mean two
3385
+ // things depending on invisible state; it is now reserved for typing.
3386
+ if (key.name === "escape") {
3387
+ if (currentOverlay !== "none") {
3388
+ // Only reachable for the leader menu — every other overlay returned
3389
+ // above, and its own branch already closed it.
3390
+ showOverlay("none");
3391
+ setToolbar();
3392
+ } else {
3393
+ showLeaderMenu();
3394
+ }
3395
+ renderer.requestRender();
3099
3396
  return;
3100
3397
  }
3101
3398
 
@@ -3246,12 +3543,18 @@ ${pack.description ?? ""}`;
3246
3543
  // ── Update check (before start) ──────────────────────────────────────
3247
3544
  const _currentVersion = getVersion();
3248
3545
  // ── Start ──────────────────────────────────────────────────────────
3249
- // Always start on the landing menu. Saved sessions restore preferences
3546
+ // Default is the landing menu. Saved sessions restore preferences
3250
3547
  // (sort/limit/trending tab) and pre-fill the query once a mode is picked,
3251
3548
  // but never auto-run a search — a stale or corrupted session (e.g. an MCP
3252
3549
  // handshake blob saved as a query) used to hijack startup and skip the
3253
- // menu on every launch.
3254
- showLanding(true);
3550
+ // menu on every launch. An explicit `ghfind --trending` is not a stale
3551
+ // session though: it is the mode the user asked for, so it skips the menu.
3552
+ if (startMode === "trending") {
3553
+ showLanding(false);
3554
+ loadTrending();
3555
+ } else {
3556
+ showLanding(true);
3557
+ }
3255
3558
  checkForUpdateAndShow();
3256
3559
  checkPostUpgradePanel();
3257
3560
  renderer.start();
@@ -3277,11 +3580,22 @@ function formatToolbar(
3277
3580
  }
3278
3581
 
3279
3582
  function cleanup(): void {
3583
+ // Stop the spinner before exiting: process.exit() would kill the process
3584
+ // anyway, but a tick already queued between here and the exit can still fire.
3585
+ activeStatusMgr?.dispose();
3586
+ activeStatusMgr = null;
3280
3587
  try {
3281
3588
  rotateHistory();
3282
3589
  } catch {
3283
3590
  /* non-critical */
3284
3591
  }
3592
+ // Release the terminal (raw mode, alternate screen) before the hard exit.
3593
+ try {
3594
+ activeRenderer?.destroy();
3595
+ } catch {
3596
+ /* non-critical */
3597
+ }
3598
+ activeRenderer = null;
3285
3599
  process.exit(0);
3286
3600
  }
3287
3601