github-search-cli 9.8.2 → 9.8.3
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 +57 -0
- package/README.md +35 -18
- package/dist/cli.js +360 -122
- package/package.json +1 -1
- package/release-notes/9.8.3.md +98 -0
- package/src/errors.ts +27 -0
- 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 +17 -1
- package/src/search.ts +19 -2
- package/src/session.ts +58 -2
- package/src/tui.ts +181 -33
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "github-search-cli",
|
|
3
|
-
"version": "9.8.
|
|
3
|
+
"version": "9.8.3",
|
|
4
4
|
"description": "Search GitHub from your terminal — repo search, trending repos, npm package search, bookmarks, deep-dive, compare, export. Keyboard-driven TUI + pipeable CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "bun@1",
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# v9.8.3
|
|
2
|
+
|
|
3
|
+
Three things went wrong often enough to be worth a release: GitHub told you
|
|
4
|
+
"Network error" for problems that had nothing to do with your network, you had
|
|
5
|
+
to leave the TUI to fix a bad token, and a session file could hijack startup.
|
|
6
|
+
The README image viewer also gained the last two cases from the 9.8.1 notice.
|
|
7
|
+
|
|
8
|
+
## Errors now say what actually happened
|
|
9
|
+
|
|
10
|
+
A rejected token and an authenticated-but-forbidden request were both reported
|
|
11
|
+
as `Network error`, which named a connectivity problem you did not have. Now:
|
|
12
|
+
|
|
13
|
+
- `401` — the token was rejected: **"Bad GitHub token (401)"**
|
|
14
|
+
- `403` that is not the rate-limit case — e.g. SAML/SSO enforcement:
|
|
15
|
+
**"Forbidden (403)"**, naming the SSO case
|
|
16
|
+
- `403` with `x-ratelimit-remaining: 0` — a rate limit, as before
|
|
17
|
+
- `5xx` and genuine connectivity failures — still a network error
|
|
18
|
+
|
|
19
|
+
A rate-limited search also used to _swallow_ the error and render as a silent,
|
|
20
|
+
successful "no repositories found". The real message now reaches the status
|
|
21
|
+
bar. For agents, the `ghfind_search_repos` MCP tool converts the error into the
|
|
22
|
+
structured flag it already had — `rateLimited: true` plus backoff advice —
|
|
23
|
+
because a tool failure reads as breakage while the flag reads as retry-later.
|
|
24
|
+
|
|
25
|
+
## Recover from errors without leaving the TUI
|
|
26
|
+
|
|
27
|
+
While the matching error is in the status bar:
|
|
28
|
+
|
|
29
|
+
| Key | Action |
|
|
30
|
+
| --- | --------------------------------------------------------------- |
|
|
31
|
+
| `t` | Inline token prompt — saves to config, re-runs the failed query |
|
|
32
|
+
| `u` | Clear the token and retry unauthenticated (401) |
|
|
33
|
+
| `c` | Swap in a different token (rate limit) |
|
|
34
|
+
| `r` | Retry the search or trending load |
|
|
35
|
+
|
|
36
|
+
`Esc` cancels the prompt. The keys are scoped to that one error — any new
|
|
37
|
+
search retires them, so `t` and `c` go back to cycling the theme and opening
|
|
38
|
+
compare. Documented in the help overlay and the README.
|
|
39
|
+
|
|
40
|
+
## A corrupted session no longer hijacks startup
|
|
41
|
+
|
|
42
|
+
A `session.json` whose query field held an MCP handshake blob (a client piped
|
|
43
|
+
its `initialize` request into the search box) made every launch skip the landing
|
|
44
|
+
menu and auto-run that garbage as a search. Two layers now:
|
|
45
|
+
|
|
46
|
+
- Startup always shows the landing menu. A saved session restores preferences
|
|
47
|
+
(sort, limit, trending tab) and pre-fills the query when you pick Repo
|
|
48
|
+
Search, but never runs a search on its own.
|
|
49
|
+
- Save and restore both go through `sanitizeSession()`. JSON-shaped or
|
|
50
|
+
oversized queries are dropped; a non-restorable mode, bad sort, `NaN` limit
|
|
51
|
+
or empty tab fall back to defaults. Reading from disk runs the same check as
|
|
52
|
+
writing, so an existing corrupt file cannot do it again.
|
|
53
|
+
|
|
54
|
+
## Images wrapped in links render now
|
|
55
|
+
|
|
56
|
+
The common badge-and-screenshot shape — `[](https://example.com)`
|
|
57
|
+
— rendered nothing at all. The markdown token walk treated any block containing
|
|
58
|
+
a link token as prose and discarded it, so `sst/opencode` lost the only image in
|
|
59
|
+
its README and `withastro/astro` lost 23 of its 25. The walk now recurses into
|
|
60
|
+
link tokens: the image renders, and its cells carry an OSC 8 hyperlink to the
|
|
61
|
+
original target, so clicking the image opens the same URL the prose version
|
|
62
|
+
would have.
|
|
63
|
+
|
|
64
|
+
## A README full of small images no longer floods the screen
|
|
65
|
+
|
|
66
|
+
`openclaw/openclaw` has 661 `<img>` tags in its README, 648 of them 48×48
|
|
67
|
+
sponsor avatars. Each rendered at 48 columns × 24 rows — a full screen per
|
|
68
|
+
avatar — so opening the README downloaded 649 images (1.6 MB) and produced
|
|
69
|
+
15,552 rows of output, in 28 seconds.
|
|
70
|
+
|
|
71
|
+
Images the README declares smaller than 100 px on any edge are now filtered
|
|
72
|
+
while parsing, before a single request is made. Images that declare no size
|
|
73
|
+
(marked `![]()` syntax) are measured from the image header after download —
|
|
74
|
+
`Bun.Image.metadata()` reads only the header, no pixel decode — and rejected
|
|
75
|
+
before rasterising. Measured on the same README:
|
|
76
|
+
|
|
77
|
+
image urls fetched: 649 -> 1
|
|
78
|
+
wall time: 28,318 ms -> ~550 ms
|
|
79
|
+
|
|
80
|
+
The trade-off is deliberate: a README that understates an image's declared size
|
|
81
|
+
hides that image (two of openclaw's avatars declare `width="48"` but are
|
|
82
|
+
420×420). The README is treated as the authority on its own layout; the
|
|
83
|
+
alternative — trusting the image over the README — is exactly what made these
|
|
84
|
+
pages unusable.
|
|
85
|
+
|
|
86
|
+
## Verification
|
|
87
|
+
|
|
88
|
+
516 tests (up from 482), plus `format:check`, `typecheck` and `lint` all green.
|
|
89
|
+
New tests pin the HTTP status → error mapping end to end, the session
|
|
90
|
+
sanitizers and the TUI startup contract, the link-recursion extraction
|
|
91
|
+
(including prose-links staying prose and nested links taking the innermost
|
|
92
|
+
href), the declared-size reader (percentages like `width="100%"` are ignored,
|
|
93
|
+
`px` suffixes accepted), the header-only pixel gate, and end-to-end view
|
|
94
|
+
behaviour: a linked-image block builds an image node instead of prose,
|
|
95
|
+
declared-small images make no request, and undeclared images still reach the
|
|
96
|
+
loader.
|
|
97
|
+
|
|
98
|
+
`README.md` Known-limitations section rewritten to match; test counts synced.
|
package/src/errors.ts
CHANGED
|
@@ -20,6 +20,33 @@ 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 — authenticated but not allowed (SAML enforcement, resource
|
|
38
|
+
* restrictions, secondary limits). Not the same as the rate-limit 403,
|
|
39
|
+
* which is detected by an exhausted x-ratelimit-remaining header first.
|
|
40
|
+
*/
|
|
41
|
+
export class ForbiddenError extends SearchCliError {
|
|
42
|
+
constructor() {
|
|
43
|
+
const msg =
|
|
44
|
+
"⚠ Forbidden (403). If your org enforces SSO, authorize the token; [t] to change it";
|
|
45
|
+
super("GitHub API returned 403 (token lacks access)", msg);
|
|
46
|
+
this.name = "ForbiddenError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
23
50
|
export class RateLimitError extends SearchCliError {
|
|
24
51
|
constructor(
|
|
25
52
|
public readonly hasToken: boolean,
|
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
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
type SkillSource,
|
|
56
56
|
} from "./skill-finder";
|
|
57
57
|
import { getVersion } from "./version";
|
|
58
|
+
import { RateLimitError } from "./errors";
|
|
58
59
|
|
|
59
60
|
// ─── Protocol constants ────────────────────────────────────────────────
|
|
60
61
|
|
|
@@ -277,7 +278,22 @@ const TOOLS: ToolDefinition[] = [
|
|
|
277
278
|
verbose: false,
|
|
278
279
|
token,
|
|
279
280
|
};
|
|
280
|
-
|
|
281
|
+
// The adapter no longer swallows rate limits as empty results — it
|
|
282
|
+
// throws RateLimitError. Convert it to the structured flag here so
|
|
283
|
+
// agents still get backoff advice (a tool failure reads as breakage;
|
|
284
|
+
// the flag reads as retry-later).
|
|
285
|
+
let response: Awaited<ReturnType<typeof provider.search>>;
|
|
286
|
+
try {
|
|
287
|
+
response = await provider.search(parsed, options);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
if (err instanceof RateLimitError) {
|
|
290
|
+
return {
|
|
291
|
+
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.`,
|
|
292
|
+
data: { totalCount: 0, repos: [], rateLimited: true },
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
throw err;
|
|
296
|
+
}
|
|
281
297
|
const repos = rankRepos(response.repos, sort);
|
|
282
298
|
|
|
283
299
|
// Make agent searches visible in the user's TUI history (best-effort).
|
package/src/search.ts
CHANGED
|
@@ -15,7 +15,13 @@ import type {
|
|
|
15
15
|
SortStrategy,
|
|
16
16
|
CacheEntry,
|
|
17
17
|
} from "./types";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
NetworkError,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
ParseError,
|
|
22
|
+
AuthError,
|
|
23
|
+
ForbiddenError,
|
|
24
|
+
} from "./errors";
|
|
19
25
|
import { parseTrendingHtml, type RawTrendingRepo } from "./trending-parser";
|
|
20
26
|
|
|
21
27
|
export interface Logger {
|
|
@@ -448,7 +454,14 @@ export class GitHubSearchAdapter implements SearchAdapter {
|
|
|
448
454
|
this.logger.error(
|
|
449
455
|
`[github] non-rate-limit 403: ${body.slice(0, 200)}`,
|
|
450
456
|
);
|
|
451
|
-
throw new
|
|
457
|
+
throw new ForbiddenError();
|
|
458
|
+
}
|
|
459
|
+
if (res.status === 401) {
|
|
460
|
+
const body = await res.text().catch(() => "");
|
|
461
|
+
this.logger.error(
|
|
462
|
+
`[github] auth rejected (401): ${body.slice(0, 200)}`,
|
|
463
|
+
);
|
|
464
|
+
throw new AuthError();
|
|
452
465
|
}
|
|
453
466
|
if (!res.ok) {
|
|
454
467
|
const body = await res.text().catch(() => "");
|
|
@@ -492,6 +505,10 @@ export class GitHubSearchAdapter implements SearchAdapter {
|
|
|
492
505
|
lastErr = undefined;
|
|
493
506
|
break;
|
|
494
507
|
} catch (err) {
|
|
508
|
+
// RateLimitError must propagate: swallowing it turns the failure
|
|
509
|
+
// into a silent zero-result "success" and the user never learns
|
|
510
|
+
// why their search died.
|
|
511
|
+
if (err instanceof RateLimitError) throw err;
|
|
495
512
|
lastErr = err as Error;
|
|
496
513
|
if (rateLimited) break;
|
|
497
514
|
}
|