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/dist/cli.js CHANGED
@@ -24,11 +24,13 @@ var AuthError = class extends SearchCliError {
24
24
  }
25
25
  };
26
26
  var ForbiddenError = class extends SearchCliError {
27
- constructor() {
28
- const msg = "\u26A0 Forbidden (403). If your org enforces SSO, authorize the token; [t] to change it";
29
- super("GitHub API returned 403 (token lacks access)", msg);
27
+ constructor(detail = "GitHub API returned 403") {
28
+ const msg = `\u26A0 Forbidden (403) \u2014 ${detail}. Common causes: org SSO not authorizing the token, or a secondary rate limit. Press [t] to change the token`;
29
+ super(msg, msg);
30
+ this.detail = detail;
30
31
  this.name = "ForbiddenError";
31
32
  }
33
+ detail;
32
34
  };
33
35
  var RateLimitError = class extends SearchCliError {
34
36
  constructor(hasToken, resetSeconds) {
@@ -39,16 +41,36 @@ var RateLimitError = class extends SearchCliError {
39
41
  }
40
42
  hasToken;
41
43
  };
44
+ var BadQueryError = class extends SearchCliError {
45
+ /** A concrete, corrected query the user can retry with. */
46
+ constructor(detail, hint) {
47
+ super(
48
+ `Invalid query: ${detail}`,
49
+ hint ? `\u26A0 Invalid query: ${detail}
50
+ Try: ${hint}` : `\u26A0 Invalid query: ${detail}`
51
+ );
52
+ this.detail = detail;
53
+ this.hint = hint;
54
+ this.name = "BadQueryError";
55
+ }
56
+ detail;
57
+ hint;
58
+ };
42
59
  var NoResultsError = class extends SearchCliError {
43
- constructor(query) {
60
+ /** Concrete loosened queries to retry, most promising first. */
61
+ constructor(query, relaxations = []) {
62
+ const tips = relaxations.length ? relaxations.map((r) => `Try: ${r}`).join("\n ") : "Tips: check spelling, try fewer qualifiers, or [b]rowse trending";
44
63
  super(
45
- `No results for "${query}"`,
46
- `\u{1F50D} No results for "${query}". Tips: check spelling, try fewer qualifiers, or [b]rowse trending`
64
+ relaxations.length > 0 ? `No results for "${query}" \u2014 try loosening: ${relaxations.join(" / ")}` : `No results for "${query}"`,
65
+ `\u{1F50D} No results for "${query}".
66
+ ${tips}`
47
67
  );
48
68
  this.query = query;
69
+ this.relaxations = relaxations;
49
70
  this.name = "NoResultsError";
50
71
  }
51
72
  query;
73
+ relaxations;
52
74
  };
53
75
  var ParseError = class extends SearchCliError {
54
76
  constructor(source, detail) {
@@ -209,9 +231,11 @@ function normalizeEnvelope(env) {
209
231
  var KNOWN_QUALIFIERS = [
210
232
  "language",
211
233
  "stars",
234
+ "forks",
212
235
  "fork",
213
236
  "archived",
214
237
  "topic",
238
+ "topics",
215
239
  "user",
216
240
  "org",
217
241
  "repo",
@@ -221,8 +245,80 @@ var KNOWN_QUALIFIERS = [
221
245
  "in",
222
246
  "size",
223
247
  "license",
224
- "created"
248
+ "created",
249
+ "followers"
225
250
  ];
251
+ var KNOWN_QUALIFIER_SET = new Set(KNOWN_QUALIFIERS);
252
+ var QUALIFIER_ALIASES = {
253
+ // language
254
+ langauge: "language",
255
+ lang: "language",
256
+ languge: "language",
257
+ // stars / forks
258
+ star: "stars",
259
+ strs: "stars",
260
+ forke: "forks",
261
+ // topic
262
+ topc: "topic",
263
+ tpics: "topic",
264
+ // user / org
265
+ usr: "user",
266
+ ogranization: "org",
267
+ orginzation: "org",
268
+ // date qualifiers
269
+ push: "pushed",
270
+ creat: "created",
271
+ updat: "updated",
272
+ // visibility
273
+ visiblity: "visibility",
274
+ visability: "visibility",
275
+ // license
276
+ licence: "license",
277
+ // size
278
+ siz: "size",
279
+ // archived
280
+ archive: "archived"
281
+ };
282
+ function editDistanceWithin(a, b, max) {
283
+ if (a === b) return 0;
284
+ if (Math.abs(a.length - b.length) > max) return max + 1;
285
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
286
+ for (let i = 1; i <= a.length; i++) {
287
+ const curr = [i];
288
+ let rowMin = i;
289
+ for (let j = 1; j <= b.length; j++) {
290
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
291
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
292
+ if (curr[j] < rowMin) rowMin = curr[j];
293
+ }
294
+ if (rowMin > max) return max + 1;
295
+ prev = curr;
296
+ }
297
+ return prev[b.length];
298
+ }
299
+ function resolveQualifierKey(key) {
300
+ const k = key.toLowerCase();
301
+ if (KNOWN_QUALIFIER_SET.has(k)) return k;
302
+ const alias = QUALIFIER_ALIASES[k];
303
+ if (alias) return alias;
304
+ if (k.length < 4) return k;
305
+ const maxDistance = k.length <= 5 ? 1 : 2;
306
+ let best;
307
+ let bestDistance = maxDistance + 1;
308
+ let ties = 0;
309
+ for (const known of KNOWN_QUALIFIERS) {
310
+ const d = editDistanceWithin(k, known, maxDistance);
311
+ if (d < bestDistance) {
312
+ bestDistance = d;
313
+ best = known;
314
+ ties = 1;
315
+ if (d === 0) break;
316
+ } else if (d === bestDistance && best !== void 0) {
317
+ ties++;
318
+ }
319
+ }
320
+ return best !== void 0 && ties === 1 ? best : k;
321
+ }
226
322
  var QUALIFIER_SUGGESTIONS = {
227
323
  l: ["language:"],
228
324
  s: ["stars:", "size:"],
@@ -238,13 +334,164 @@ var QUALIFIER_SUGGESTIONS = {
238
334
  a: ["archived:"]
239
335
  };
240
336
  function suggestFor(input) {
241
- const trimmed = input.trim().toLowerCase();
337
+ const trimmed = input.trim().toLowerCase().replace(/:$/, "");
242
338
  if (trimmed.length === 0) return [];
243
339
  if (trimmed.length === 1 && QUALIFIER_SUGGESTIONS[trimmed]) {
244
340
  return QUALIFIER_SUGGESTIONS[trimmed];
245
341
  }
246
- const matches = KNOWN_QUALIFIERS.filter((k) => k.startsWith(trimmed));
247
- return matches.map((k) => `${k}:`);
342
+ const resolved = resolveQualifierKey(trimmed);
343
+ const aliasHit = resolved !== trimmed && KNOWN_QUALIFIER_SET.has(resolved) ? [`${resolved}:`] : [];
344
+ const matches = KNOWN_QUALIFIERS.filter(
345
+ (k) => k.startsWith(trimmed) && !aliasHit.includes(`${k}:`)
346
+ ).map((k) => `${k}:`);
347
+ return [...aliasHit, ...matches];
348
+ }
349
+ var NUMERIC_QUALIFIERS = /* @__PURE__ */ new Set([
350
+ "forks",
351
+ "size",
352
+ "stars",
353
+ "topics",
354
+ "followers"
355
+ ]);
356
+ var BOOLEAN_QUALIFIERS = /* @__PURE__ */ new Set(["archived"]);
357
+ var VISIBILITY_VALUES = /* @__PURE__ */ new Set(["public", "private", "internal"]);
358
+ var IN_FIELDS = /* @__PURE__ */ new Set([
359
+ "name",
360
+ "description",
361
+ "topics",
362
+ "readme",
363
+ "comments"
364
+ ]);
365
+ var DATE_QUALIFIERS = /* @__PURE__ */ new Set(["created", "pushed", "updated"]);
366
+ var COMPARISON_OPERATORS = [">=", "<=", ">", "<"];
367
+ function redactToken(url, token) {
368
+ return token ? url.replace(token, "<token>") : url;
369
+ }
370
+ function splitComparison(value) {
371
+ for (const op of COMPARISON_OPERATORS) {
372
+ if (value.startsWith(op)) {
373
+ return { op, rest: value.slice(op.length) };
374
+ }
375
+ }
376
+ return { op: "", rest: value };
377
+ }
378
+ function parseCount(raw) {
379
+ const trimmed = raw.trim().replace(/_/g, "");
380
+ if (trimmed === "") return null;
381
+ const match = /^(\d+(?:\.\d+)?)([km])?$/i.exec(trimmed);
382
+ if (!match) return null;
383
+ if (!match[2] && match[1].includes(".")) return null;
384
+ const base = Number.parseFloat(match[1]);
385
+ if (!Number.isFinite(base)) return null;
386
+ const suffix = match[2]?.toLowerCase();
387
+ const multiplier = suffix === "k" ? 1e3 : suffix === "m" ? 1e6 : 1;
388
+ return Math.floor(base * multiplier);
389
+ }
390
+ function isValidISODate(raw) {
391
+ const trimmed = raw.trim();
392
+ const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?(?:Z|[+-]\d{2}:?\d{2})?)?$/.exec(
393
+ trimmed
394
+ );
395
+ if (!m) return false;
396
+ const [, y, mo, d, h = "0", mi = "0", s = "0"] = m;
397
+ const year = Number(y);
398
+ const month = Number(mo);
399
+ const day = Number(d);
400
+ if (month < 1 || month > 12) return false;
401
+ if (day < 1 || day > 31) return false;
402
+ if (Number(h) > 23 || Number(mi) > 59 || Number(s) > 59) return false;
403
+ const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
404
+ return day <= daysInMonth;
405
+ }
406
+ function normalizeQualifierValue(key, value) {
407
+ const normalizedKey = key.toLowerCase();
408
+ if (!NUMERIC_QUALIFIERS.has(normalizedKey)) return value;
409
+ if (value === "") return value;
410
+ const { op, rest } = splitComparison(value);
411
+ const rangeIdx = rest.indexOf("..");
412
+ if (rangeIdx > 0) {
413
+ const lo = parseCount(rest.slice(0, rangeIdx));
414
+ const hi = parseCount(rest.slice(rangeIdx + 2));
415
+ if (lo === null || hi === null) return value;
416
+ return `${op}${lo}..${hi}`;
417
+ }
418
+ if (!/[km]$/i.test(rest.trim())) return value;
419
+ const count = parseCount(rest);
420
+ if (count === null) return value;
421
+ return `${op || ">="}${count}`;
422
+ }
423
+ function validateQualifierValue(key, value) {
424
+ const k = key.toLowerCase();
425
+ if (!KNOWN_QUALIFIER_SET.has(k)) return null;
426
+ if (value.trim() === "") {
427
+ return `"${key}:" needs a value (e.g. ${key}:<something>)`;
428
+ }
429
+ if (NUMERIC_QUALIFIERS.has(k)) {
430
+ const { rest } = splitComparison(value.trim());
431
+ const rangeIdx = rest.indexOf("..");
432
+ if (rangeIdx > 0) {
433
+ const lo = parseCount(rest.slice(0, rangeIdx));
434
+ const hi = parseCount(rest.slice(rangeIdx + 2));
435
+ if (lo === null || hi === null) {
436
+ return `"${key}:${value}" is not a valid range \u2014 use ${k}:100..500 or ${k}:>=100`;
437
+ }
438
+ if (lo > hi) {
439
+ return `"${key}:${value}" has a reversed range (${lo} > ${hi})`;
440
+ }
441
+ return null;
442
+ }
443
+ if (parseCount(rest) === null) {
444
+ return `"${key}:${value}" is not a number \u2014 use ${k}:100, ${k}:>=100, or ${k}:1k..10k`;
445
+ }
446
+ return null;
447
+ }
448
+ if (k === "fork") {
449
+ const v = value.trim().toLowerCase();
450
+ if (v !== "true" && v !== "false" && v !== "only") {
451
+ return `"fork:${value}" is not valid \u2014 use fork:true, fork:false, or fork:only`;
452
+ }
453
+ return null;
454
+ }
455
+ if (BOOLEAN_QUALIFIERS.has(k)) {
456
+ const v = value.trim().toLowerCase();
457
+ if (v !== "true" && v !== "false") {
458
+ return `"${key}:${value}" is not valid \u2014 use ${k}:true or ${k}:false`;
459
+ }
460
+ return null;
461
+ }
462
+ if (k === "visibility") {
463
+ const v = value.trim().toLowerCase();
464
+ if (!VISIBILITY_VALUES.has(v)) {
465
+ return `"visibility:${value}" is not valid \u2014 use visibility:public, visibility:private, or visibility:internal`;
466
+ }
467
+ return null;
468
+ }
469
+ if (k === "in") {
470
+ const parts = value.split(",").map((p) => p.trim().toLowerCase());
471
+ if (parts.some((p) => !IN_FIELDS.has(p))) {
472
+ const bad = parts.find((p) => !IN_FIELDS.has(p));
473
+ return `"in:${value}" is not valid \u2014 ${bad ? `"${bad}" is not a ` : ""}searchable field. Try in:name, in:description, in:topics, or in:readme`;
474
+ }
475
+ return null;
476
+ }
477
+ if (DATE_QUALIFIERS.has(k)) {
478
+ const trimmed = value.trim();
479
+ const { rest } = splitComparison(trimmed);
480
+ const rangeIdx = rest.indexOf("..");
481
+ if (rangeIdx > 0) {
482
+ const lo = rest.slice(0, rangeIdx);
483
+ const hi = rest.slice(rangeIdx + 2);
484
+ if (!isValidISODate(lo) || !isValidISODate(hi)) {
485
+ return `"${key}:${value}" is not a valid date range \u2014 use ${k}:2020-01-01..2024-01-01`;
486
+ }
487
+ return null;
488
+ }
489
+ if (!isValidISODate(rest)) {
490
+ return `"${key}:${value}" is not a valid date \u2014 use ${k}:2024-01-01 (optionally with >, >=, <, <=)`;
491
+ }
492
+ return null;
493
+ }
494
+ return null;
248
495
  }
249
496
  function tokenize(input) {
250
497
  const tokens = [];
@@ -272,22 +519,46 @@ function tokenize(input) {
272
519
  }
273
520
  return tokens;
274
521
  }
522
+ var EMPTY_QUERY = {
523
+ keywords: [],
524
+ qualifiers: [],
525
+ raw: "",
526
+ corrections: []
527
+ };
528
+ function trendingQuery() {
529
+ return { ...EMPTY_QUERY, raw: "trending" };
530
+ }
275
531
  function parseQuery(raw) {
276
532
  const trimmed = raw.trim();
277
533
  if (trimmed === "") {
278
- return { keywords: [], qualifiers: [], raw };
534
+ return { keywords: [], qualifiers: [], raw, corrections: [] };
279
535
  }
280
536
  const keywords = [];
281
537
  const qualifiers = [];
538
+ const corrections = [];
282
539
  for (const token of tokenize(trimmed)) {
283
540
  const qualifier = matchQualifier(token);
284
541
  if (qualifier) {
285
- qualifiers.push(qualifier);
542
+ const resolvedKey = resolveQualifierKey(qualifier.key);
543
+ const normalizedValue = normalizeQualifierValue(
544
+ resolvedKey,
545
+ qualifier.value
546
+ );
547
+ const original = `${qualifier.negated ? "-" : ""}${qualifier.key}:${qualifier.value}`;
548
+ const corrected = `${qualifier.negated ? "-" : ""}${resolvedKey}:${normalizedValue}`;
549
+ if (original !== corrected) {
550
+ corrections.push({ from: original, to: corrected });
551
+ }
552
+ qualifiers.push({
553
+ key: resolvedKey,
554
+ value: normalizedValue,
555
+ negated: qualifier.negated
556
+ });
286
557
  } else {
287
558
  keywords.push(token);
288
559
  }
289
560
  }
290
- return { keywords, qualifiers, raw: trimmed };
561
+ return { keywords, qualifiers, raw: trimmed, corrections };
291
562
  }
292
563
  function matchQualifier(token) {
293
564
  const negated = token.startsWith("-");
@@ -296,8 +567,7 @@ function matchQualifier(token) {
296
567
  if (idx <= 0) return null;
297
568
  const key = body.slice(0, idx).toLowerCase();
298
569
  let value = body.slice(idx + 1);
299
- if (value === "") return null;
300
- if (!/^[a-z]+$/i.test(key)) return null;
570
+ if (!/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/i.test(key)) return null;
301
571
  if (value.startsWith("//")) return null;
302
572
  if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
303
573
  value = value.slice(1, -1);
@@ -330,11 +600,143 @@ function applyFlagFilters(query, flags) {
330
600
  });
331
601
  return { ...query, qualifiers };
332
602
  }
603
+ function withoutQualifier(query, index) {
604
+ const remaining = query.qualifiers.filter((_, i) => i !== index);
605
+ const parts = [...query.keywords];
606
+ for (const q of remaining) {
607
+ const needsQuote = q.value.includes(" ") && !q.value.startsWith('"');
608
+ const value = needsQuote ? `"${q.value}"` : q.value;
609
+ parts.push(`${q.negated ? "-" : ""}${q.key}:${value}`);
610
+ }
611
+ const text = parts.join(" ").trim();
612
+ return text === "" ? null : text;
613
+ }
333
614
  function validateQuery(query) {
334
615
  const has = (key) => query.qualifiers.some((q) => q.key === key && !q.negated);
335
616
  if (has("visibility") && has("private")) {
336
- throw new Error("Cannot combine `visibility:` and `private:` filters.");
617
+ throw new BadQueryError(
618
+ "Cannot combine `visibility:` and `private:` filters.",
619
+ `${query.qualifiers.filter((q) => q.key === "private" && !q.negated).map((q) => withoutQualifier(query, query.qualifiers.indexOf(q))).find((v) => v !== null) ?? query.raw}`
620
+ );
621
+ }
622
+ for (let i = 0; i < query.qualifiers.length; i++) {
623
+ const q = query.qualifiers[i];
624
+ const problem = validateQualifierValue(q.key, q.value);
625
+ if (problem) {
626
+ throw new BadQueryError(problem, withoutQualifier(query, i) ?? void 0);
627
+ }
628
+ }
629
+ }
630
+ var OVER_CONSTRAINT_WEIGHT = {
631
+ size: 100,
632
+ stars: 95,
633
+ forks: 80,
634
+ created: 75,
635
+ pushed: 70,
636
+ updated: 70,
637
+ visibility: 65,
638
+ repo: 60,
639
+ user: 55,
640
+ org: 50,
641
+ topic: 45,
642
+ in: 30,
643
+ license: 25,
644
+ language: 20,
645
+ fork: 15,
646
+ archived: 10
647
+ };
648
+ function loosenNumericBound(value) {
649
+ const { op, rest } = splitComparison(value.trim());
650
+ const count = parseCount(rest);
651
+ if (count === null || count === 0) return null;
652
+ const loosened = Math.max(1, Math.floor(count / 10));
653
+ return `${op || ">="}${loosened}`;
654
+ }
655
+ function loosenDateBound(value) {
656
+ const { op, rest } = splitComparison(value.trim());
657
+ if (!isValidISODate(rest)) return null;
658
+ const year = Number.parseInt(rest.slice(0, 4), 10);
659
+ if (!Number.isFinite(year)) return null;
660
+ const shifted = op === "<" || op === "<=" ? year + 2 : Math.max(1970, year - 2);
661
+ return { op, date: `${shifted}${rest.slice(4)}` };
662
+ }
663
+ function suggestRelaxations(query, limit = 3) {
664
+ const out = [];
665
+ for (let i = 0; i < query.qualifiers.length; i++) {
666
+ const q = query.qualifiers[i];
667
+ const key = q.key.toLowerCase();
668
+ if (q.negated) continue;
669
+ const weight = OVER_CONSTRAINT_WEIGHT[key] ?? 40;
670
+ const emit = (relaxed, qualifier) => {
671
+ const rest = query.qualifiers.filter((_, j) => j !== i).map((other) => {
672
+ const needsQuote = other.value.includes(" ") && !other.value.startsWith('"');
673
+ const value = needsQuote ? `"${other.value}"` : other.value;
674
+ return `${other.negated ? "-" : ""}${other.key}:${value}`;
675
+ });
676
+ const text = [...query.keywords, ...rest, relaxed].join(" ").trim();
677
+ out.push({ suggestion: text, qualifier, confidence: weight });
678
+ };
679
+ if (NUMERIC_QUALIFIERS.has(key) || key === "followers") {
680
+ const rangeIdx = q.value.indexOf("..");
681
+ if (rangeIdx > 0) {
682
+ const hi = parseCount(q.value.slice(rangeIdx + 2));
683
+ if (hi !== null) {
684
+ const lo = q.value.slice(0, rangeIdx);
685
+ emit(`${key}:${lo}..${Math.floor(hi / 10) || hi * 2}`, key);
686
+ }
687
+ } else {
688
+ const looser = loosenNumericBound(q.value);
689
+ if (looser) emit(`${key}:${looser}`, key);
690
+ else {
691
+ const dropped2 = withoutQualifier(query, i);
692
+ if (dropped2) {
693
+ out.push({
694
+ suggestion: dropped2,
695
+ qualifier: key,
696
+ confidence: weight - 30
697
+ });
698
+ }
699
+ }
700
+ }
701
+ continue;
702
+ }
703
+ if (DATE_QUALIFIERS.has(key)) {
704
+ const loosened = loosenDateBound(q.value);
705
+ if (loosened) {
706
+ emit(`${key}:${loosened.op || ">="}${loosened.date}`, key);
707
+ } else {
708
+ const dropped2 = withoutQualifier(query, i);
709
+ if (dropped2) {
710
+ out.push({
711
+ suggestion: dropped2,
712
+ qualifier: key,
713
+ confidence: weight - 30
714
+ });
715
+ }
716
+ }
717
+ continue;
718
+ }
719
+ const dropped = withoutQualifier(query, i);
720
+ if (dropped) {
721
+ out.push({
722
+ suggestion: dropped,
723
+ qualifier: key,
724
+ confidence: weight - 30
725
+ });
726
+ }
337
727
  }
728
+ const seen = /* @__PURE__ */ new Set();
729
+ const unique = out.filter((r) => {
730
+ if (seen.has(r.suggestion)) return false;
731
+ seen.add(r.suggestion);
732
+ return true;
733
+ });
734
+ unique.sort((a, b) => b.confidence - a.confidence);
735
+ return unique.slice(0, limit).map((r) => ({
736
+ ...r,
737
+ // Clamp so a "drop the qualifier" hint never reports a negative score.
738
+ confidence: Math.max(0, r.confidence)
739
+ }));
338
740
  }
339
741
  function buildGitHubQuery(query) {
340
742
  const parts = [];
@@ -431,6 +833,11 @@ var GitHubSearchAdapter = class {
431
833
  });
432
834
  const remainingStr = res.headers.get("x-ratelimit-remaining");
433
835
  rateLimitRemaining = remainingStr !== null ? Number(remainingStr) : void 0;
836
+ if (process.env.GHFIND_LOG) {
837
+ this.logger.debug(
838
+ `[github] ${res.status} remaining=${remainingStr ?? "none"} url=${redactToken(pagedUrl, token)}`
839
+ );
840
+ }
434
841
  if (res.status === 403) {
435
842
  if (remainingStr === "0" || remainingStr === null) {
436
843
  if (this.tokens.length > 0 && attempt < this.tokens.length) {
@@ -442,9 +849,18 @@ var GitHubSearchAdapter = class {
442
849
  }
443
850
  const body = await res.text().catch(() => "");
444
851
  this.logger.error(
445
- `[github] non-rate-limit 403: ${body.slice(0, 200)}`
852
+ `[github] non-rate-limit 403: remaining=${remainingStr} retry-after=${res.headers.get("retry-after") ?? "none"} x-ratelimit-reset=${res.headers.get("x-ratelimit-reset") ?? "none"} authed=${token ? "yes" : "no"} url=${redactToken(pagedUrl, token)}`
446
853
  );
447
- throw new ForbiddenError();
854
+ this.logger.error(`[github] 403 body: ${body.slice(0, 400)}`);
855
+ let reason = "no reason given";
856
+ try {
857
+ const parsed2 = JSON.parse(body);
858
+ if (typeof parsed2.message === "string" && parsed2.message) {
859
+ reason = parsed2.message;
860
+ }
861
+ } catch {
862
+ }
863
+ throw new ForbiddenError(reason);
448
864
  }
449
865
  if (res.status === 401) {
450
866
  const body = await res.text().catch(() => "");
@@ -1737,7 +2153,7 @@ var cached = null;
1737
2153
  function getVersion() {
1738
2154
  if (cached) return cached;
1739
2155
  if (true) {
1740
- cached = "9.8.3";
2156
+ cached = "9.10.0";
1741
2157
  return cached;
1742
2158
  }
1743
2159
  try {
@@ -1996,22 +2412,22 @@ async function runWatch(opts, onChange, onError) {
1996
2412
  let previous = [];
1997
2413
  let failCount = 0;
1998
2414
  let intervalId = null;
2415
+ if (!opts.trending) {
2416
+ validateQuery(applyFlagFilters(parseQuery(opts.query), {}));
2417
+ }
1999
2418
  const tick = async () => {
2000
2419
  try {
2001
2420
  let repos;
2002
2421
  if (opts.trending) {
2003
2422
  const since = opts.query === "weekly" ? "weekly" : opts.query === "monthly" ? "monthly" : "daily";
2004
2423
  const trendingSearch = createTrendingSearch();
2005
- const response = await trendingSearch.search(
2006
- { keywords: [], qualifiers: [], raw: "trending" },
2007
- {
2008
- limit: opts.limit,
2009
- sort: opts.sort,
2010
- json: false,
2011
- verbose: false,
2012
- trendingSince: since
2013
- }
2014
- );
2424
+ const response = await trendingSearch.search(trendingQuery(), {
2425
+ limit: opts.limit,
2426
+ sort: opts.sort,
2427
+ json: false,
2428
+ verbose: false,
2429
+ trendingSince: since
2430
+ });
2015
2431
  repos = response.repos;
2016
2432
  } else {
2017
2433
  const parsed = applyFlagFilters(parseQuery(opts.query), {});
@@ -3422,6 +3838,7 @@ import {
3422
3838
  SelectRenderable,
3423
3839
  ScrollBoxRenderable as ScrollBoxRenderable3
3424
3840
  } from "@opentui/core";
3841
+ import { appendFileSync as appendFileSync2 } from "fs";
3425
3842
 
3426
3843
  // src/trending.ts
3427
3844
  import {
@@ -3457,6 +3874,135 @@ if (import.meta.main && false) {
3457
3874
  launchTrending();
3458
3875
  }
3459
3876
 
3877
+ // src/tui/mouse.ts
3878
+ var DOUBLE_CLICK_MS = 400;
3879
+ function hitIndexForY(geometry, screenY) {
3880
+ const pitch = geometry.linesPerItem;
3881
+ if (pitch <= 0) return -1;
3882
+ if (geometry.optionCount <= 0) return -1;
3883
+ const localY = screenY - geometry.screenY;
3884
+ if (localY < 0 || localY >= geometry.height) return -1;
3885
+ const row = Math.floor(localY / pitch);
3886
+ const index = geometry.scrollOffset + row;
3887
+ const last = geometry.optionCount - 1;
3888
+ if (index < 0) return 0;
3889
+ return index > last ? last : index;
3890
+ }
3891
+ function selectRowGeometry(select) {
3892
+ const internal = select;
3893
+ return {
3894
+ screenY: select.screenY,
3895
+ height: select.height,
3896
+ optionCount: select.options.length,
3897
+ scrollOffset: internal.scrollOffset ?? 0,
3898
+ linesPerItem: internal.linesPerItem ?? 0
3899
+ };
3900
+ }
3901
+ function attachClickToSelect(renderer, select, options = {}) {
3902
+ const wheelStep = options.wheelStep ?? 3;
3903
+ let lastClickIndex = -1;
3904
+ let lastClickAt = 0;
3905
+ let lastHovered = -1;
3906
+ select.onMouseDown = (event) => {
3907
+ event.stopPropagation();
3908
+ if (options.keepFocus) event.preventDefault();
3909
+ const index = hitIndexForY(selectRowGeometry(select), event.y);
3910
+ if (index < 0) {
3911
+ lastClickIndex = -1;
3912
+ options.onClickMiss?.();
3913
+ return;
3914
+ }
3915
+ const now = Date.now();
3916
+ const alreadySelected = select.getSelectedIndex() === index;
3917
+ const isDouble = alreadySelected && index === lastClickIndex && now - lastClickAt < DOUBLE_CLICK_MS;
3918
+ lastClickIndex = index;
3919
+ lastClickAt = now;
3920
+ if (isDouble) {
3921
+ lastClickIndex = -1;
3922
+ options.onOpen?.();
3923
+ return;
3924
+ }
3925
+ select.setSelectedIndex(index);
3926
+ };
3927
+ select.onMouseScroll = (event) => {
3928
+ event.stopPropagation();
3929
+ const direction = event.scroll?.direction;
3930
+ if (direction === "up") select.moveUp(wheelStep);
3931
+ else if (direction === "down") select.moveDown(wheelStep);
3932
+ else return;
3933
+ renderer.requestRender();
3934
+ };
3935
+ attachHoverHighlight(renderer, select, {
3936
+ onHover: (index) => {
3937
+ lastHovered = index;
3938
+ options.onHoverRow?.(index);
3939
+ },
3940
+ // A stale row must never outlive the options it pointed at.
3941
+ onLeave: () => {
3942
+ lastHovered = -1;
3943
+ }
3944
+ });
3945
+ return {
3946
+ reset() {
3947
+ lastClickIndex = -1;
3948
+ lastClickAt = 0;
3949
+ },
3950
+ get lastHovered() {
3951
+ return lastHovered;
3952
+ }
3953
+ };
3954
+ }
3955
+ function attachHoverHighlight(renderer, select, options = {}) {
3956
+ const idleBackground = select.backgroundColor;
3957
+ const hoverBackground = "#ffffff0f";
3958
+ let hovered = -1;
3959
+ select.onMouseOver = () => {
3960
+ renderer.setMousePointer("pointer");
3961
+ select.backgroundColor = hoverBackground;
3962
+ renderer.requestRender();
3963
+ };
3964
+ select.onMouseOut = () => {
3965
+ renderer.setMousePointer("default");
3966
+ select.backgroundColor = idleBackground;
3967
+ if (hovered !== -1) {
3968
+ hovered = -1;
3969
+ options.onLeave?.();
3970
+ }
3971
+ renderer.requestRender();
3972
+ };
3973
+ select.onMouseMove = (event) => {
3974
+ const index = hitIndexForY(selectRowGeometry(select), event.y);
3975
+ if (index === hovered) return;
3976
+ hovered = index;
3977
+ options.onHover?.(index);
3978
+ };
3979
+ }
3980
+ function attachClickOutside(target, onDismiss) {
3981
+ target.onMouseDown = (event) => {
3982
+ event.stopPropagation();
3983
+ onDismiss();
3984
+ };
3985
+ }
3986
+ function attachClickableBox(renderer, box, handlers) {
3987
+ box.onMouseDown = (event) => {
3988
+ event.stopPropagation();
3989
+ handlers.onActivate();
3990
+ };
3991
+ box.onMouseOver = () => {
3992
+ renderer.setMousePointer("pointer");
3993
+ box.borderColor = handlers.activeBorder;
3994
+ handlers.onStateChange?.();
3995
+ renderer.requestRender();
3996
+ };
3997
+ box.onMouseOut = () => {
3998
+ renderer.setMousePointer("default");
3999
+ box.borderColor = handlers.idleBorder;
4000
+ handlers.onStateChange?.();
4001
+ renderer.requestRender();
4002
+ };
4003
+ }
4004
+ var NON_INTERACTIVE_TEXT = { selectable: false };
4005
+
3460
4006
  // src/help.ts
3461
4007
  function buildHelpSections() {
3462
4008
  return [
@@ -3478,7 +4024,7 @@ function buildHelpSections() {
3478
4024
  { keys: "\u2191 \u2193 / j k", action: "Move selection" },
3479
4025
  { keys: "Enter", action: "Open repo in browser" },
3480
4026
  { keys: "/", action: "Focus search input" },
3481
- { keys: "Space", action: "Open command menu" },
4027
+ { keys: "Esc", action: "Open command menu" },
3482
4028
  { keys: "? / Ctrl+H", action: "Toggle this help screen" },
3483
4029
  { keys: "q", action: "Quit" }
3484
4030
  ]
@@ -3512,7 +4058,8 @@ function buildHelpSections() {
3512
4058
  rows: [
3513
4059
  { keys: "1-5", action: "Switch tab: Today \u2192 Week \u2192 Month \u2192 \u2026" },
3514
4060
  { keys: "\u2190 \u2192 / h l", action: "Navigate tabs" }
3515
- ]
4061
+ ],
4062
+ note: "Tabs are also clickable."
3516
4063
  },
3517
4064
  {
3518
4065
  title: " Packages Mode ",
@@ -3521,12 +4068,12 @@ function buildHelpSections() {
3521
4068
  { keys: "/", action: "Search npm packages" },
3522
4069
  { keys: "\u2191 \u2193 / j k", action: "Move selection" },
3523
4070
  { keys: "Enter", action: "View package details" },
3524
- { keys: "Space", action: "Open command menu" }
4071
+ { keys: "Esc", action: "Open command menu" }
3525
4072
  ],
3526
4073
  note: "Search by name, description, or tags. Sort by downloads or score."
3527
4074
  },
3528
4075
  {
3529
- title: " Command Menu (Space)",
4076
+ title: " Command Menu (Esc)",
3530
4077
  titleColor: "purple",
3531
4078
  rows: [
3532
4079
  { keys: "Sort", action: "Cycle sort: best-match \u2192 stars \u2192 \u2026" },
@@ -3551,7 +4098,7 @@ function buildHelpSections() {
3551
4098
  { keys: "History", action: "View search history" },
3552
4099
  { keys: "Help", action: "Show this help screen" }
3553
4100
  ],
3554
- note: "Navigate menu with \u2191 \u2193, select with Enter, back with \u2190 / Esc"
4101
+ note: "Navigate menu with \u2191 \u2193, select with Enter, back with \u2190 / Esc. Clicking a row runs it directly. Esc (or the \u2630 menu button) opens this menu; Space types a space in the query."
3555
4102
  },
3556
4103
  {
3557
4104
  title: " Bookmarks Page ",
@@ -3564,6 +4111,22 @@ function buildHelpSections() {
3564
4111
  ],
3565
4112
  note: "A full page, not an overlay \u2014 Esc returns to the mode menu."
3566
4113
  },
4114
+ {
4115
+ title: " Mouse ",
4116
+ titleColor: "green",
4117
+ rows: [
4118
+ { keys: "Click", action: "Select a row / card / tab" },
4119
+ { keys: "Double-click", action: "Open the repo or run the action" },
4120
+ { keys: "Scroll wheel", action: "Move the selection / scroll a pane" },
4121
+ { keys: "Click outside", action: "Dismiss an open overlay" },
4122
+ { keys: "Click query box", action: "Focus the search input" },
4123
+ {
4124
+ keys: "\u2630 menu button",
4125
+ action: "Open the command menu (bottom-right)"
4126
+ }
4127
+ ],
4128
+ note: "Every list is fully clickable; keyboard and mouse are interchangeable."
4129
+ },
3567
4130
  {
3568
4131
  title: " Overlay Keys ",
3569
4132
  titleColor: "teal",
@@ -4455,19 +5018,40 @@ function shouldCheckUpdate() {
4455
5018
  if (Date.now() - state.lastCheck < CHECK_INTERVAL_MS) return false;
4456
5019
  return true;
4457
5020
  }
5021
+ function semverParts(v) {
5022
+ const core = v.trim().replace(/^v/i, "").split(/[-+]/)[0];
5023
+ return core.split(".").map((s) => {
5024
+ const n = Number(s);
5025
+ return Number.isFinite(n) ? n : 0;
5026
+ });
5027
+ }
5028
+ function isPrerelease(v) {
5029
+ const core = v.trim().replace(/^v/i, "");
5030
+ const plus = core.indexOf("+");
5031
+ const coreOnly = plus === -1 ? core : core.slice(0, plus);
5032
+ return coreOnly.includes("-");
5033
+ }
4458
5034
  function isNewerVersion(current, latest) {
4459
5035
  if (typeof Bun !== "undefined" && Bun.semver) {
4460
5036
  return Bun.semver.order(current, latest) < 0;
4461
5037
  }
4462
- const curParts = current.split(".").map((s) => Number(s.split("-")[0]));
4463
- const newParts = latest.split(".").map((s) => Number(s.split("-")[0]));
5038
+ const curParts = semverParts(current);
5039
+ const newParts = semverParts(latest);
5040
+ let order = 0;
4464
5041
  for (let i = 0; i < Math.max(curParts.length, newParts.length); i++) {
4465
5042
  const c = curParts[i] ?? 0;
4466
5043
  const n = newParts[i] ?? 0;
4467
- if (n > c) return true;
4468
- if (n < c) return false;
5044
+ if (n > c) {
5045
+ order = 1;
5046
+ break;
5047
+ }
5048
+ if (n < c) {
5049
+ order = -1;
5050
+ break;
5051
+ }
4469
5052
  }
4470
- return false;
5053
+ if (order !== 0) return order > 0;
5054
+ return isPrerelease(current) && !isPrerelease(latest);
4471
5055
  }
4472
5056
  async function checkForUpdate(currentVersion) {
4473
5057
  if (process.env.DEBUG)
@@ -4574,6 +5158,17 @@ function recordPreUpdateState(currentVersion) {
4574
5158
  lastInstalledVersion: currentVersion
4575
5159
  });
4576
5160
  }
5161
+ function markPostUpgradeSeen(currentVersion) {
5162
+ try {
5163
+ const state = readUpdateState();
5164
+ if (state.lastInstalledVersion === currentVersion) return;
5165
+ if (isNewerVersion(currentVersion, state.lastInstalledVersion ?? "")) {
5166
+ return;
5167
+ }
5168
+ writeUpdateState({ ...state, lastInstalledVersion: currentVersion });
5169
+ } catch {
5170
+ }
5171
+ }
4577
5172
  function fetchReleaseNotes(version) {
4578
5173
  try {
4579
5174
  const notesPath = join6(PKG_DIR2, "release-notes", `${version}.md`);
@@ -4648,16 +5243,33 @@ var colors = {
4648
5243
  surfaceDim: "#1a1919",
4649
5244
  borderAccent: "#1a1919"
4650
5245
  };
4651
- var logger = {
4652
- debug: () => {
4653
- },
4654
- info: () => {
4655
- },
4656
- warn: () => {
4657
- },
4658
- error: () => {
4659
- }
4660
- };
5246
+ function createTuiLogger() {
5247
+ const target = process.env.GHFIND_LOG;
5248
+ if (!target) {
5249
+ return { debug: () => {
5250
+ }, info: () => {
5251
+ }, warn: () => {
5252
+ }, error: () => {
5253
+ } };
5254
+ }
5255
+ const write = (level, msg) => {
5256
+ try {
5257
+ appendFileSync2(
5258
+ target,
5259
+ `[${(/* @__PURE__ */ new Date()).toISOString()}] [${level}] ${msg}
5260
+ `
5261
+ );
5262
+ } catch {
5263
+ }
5264
+ };
5265
+ return {
5266
+ debug: (m) => write("debug", m),
5267
+ info: (m) => write("info", m),
5268
+ warn: (m) => write("warn", m),
5269
+ error: (m) => write("error", m)
5270
+ };
5271
+ }
5272
+ var logger = createTuiLogger();
4661
5273
  var SORT_MODES = [
4662
5274
  { key: "best-match", label: "best-match" },
4663
5275
  { key: "stars", label: "stars" },
@@ -4731,7 +5343,7 @@ async function launchBrowser(theme_override) {
4731
5343
  const compareList = [];
4732
5344
  const _quitArmed = false;
4733
5345
  const header = new TextRenderable5(renderer, {
4734
- content: " ghfind \u2014 GitHub repo browser [/]search [Space]menu [\u2192]open [?]help [q]uit",
5346
+ content: " ghfind \u2014 GitHub repo browser [/]search [Esc]menu [\u2192]open [?]help [q]uit \xB7 click select \xB7 dbl-click open",
4735
5347
  bg: colors.bg,
4736
5348
  fg: colors.muted,
4737
5349
  height: 1
@@ -4752,15 +5364,25 @@ async function launchBrowser(theme_override) {
4752
5364
  const isActive = name === trendingTab;
4753
5365
  const label = ` ${name} `;
4754
5366
  const tt = new TextRenderable5(renderer, {
5367
+ ...NON_INTERACTIVE_TEXT,
4755
5368
  content: label,
4756
5369
  fg: isActive ? colors.bg : colors.muted,
4757
5370
  bg: isActive ? colors.blue : colors.bg,
4758
5371
  height: 1
4759
5372
  });
5373
+ tt.onMouseDown = (event) => {
5374
+ event.stopPropagation();
5375
+ if (name === trendingTab) return;
5376
+ trendingTab = name;
5377
+ loadTrending();
5378
+ };
5379
+ tt.onMouseOver = () => renderer.setMousePointer("pointer");
5380
+ tt.onMouseOut = () => renderer.setMousePointer("default");
4760
5381
  trendingTabTexts.push(tt);
4761
5382
  trendingTabBox.add(tt);
4762
5383
  if (i < TAB_NAMES.length - 1) {
4763
5384
  const sp = new TextRenderable5(renderer, {
5385
+ ...NON_INTERACTIVE_TEXT,
4764
5386
  content: " ",
4765
5387
  fg: colors.muted,
4766
5388
  bg: colors.bg,
@@ -4848,7 +5470,10 @@ async function launchBrowser(theme_override) {
4848
5470
  const detailText = new TextRenderable5(renderer, {
4849
5471
  content: "",
4850
5472
  fg: colors.text,
4851
- wrapMode: "none"
5473
+ wrapMode: "none",
5474
+ // Non-interactive: a left-press here would otherwise be swallowed as a
5475
+ // text-selection drag instead of reaching the click handlers.
5476
+ selectable: false
4852
5477
  });
4853
5478
  detailBox.add(detailText);
4854
5479
  body.add(resultsBox);
@@ -4863,6 +5488,7 @@ async function launchBrowser(theme_override) {
4863
5488
  });
4864
5489
  root.add(landingBox);
4865
5490
  const landingBanner = new TextRenderable5(renderer, {
5491
+ ...NON_INTERACTIVE_TEXT,
4866
5492
  content: [
4867
5493
  " \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557\u2588\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2557 ",
4868
5494
  "\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557",
@@ -4940,7 +5566,8 @@ async function launchBrowser(theme_override) {
4940
5566
  card.add(spacerBot);
4941
5567
  }
4942
5568
  const landingHint = new TextRenderable5(renderer, {
4943
- content: " \u2191\u2193 Navigate \u21E9 Select [b]ookmarks [?]help [q]uit",
5569
+ ...NON_INTERACTIVE_TEXT,
5570
+ content: " \u2191\u2193 Navigate \u21E9 Select [b]ookmarks [?]help [q]uit \xB7 click to pick a mode",
4944
5571
  fg: colors.muted,
4945
5572
  bg: colors.bg,
4946
5573
  height: 1,
@@ -4968,6 +5595,28 @@ async function launchBrowser(theme_override) {
4968
5595
  }
4969
5596
  }
4970
5597
  }
5598
+ landingCards.forEach((card, i) => {
5599
+ let lastClickAt = 0;
5600
+ card.onMouseDown = (event) => {
5601
+ event.stopPropagation();
5602
+ const now = Date.now();
5603
+ const isDouble = landingSelected === i && now - lastClickAt < DOUBLE_CLICK_MS;
5604
+ lastClickAt = now;
5605
+ if (isDouble) {
5606
+ landingOptions[i].action();
5607
+ return;
5608
+ }
5609
+ landingSelected = i;
5610
+ updateLandingCards();
5611
+ renderer.requestRender();
5612
+ };
5613
+ card.onMouseOver = () => {
5614
+ renderer.setMousePointer("pointer");
5615
+ };
5616
+ card.onMouseOut = () => {
5617
+ renderer.setMousePointer("default");
5618
+ };
5619
+ });
4971
5620
  renderer.keyInput.on("keypress", (key) => {
4972
5621
  if (!landingKeysActive(currentMode, currentOverlay)) return;
4973
5622
  if (key.name === "up" || key.name === "k") {
@@ -5032,6 +5681,7 @@ async function launchBrowser(theme_override) {
5032
5681
  updateDim.visible = false;
5033
5682
  if (type === "none") {
5034
5683
  currentOverlay = "none";
5684
+ menuButton.visible = true;
5035
5685
  if (currentMode === "landing") {
5036
5686
  landingBox.visible = true;
5037
5687
  body.visible = false;
@@ -5045,6 +5695,7 @@ async function launchBrowser(theme_override) {
5045
5695
  return;
5046
5696
  }
5047
5697
  currentOverlay = type;
5698
+ menuButton.visible = false;
5048
5699
  landingBox.visible = false;
5049
5700
  if (type !== "leader") {
5050
5701
  hideMainContent();
@@ -5306,7 +5957,8 @@ async function launchBrowser(theme_override) {
5306
5957
  const compareText = new TextRenderable5(renderer, {
5307
5958
  content: "",
5308
5959
  fg: colors.text,
5309
- bg: colors.bg
5960
+ bg: colors.bg,
5961
+ selectable: false
5310
5962
  });
5311
5963
  compareBox.add(compareText);
5312
5964
  root.add(compareBox);
@@ -5435,7 +6087,8 @@ async function launchBrowser(theme_override) {
5435
6087
  itemSpacing: 0
5436
6088
  });
5437
6089
  const leaderFooter = new TextRenderable5(renderer, {
5438
- content: " \u2191\u2193 select Enter run Esc/q close ",
6090
+ ...NON_INTERACTIVE_TEXT,
6091
+ content: " \u2191\u2193 select Enter run Esc/q close \xB7 click to run click outside to close ",
5439
6092
  fg: colors.muted,
5440
6093
  bg: colors.surfaceDim,
5441
6094
  height: 1,
@@ -5567,10 +6220,59 @@ async function launchBrowser(theme_override) {
5567
6220
  neverText.fg = updateSelectedOption === 2 ? colors.yellow : colors.text;
5568
6221
  renderer.requestRender();
5569
6222
  }
6223
+ function runUpdateOption(index) {
6224
+ updateSelectedOption = index;
6225
+ renderUpdateOptions();
6226
+ const action = ["now", "later", "never"][index];
6227
+ if (action === "now") {
6228
+ showOverlay("none");
6229
+ recordPreUpdateState(cachedVersion ?? "");
6230
+ performUpdate().then((ok) => {
6231
+ if (ok) {
6232
+ setStatus("\u2713 Update complete \u2014 restart ghfind");
6233
+ } else {
6234
+ setStatus("\u2717 Update failed \u2014 check terminal output");
6235
+ }
6236
+ }).catch(() => {
6237
+ setStatus("\u2717 Update failed \u2014 check terminal output");
6238
+ });
6239
+ } else if (action === "never") {
6240
+ suppressUpdateNotices();
6241
+ showOverlay("none");
6242
+ setStatus("Update notices suppressed");
6243
+ } else {
6244
+ snoozeUpdateNotices(3);
6245
+ showOverlay("none");
6246
+ setStatus("Update reminder snoozed for 3 days");
6247
+ }
6248
+ renderer.requestRender();
6249
+ }
6250
+ const updateCards = [
6251
+ [updateNowBox, 0],
6252
+ [laterBox, 1],
6253
+ [neverBox, 2]
6254
+ ];
6255
+ for (const [box, index] of updateCards) {
6256
+ attachClickableBox(renderer, box, {
6257
+ onActivate: () => runUpdateOption(index),
6258
+ idleBorder: updateSelectedOption === index ? colors.yellow : colors.border,
6259
+ activeBorder: colors.yellow,
6260
+ // Hovering must not fight the selection highlight, so it only moves the
6261
+ // pointer cursor; the border stays owned by renderUpdateOptions().
6262
+ onStateChange: () => {
6263
+ box.borderColor = updateSelectedOption === index ? colors.yellow : colors.border;
6264
+ }
6265
+ });
6266
+ }
6267
+ attachClickOutside(updateDim, () => {
6268
+ showOverlay("none");
6269
+ renderer.requestRender();
6270
+ });
5570
6271
  updateOptionsRow.add(updateNowBox);
5571
6272
  updateOptionsRow.add(laterBox);
5572
6273
  const updateFooter = new TextRenderable5(renderer, {
5573
- content: " \u2191\u2193/jk/PgUp/PgDn scroll \u2190\u2192 select Enter Esc/q close",
6274
+ ...NON_INTERACTIVE_TEXT,
6275
+ content: " \u2191\u2193/jk/PgUp/PgDn scroll \u2190\u2192 select Enter Esc/q close \xB7 click a button click outside to close",
5574
6276
  fg: colors.muted,
5575
6277
  bg: colors.surfaceDim,
5576
6278
  height: 1,
@@ -5926,6 +6628,7 @@ async function launchBrowser(theme_override) {
5926
6628
  if (!state.lastInstalledVersion) return;
5927
6629
  if (state.lastInstalledVersion === currentVersion) return;
5928
6630
  const notes = fetchReleaseNotes(currentVersion);
6631
+ markPostUpgradeSeen(currentVersion);
5929
6632
  updateSelectedOption = 0;
5930
6633
  updateHeader.content = ` Updated ghfind ${state.lastInstalledVersion} \u2192 ${currentVersion}
5931
6634
  `;
@@ -6204,6 +6907,40 @@ async function launchBrowser(theme_override) {
6204
6907
  paddingX: 1
6205
6908
  });
6206
6909
  root.add(statusBar);
6910
+ const menuButton = new BoxRenderable5(renderer, {
6911
+ position: "absolute",
6912
+ bottom: 1,
6913
+ right: 1,
6914
+ height: 1,
6915
+ paddingX: 1,
6916
+ backgroundColor: colors.surface,
6917
+ focusable: false
6918
+ });
6919
+ const menuButtonLabel = new TextRenderable5(renderer, {
6920
+ ...NON_INTERACTIVE_TEXT,
6921
+ content: "\u2630 menu",
6922
+ fg: colors.muted,
6923
+ bg: colors.surface,
6924
+ height: 1
6925
+ });
6926
+ menuButton.add(menuButtonLabel);
6927
+ root.add(menuButton);
6928
+ menuButton.onMouseDown = (event) => {
6929
+ event.stopPropagation();
6930
+ if (currentOverlay === "leader") showOverlay("none");
6931
+ else showLeaderMenu();
6932
+ renderer.requestRender();
6933
+ };
6934
+ menuButton.onMouseOver = () => {
6935
+ renderer.setMousePointer("pointer");
6936
+ menuButtonLabel.fg = colors.accent;
6937
+ renderer.requestRender();
6938
+ };
6939
+ menuButton.onMouseOut = () => {
6940
+ renderer.setMousePointer("default");
6941
+ menuButtonLabel.fg = colors.muted;
6942
+ renderer.requestRender();
6943
+ };
6207
6944
  const statusMgr = new StatusManager((text) => {
6208
6945
  statusBar.content = ` ${text}`;
6209
6946
  renderer.requestRender();
@@ -6522,16 +7259,13 @@ async function launchBrowser(theme_override) {
6522
7259
  renderer.requestRender();
6523
7260
  try {
6524
7261
  const searchModule = new SearchModule(new TrendingAdapter());
6525
- const response = await searchModule.search(
6526
- { keywords: [], qualifiers: [], raw: "trending" },
6527
- {
6528
- limit: 25,
6529
- sort: "stars",
6530
- json: false,
6531
- verbose: false,
6532
- trendingSince: tabSince(trendingTab)
6533
- }
6534
- );
7262
+ const response = await searchModule.search(trendingQuery(), {
7263
+ limit: 25,
7264
+ sort: "stars",
7265
+ json: false,
7266
+ verbose: false,
7267
+ trendingSince: tabSince(trendingTab)
7268
+ });
6535
7269
  const fetched = response.repos;
6536
7270
  const since = tabSince(trendingTab);
6537
7271
  trendingPeriod = since === "daily" ? "today" : since === "weekly" ? "this week" : "this month";
@@ -6671,7 +7405,10 @@ async function launchBrowser(theme_override) {
6671
7405
  currentRepos = newRepos;
6672
7406
  }
6673
7407
  if (currentRepos.length === 0) {
6674
- throw new NoResultsError(q);
7408
+ throw new NoResultsError(
7409
+ q,
7410
+ suggestRelaxations(parsed).map((r) => r.suggestion)
7411
+ );
6675
7412
  } else {
6676
7413
  resultsSelect.options = currentRepos.map((r) => ({
6677
7414
  name: formatResultLine(r),
@@ -6797,6 +7534,158 @@ ${pack.description ?? ""}`;
6797
7534
  if (repo) openUrl(repo.url);
6798
7535
  }
6799
7536
  });
7537
+ attachClickToSelect(renderer, resultsSelect, {
7538
+ keepFocus: true,
7539
+ onOpen: () => resultsSelect.selectCurrent()
7540
+ });
7541
+ function activateOverlaySelection() {
7542
+ switch (currentOverlay) {
7543
+ case "history": {
7544
+ const sel = historySelect.getSelectedOption();
7545
+ if (sel?.value) {
7546
+ const entry = sel.value.entry;
7547
+ showOverlay("none");
7548
+ if (!entry) return;
7549
+ if (entry.mode === "trending") {
7550
+ if (entry.tab)
7551
+ trendingTab = entry.tab;
7552
+ loadTrending();
7553
+ } else {
7554
+ searchInput.value = entry.query;
7555
+ showSearchMode();
7556
+ doSearch(entry.query);
7557
+ }
7558
+ }
7559
+ return;
7560
+ }
7561
+ case "bookmarks": {
7562
+ const sel = bookmarksSelect.getSelectedOption();
7563
+ if (sel?.value)
7564
+ openUrl(sel.value.repo?.url ?? "");
7565
+ return;
7566
+ }
7567
+ case "saved": {
7568
+ const sel = savedSelect.getSelectedOption();
7569
+ if (sel?.value) {
7570
+ const s = sel.value;
7571
+ showOverlay("none");
7572
+ touchSavedSearch(s.name);
7573
+ if (s.mode === "trending") {
7574
+ if (s.tab) trendingTab = s.tab;
7575
+ loadTrending();
7576
+ } else {
7577
+ searchInput.value = s.query;
7578
+ showSearchMode();
7579
+ currentSort = s.sort;
7580
+ currentLimit = s.limit;
7581
+ setToolbar();
7582
+ doSearch(s.query);
7583
+ }
7584
+ }
7585
+ return;
7586
+ }
7587
+ case "topics": {
7588
+ const sel = topicsSelect.getSelectedOption();
7589
+ if (sel?.value) {
7590
+ const topic = sel.value;
7591
+ showOverlay("none");
7592
+ searchInput.value = `topic:${topic.name ?? ""}`;
7593
+ showSearchMode();
7594
+ doSearch(`topic:${topic.name}`);
7595
+ }
7596
+ return;
7597
+ }
7598
+ case "export": {
7599
+ const sel = exportSelect.getSelectedOption();
7600
+ if (sel?.value && currentRepos.length > 0) {
7601
+ const format2 = sel.value;
7602
+ const path = exportToFile(currentRepos, format2);
7603
+ showOverlay("none");
7604
+ setStatus(`\u2713 Exported to ${path}`);
7605
+ } else {
7606
+ setStatus("No results to export");
7607
+ showOverlay("none");
7608
+ }
7609
+ renderer.requestRender();
7610
+ return;
7611
+ }
7612
+ case "share": {
7613
+ const sel = shareSelect.getSelectedOption();
7614
+ if (sel?.value) {
7615
+ const opt = resultsSelect.getSelectedOption();
7616
+ const repo = opt?.value;
7617
+ if (repo) {
7618
+ const text = formatShare(repo, sel.value);
7619
+ copyToClipboard(text).then((ok) => {
7620
+ showOverlay("none");
7621
+ setStatus(ok ? "\u2713 Copied!" : "Clipboard not available");
7622
+ });
7623
+ }
7624
+ }
7625
+ renderer.requestRender();
7626
+ return;
7627
+ }
7628
+ case "help": {
7629
+ showOverlay("none");
7630
+ renderer.requestRender();
7631
+ return;
7632
+ }
7633
+ default:
7634
+ return;
7635
+ }
7636
+ }
7637
+ function attachOverlayMouse(box, select) {
7638
+ attachClickToSelect(renderer, select, {
7639
+ onOpen: () => activateOverlaySelection(),
7640
+ onClickMiss: () => {
7641
+ showOverlay("none");
7642
+ renderer.requestRender();
7643
+ }
7644
+ });
7645
+ attachClickOutside(box, () => {
7646
+ showOverlay("none");
7647
+ renderer.requestRender();
7648
+ });
7649
+ }
7650
+ attachOverlayMouse(historyBox, historySelect);
7651
+ attachOverlayMouse(bookmarksBox, bookmarksSelect);
7652
+ attachOverlayMouse(savedBox, savedSelect);
7653
+ attachOverlayMouse(topicsBox, topicsSelect);
7654
+ attachOverlayMouse(exportBox, exportSelect);
7655
+ attachOverlayMouse(shareBox, shareSelect);
7656
+ for (const panel of [helpBox, orgBox, readmeBox, compareBox, notifsBox]) {
7657
+ attachClickOutside(panel, () => {
7658
+ showOverlay("none");
7659
+ renderer.requestRender();
7660
+ });
7661
+ }
7662
+ for (const scroller of [helpScroll, orgScroll, readmeScroll, updateScroll]) {
7663
+ scroller.onMouseScroll = (event) => {
7664
+ event.stopPropagation();
7665
+ };
7666
+ }
7667
+ attachClickToSelect(renderer, leaderSelect, {
7668
+ onOpen: () => {
7669
+ const entry = leaderSelect.getSelectedOption()?.value;
7670
+ if (!entry) return;
7671
+ if (entry.type === "category") {
7672
+ pushMenuLevel(` Menu > ${entry.name}`, entry.name, entry.children, 0);
7673
+ } else {
7674
+ resetMenu();
7675
+ showOverlay("none");
7676
+ entry.action();
7677
+ }
7678
+ renderer.requestRender();
7679
+ },
7680
+ onClickMiss: () => {
7681
+ showOverlay("none");
7682
+ renderer.requestRender();
7683
+ }
7684
+ });
7685
+ attachClickOutside(leaderDim, () => {
7686
+ showOverlay("none");
7687
+ renderer.requestRender();
7688
+ });
6800
7689
  renderer.keyInput.on("keypress", (key) => {
6801
7690
  if (tokenPromptBox.visible) {
6802
7691
  if (key.name === "escape") {
@@ -6890,21 +7779,7 @@ ${pack.description ?? ""}`;
6890
7779
  return;
6891
7780
  }
6892
7781
  if (key.name === "enter" || key.name === "return") {
6893
- const sel = historySelect.getSelectedOption();
6894
- if (sel?.value) {
6895
- const entry = sel.value.entry;
6896
- showOverlay("none");
6897
- if (!entry) return;
6898
- if (entry.mode === "trending") {
6899
- if (entry.tab)
6900
- trendingTab = entry.tab;
6901
- loadTrending();
6902
- } else {
6903
- searchInput.value = entry.query;
6904
- showSearchMode();
6905
- doSearch(entry.query);
6906
- }
6907
- }
7782
+ activateOverlaySelection();
6908
7783
  return;
6909
7784
  }
6910
7785
  return;
@@ -6923,9 +7798,7 @@ ${pack.description ?? ""}`;
6923
7798
  return;
6924
7799
  }
6925
7800
  if (key.name === "enter" || key.name === "return") {
6926
- const sel = bookmarksSelect.getSelectedOption();
6927
- if (sel?.value)
6928
- openUrl(sel.value.repo?.url ?? "");
7801
+ activateOverlaySelection();
6929
7802
  return;
6930
7803
  }
6931
7804
  return;
@@ -6942,54 +7815,21 @@ ${pack.description ?? ""}`;
6942
7815
  return;
6943
7816
  }
6944
7817
  if (key.name === "enter" || key.name === "return") {
6945
- const sel = savedSelect.getSelectedOption();
6946
- if (sel?.value) {
6947
- const s = sel.value;
6948
- showOverlay("none");
6949
- touchSavedSearch(s.name);
6950
- if (s.mode === "trending") {
6951
- if (s.tab) trendingTab = s.tab;
6952
- loadTrending();
6953
- } else {
6954
- searchInput.value = s.query;
6955
- showSearchMode();
6956
- currentSort = s.sort;
6957
- currentLimit = s.limit;
6958
- setToolbar();
6959
- doSearch(s.query);
6960
- }
6961
- }
7818
+ activateOverlaySelection();
6962
7819
  return;
6963
7820
  }
6964
7821
  return;
6965
7822
  }
6966
7823
  if (currentOverlay === "topics") {
6967
7824
  if (key.name === "enter" || key.name === "return") {
6968
- const sel = topicsSelect.getSelectedOption();
6969
- if (sel?.value) {
6970
- const topic = sel.value;
6971
- showOverlay("none");
6972
- searchInput.value = `topic:${topic.name ?? ""}`;
6973
- showSearchMode();
6974
- doSearch(`topic:${topic.name}`);
6975
- }
7825
+ activateOverlaySelection();
6976
7826
  return;
6977
7827
  }
6978
7828
  return;
6979
7829
  }
6980
7830
  if (currentOverlay === "export") {
6981
7831
  if (key.name === "enter" || key.name === "return") {
6982
- const sel = exportSelect.getSelectedOption();
6983
- if (sel?.value && currentRepos.length > 0) {
6984
- const format2 = sel.value;
6985
- const path = exportToFile(currentRepos, format2);
6986
- showOverlay("none");
6987
- setStatus(`\u2713 Exported to ${path}`);
6988
- } else {
6989
- setStatus("No results to export");
6990
- showOverlay("none");
6991
- }
6992
- renderer.requestRender();
7832
+ activateOverlaySelection();
6993
7833
  return;
6994
7834
  }
6995
7835
  return;
@@ -7018,26 +7858,13 @@ ${pack.description ?? ""}`;
7018
7858
  }
7019
7859
  if (currentOverlay === "share") {
7020
7860
  if (key.name === "enter" || key.name === "return") {
7021
- const sel = shareSelect.getSelectedOption();
7022
- if (sel?.value) {
7023
- const opt = resultsSelect.getSelectedOption();
7024
- const repo = opt?.value;
7025
- if (repo) {
7026
- const text = formatShare(repo, sel.value);
7027
- copyToClipboard(text).then((ok) => {
7028
- showOverlay("none");
7029
- setStatus(ok ? "\u2713 Copied!" : "Clipboard not available");
7030
- });
7031
- }
7032
- }
7033
- renderer.requestRender();
7861
+ activateOverlaySelection();
7034
7862
  return;
7035
7863
  }
7036
7864
  return;
7037
7865
  }
7038
7866
  if (currentOverlay === "help") {
7039
- showOverlay("none");
7040
- renderer.requestRender();
7867
+ activateOverlaySelection();
7041
7868
  return;
7042
7869
  }
7043
7870
  if (currentOverlay === "update") {
@@ -7062,29 +7889,7 @@ ${pack.description ?? ""}`;
7062
7889
  return;
7063
7890
  }
7064
7891
  if (key.name === "enter" || key.name === "return") {
7065
- const action = ["now", "later", "never"][updateSelectedOption];
7066
- if (action === "now") {
7067
- showOverlay("none");
7068
- recordPreUpdateState(cachedVersion ?? "");
7069
- performUpdate().then((ok) => {
7070
- if (ok) {
7071
- setStatus("\u2713 Update complete \u2014 restart ghfind");
7072
- } else {
7073
- setStatus("\u2717 Update failed \u2014 check terminal output");
7074
- }
7075
- }).catch(() => {
7076
- setStatus("\u2717 Update failed \u2014 check terminal output");
7077
- });
7078
- } else if (action === "never") {
7079
- suppressUpdateNotices();
7080
- showOverlay("none");
7081
- setStatus("Update notices suppressed");
7082
- } else {
7083
- snoozeUpdateNotices(3);
7084
- showOverlay("none");
7085
- setStatus("Update reminder snoozed for 3 days");
7086
- }
7087
- renderer.requestRender();
7892
+ runUpdateOption(updateSelectedOption);
7088
7893
  return;
7089
7894
  }
7090
7895
  return;
@@ -7107,8 +7912,14 @@ ${pack.description ?? ""}`;
7107
7912
  toggleGraph();
7108
7913
  return;
7109
7914
  }
7110
- if (key.name === "space" && !searchInput.focused) {
7111
- showLeaderMenu();
7915
+ if (key.name === "escape") {
7916
+ if (currentOverlay !== "none") {
7917
+ showOverlay("none");
7918
+ setToolbar();
7919
+ } else {
7920
+ showLeaderMenu();
7921
+ }
7922
+ renderer.requestRender();
7112
7923
  return;
7113
7924
  }
7114
7925
  if (!searchInput.focused && errorHint) {
@@ -7663,7 +8474,7 @@ function formatPackageLines(pkgs) {
7663
8474
  function asMcpToolError(err) {
7664
8475
  return new McpToolError(err instanceof Error ? err.message : String(err));
7665
8476
  }
7666
- var TRENDING_QUERY = { keywords: [], qualifiers: [], raw: "trending" };
8477
+ var TRENDING_QUERY = trendingQuery();
7667
8478
  var REPO_SORTS = ["best-match", "stars", "updated", "forks"];
7668
8479
  var SKILL_SOURCES = ["local", "registry"];
7669
8480
  var TRENDING_SINCE = ["daily", "weekly", "monthly"];
@@ -7710,6 +8521,17 @@ var TOOLS = [
7710
8521
  const token = resolveToken(state, args.token);
7711
8522
  const provider = createGitHubSearch(void 0, token ? [token] : []);
7712
8523
  const parsed = parseQuery(query);
8524
+ try {
8525
+ validateQuery(parsed);
8526
+ } catch (err) {
8527
+ if (err instanceof BadQueryError) {
8528
+ return {
8529
+ text: `Invalid query: ${err.detail}${err.hint ? ` Try: ${err.hint}` : ""}`,
8530
+ data: { totalCount: 0, repos: [], invalidQuery: true }
8531
+ };
8532
+ }
8533
+ throw err;
8534
+ }
7713
8535
  const options = {
7714
8536
  limit,
7715
8537
  sort,
@@ -8667,6 +9489,7 @@ Options:
8667
9489
  }
8668
9490
  function buildSearchContext(flags) {
8669
9491
  const parsed = applyFlagFilters(parseQuery(flags.query), {});
9492
+ validateQuery(parsed);
8670
9493
  return {
8671
9494
  query: flags.query,
8672
9495
  parsed,
@@ -9098,17 +9921,14 @@ async function runNonInteractive(flags, outputFormat) {
9098
9921
  console.error(err instanceof Error ? err.message : String(err));
9099
9922
  process.exit(1);
9100
9923
  }
9101
- const response = await trendingSearch.search(
9102
- { keywords: [], qualifiers: [], raw: "trending" },
9103
- {
9104
- limit: 25,
9105
- sort: "stars",
9106
- json: false,
9107
- verbose: false,
9108
- trendingSince: since,
9109
- ...language ? { trendingLanguage: language } : {}
9110
- }
9111
- );
9924
+ const response = await trendingSearch.search(trendingQuery(), {
9925
+ limit: 25,
9926
+ sort: "stars",
9927
+ json: false,
9928
+ verbose: false,
9929
+ trendingSince: since,
9930
+ ...language ? { trendingLanguage: language } : {}
9931
+ });
9112
9932
  if (outputFormat) {
9113
9933
  console.log(format(response.repos, outputFormat));
9114
9934
  } else if (flags.count) {