github-search-cli 9.8.3 → 9.10.0

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
@@ -31,6 +31,7 @@ import {
31
31
  SelectRenderable,
32
32
  ScrollBoxRenderable,
33
33
  } from "@opentui/core";
34
+ import { appendFileSync } from "node:fs";
34
35
  import type { CliRenderer } from "@opentui/core";
35
36
  import type {
36
37
  Repo,
@@ -47,6 +48,8 @@ import {
47
48
  applyFlagFilters,
48
49
  validateQuery,
49
50
  suggestFor,
51
+ suggestRelaxations,
52
+ trendingQuery,
50
53
  rankRepos,
51
54
  createGitHubSearch,
52
55
  SearchModule,
@@ -54,6 +57,13 @@ import {
54
57
  type Logger,
55
58
  } from "./search";
56
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";
57
67
  import { loadConfig, saveConfig } from "./config";
58
68
  import { buildHelpSections, HELP_KEYS_COLUMN } from "./help";
59
69
  import {
@@ -120,6 +130,7 @@ import {
120
130
  fetchReleaseNotes,
121
131
  readUpdateState,
122
132
  recordPreUpdateState,
133
+ markPostUpgradeSeen,
123
134
  } from "./update-check";
124
135
  import { debugLog } from "./storage";
125
136
  import {
@@ -152,12 +163,41 @@ const colors: Record<string, string> = {
152
163
  };
153
164
 
154
165
  // ─── Logger ───────────────────────────────────────────────────────────
155
- const logger: Logger = {
156
- debug: () => {},
157
- info: () => {},
158
- warn: () => {},
159
- error: () => {},
160
- };
166
+ /**
167
+ * TUI diagnostic log.
168
+ *
169
+ * The TUI owns the screen, so it cannot print debug output to stdout without
170
+ * corrupting the render — and the logger used to be a silent no-op, which made
171
+ * API failures (403/401) undiagnosable from the status-bar message alone.
172
+ *
173
+ * Set GHFIND_LOG=<path> to append timestamped lines to that file. Unset (the
174
+ * default) keeps logging completely off, so there is no cost in normal use.
175
+ * Only the shape of a response is recorded; tokens and headers are redacted.
176
+ */
177
+ function createTuiLogger(): Logger {
178
+ const target = process.env.GHFIND_LOG;
179
+ if (!target) {
180
+ return { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
181
+ }
182
+ const write = (level: string, msg: string) => {
183
+ try {
184
+ appendFileSync(
185
+ target,
186
+ `[${new Date().toISOString()}] [${level}] ${msg}\n`,
187
+ );
188
+ } catch {
189
+ // A broken log path must never take down the TUI.
190
+ }
191
+ };
192
+ return {
193
+ debug: (m) => write("debug", m),
194
+ info: (m) => write("info", m),
195
+ warn: (m) => write("warn", m),
196
+ error: (m) => write("error", m),
197
+ };
198
+ }
199
+
200
+ const logger: Logger = createTuiLogger();
161
201
 
162
202
  // ─── Sort cycle ───────────────────────────────────────────────────────
163
203
  const SORT_MODES: { key: SortStrategy; label: string }[] = [
@@ -277,7 +317,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
277
317
 
278
318
  const header = new TextRenderable(renderer, {
279
319
  content:
280
- " ghfind — GitHub repo browser [/]search [Space]menu [\u2192]open [?]help [q]uit",
320
+ " ghfind — GitHub repo browser [/]search [Esc]menu [\u2192]open [?]help [q]uit · click select · dbl-click open",
281
321
  bg: colors.bg,
282
322
  fg: colors.muted,
283
323
  height: 1,
@@ -302,15 +342,26 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
302
342
  const isActive = name === trendingTab;
303
343
  const label = ` ${name} `;
304
344
  const tt = new TextRenderable(renderer, {
345
+ ...NON_INTERACTIVE_TEXT,
305
346
  content: label,
306
347
  fg: isActive ? colors.bg : colors.muted,
307
348
  bg: isActive ? colors.blue : colors.bg,
308
349
  height: 1,
309
350
  });
351
+ // Click a tab to switch to it — same path as the 1-5 number keys.
352
+ tt.onMouseDown = (event) => {
353
+ event.stopPropagation();
354
+ if (name === trendingTab) return;
355
+ trendingTab = name;
356
+ loadTrending();
357
+ };
358
+ tt.onMouseOver = () => renderer.setMousePointer("pointer");
359
+ tt.onMouseOut = () => renderer.setMousePointer("default");
310
360
  trendingTabTexts.push(tt);
311
361
  trendingTabBox.add(tt);
312
362
  if (i < TAB_NAMES.length - 1) {
313
363
  const sp = new TextRenderable(renderer, {
364
+ ...NON_INTERACTIVE_TEXT,
314
365
  content: " ",
315
366
  fg: colors.muted,
316
367
  bg: colors.bg,
@@ -409,6 +460,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
409
460
  content: "",
410
461
  fg: colors.text,
411
462
  wrapMode: "none",
463
+ // Non-interactive: a left-press here would otherwise be swallowed as a
464
+ // text-selection drag instead of reaching the click handlers.
465
+ selectable: false,
412
466
  });
413
467
  detailBox.add(detailText);
414
468
 
@@ -425,6 +479,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
425
479
  });
426
480
  root.add(landingBox);
427
481
  const landingBanner = new TextRenderable(renderer, {
482
+ ...NON_INTERACTIVE_TEXT,
428
483
  content: [
429
484
  " ██████╗ ██╗ ██╗███████╗██╗███╗ ██╗██████╗ ",
430
485
  "██╔════╝ ██║ ██║██╔════╝██║████╗ ██║██╔══██╗",
@@ -507,8 +562,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
507
562
  }
508
563
 
509
564
  const landingHint = new TextRenderable(renderer, {
565
+ ...NON_INTERACTIVE_TEXT,
510
566
  content:
511
- " \u2191\u2193 Navigate \u21E9 Select [b]ookmarks [?]help [q]uit",
567
+ " \u2191\u2193 Navigate \u21E9 Select [b]ookmarks [?]help [q]uit · click to pick a mode",
512
568
  fg: colors.muted,
513
569
  bg: colors.bg,
514
570
  height: 1,
@@ -539,6 +595,34 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
539
595
  }
540
596
  }
541
597
 
598
+ // ── Landing mouse ───────────────────────────────────────────
599
+ // Click selects, double-click runs the card's action (same as Enter), hover
600
+ // shows a pointer cursor. `updateLandingCards` is the single source of truth
601
+ // for the card colours, so hover and selection cannot drift.
602
+ landingCards.forEach((card, i) => {
603
+ let lastClickAt = 0;
604
+ card.onMouseDown = (event) => {
605
+ event.stopPropagation();
606
+ const now = Date.now();
607
+ const isDouble =
608
+ landingSelected === i && now - lastClickAt < DOUBLE_CLICK_MS;
609
+ lastClickAt = now;
610
+ if (isDouble) {
611
+ landingOptions[i].action();
612
+ return;
613
+ }
614
+ landingSelected = i;
615
+ updateLandingCards();
616
+ renderer.requestRender();
617
+ };
618
+ card.onMouseOver = () => {
619
+ renderer.setMousePointer("pointer");
620
+ };
621
+ card.onMouseOut = () => {
622
+ renderer.setMousePointer("default");
623
+ };
624
+ });
625
+
542
626
  // Landing keyboard handler. Registered before the global handler below,
543
627
  // and both listeners receive the same key event — so keys the landing
544
628
  // screen consumes must be claimed with stopPropagation(), or the global
@@ -612,6 +696,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
612
696
  updateDim.visible = false;
613
697
  if (type === "none") {
614
698
  currentOverlay = "none";
699
+ menuButton.visible = true;
615
700
  if (currentMode === "landing") {
616
701
  landingBox.visible = true;
617
702
  body.visible = false;
@@ -625,6 +710,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
625
710
  return;
626
711
  }
627
712
  currentOverlay = type;
713
+ // The button floats above the layout, so it would sit on top of every
714
+ // full-screen overlay (and stay clickable through the dim layer).
715
+ menuButton.visible = false;
628
716
  // An overlay opened from the landing screen must hide the menu too
629
717
  // (hideMainContent only covers the search/trending/toolbar boxes).
630
718
  landingBox.visible = false;
@@ -912,6 +1000,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
912
1000
  content: "",
913
1001
  fg: colors.text,
914
1002
  bg: colors.bg,
1003
+ selectable: false,
915
1004
  });
916
1005
  compareBox.add(compareText);
917
1006
  root.add(compareBox);
@@ -1007,7 +1096,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1007
1096
  savedSelect.setSelectedIndex(0);
1008
1097
  }
1009
1098
 
1010
- // ── Leader menu overlay (Space key) ─────────────────────────────
1099
+ // ── Leader menu overlay (Esc key) ─────────────────────────────
1011
1100
  // Dim overlay — sits behind the menu, above main content
1012
1101
  const leaderDim = new BoxRenderable(renderer, {
1013
1102
  visible: false,
@@ -1053,7 +1142,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1053
1142
  itemSpacing: 0,
1054
1143
  });
1055
1144
  const leaderFooter = new TextRenderable(renderer, {
1056
- content: " ↑↓ select Enter run Esc/q close ",
1145
+ ...NON_INTERACTIVE_TEXT,
1146
+ content:
1147
+ " ↑↓ select Enter run Esc/q close · click to run click outside to close ",
1057
1148
  fg: colors.muted,
1058
1149
  bg: colors.surfaceDim,
1059
1150
  height: 1,
@@ -1203,11 +1294,77 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1203
1294
  renderer.requestRender();
1204
1295
  }
1205
1296
 
1297
+ // ── Update panel mouse ────────────────────────────────────────────
1298
+ // Click a card to select *and* run it in one gesture: these three options are
1299
+ // terminal actions (install now / snooze / suppress), and a modal that needs
1300
+ // select-then-Enter would be two clicks for the common case. `renderUpdateOptions`
1301
+ // stays the single source of truth for the colours.
1302
+ function runUpdateOption(index: number) {
1303
+ updateSelectedOption = index;
1304
+ renderUpdateOptions();
1305
+ const action = ["now", "later", "never"][index] as
1306
+ "now" | "later" | "never" | undefined;
1307
+ if (action === "now") {
1308
+ showOverlay("none");
1309
+ // ponytail: record the running version so the next launch detects the upgrade
1310
+ recordPreUpdateState(cachedVersion ?? "");
1311
+ performUpdate()
1312
+ .then((ok) => {
1313
+ if (ok) {
1314
+ setStatus("✓ Update complete — restart ghfind");
1315
+ } else {
1316
+ setStatus("✗ Update failed — check terminal output");
1317
+ }
1318
+ })
1319
+ .catch(() => {
1320
+ setStatus("✗ Update failed — check terminal output");
1321
+ });
1322
+ } else if (action === "never") {
1323
+ suppressUpdateNotices();
1324
+ showOverlay("none");
1325
+ setStatus("Update notices suppressed");
1326
+ } else {
1327
+ snoozeUpdateNotices(3);
1328
+ showOverlay("none");
1329
+ setStatus("Update reminder snoozed for 3 days");
1330
+ }
1331
+ renderer.requestRender();
1332
+ }
1333
+
1334
+ const updateCards: [BoxRenderable, number][] = [
1335
+ [updateNowBox, 0],
1336
+ [laterBox, 1],
1337
+ [neverBox, 2],
1338
+ ];
1339
+ for (const [box, index] of updateCards) {
1340
+ attachClickableBox(renderer, box, {
1341
+ onActivate: () => runUpdateOption(index),
1342
+ idleBorder:
1343
+ updateSelectedOption === index ? colors.yellow : colors.border,
1344
+ activeBorder: colors.yellow,
1345
+ // Hovering must not fight the selection highlight, so it only moves the
1346
+ // pointer cursor; the border stays owned by renderUpdateOptions().
1347
+ onStateChange: () => {
1348
+ box.borderColor =
1349
+ updateSelectedOption === index ? colors.yellow : colors.border;
1350
+ },
1351
+ });
1352
+ }
1353
+
1354
+ // Clicking the dim layer behind the panel dismisses it without applying
1355
+ // anything (equivalent to Esc).
1356
+ attachClickOutside(updateDim, () => {
1357
+ showOverlay("none");
1358
+ renderer.requestRender();
1359
+ });
1360
+
1206
1361
  updateOptionsRow.add(updateNowBox);
1207
1362
  updateOptionsRow.add(laterBox);
1208
1363
 
1209
1364
  const updateFooter = new TextRenderable(renderer, {
1210
- content: " ↑↓/jk/PgUp/PgDn scroll ←→ select Enter Esc/q close",
1365
+ ...NON_INTERACTIVE_TEXT,
1366
+ content:
1367
+ " ↑↓/jk/PgUp/PgDn scroll ←→ select Enter Esc/q close · click a button click outside to close",
1211
1368
  fg: colors.muted,
1212
1369
  bg: colors.surfaceDim,
1213
1370
  height: 1,
@@ -1625,6 +1782,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1625
1782
  if (state.lastInstalledVersion === currentVersion) return;
1626
1783
  // We just upgraded — show notes for the new version
1627
1784
  const notes = fetchReleaseNotes(currentVersion);
1785
+ // Acknowledge immediately: this panel's trigger is a version inequality,
1786
+ // so leaving it unacknowledged makes it reappear on every launch.
1787
+ markPostUpgradeSeen(currentVersion);
1628
1788
  updateSelectedOption = 0;
1629
1789
  updateHeader.content = ` Updated ghfind ${state.lastInstalledVersion} → ${currentVersion}\n`;
1630
1790
  updateNotes.setContent(
@@ -1928,6 +2088,48 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1928
2088
  });
1929
2089
  root.add(statusBar);
1930
2090
 
2091
+ // ── Menu button (bottom-right corner) ─────────────────────────────
2092
+ // Mouse-only affordance: Space types a space in the query, so a mouse user
2093
+ // needs a target to reach the command menu. Absolutely positioned so it
2094
+ // floats over the results pane instead of stealing a flex row, and added
2095
+ // after every other root child so it wins the hit test on overlap.
2096
+ const menuButton = new BoxRenderable(renderer, {
2097
+ position: "absolute",
2098
+ bottom: 1,
2099
+ right: 1,
2100
+ height: 1,
2101
+ paddingX: 1,
2102
+ backgroundColor: colors.surface,
2103
+ focusable: false,
2104
+ });
2105
+ const menuButtonLabel = new TextRenderable(renderer, {
2106
+ ...NON_INTERACTIVE_TEXT,
2107
+ content: "☰ menu",
2108
+ fg: colors.muted,
2109
+ bg: colors.surface,
2110
+ height: 1,
2111
+ });
2112
+ menuButton.add(menuButtonLabel);
2113
+ root.add(menuButton);
2114
+
2115
+ menuButton.onMouseDown = (event) => {
2116
+ event.stopPropagation();
2117
+ // Toggle, so clicking the button twice returns to where you were.
2118
+ if (currentOverlay === "leader") showOverlay("none");
2119
+ else showLeaderMenu();
2120
+ renderer.requestRender();
2121
+ };
2122
+ menuButton.onMouseOver = () => {
2123
+ renderer.setMousePointer("pointer");
2124
+ menuButtonLabel.fg = colors.accent;
2125
+ renderer.requestRender();
2126
+ };
2127
+ menuButton.onMouseOut = () => {
2128
+ renderer.setMousePointer("default");
2129
+ menuButtonLabel.fg = colors.muted;
2130
+ renderer.requestRender();
2131
+ };
2132
+
1931
2133
  const statusMgr = new StatusManager((text) => {
1932
2134
  statusBar.content = ` ${text}`;
1933
2135
  renderer.requestRender();
@@ -2365,16 +2567,13 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
2365
2567
 
2366
2568
  try {
2367
2569
  const searchModule = new SearchModule(new TrendingAdapter());
2368
- const response = await searchModule.search(
2369
- { keywords: [], qualifiers: [], raw: "trending" },
2370
- {
2371
- limit: 25,
2372
- sort: "stars",
2373
- json: false,
2374
- verbose: false,
2375
- trendingSince: tabSince(trendingTab),
2376
- },
2377
- );
2570
+ const response = await searchModule.search(trendingQuery(), {
2571
+ limit: 25,
2572
+ sort: "stars",
2573
+ json: false,
2574
+ verbose: false,
2575
+ trendingSince: tabSince(trendingTab),
2576
+ });
2378
2577
  const fetched = response.repos;
2379
2578
  const since = tabSince(trendingTab);
2380
2579
  trendingPeriod =
@@ -2528,7 +2727,12 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
2528
2727
  }
2529
2728
 
2530
2729
  if (currentRepos.length === 0) {
2531
- throw new NoResultsError(q);
2730
+ // A zero-result search usually means one qualifier over-constrained
2731
+ // it. Offer the ranked loosenings instead of generic advice.
2732
+ throw new NoResultsError(
2733
+ q,
2734
+ suggestRelaxations(parsed).map((r) => r.suggestion),
2735
+ );
2532
2736
  } else {
2533
2737
  resultsSelect.options = currentRepos.map((r) => ({
2534
2738
  name: formatResultLine(r),
@@ -2675,6 +2879,192 @@ ${pack.description ?? ""}`;
2675
2879
  }
2676
2880
  });
2677
2881
 
2882
+ // ── Mouse on the results list ───────────────────────────────────────
2883
+ // Click selects (the `selectionChanged` handler above refreshes the detail
2884
+ // pane), double-click opens via the same path as Enter, and the wheel steps
2885
+ // the selection. `keepFocus` stops OpenTUI from auto-focusing the list away
2886
+ // from the query input, so typing after a click still edits the query.
2887
+ attachClickToSelect(renderer, resultsSelect, {
2888
+ keepFocus: true,
2889
+ onOpen: () => resultsSelect.selectCurrent(),
2890
+ });
2891
+
2892
+ /**
2893
+ * Enter-equivalent for every list overlay. Single source of truth for both
2894
+ * the keyboard handler below and the overlay mouse wiring, so a click can
2895
+ * never drift from pressing Enter.
2896
+ */
2897
+ function activateOverlaySelection() {
2898
+ switch (currentOverlay) {
2899
+ case "history": {
2900
+ const sel = historySelect.getSelectedOption();
2901
+ if (sel?.value) {
2902
+ const entry = (sel.value as { entry?: HistoryEntry }).entry;
2903
+ showOverlay("none");
2904
+ if (!entry) return;
2905
+ if (entry.mode === "trending") {
2906
+ if (entry.tab)
2907
+ trendingTab = entry.tab as (typeof TAB_NAMES)[number];
2908
+ loadTrending();
2909
+ } else {
2910
+ searchInput.value = entry.query;
2911
+ showSearchMode();
2912
+ doSearch(entry.query);
2913
+ }
2914
+ }
2915
+ return;
2916
+ }
2917
+ case "bookmarks": {
2918
+ const sel = bookmarksSelect.getSelectedOption();
2919
+ if (sel?.value)
2920
+ openUrl((sel.value as { repo?: { url?: string } }).repo?.url ?? "");
2921
+ return;
2922
+ }
2923
+ case "saved": {
2924
+ const sel = savedSelect.getSelectedOption();
2925
+ if (sel?.value) {
2926
+ const s = sel.value as SavedSearch;
2927
+ showOverlay("none");
2928
+ touchSavedSearch(s.name);
2929
+ if (s.mode === "trending") {
2930
+ if (s.tab) trendingTab = s.tab as (typeof TAB_NAMES)[number];
2931
+ loadTrending();
2932
+ } else {
2933
+ searchInput.value = s.query;
2934
+ showSearchMode();
2935
+ currentSort = s.sort;
2936
+ currentLimit = s.limit;
2937
+ setToolbar();
2938
+ doSearch(s.query);
2939
+ }
2940
+ }
2941
+ return;
2942
+ }
2943
+ case "topics": {
2944
+ const sel = topicsSelect.getSelectedOption();
2945
+ if (sel?.value) {
2946
+ const topic = sel.value as { name?: string };
2947
+ showOverlay("none");
2948
+ searchInput.value = `topic:${topic.name ?? ""}`;
2949
+ showSearchMode();
2950
+ doSearch(`topic:${topic.name}`);
2951
+ }
2952
+ return;
2953
+ }
2954
+ case "export": {
2955
+ const sel = exportSelect.getSelectedOption();
2956
+ if (sel?.value && currentRepos.length > 0) {
2957
+ const format = sel.value as ExportFormat;
2958
+ const path = exportToFile(currentRepos, format);
2959
+ showOverlay("none");
2960
+ setStatus(`✓ Exported to ${path}`);
2961
+ } else {
2962
+ setStatus("No results to export");
2963
+ showOverlay("none");
2964
+ }
2965
+ renderer.requestRender();
2966
+ return;
2967
+ }
2968
+ case "share": {
2969
+ const sel = shareSelect.getSelectedOption();
2970
+ if (sel?.value) {
2971
+ const opt = resultsSelect.getSelectedOption();
2972
+ const repo = opt?.value as Repo | undefined;
2973
+ if (repo) {
2974
+ const text = formatShare(repo, sel.value as ShareFormat);
2975
+ copyToClipboard(text).then((ok) => {
2976
+ showOverlay("none");
2977
+ setStatus(ok ? "✓ Copied!" : "Clipboard not available");
2978
+ });
2979
+ }
2980
+ }
2981
+ renderer.requestRender();
2982
+ return;
2983
+ }
2984
+ case "help": {
2985
+ showOverlay("none");
2986
+ renderer.requestRender();
2987
+ return;
2988
+ }
2989
+ default:
2990
+ // Leader / update / readme / org / compare / notifications have their
2991
+ // own activation paths (see the key handler) or are not list-driven.
2992
+ return;
2993
+ }
2994
+ }
2995
+
2996
+ /**
2997
+ * Wire the overlay lists. A click selects, a double-click activates (the
2998
+ * overlay's Enter-equivalent), the wheel scrolls, and a click on the panel
2999
+ * outside the rows dismisses the overlay like Esc.
3000
+ */
3001
+ function attachOverlayMouse(
3002
+ box: Parameters<typeof attachClickOutside>[0],
3003
+ select: Parameters<typeof attachClickToSelect>[1],
3004
+ ) {
3005
+ attachClickToSelect(renderer, select, {
3006
+ onOpen: () => activateOverlaySelection(),
3007
+ onClickMiss: () => {
3008
+ showOverlay("none");
3009
+ renderer.requestRender();
3010
+ },
3011
+ });
3012
+ attachClickOutside(box, () => {
3013
+ showOverlay("none");
3014
+ renderer.requestRender();
3015
+ });
3016
+ }
3017
+
3018
+ attachOverlayMouse(historyBox, historySelect);
3019
+ attachOverlayMouse(bookmarksBox, bookmarksSelect);
3020
+ attachOverlayMouse(savedBox, savedSelect);
3021
+ attachOverlayMouse(topicsBox, topicsSelect);
3022
+ attachOverlayMouse(exportBox, exportSelect);
3023
+ attachOverlayMouse(shareBox, shareSelect);
3024
+
3025
+ // Read-only overlays have no rows to click, so the panel itself is the
3026
+ // dismiss target (same as Esc). Their ScrollBox children handle the wheel
3027
+ // natively; claiming it here stops the renderer from also feeding the
3028
+ // focused-renderable fallback.
3029
+ for (const panel of [helpBox, orgBox, readmeBox, compareBox, notifsBox]) {
3030
+ attachClickOutside(panel, () => {
3031
+ showOverlay("none");
3032
+ renderer.requestRender();
3033
+ });
3034
+ }
3035
+ for (const scroller of [helpScroll, orgScroll, readmeScroll, updateScroll]) {
3036
+ scroller.onMouseScroll = (event) => {
3037
+ event.stopPropagation();
3038
+ };
3039
+ }
3040
+
3041
+ // Leader menu: the list activates like Enter, but the dim layer behind the
3042
+ // floating menu is the click-outside target (the menu box itself is nested
3043
+ // inside it and stops propagation).
3044
+ attachClickToSelect(renderer, leaderSelect, {
3045
+ onOpen: () => {
3046
+ const entry = leaderSelect.getSelectedOption()?.value as
3047
+ MenuEntry | undefined;
3048
+ if (!entry) return;
3049
+ if (entry.type === "category") {
3050
+ pushMenuLevel(` Menu > ${entry.name}`, entry.name, entry.children, 0);
3051
+ } else {
3052
+ resetMenu();
3053
+ showOverlay("none");
3054
+ entry.action();
3055
+ }
3056
+ renderer.requestRender();
3057
+ },
3058
+ onClickMiss: () => {
3059
+ showOverlay("none");
3060
+ renderer.requestRender();
3061
+ },
3062
+ });
3063
+ attachClickOutside(leaderDim, () => {
3064
+ showOverlay("none");
3065
+ renderer.requestRender();
3066
+ });
3067
+
2678
3068
  // ── Global keyboard shortcuts ──────────────────────────────────────
2679
3069
  renderer.keyInput.on("keypress", (key) => {
2680
3070
  // ── Token prompt (own keyboard scope) ──────────────────────────
@@ -2787,21 +3177,7 @@ ${pack.description ?? ""}`;
2787
3177
  return;
2788
3178
  }
2789
3179
  if (key.name === "enter" || key.name === "return") {
2790
- const sel = historySelect.getSelectedOption();
2791
- if (sel?.value) {
2792
- const entry = (sel.value as { entry?: HistoryEntry }).entry;
2793
- showOverlay("none");
2794
- if (!entry) return;
2795
- if (entry.mode === "trending") {
2796
- if (entry.tab)
2797
- trendingTab = entry.tab as (typeof TAB_NAMES)[number];
2798
- loadTrending();
2799
- } else {
2800
- searchInput.value = entry.query;
2801
- showSearchMode();
2802
- doSearch(entry.query);
2803
- }
2804
- }
3180
+ activateOverlaySelection();
2805
3181
  return;
2806
3182
  }
2807
3183
  return;
@@ -2823,9 +3199,7 @@ ${pack.description ?? ""}`;
2823
3199
  return;
2824
3200
  }
2825
3201
  if (key.name === "enter" || key.name === "return") {
2826
- const sel = bookmarksSelect.getSelectedOption();
2827
- if (sel?.value)
2828
- openUrl((sel.value as { repo?: { url?: string } }).repo?.url ?? "");
3202
+ activateOverlaySelection();
2829
3203
  return;
2830
3204
  }
2831
3205
  return;
@@ -2844,23 +3218,7 @@ ${pack.description ?? ""}`;
2844
3218
  return;
2845
3219
  }
2846
3220
  if (key.name === "enter" || key.name === "return") {
2847
- const sel = savedSelect.getSelectedOption();
2848
- if (sel?.value) {
2849
- const s = sel.value as SavedSearch;
2850
- showOverlay("none");
2851
- touchSavedSearch(s.name);
2852
- if (s.mode === "trending") {
2853
- if (s.tab) trendingTab = s.tab as (typeof TAB_NAMES)[number];
2854
- loadTrending();
2855
- } else {
2856
- searchInput.value = s.query;
2857
- showSearchMode();
2858
- currentSort = s.sort;
2859
- currentLimit = s.limit;
2860
- setToolbar();
2861
- doSearch(s.query);
2862
- }
2863
- }
3221
+ activateOverlaySelection();
2864
3222
  return;
2865
3223
  }
2866
3224
  return;
@@ -2869,14 +3227,7 @@ ${pack.description ?? ""}`;
2869
3227
  // Topics explorer overlay
2870
3228
  if (currentOverlay === "topics") {
2871
3229
  if (key.name === "enter" || key.name === "return") {
2872
- const sel = topicsSelect.getSelectedOption();
2873
- if (sel?.value) {
2874
- const topic = sel.value as { name?: string };
2875
- showOverlay("none");
2876
- searchInput.value = `topic:${topic.name ?? ""}`;
2877
- showSearchMode();
2878
- doSearch(`topic:${topic.name}`);
2879
- }
3230
+ activateOverlaySelection();
2880
3231
  return;
2881
3232
  }
2882
3233
  return;
@@ -2885,17 +3236,7 @@ ${pack.description ?? ""}`;
2885
3236
  // Export overlay
2886
3237
  if (currentOverlay === "export") {
2887
3238
  if (key.name === "enter" || key.name === "return") {
2888
- const sel = exportSelect.getSelectedOption();
2889
- if (sel?.value && currentRepos.length > 0) {
2890
- const format = sel.value as ExportFormat;
2891
- const path = exportToFile(currentRepos, format);
2892
- showOverlay("none");
2893
- setStatus(`✓ Exported to ${path}`);
2894
- } else {
2895
- setStatus("No results to export");
2896
- showOverlay("none");
2897
- }
2898
- renderer.requestRender();
3239
+ activateOverlaySelection();
2899
3240
  return;
2900
3241
  }
2901
3242
  return;
@@ -2930,19 +3271,7 @@ ${pack.description ?? ""}`;
2930
3271
  // Share overlay
2931
3272
  if (currentOverlay === "share") {
2932
3273
  if (key.name === "enter" || key.name === "return") {
2933
- const sel = shareSelect.getSelectedOption();
2934
- if (sel?.value) {
2935
- const opt = resultsSelect.getSelectedOption();
2936
- const repo = opt?.value as Repo | undefined;
2937
- if (repo) {
2938
- const text = formatShare(repo, sel.value as ShareFormat);
2939
- copyToClipboard(text).then((ok) => {
2940
- showOverlay("none");
2941
- setStatus(ok ? "✓ Copied!" : "Clipboard not available");
2942
- });
2943
- }
2944
- }
2945
- renderer.requestRender();
3274
+ activateOverlaySelection();
2946
3275
  return;
2947
3276
  }
2948
3277
  return;
@@ -2950,8 +3279,7 @@ ${pack.description ?? ""}`;
2950
3279
 
2951
3280
  // Help overlay: any key closes
2952
3281
  if (currentOverlay === "help") {
2953
- showOverlay("none");
2954
- renderer.requestRender();
3282
+ activateOverlaySelection();
2955
3283
  return;
2956
3284
  }
2957
3285
 
@@ -2991,33 +3319,7 @@ ${pack.description ?? ""}`;
2991
3319
  return;
2992
3320
  }
2993
3321
  if (key.name === "enter" || key.name === "return") {
2994
- const action = ["now", "later", "never"][updateSelectedOption] as
2995
- "now" | "later" | "never" | undefined;
2996
- if (action === "now") {
2997
- showOverlay("none");
2998
- // ponytail: record the running version so the next launch detects the upgrade
2999
- recordPreUpdateState(cachedVersion ?? "");
3000
- performUpdate()
3001
- .then((ok) => {
3002
- if (ok) {
3003
- setStatus("✓ Update complete — restart ghfind");
3004
- } else {
3005
- setStatus("✗ Update failed — check terminal output");
3006
- }
3007
- })
3008
- .catch(() => {
3009
- setStatus("✗ Update failed — check terminal output");
3010
- });
3011
- } else if (action === "never") {
3012
- suppressUpdateNotices();
3013
- showOverlay("none");
3014
- setStatus("Update notices suppressed");
3015
- } else {
3016
- snoozeUpdateNotices(3);
3017
- showOverlay("none");
3018
- setStatus("Update reminder snoozed for 3 days");
3019
- }
3020
- renderer.requestRender();
3322
+ runUpdateOption(updateSelectedOption);
3021
3323
  return;
3022
3324
  }
3023
3325
  return;
@@ -3053,11 +3355,19 @@ ${pack.description ?? ""}`;
3053
3355
  return;
3054
3356
  }
3055
3357
 
3056
- // Space toggles leader menu (open if closed, close if open)
3057
- // Skip when the search input is focused so spaces can be typed in queries
3058
- // (overlays were already handled above, so this is always "open" here)
3059
- if (key.name === "space" && !searchInput.focused) {
3060
- showLeaderMenu();
3358
+ // Esc toggles the leader menu — the only binding for it. Space used to open it
3359
+ // when the query input was not focused, which made the same key mean two
3360
+ // things depending on invisible state; it is now reserved for typing.
3361
+ if (key.name === "escape") {
3362
+ if (currentOverlay !== "none") {
3363
+ // Only reachable for the leader menu — every other overlay returned
3364
+ // above, and its own branch already closed it.
3365
+ showOverlay("none");
3366
+ setToolbar();
3367
+ } else {
3368
+ showLeaderMenu();
3369
+ }
3370
+ renderer.requestRender();
3061
3371
  return;
3062
3372
  }
3063
3373