github-search-cli 9.8.2 → 9.9.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/errors.ts CHANGED
@@ -20,6 +20,39 @@ export class NetworkError extends SearchCliError {
20
20
  }
21
21
  }
22
22
 
23
+ /**
24
+ * HTTP 401 — the configured GitHub token was rejected.
25
+ * Distinct from NetworkError: connectivity is fine, the credential is not.
26
+ */
27
+ export class AuthError extends SearchCliError {
28
+ constructor() {
29
+ const msg =
30
+ "⚠ Bad GitHub token (401). Press [t] to fix it, [u] to search without one";
31
+ super("GitHub API returned 401 (invalid or revoked token)", msg);
32
+ this.name = "AuthError";
33
+ }
34
+ }
35
+
36
+ /**
37
+ * HTTP 403 that is NOT a primary rate limit (i.e. `x-ratelimit-remaining`
38
+ * was greater than zero). Three unrelated causes land here and the status
39
+ * code cannot tell them apart:
40
+ * - the token's org enforces SAML SSO and has not authorized it
41
+ * - a secondary/abuse rate limit (concurrent or rapid-fire requests)
42
+ * - a valid token blocked by org or resource restrictions
43
+ * The old message asserted SSO unconditionally, which sent people to fix a
44
+ * credential problem that did not exist. Check GHFIND_LOG for the body.
45
+ */
46
+ export class ForbiddenError extends SearchCliError {
47
+ constructor(public readonly detail: string = "GitHub API returned 403") {
48
+ const msg =
49
+ `⚠ Forbidden (403) — ${detail}. Common causes: org SSO not authorizing the token, ` +
50
+ `or a secondary rate limit. Press [t] to change the token`;
51
+ super(msg, msg);
52
+ this.name = "ForbiddenError";
53
+ }
54
+ }
55
+
23
56
  export class RateLimitError extends SearchCliError {
24
57
  constructor(
25
58
  public readonly hasToken: boolean,
@@ -34,17 +67,35 @@ export class RateLimitError extends SearchCliError {
34
67
  }
35
68
 
36
69
  export class BadQueryError extends SearchCliError {
37
- constructor(public readonly detail: string) {
38
- super(`Invalid query: ${detail}`, `⚠ Invalid query: ${detail}`);
70
+ /** A concrete, corrected query the user can retry with. */
71
+ constructor(
72
+ public readonly detail: string,
73
+ public readonly hint?: string,
74
+ ) {
75
+ super(
76
+ `Invalid query: ${detail}`,
77
+ hint
78
+ ? `⚠ Invalid query: ${detail}\n Try: ${hint}`
79
+ : `⚠ Invalid query: ${detail}`,
80
+ );
39
81
  this.name = "BadQueryError";
40
82
  }
41
83
  }
42
84
 
43
85
  export class NoResultsError extends SearchCliError {
44
- constructor(public readonly query: string) {
86
+ /** Concrete loosened queries to retry, most promising first. */
87
+ constructor(
88
+ public readonly query: string,
89
+ public readonly relaxations: string[] = [],
90
+ ) {
91
+ const tips = relaxations.length
92
+ ? relaxations.map((r) => `Try: ${r}`).join("\n ")
93
+ : "Tips: check spelling, try fewer qualifiers, or [b]rowse trending";
45
94
  super(
46
- `No results for "${query}"`,
47
- `🔍 No results for "${query}". Tips: check spelling, try fewer qualifiers, or [b]rowse trending`,
95
+ relaxations.length > 0
96
+ ? `No results for "${query}" — try loosening: ${relaxations.join(" / ")}`
97
+ : `No results for "${query}"`,
98
+ `🔍 No results for "${query}".\n ${tips}`,
48
99
  );
49
100
  this.name = "NoResultsError";
50
101
  }
package/src/help.ts CHANGED
@@ -47,6 +47,17 @@ export function buildHelpSections(): HelpSection[] {
47
47
  { keys: "PageUp", action: "Scroll to top of results" },
48
48
  ],
49
49
  },
50
+ {
51
+ title: " Error Recovery ",
52
+ titleColor: "red",
53
+ rows: [
54
+ { keys: "r", action: "Retry after an error" },
55
+ { keys: "t", action: "Fix GitHub token (401/403)" },
56
+ { keys: "u", action: "Search without a token (401)" },
57
+ { keys: "c", action: "Change token (rate limit)" },
58
+ ],
59
+ note: "Active only while the matching error is in the status bar.",
60
+ },
50
61
  {
51
62
  title: " Trending Mode ",
52
63
  titleColor: "yellow",
@@ -6,6 +6,7 @@
6
6
  * inline `data:` URIs. Only some of them are worth downloading, and all of
7
7
  * the downloads are cached because a README is re-opened often.
8
8
  */
9
+ import { declaresTooSmall, type DeclaredSize } from "./markdown-images";
9
10
 
10
11
  /** Reject anything bigger than this before buffering it. */
11
12
  export const MAX_IMAGE_BYTES = 6 * 1024 * 1024;
@@ -263,8 +264,7 @@ async function loadImageBytes(
263
264
  }
264
265
  }
265
266
 
266
- /**
267
- * Absolute URL of the README file itself, used as the base for relative
267
+ /** Absolute URL of the README file itself, used as the base for relative
268
268
  * image paths.
269
269
  */
270
270
  export function readmeBaseUrl(
@@ -275,3 +275,58 @@ export function readmeBaseUrl(
275
275
  ): string {
276
276
  return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
277
277
  }
278
+
279
+ /**
280
+ * Pixel size an image declares about itself, read from its header, or
281
+ * `undefined` when the runtime has no image codec or the bytes are unreadable.
282
+ * `Bun.Image.metadata()` reads only the header — no pixel decode — so this is
283
+ * cheap to run on bytes that are already in hand.
284
+ */
285
+ async function imageSizeFromBytes(
286
+ bytes: Uint8Array,
287
+ ): Promise<DeclaredSize | undefined> {
288
+ const ImageCtor = (
289
+ globalThis as {
290
+ Bun?: {
291
+ Image?: new (
292
+ input: Uint8Array,
293
+ constructorOptions?: { maxPixels?: number },
294
+ ) => {
295
+ metadata(): Promise<{ width: number; height: number }>;
296
+ };
297
+ };
298
+ }
299
+ ).Bun?.Image;
300
+ if (!ImageCtor) return undefined;
301
+ try {
302
+ const meta = await new ImageCtor(bytes).metadata();
303
+ if (!meta.width || !meta.height) return undefined;
304
+ return { width: meta.width, height: meta.height };
305
+ } catch {
306
+ return undefined;
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Whether a downloaded image is worth rasterising.
312
+ *
313
+ * A reference that declares its size in the README is trusted (`declared`);
314
+ * one that doesn't is measured from the image header. Images below the
315
+ * 100 px floor are rejected *after* the download but *before* any pixel
316
+ * decode — markdown `![]()` images carry no declared size, so this is the
317
+ * only place their real size can be checked, and the header read is the
318
+ * cheapest check that works.
319
+ *
320
+ * On a runtime without `Bun.Image` (plain Node) there is nothing to measure
321
+ * with, so the image is accepted and left to the rasteriser, which fails
322
+ * into a caption anyway.
323
+ */
324
+ export async function shouldRenderImage(
325
+ bytes: Uint8Array,
326
+ declared?: DeclaredSize,
327
+ ): Promise<boolean> {
328
+ if (declared) return !declaresTooSmall(declared);
329
+ const measured = await imageSizeFromBytes(bytes);
330
+ if (!measured) return true;
331
+ return !declaresTooSmall(measured);
332
+ }
@@ -14,6 +14,26 @@ export interface InlineTokenLike {
14
14
  tokens?: InlineTokenLike[];
15
15
  }
16
16
 
17
+ /**
18
+ * A pixel size an image reference declares about itself — the `width`/`height`
19
+ * attributes of an `<img>` tag, or the dimensions `Bun.Image.metadata()` reads
20
+ * from an image header. `undefined` means "not declared".
21
+ */
22
+ export interface DeclaredSize {
23
+ width: number;
24
+ height: number;
25
+ }
26
+
27
+ /**
28
+ * Images whose larger declared edge is below this are skipped before any
29
+ * request is made. Badges and 48x48 sponsor avatars are unreadable at cell
30
+ * resolution and, at 24 rows per image, a README full of them is unusable —
31
+ * 648 avatars in `openclaw/openclaw` measured 15,552 rows and 28 s to open.
32
+ * The README is the authority here: it can hide an image whose declared size
33
+ * understates it, which is judged better than a screen full of avatars.
34
+ */
35
+ export const MIN_IMAGE_PIXELS = 100;
36
+
17
37
  /** Minimal shape of a marked block token. */
18
38
  export interface BlockTokenLike extends InlineTokenLike {
19
39
  depth?: number;
@@ -22,12 +42,55 @@ export interface BlockTokenLike extends InlineTokenLike {
22
42
  export interface ImageRef {
23
43
  href: string;
24
44
  alt: string;
45
+ /** Size the reference declares about itself, when it does. */
46
+ declared?: DeclaredSize;
47
+ /** Link target when the image is wrapped in a link (`[![alt](src)](url)`). */
48
+ linkUrl?: string;
25
49
  }
26
50
 
27
51
  const IMG_TAG = /<img\b[^>]*?src\s*=\s*["']([^"']+)["'][^>]*>/gi;
28
52
  const ALT_ATTR = /\balt\s*=\s*["']([^"']*)["']/i;
53
+ const LENGTH_ATTR = /\b(width|height)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
54
+ /** Whole-value integers, with or without a `px` suffix. */
55
+ const PIXEL_VALUE = /^(\d+)(?:px)?$/i;
29
56
  const HTML_TAG = /<\/?[a-z][^>]*>/gi;
30
57
 
58
+ /**
59
+ * Pixel size an `<img>` tag declares via its `width`/`height` attributes, or
60
+ * `undefined` when it declares none. Only whole pixel values count —
61
+ * percentages (`width="100%"`) say nothing about real pixels, so they are
62
+ * ignored rather than read as a 100 px image.
63
+ */
64
+ export function declaredImageSize(tag: string): DeclaredSize | undefined {
65
+ const values: Partial<Record<"width" | "height", number>> = {};
66
+ for (const [, name, dq, sq] of tag.matchAll(LENGTH_ATTR)) {
67
+ const raw = dq ?? sq ?? "";
68
+ const match = PIXEL_VALUE.exec(raw.trim());
69
+ if (!match) continue;
70
+ const n = Number.parseInt(match[1], 10);
71
+ if (Number.isFinite(n) && n > 0) {
72
+ values[name as "width" | "height"] = n;
73
+ }
74
+ }
75
+ if (values.width === undefined && values.height === undefined)
76
+ return undefined;
77
+ return {
78
+ width: values.width ?? values.height ?? 0,
79
+ height: values.height ?? values.width ?? 0,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Whether a reference declaring `size` is too small to render: an edge under
85
+ * the floor (the shipped contract — "images declaring an edge under 100px are
86
+ * skipped"). `undefined` (nothing declared) is never too small — callers
87
+ * decide from real pixels or render and let the rasteriser cap it.
88
+ */
89
+ export function declaresTooSmall(size: DeclaredSize | undefined): boolean {
90
+ if (!size) return false;
91
+ return size.width < MIN_IMAGE_PIXELS || size.height < MIN_IMAGE_PIXELS;
92
+ }
93
+
31
94
  /** Images embedded as raw `<img>` tags, when there's no other content. */
32
95
  function imagesFromHtml(raw: string | undefined): ImageRef[] {
33
96
  if (!raw || !/<img\b/i.test(raw)) return [];
@@ -43,7 +106,7 @@ function imagesFromHtml(raw: string | undefined): ImageRef[] {
43
106
  for (const match of raw.matchAll(IMG_TAG)) {
44
107
  const tag = match[0];
45
108
  const alt = ALT_ATTR.exec(tag)?.[1] ?? "";
46
- refs.push({ href: match[1], alt });
109
+ refs.push({ href: match[1], alt, declared: declaredImageSize(tag) });
47
110
  }
48
111
  return refs;
49
112
  }
@@ -81,13 +144,29 @@ export function inlinePlainText(tokens: InlineTokenLike[] | undefined): string {
81
144
  */
82
145
  export function extractImageRefs(token: BlockTokenLike): ImageRef[] | null {
83
146
  const images: ImageRef[] = [];
147
+ let linkUrl: string | undefined;
84
148
 
85
149
  const collect = (inline: InlineTokenLike): boolean => {
86
150
  if (inline.type === "image") {
87
151
  if (inline.href)
88
- images.push({ href: inline.href, alt: inline.text ?? "" });
152
+ images.push({ href: inline.href, alt: inline.text ?? "", linkUrl });
89
153
  return true;
90
154
  }
155
+ if (inline.type === "link" || inline.type === "linkReference") {
156
+ // `[![alt](img)](url)` — the badge-and-screenshot shape. marked hands
157
+ // the image to us as a child of the link token, so recurse; rejecting
158
+ // the block here discarded the image entirely (sst/opencode,
159
+ // withastro/astro). A link whose content is prose still fails the
160
+ // inner `text` check, so those blocks stay prose.
161
+ if (inline.tokens?.length) {
162
+ const outer = linkUrl;
163
+ linkUrl = inline.href || outer;
164
+ const ok = inline.tokens.every(collect);
165
+ linkUrl = outer;
166
+ return ok;
167
+ }
168
+ return false;
169
+ }
91
170
  if (inline.type === "html") {
92
171
  const refs = imagesFromHtml(inline.raw);
93
172
  if (refs.length > 0) {
@@ -111,7 +190,7 @@ export function extractImageRefs(token: BlockTokenLike): ImageRef[] | null {
111
190
 
112
191
  if (token.type === "html") {
113
192
  const refs = imagesFromHtml(token.raw);
114
- return refs.length > 0 ? refs : null;
193
+ return refs.length > 0 ? dropTooSmall(refs) : null;
115
194
  }
116
195
 
117
196
  const inline = token.tokens ?? [];
@@ -119,5 +198,15 @@ export function extractImageRefs(token: BlockTokenLike): ImageRef[] | null {
119
198
  for (const child of inline) {
120
199
  if (!collect(child)) return null;
121
200
  }
122
- return images.length > 0 ? images : null;
201
+ return images.length > 0 ? dropTooSmall(images) : null;
202
+ }
203
+
204
+ /**
205
+ * Filter out refs the README declares too small to be worth a request.
206
+ * An empty return means "pure image block, but nothing in it is worth
207
+ * drawing" — distinct from `null` (prose), so the view can render neither
208
+ * prose nor the raw markdown of a filtered-out image.
209
+ */
210
+ function dropTooSmall(refs: ImageRef[]): ImageRef[] {
211
+ return refs.filter((image) => !declaresTooSmall(image.declared));
123
212
  }
@@ -24,11 +24,12 @@ import {
24
24
  type HalfBlockImage,
25
25
  type Rgb,
26
26
  } from "./image-render";
27
- import { resolveReadmeImageUrl } from "./image-loader";
27
+ import { resolveReadmeImageUrl, shouldRenderImage } from "./image-loader";
28
28
  import {
29
29
  extractImageRefs,
30
30
  inlinePlainText,
31
31
  type BlockTokenLike,
32
+ type DeclaredSize,
32
33
  type ImageRef,
33
34
  } from "./markdown-images";
34
35
 
@@ -105,17 +106,21 @@ function rgba(color: Rgb): RGBA {
105
106
  }
106
107
 
107
108
  /** Half-block rows as one styled-text block (one line per image row). */
108
- function imageStyledText(image: HalfBlockImage): StyledText {
109
+ function imageStyledText(image: HalfBlockImage, linkUrl?: string): StyledText {
109
110
  const chunks: TextChunk[] = [];
110
111
  toImageRuns(image).forEach((runs, rowIndex) => {
111
112
  if (rowIndex > 0) chunks.push({ __isChunk: true, text: "\n" });
112
113
  for (const run of runs) {
113
- chunks.push({
114
+ const chunk: TextChunk = {
114
115
  __isChunk: true,
115
116
  text: run.text,
116
117
  fg: rgba(run.top),
117
118
  bg: rgba(run.bottom),
118
- });
119
+ };
120
+ // `[![alt](img)](url)` keeps its link: OSC 8 on the image cells, so a
121
+ // click in the terminal opens the same URL the prose version would.
122
+ if (linkUrl) chunk.link = { url: linkUrl };
123
+ chunks.push(chunk);
119
124
  }
120
125
  });
121
126
  return new StyledText(chunks);
@@ -158,17 +163,26 @@ export function createMarkdownView(opts: MarkdownViewOptions): MarkdownView {
158
163
  width: "100%",
159
164
  });
160
165
 
161
- const caption = (label: string, color = muted()) =>
166
+ const caption = (label: string, linkUrl?: string, color = muted()) =>
162
167
  new TextRenderable(renderer, {
163
- content: ` ${label}`,
164
- fg: color,
168
+ content: new StyledText([
169
+ {
170
+ __isChunk: true,
171
+ text: ` ${label}`,
172
+ fg: RGBA.fromHex(color),
173
+ ...(linkUrl ? { link: { url: linkUrl } } : {}),
174
+ },
175
+ ]),
165
176
  bg: bg(),
166
177
  wrapMode: "word",
167
178
  flexShrink: 0,
168
179
  width: "100%",
169
180
  });
170
181
 
171
- const rasterize = (url: string): Promise<HalfBlockImage | null> => {
182
+ const rasterize = (
183
+ url: string,
184
+ declared?: DeclaredSize,
185
+ ): Promise<HalfBlockImage | null> => {
172
186
  // Size is part of the key: a resized terminal needs fresh cells.
173
187
  const maxCols = Math.max(8, Math.floor(opts.getImageWidth?.() ?? 100));
174
188
  const key = `${maxCols}|${url}`;
@@ -179,6 +193,11 @@ export function createMarkdownView(opts: MarkdownViewOptions): MarkdownView {
179
193
  if (!loader) return null;
180
194
  const bytes = await loader(url);
181
195
  if (!bytes) return null;
196
+ // Size gate before any pixel decode. Declared sizes were already
197
+ // filtered at parse time (markdown-images.ts); markdown `![]()`
198
+ // images carry no declared size, so they are measured from the image
199
+ // header here — the rejected `null` is cached like any other failure.
200
+ if (!(await shouldRenderImage(bytes, declared))) return null;
182
201
  return rasterizeImage(bytes, {
183
202
  maxCols,
184
203
  maxRows: maxImageRows,
@@ -199,28 +218,29 @@ export function createMarkdownView(opts: MarkdownViewOptions): MarkdownView {
199
218
  const imageNode = (image: ImageRef): Renderable => {
200
219
  const label = `🖼 ${image.alt.trim() || "image"}`;
201
220
  const url = imageBase ? resolveReadmeImageUrl(image.href, imageBase) : null;
202
- if (!imagesEnabled || !url || !opts.loadImage) return caption(label);
221
+ if (!imagesEnabled || !url || !opts.loadImage)
222
+ return caption(label, image.linkUrl);
203
223
 
204
224
  const container = new BoxRenderable(renderer, {
205
225
  flexDirection: "column",
206
226
  flexShrink: 0,
207
227
  width: "100%",
208
228
  });
209
- const placeholder = caption(`${label} (loading…)`);
229
+ const placeholder = caption(`${label} (loading…)`, image.linkUrl);
210
230
  container.add(placeholder);
211
231
 
212
- void rasterize(url).then(
232
+ void rasterize(url, image.declared).then(
213
233
  (cells) => {
214
234
  // The document may have been replaced (or the overlay closed) while
215
235
  // the image was downloading.
216
236
  if (container.isDestroyed || !container.parent) return;
217
237
  container.remove(placeholder);
218
238
  placeholder.destroy();
219
- container.add(caption(label));
239
+ container.add(caption(label, image.linkUrl));
220
240
  if (cells) {
221
241
  container.add(
222
242
  new TextRenderable(renderer, {
223
- content: imageStyledText(cells),
243
+ content: imageStyledText(cells, image.linkUrl),
224
244
  bg: bg(),
225
245
  wrapMode: "none",
226
246
  selectable: false,
@@ -270,17 +290,26 @@ export function createMarkdownView(opts: MarkdownViewOptions): MarkdownView {
270
290
  ): Renderable | undefined => {
271
291
  if (token.type === "heading") return headingNode(token);
272
292
  const images = extractImageRefs(token);
273
- if (images && images.length > 0) {
274
- const box = new BoxRenderable(renderer, {
275
- flexDirection: "column",
293
+ if (images === null) return undefined; // prose: default rendering
294
+ if (images.length === 0) {
295
+ // Pure image block whose refs the README declares too small to be
296
+ // worth drawing (openclaw's 648 48x48 avatars). Render an empty
297
+ // block rather than falling through to prose, which would print the
298
+ // raw markdown of the filtered-out image.
299
+ return new BoxRenderable(renderer, {
276
300
  flexShrink: 0,
277
301
  width: "100%",
278
- marginTop: 1,
302
+ height: 0,
279
303
  });
280
- for (const image of images) box.add(imageNode(image));
281
- return box;
282
304
  }
283
- return undefined;
305
+ const box = new BoxRenderable(renderer, {
306
+ flexDirection: "column",
307
+ flexShrink: 0,
308
+ width: "100%",
309
+ marginTop: 1,
310
+ });
311
+ for (const image of images) box.add(imageNode(image));
312
+ return box;
284
313
  };
285
314
 
286
315
  renderable.renderNode = buildRenderNode();
package/src/mcp-server.ts CHANGED
@@ -29,6 +29,8 @@ import {
29
29
  createGitHubSearch,
30
30
  createTrendingSearch,
31
31
  resolveTrendingLanguage,
32
+ trendingQuery,
33
+ validateQuery,
32
34
  } from "./search";
33
35
  import {
34
36
  createPackageSearch,
@@ -55,6 +57,7 @@ import {
55
57
  type SkillSource,
56
58
  } from "./skill-finder";
57
59
  import { getVersion } from "./version";
60
+ import { RateLimitError, BadQueryError } from "./errors";
58
61
 
59
62
  // ─── Protocol constants ────────────────────────────────────────────────
60
63
 
@@ -214,7 +217,7 @@ function asMcpToolError(err: unknown): McpToolError {
214
217
  return new McpToolError(err instanceof Error ? err.message : String(err));
215
218
  }
216
219
 
217
- const TRENDING_QUERY = { keywords: [], qualifiers: [], raw: "trending" };
220
+ const TRENDING_QUERY = trendingQuery();
218
221
 
219
222
  // ─── Tool registry ─────────────────────────────────────────────────────
220
223
 
@@ -270,6 +273,20 @@ const TOOLS: ToolDefinition[] = [
270
273
 
271
274
  const provider = createGitHubSearch(undefined, token ? [token] : []);
272
275
  const parsed = parseQuery(query);
276
+ // Reject a malformed qualifier here rather than spending an API call:
277
+ // for an agent, GitHub's opaque 422 reads as breakage, whereas the
278
+ // specific message names the bad token and the fix.
279
+ try {
280
+ validateQuery(parsed);
281
+ } catch (err) {
282
+ if (err instanceof BadQueryError) {
283
+ return {
284
+ text: `Invalid query: ${err.detail}${err.hint ? ` Try: ${err.hint}` : ""}`,
285
+ data: { totalCount: 0, repos: [], invalidQuery: true },
286
+ };
287
+ }
288
+ throw err;
289
+ }
273
290
  const options: SearchOptions = {
274
291
  limit,
275
292
  sort,
@@ -277,7 +294,22 @@ const TOOLS: ToolDefinition[] = [
277
294
  verbose: false,
278
295
  token,
279
296
  };
280
- const response = await provider.search(parsed, options);
297
+ // The adapter no longer swallows rate limits as empty results — it
298
+ // throws RateLimitError. Convert it to the structured flag here so
299
+ // agents still get backoff advice (a tool failure reads as breakage;
300
+ // the flag reads as retry-later).
301
+ let response: Awaited<ReturnType<typeof provider.search>>;
302
+ try {
303
+ response = await provider.search(parsed, options);
304
+ } catch (err) {
305
+ if (err instanceof RateLimitError) {
306
+ return {
307
+ text: `GitHub API rate limit exceeded for "${query}". Set a token (GITHUB_TOKEN or the tool's token argument) to raise the limit from 60/hr to 5,000/hr.`,
308
+ data: { totalCount: 0, repos: [], rateLimited: true },
309
+ };
310
+ }
311
+ throw err;
312
+ }
281
313
  const repos = rankRepos(response.repos, sort);
282
314
 
283
315
  // Make agent searches visible in the user's TUI history (best-effort).
@@ -14,6 +14,7 @@ import {
14
14
  createGitHubSearch,
15
15
  SearchModule,
16
16
  TrendingAdapter,
17
+ trendingQuery,
17
18
  } from "./search";
18
19
  import { restoreSession, saveSession } from "./session";
19
20
  import { appendHistory } from "./history";
@@ -155,16 +156,13 @@ export class SearchStateManager {
155
156
  this.state.trendingTab = tab;
156
157
  this.state.currentMode = "trending";
157
158
  try {
158
- const response = await this.trendingSearch.search(
159
- { keywords: [], qualifiers: [], raw: "trending" },
160
- {
161
- limit: 25,
162
- sort: "stars",
163
- json: false,
164
- verbose: false,
165
- trendingSince: tabSince(tab),
166
- },
167
- );
159
+ const response = await this.trendingSearch.search(trendingQuery(), {
160
+ limit: 25,
161
+ sort: "stars",
162
+ json: false,
163
+ verbose: false,
164
+ trendingSince: tabSince(tab),
165
+ });
168
166
  this.state.currentRepos = response.repos;
169
167
  this.state.totalCount = response.totalCount;
170
168
  appendHistory({