github-search-cli 9.9.0 → 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
@@ -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 {
@@ -310,7 +317,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
310
317
 
311
318
  const header = new TextRenderable(renderer, {
312
319
  content:
313
- " 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",
314
321
  bg: colors.bg,
315
322
  fg: colors.muted,
316
323
  height: 1,
@@ -335,15 +342,26 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
335
342
  const isActive = name === trendingTab;
336
343
  const label = ` ${name} `;
337
344
  const tt = new TextRenderable(renderer, {
345
+ ...NON_INTERACTIVE_TEXT,
338
346
  content: label,
339
347
  fg: isActive ? colors.bg : colors.muted,
340
348
  bg: isActive ? colors.blue : colors.bg,
341
349
  height: 1,
342
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");
343
360
  trendingTabTexts.push(tt);
344
361
  trendingTabBox.add(tt);
345
362
  if (i < TAB_NAMES.length - 1) {
346
363
  const sp = new TextRenderable(renderer, {
364
+ ...NON_INTERACTIVE_TEXT,
347
365
  content: " ",
348
366
  fg: colors.muted,
349
367
  bg: colors.bg,
@@ -442,6 +460,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
442
460
  content: "",
443
461
  fg: colors.text,
444
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,
445
466
  });
446
467
  detailBox.add(detailText);
447
468
 
@@ -458,6 +479,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
458
479
  });
459
480
  root.add(landingBox);
460
481
  const landingBanner = new TextRenderable(renderer, {
482
+ ...NON_INTERACTIVE_TEXT,
461
483
  content: [
462
484
  " ██████╗ ██╗ ██╗███████╗██╗███╗ ██╗██████╗ ",
463
485
  "██╔════╝ ██║ ██║██╔════╝██║████╗ ██║██╔══██╗",
@@ -540,8 +562,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
540
562
  }
541
563
 
542
564
  const landingHint = new TextRenderable(renderer, {
565
+ ...NON_INTERACTIVE_TEXT,
543
566
  content:
544
- " \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",
545
568
  fg: colors.muted,
546
569
  bg: colors.bg,
547
570
  height: 1,
@@ -572,6 +595,34 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
572
595
  }
573
596
  }
574
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
+
575
626
  // Landing keyboard handler. Registered before the global handler below,
576
627
  // and both listeners receive the same key event — so keys the landing
577
628
  // screen consumes must be claimed with stopPropagation(), or the global
@@ -645,6 +696,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
645
696
  updateDim.visible = false;
646
697
  if (type === "none") {
647
698
  currentOverlay = "none";
699
+ menuButton.visible = true;
648
700
  if (currentMode === "landing") {
649
701
  landingBox.visible = true;
650
702
  body.visible = false;
@@ -658,6 +710,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
658
710
  return;
659
711
  }
660
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;
661
716
  // An overlay opened from the landing screen must hide the menu too
662
717
  // (hideMainContent only covers the search/trending/toolbar boxes).
663
718
  landingBox.visible = false;
@@ -945,6 +1000,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
945
1000
  content: "",
946
1001
  fg: colors.text,
947
1002
  bg: colors.bg,
1003
+ selectable: false,
948
1004
  });
949
1005
  compareBox.add(compareText);
950
1006
  root.add(compareBox);
@@ -1040,7 +1096,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1040
1096
  savedSelect.setSelectedIndex(0);
1041
1097
  }
1042
1098
 
1043
- // ── Leader menu overlay (Space key) ─────────────────────────────
1099
+ // ── Leader menu overlay (Esc key) ─────────────────────────────
1044
1100
  // Dim overlay — sits behind the menu, above main content
1045
1101
  const leaderDim = new BoxRenderable(renderer, {
1046
1102
  visible: false,
@@ -1086,7 +1142,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1086
1142
  itemSpacing: 0,
1087
1143
  });
1088
1144
  const leaderFooter = new TextRenderable(renderer, {
1089
- 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 ",
1090
1148
  fg: colors.muted,
1091
1149
  bg: colors.surfaceDim,
1092
1150
  height: 1,
@@ -1236,11 +1294,77 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1236
1294
  renderer.requestRender();
1237
1295
  }
1238
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
+
1239
1361
  updateOptionsRow.add(updateNowBox);
1240
1362
  updateOptionsRow.add(laterBox);
1241
1363
 
1242
1364
  const updateFooter = new TextRenderable(renderer, {
1243
- 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",
1244
1368
  fg: colors.muted,
1245
1369
  bg: colors.surfaceDim,
1246
1370
  height: 1,
@@ -1964,6 +2088,48 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1964
2088
  });
1965
2089
  root.add(statusBar);
1966
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
+
1967
2133
  const statusMgr = new StatusManager((text) => {
1968
2134
  statusBar.content = ` ${text}`;
1969
2135
  renderer.requestRender();
@@ -2713,6 +2879,192 @@ ${pack.description ?? ""}`;
2713
2879
  }
2714
2880
  });
2715
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
+
2716
3068
  // ── Global keyboard shortcuts ──────────────────────────────────────
2717
3069
  renderer.keyInput.on("keypress", (key) => {
2718
3070
  // ── Token prompt (own keyboard scope) ──────────────────────────
@@ -2825,21 +3177,7 @@ ${pack.description ?? ""}`;
2825
3177
  return;
2826
3178
  }
2827
3179
  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
- }
3180
+ activateOverlaySelection();
2843
3181
  return;
2844
3182
  }
2845
3183
  return;
@@ -2861,9 +3199,7 @@ ${pack.description ?? ""}`;
2861
3199
  return;
2862
3200
  }
2863
3201
  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 ?? "");
3202
+ activateOverlaySelection();
2867
3203
  return;
2868
3204
  }
2869
3205
  return;
@@ -2882,23 +3218,7 @@ ${pack.description ?? ""}`;
2882
3218
  return;
2883
3219
  }
2884
3220
  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
- }
3221
+ activateOverlaySelection();
2902
3222
  return;
2903
3223
  }
2904
3224
  return;
@@ -2907,14 +3227,7 @@ ${pack.description ?? ""}`;
2907
3227
  // Topics explorer overlay
2908
3228
  if (currentOverlay === "topics") {
2909
3229
  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
- }
3230
+ activateOverlaySelection();
2918
3231
  return;
2919
3232
  }
2920
3233
  return;
@@ -2923,17 +3236,7 @@ ${pack.description ?? ""}`;
2923
3236
  // Export overlay
2924
3237
  if (currentOverlay === "export") {
2925
3238
  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();
3239
+ activateOverlaySelection();
2937
3240
  return;
2938
3241
  }
2939
3242
  return;
@@ -2968,19 +3271,7 @@ ${pack.description ?? ""}`;
2968
3271
  // Share overlay
2969
3272
  if (currentOverlay === "share") {
2970
3273
  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();
3274
+ activateOverlaySelection();
2984
3275
  return;
2985
3276
  }
2986
3277
  return;
@@ -2988,8 +3279,7 @@ ${pack.description ?? ""}`;
2988
3279
 
2989
3280
  // Help overlay: any key closes
2990
3281
  if (currentOverlay === "help") {
2991
- showOverlay("none");
2992
- renderer.requestRender();
3282
+ activateOverlaySelection();
2993
3283
  return;
2994
3284
  }
2995
3285
 
@@ -3029,33 +3319,7 @@ ${pack.description ?? ""}`;
3029
3319
  return;
3030
3320
  }
3031
3321
  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();
3322
+ runUpdateOption(updateSelectedOption);
3059
3323
  return;
3060
3324
  }
3061
3325
  return;
@@ -3091,11 +3355,19 @@ ${pack.description ?? ""}`;
3091
3355
  return;
3092
3356
  }
3093
3357
 
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();
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();
3099
3371
  return;
3100
3372
  }
3101
3373