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/CHANGELOG.md +129 -0
- package/README.md +65 -26
- package/dist/cli.js +898 -184
- package/docs/HOW-TO.md +254 -0
- package/package.json +2 -1
- package/release-notes/9.8.3.md +98 -0
- package/release-notes/9.9.0.md +193 -0
- package/src/cli.ts +13 -11
- package/src/errors.ts +56 -5
- package/src/help.ts +11 -0
- package/src/image-loader.ts +57 -2
- package/src/markdown-images.ts +93 -4
- package/src/markdown-view.ts +49 -20
- package/src/mcp-server.ts +34 -2
- package/src/search-state.ts +8 -10
- package/src/search.ts +701 -12
- package/src/session.ts +58 -2
- package/src/trending.ts +2 -6
- package/src/tui.ts +236 -50
- package/src/types.ts +11 -1
- package/src/update-check.ts +72 -6
- package/src/watch.ts +16 -11
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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",
|
package/src/image-loader.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/markdown-images.ts
CHANGED
|
@@ -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 (`[](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
|
+
// `[](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
|
}
|
package/src/markdown-view.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
// `[](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:
|
|
164
|
-
|
|
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 = (
|
|
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)
|
|
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
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
302
|
+
height: 0,
|
|
279
303
|
});
|
|
280
|
-
for (const image of images) box.add(imageNode(image));
|
|
281
|
-
return box;
|
|
282
304
|
}
|
|
283
|
-
|
|
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 =
|
|
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
|
-
|
|
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).
|
package/src/search-state.ts
CHANGED
|
@@ -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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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({
|