github-search-cli 9.7.0 → 9.8.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.
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Full markdown view for OpenTUI.
3
+ *
4
+ * A real `MarkdownRenderable` with a theme-derived syntax style, themed
5
+ * headings, and inline images drawn as half blocks. Used by the README
6
+ * viewer and by the update modal's release notes.
7
+ */
8
+ import {
9
+ BoxRenderable,
10
+ MarkdownRenderable,
11
+ RGBA,
12
+ StyledText,
13
+ SyntaxStyle,
14
+ TextRenderable,
15
+ createTextAttributes,
16
+ type CliRenderer,
17
+ type RenderNodeContext,
18
+ type Renderable,
19
+ type TextChunk,
20
+ } from "@opentui/core";
21
+ import {
22
+ rasterizeImage,
23
+ toImageRuns,
24
+ type HalfBlockImage,
25
+ type Rgb,
26
+ } from "./image-render";
27
+ import { resolveReadmeImageUrl } from "./image-loader";
28
+ import {
29
+ extractImageRefs,
30
+ inlinePlainText,
31
+ type BlockTokenLike,
32
+ type ImageRef,
33
+ } from "./markdown-images";
34
+
35
+ /** Tallest inline image, in terminal cells. */
36
+ export const MAX_IMAGE_ROWS = 24;
37
+
38
+ export interface MarkdownViewOptions {
39
+ renderer: CliRenderer;
40
+ /** Theme palette (see src/themes.ts). */
41
+ colors: Record<string, string>;
42
+ /** URL of the markdown document, used to resolve relative image paths. */
43
+ imageBase?: string;
44
+ /** Panel background, painted behind text, captions and images. Default: colors.bg. */
45
+ background?: string;
46
+ /** Render inline images. Default: true. */
47
+ images?: boolean;
48
+ /** Fetches bytes for a resolved image URL. */
49
+ loadImage?: (url: string) => Promise<Uint8Array | null>;
50
+ /** Width available for an image, in cells. */
51
+ getImageWidth?: () => number;
52
+ /** Height cap for one image, in cells. Default: MAX_IMAGE_ROWS. */
53
+ maxImageRows?: number;
54
+ }
55
+
56
+ export interface MarkdownView {
57
+ readonly renderable: MarkdownRenderable;
58
+ readonly imagesEnabled: boolean;
59
+ /** Replace the document; relative images resolve against `imageBase`. */
60
+ setContent(markdown: string, imageBase?: string): void;
61
+ /** Turn inline images on/off and rebuild the document. */
62
+ setImagesEnabled(enabled: boolean): void;
63
+ }
64
+
65
+ /** Syntax style for the markdown grammar's highlight groups. */
66
+ export function createMarkdownStyle(
67
+ colors: Record<string, string>,
68
+ ): SyntaxStyle {
69
+ const surface = colors.surface ?? colors.bg;
70
+ return SyntaxStyle.fromStyles({
71
+ default: { fg: colors.text ?? "#ffffff" },
72
+ conceal: { fg: colors.muted ?? "#888888" },
73
+ "markup.heading": { fg: colors.accent ?? colors.blue, bold: true },
74
+ "markup.strong": { fg: colors.text ?? "#ffffff", bold: true },
75
+ "markup.italic": { fg: colors.text ?? "#ffffff", italic: true },
76
+ "markup.strikethrough": { fg: colors.muted ?? "#888888", dim: true },
77
+ "markup.raw": { fg: colors.green ?? "#a6e3a1", bg: surface },
78
+ "markup.raw.block": { fg: colors.green ?? "#a6e3a1" },
79
+ "markup.link": { fg: colors.accent ?? colors.blue },
80
+ "markup.link.label": {
81
+ fg: colors.accent ?? colors.blue,
82
+ underline: true,
83
+ },
84
+ "markup.link.url": { fg: colors.muted ?? "#888888", underline: true },
85
+ "markup.quote": { fg: colors.muted ?? "#888888", italic: true },
86
+ "markup.list": { fg: colors.blue ?? colors.accent },
87
+ });
88
+ }
89
+
90
+ function hexToRgb(hex: string | undefined): Rgb {
91
+ const fallback: Rgb = [0, 0, 0];
92
+ if (!hex) return fallback;
93
+ const match = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
94
+ if (!match) return fallback;
95
+ const value = Number.parseInt(match[1], 16);
96
+ return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff];
97
+ }
98
+
99
+ function rgba(color: Rgb): RGBA {
100
+ return RGBA.fromValues(color[0], color[1], color[2], 255);
101
+ }
102
+
103
+ /** Half-block rows as one styled-text block (one line per image row). */
104
+ function imageStyledText(image: HalfBlockImage): StyledText {
105
+ const chunks: TextChunk[] = [];
106
+ toImageRuns(image).forEach((runs, rowIndex) => {
107
+ if (rowIndex > 0) chunks.push({ __isChunk: true, text: "\n" });
108
+ for (const run of runs) {
109
+ chunks.push({
110
+ __isChunk: true,
111
+ text: run.text,
112
+ fg: rgba(run.top),
113
+ bg: rgba(run.bottom),
114
+ });
115
+ }
116
+ });
117
+ return new StyledText(chunks);
118
+ }
119
+
120
+ /**
121
+ * Build the markdown renderable and its block renderers.
122
+ *
123
+ * Two OpenTUI quirks shape the options below:
124
+ *
125
+ * - `streaming`: without a loaded tree-sitter markdown grammar a finished
126
+ * block has no highlights to draw and renders as blank lines, so the
127
+ * styled initial text has to be kept alive instead.
128
+ * - `internalBlockMode: "top-level"`: in the default coalesced mode every
129
+ * style refresh re-renders blocks with the built-in renderers, which
130
+ * throws away the custom heading/image renderables created here.
131
+ */
132
+ export function createMarkdownView(opts: MarkdownViewOptions): MarkdownView {
133
+ const { renderer, colors } = opts;
134
+ const maxImageRows = opts.maxImageRows ?? MAX_IMAGE_ROWS;
135
+ const rasterCache = new Map<string, Promise<HalfBlockImage | null>>();
136
+ let imagesEnabled = opts.images ?? true;
137
+ let imageBase = opts.imageBase;
138
+
139
+ const text = () => colors.text ?? "#ffffff";
140
+ const bg = () => opts.background ?? colors.bg ?? "#000000";
141
+ const muted = () => colors.muted ?? "#888888";
142
+
143
+ const renderable = new MarkdownRenderable(renderer, {
144
+ content: "",
145
+ syntaxStyle: createMarkdownStyle(colors),
146
+ fg: text(),
147
+ bg: bg(),
148
+ conceal: true,
149
+ streaming: true,
150
+ internalBlockMode: "top-level",
151
+ // Borderless columns are the top-level default; boxed tables read better
152
+ // for the multi-column tables READMEs use.
153
+ tableOptions: { style: "grid" },
154
+ width: "100%",
155
+ });
156
+
157
+ const caption = (label: string, color = muted()) =>
158
+ new TextRenderable(renderer, {
159
+ content: ` ${label}`,
160
+ fg: color,
161
+ bg: bg(),
162
+ wrapMode: "word",
163
+ flexShrink: 0,
164
+ width: "100%",
165
+ });
166
+
167
+ const rasterize = (url: string): Promise<HalfBlockImage | null> => {
168
+ // Size is part of the key: a resized terminal needs fresh cells.
169
+ const maxCols = Math.max(8, Math.floor(opts.getImageWidth?.() ?? 100));
170
+ const key = `${maxCols}|${url}`;
171
+ const cached = rasterCache.get(key);
172
+ if (cached) return cached;
173
+ const pending = (async () => {
174
+ const loader = opts.loadImage;
175
+ if (!loader) return null;
176
+ const bytes = await loader(url);
177
+ if (!bytes) return null;
178
+ return rasterizeImage(bytes, {
179
+ maxCols,
180
+ maxRows: maxImageRows,
181
+ background: hexToRgb(bg()),
182
+ });
183
+ })();
184
+ rasterCache.set(key, pending);
185
+ return pending;
186
+ };
187
+
188
+ const imageNode = (image: ImageRef): Renderable => {
189
+ const label = `🖼 ${image.alt.trim() || "image"}`;
190
+ const url = imageBase ? resolveReadmeImageUrl(image.href, imageBase) : null;
191
+ if (!imagesEnabled || !url || !opts.loadImage) return caption(label);
192
+
193
+ const container = new BoxRenderable(renderer, {
194
+ flexDirection: "column",
195
+ flexShrink: 0,
196
+ width: "100%",
197
+ });
198
+ const placeholder = caption(`${label} (loading…)`);
199
+ container.add(placeholder);
200
+
201
+ void rasterize(url).then(
202
+ (cells) => {
203
+ console.error(
204
+ `[dbg] image resolved: cells=${cells ? `${cells.cols}x${cells.rows}` : "null"} destroyed=${container.isDestroyed} parent=${!!container.parent} enabled=${imagesEnabled}`,
205
+ );
206
+ // The document may have been replaced (or the overlay closed) while
207
+ // the image was downloading.
208
+ if (container.isDestroyed || !container.parent) return;
209
+ container.remove(placeholder);
210
+ placeholder.destroy();
211
+ container.add(caption(label));
212
+ if (cells) {
213
+ container.add(
214
+ new TextRenderable(renderer, {
215
+ content: imageStyledText(cells),
216
+ bg: bg(),
217
+ wrapMode: "none",
218
+ selectable: false,
219
+ flexShrink: 0,
220
+ }),
221
+ );
222
+ }
223
+ renderable.requestRender();
224
+ },
225
+ (err) => {
226
+ console.error("[dbg] image promise rejected:", err);
227
+ },
228
+ );
229
+ return container;
230
+ };
231
+
232
+ const headingNode = (token: BlockTokenLike): Renderable | undefined => {
233
+ const heading = inlinePlainText(token.tokens).trim();
234
+ if (!heading) return undefined;
235
+ const depth = token.depth ?? 1;
236
+ const color = depth <= 2 ? colors.accent : (colors.teal ?? colors.accent);
237
+ return new TextRenderable(renderer, {
238
+ content: new StyledText([
239
+ {
240
+ __isChunk: true,
241
+ text: heading,
242
+ fg: RGBA.fromHex(color ?? "#ffffff"),
243
+ attributes: createTextAttributes({
244
+ bold: true,
245
+ underline: depth === 1,
246
+ }),
247
+ },
248
+ ]),
249
+ bg: bg(),
250
+ wrapMode: "word",
251
+ flexShrink: 0,
252
+ width: "100%",
253
+ });
254
+ };
255
+
256
+ const buildRenderNode =
257
+ () =>
258
+ (
259
+ token: BlockTokenLike,
260
+ _context: RenderNodeContext,
261
+ ): Renderable | undefined => {
262
+ if (token.type === "heading") return headingNode(token);
263
+ const images = extractImageRefs(token);
264
+ if (images && images.length > 0) {
265
+ const box = new BoxRenderable(renderer, {
266
+ flexDirection: "column",
267
+ flexShrink: 0,
268
+ width: "100%",
269
+ marginTop: 1,
270
+ });
271
+ for (const image of images) box.add(imageNode(image));
272
+ return box;
273
+ }
274
+ return undefined;
275
+ };
276
+
277
+ renderable.renderNode = buildRenderNode();
278
+
279
+ return {
280
+ renderable,
281
+ get imagesEnabled() {
282
+ return imagesEnabled;
283
+ },
284
+ setContent(markdown: string, base?: string) {
285
+ if (base !== undefined) imageBase = base;
286
+ // Rebuild the style so theme switches (which mutate `colors`) apply.
287
+ renderable.syntaxStyle = createMarkdownStyle(colors);
288
+ renderable.fg = text();
289
+ renderable.bg = bg();
290
+ renderable.content = markdown;
291
+ },
292
+ setImagesEnabled(enabled: boolean) {
293
+ if (imagesEnabled === enabled) return;
294
+ imagesEnabled = enabled;
295
+ // A fresh renderNode identity forces a full block rebuild.
296
+ renderable.renderNode = buildRenderNode();
297
+ renderable.requestRender();
298
+ },
299
+ };
300
+ }
package/src/mcp-server.ts CHANGED
@@ -801,7 +801,7 @@ export async function processServerLine(
801
801
  // ─── stdio transport ───────────────────────────────────────────────────
802
802
 
803
803
  function writeStdout(line: string): void {
804
- process.stdout.write(line + "\n");
804
+ process.stdout.write(`${line}\n`);
805
805
  }
806
806
 
807
807
  /**
@@ -831,8 +831,9 @@ export async function runMcpServer(
831
831
  let buffer = "";
832
832
  process.stdin.on("data", (chunk: string) => {
833
833
  buffer += chunk;
834
- let idx: number;
835
- while ((idx = buffer.indexOf("\n")) >= 0) {
834
+ for (;;) {
835
+ const idx = buffer.indexOf("\n");
836
+ if (idx < 0) break;
836
837
  const line = buffer.slice(0, idx).trim();
837
838
  buffer = buffer.slice(idx + 1);
838
839
  if (!line) continue;
package/src/open-url.ts CHANGED
@@ -1,15 +1,26 @@
1
1
  /**
2
- * Open a URL in the user's default browser — platform-aware.
2
+ * Open a URL in the user's default browser — platform- and runtime-aware.
3
3
  *
4
- * only if the open/xdg-open pattern fails somewhere.
4
+ * Windows note: `explorer.exe` is used instead of the classic
5
+ * `cmd /c start "" <url>` idiom. cmd splits the URL at the first unquoted
6
+ * `&` (a query-string separator) and executes the rest as a command, which
7
+ * truncated every URL with query params. explorer.exe receives the URL as a
8
+ * single CreateProcess argument, so `&` is never interpreted.
5
9
  */
10
+ import { spawn } from "node:child_process";
6
11
 
7
12
  export function openUrl(url: string): void {
8
13
  try {
9
14
  const p = process.platform;
10
- if (p === "win32") Bun.spawn(["cmd", "/c", "start", "", url]);
11
- else if (p === "darwin") Bun.spawn(["open", url]);
12
- else Bun.spawn(["xdg-open", url]);
15
+ const cmd =
16
+ p === "win32" ? "explorer" : p === "darwin" ? "open" : "xdg-open";
17
+ if (typeof Bun !== "undefined") {
18
+ Bun.spawn([cmd, url]);
19
+ return;
20
+ }
21
+ // Node fallback — detached so the child survives our exit.
22
+ const child = spawn(cmd, [url], { detached: true, stdio: "ignore" });
23
+ child.unref();
13
24
  } catch {
14
25
  // non-critical — user can copy the URL manually
15
26
  }
package/src/output.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * clone-commands, ids) register in a single registry. One `format()` function
6
6
  * dispatches. `exportToFile` and `pipeExec` are methods on the same module.
7
7
  */
8
- import { writeFileSync } from "fs";
8
+ import { writeFileSync } from "node:fs";
9
9
  import type { Repo } from "./types";
10
10
  import { openUrl } from "./open-url";
11
11
 
@@ -49,7 +49,7 @@ function formatPackageLine(pack: Package): StyledText {
49
49
  const scoreStr = `score ${pack.score.toFixed(2)}`.padEnd(12).slice(0, 12);
50
50
  const desc =
51
51
  (pack.description ?? "").length > 60
52
- ? (pack.description ?? "").slice(0, 57) + "..."
52
+ ? `${(pack.description ?? "").slice(0, 57)}...`
53
53
  : (pack.description ?? "");
54
54
  const line1 = t`${bold(fg(C.nameText)(nameStr))}${fg(C.cyan)(dlStr)}${fg(C.gold)(scoreStr)}`;
55
55
  const line2 = t`${dim(fg(C.descText)(` ${desc}`))}`;
package/src/png.ts ADDED
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Minimal PNG decoder — pure TypeScript, no native deps.
3
+ *
4
+ * Only used to turn an image into cells we can print, so it covers the
5
+ * subset every encoder emits: no interlace, all five colour types, bit
6
+ * depths 1–16, tRNS transparency. Palette + tRNS are expanded to RGBA so
7
+ * callers only ever see 8-bit RGBA.
8
+ *
9
+ * Sources that aren't PNG (JPEG, WebP, GIF, AVIF, …) are normalised
10
+ * upstream by `Bun.Image` before they get here.
11
+ */
12
+ import { inflateSync } from "node:zlib";
13
+
14
+ export interface DecodedImage {
15
+ width: number;
16
+ height: number;
17
+ /** Row-major RGBA, 4 bytes per pixel. */
18
+ data: Uint8Array;
19
+ }
20
+
21
+ const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
22
+
23
+ /** Number of samples per pixel for each PNG colour type. */
24
+ function channelsForColorType(colorType: number): number {
25
+ switch (colorType) {
26
+ case 0:
27
+ return 1; // greyscale
28
+ case 2:
29
+ return 3; // truecolour
30
+ case 3:
31
+ return 1; // palette index
32
+ case 4:
33
+ return 2; // greyscale + alpha
34
+ case 6:
35
+ return 4; // truecolour + alpha
36
+ default:
37
+ throw new Error(`Unsupported PNG colour type ${colorType}`);
38
+ }
39
+ }
40
+
41
+ /** Paeth predictor, per the PNG spec (§9.4). */
42
+ function paeth(a: number, b: number, c: number): number {
43
+ const p = a + b - c;
44
+ const pa = Math.abs(p - a);
45
+ const pb = Math.abs(p - b);
46
+ const pc = Math.abs(p - c);
47
+ if (pa <= pb && pa <= pc) return a;
48
+ if (pb <= pc) return b;
49
+ return c;
50
+ }
51
+
52
+ /** Reverse the per-scanline filters applied by the encoder. */
53
+ function unfilter(
54
+ raw: Uint8Array,
55
+ height: number,
56
+ bytesPerRow: number,
57
+ bytesPerPixel: number,
58
+ ): Uint8Array {
59
+ const out = new Uint8Array(height * bytesPerRow);
60
+ let src = 0;
61
+ for (let y = 0; y < height; y++) {
62
+ const filter = raw[src++];
63
+ const rowStart = y * bytesPerRow;
64
+ const prevStart = rowStart - bytesPerRow;
65
+ for (let x = 0; x < bytesPerRow; x++) {
66
+ const value = raw[src + x];
67
+ const left = x >= bytesPerPixel ? out[rowStart + x - bytesPerPixel] : 0;
68
+ const up = y > 0 ? out[prevStart + x] : 0;
69
+ const upLeft =
70
+ y > 0 && x >= bytesPerPixel ? out[prevStart + x - bytesPerPixel] : 0;
71
+ let result: number;
72
+ switch (filter) {
73
+ case 0:
74
+ result = value;
75
+ break;
76
+ case 1:
77
+ result = value + left;
78
+ break;
79
+ case 2:
80
+ result = value + up;
81
+ break;
82
+ case 3:
83
+ result = value + ((left + up) >> 1);
84
+ break;
85
+ case 4:
86
+ result = value + paeth(left, up, upLeft);
87
+ break;
88
+ default:
89
+ throw new Error(`Unsupported PNG filter type ${filter}`);
90
+ }
91
+ out[rowStart + x] = result & 0xff;
92
+ }
93
+ src += bytesPerRow;
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /** Read the `index`-th sample of a row for sub-byte bit depths. */
99
+ function sampleAt(
100
+ row: Uint8Array,
101
+ offsetInRow: number,
102
+ index: number,
103
+ bitDepth: number,
104
+ ): number {
105
+ if (bitDepth === 8) return row[offsetInRow + index];
106
+ if (bitDepth === 16) return row[offsetInRow + index * 2];
107
+ const bitOffset = index * bitDepth;
108
+ const byte = row[offsetInRow + (bitOffset >> 3)];
109
+ const shift = 8 - bitDepth - (bitOffset & 7);
110
+ return (byte >> shift) & ((1 << bitDepth) - 1);
111
+ }
112
+
113
+ /** Decode PNG bytes into 8-bit RGBA. Throws on unsupported variants. */
114
+ export function decodePng(bytes: Uint8Array): DecodedImage {
115
+ if (bytes.length < 8) throw new Error("PNG too short");
116
+ for (let i = 0; i < PNG_SIGNATURE.length; i++) {
117
+ if (bytes[i] !== PNG_SIGNATURE[i]) throw new Error("Not a PNG file");
118
+ }
119
+
120
+ let width = 0;
121
+ let height = 0;
122
+ let bitDepth = 8;
123
+ let colorType = 6;
124
+ let interlace = 0;
125
+ let palette: Uint8Array | null = null;
126
+ let transparency: Uint8Array | null = null;
127
+ const idat: Uint8Array[] = [];
128
+
129
+ let offset = 8;
130
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
131
+ while (offset + 8 <= bytes.length) {
132
+ const length = view.getUint32(offset);
133
+ const type = String.fromCharCode(
134
+ bytes[offset + 4],
135
+ bytes[offset + 5],
136
+ bytes[offset + 6],
137
+ bytes[offset + 7],
138
+ );
139
+ const dataStart = offset + 8;
140
+ const dataEnd = dataStart + length;
141
+ if (dataEnd > bytes.length) throw new Error("Truncated PNG chunk");
142
+
143
+ switch (type) {
144
+ case "IHDR": {
145
+ width = view.getUint32(dataStart);
146
+ height = view.getUint32(dataStart + 4);
147
+ bitDepth = bytes[dataStart + 8];
148
+ colorType = bytes[dataStart + 9];
149
+ interlace = bytes[dataStart + 12];
150
+ break;
151
+ }
152
+ case "PLTE":
153
+ palette = bytes.subarray(dataStart, dataEnd);
154
+ break;
155
+ case "tRNS":
156
+ transparency = bytes.subarray(dataStart, dataEnd);
157
+ break;
158
+ case "IDAT":
159
+ idat.push(bytes.subarray(dataStart, dataEnd));
160
+ break;
161
+ default:
162
+ break;
163
+ }
164
+ offset = dataEnd + 4; // skip CRC
165
+ if (type === "IEND") break;
166
+ }
167
+
168
+ if (width === 0 || height === 0) throw new Error("PNG has no IHDR");
169
+ if (interlace !== 0) throw new Error("Interlaced PNG is not supported");
170
+
171
+ const channels = channelsForColorType(colorType);
172
+ const bitsPerPixel = channels * bitDepth;
173
+ const bytesPerRow = Math.ceil((width * bitsPerPixel) / 8);
174
+ const bytesPerPixel = Math.max(1, Math.ceil(bitsPerPixel / 8));
175
+
176
+ const compressed = idat.length === 1 ? idat[0] : concat(idat);
177
+ const raw = new Uint8Array(inflateSync(compressed));
178
+ // One filter byte per scanline plus the samples themselves.
179
+ if (raw.length < height * (bytesPerRow + 1)) {
180
+ throw new Error("Truncated PNG pixel data");
181
+ }
182
+ const rows = unfilter(raw, height, bytesPerRow, bytesPerPixel);
183
+
184
+ const out = new Uint8Array(width * height * 4);
185
+ const maxSample = (1 << Math.min(bitDepth, 8)) - 1;
186
+ const scale = bitDepth === 16 ? 1 / 257 : 255 / maxSample;
187
+
188
+ for (let y = 0; y < height; y++) {
189
+ const row = rows.subarray(y * bytesPerRow, (y + 1) * bytesPerRow);
190
+ for (let x = 0; x < width; x++) {
191
+ const to8 = (sample: number) =>
192
+ bitDepth === 8 || bitDepth === 16 ? sample : Math.round(sample * scale);
193
+ let r: number;
194
+ let g: number;
195
+ let b: number;
196
+ let a = 255;
197
+ switch (colorType) {
198
+ case 0: {
199
+ const grey = to8(sampleAt(row, 0, x, bitDepth));
200
+ r = g = b = grey;
201
+ break;
202
+ }
203
+ case 2:
204
+ r = to8(sampleAt(row, 0, x * 3, bitDepth));
205
+ g = to8(sampleAt(row, 0, x * 3 + 1, bitDepth));
206
+ b = to8(sampleAt(row, 0, x * 3 + 2, bitDepth));
207
+ break;
208
+ case 3: {
209
+ const index = sampleAt(row, 0, x, bitDepth);
210
+ const p = index * 3;
211
+ if (!palette || p + 2 >= palette.length)
212
+ throw new Error("PNG palette entry missing");
213
+ r = palette[p];
214
+ g = palette[p + 1];
215
+ b = palette[p + 2];
216
+ if (transparency && index < transparency.length)
217
+ a = transparency[index];
218
+ break;
219
+ }
220
+ case 4: {
221
+ const grey = to8(sampleAt(row, 0, x * 2, bitDepth));
222
+ r = g = b = grey;
223
+ a = to8(sampleAt(row, 0, x * 2 + 1, bitDepth));
224
+ break;
225
+ }
226
+ default: {
227
+ r = to8(sampleAt(row, 0, x * 4, bitDepth));
228
+ g = to8(sampleAt(row, 0, x * 4 + 1, bitDepth));
229
+ b = to8(sampleAt(row, 0, x * 4 + 2, bitDepth));
230
+ a = to8(sampleAt(row, 0, x * 4 + 3, bitDepth));
231
+ break;
232
+ }
233
+ }
234
+ if (colorType === 0 && transparency && transparency.length >= 2) {
235
+ const key = (transparency[0] << 8) | transparency[1];
236
+ const sample = sampleAt(row, 0, x, bitDepth);
237
+ if (sample === key) a = 0;
238
+ }
239
+ const o = (y * width + x) * 4;
240
+ out[o] = r;
241
+ out[o + 1] = g;
242
+ out[o + 2] = b;
243
+ out[o + 3] = a;
244
+ }
245
+ }
246
+
247
+ return { width, height, data: out };
248
+ }
249
+
250
+ function concat(chunks: Uint8Array[]): Uint8Array {
251
+ let total = 0;
252
+ for (const c of chunks) total += c.length;
253
+ const merged = new Uint8Array(total);
254
+ let offset = 0;
255
+ for (const c of chunks) {
256
+ merged.set(c, offset);
257
+ offset += c.length;
258
+ }
259
+ return merged;
260
+ }
package/src/releases.ts CHANGED
@@ -1,16 +1,3 @@
1
- /**
2
- * Release tracker — checks bookmarked repos for new GitHub releases.
3
- *
4
- * Decisions (map 003):
5
- * - Signal: new GitHub releases only (`GET /repos/{o}/{r}/releases?per_page=5`).
6
- * - Seen state: `lastSeenAt` per bookmark; "new" = published after it.
7
- * - Rate budget: etag conditional requests + token when present.
8
- * - Prereleases shown (tagged `[pre]`), drafts never.
9
- *
10
- * ponytail: sequential fetches — fine for ≤100 bookmarks at launch;
11
- * parallelize with p-limit if users report slow startup.
12
- */
13
- import type { Bookmark } from "./types";
14
1
  import { readJSON, writeJSON } from "./storage";
15
2
  import { getBookmarks, getLastSeen, markSeen } from "./bookmarks";
16
3
  import { addNotification } from "./notifications";
@@ -68,7 +55,7 @@ async function fetchReleases(
68
55
  Accept: "application/vnd.github+json",
69
56
  };
70
57
  if (etag) headers["If-None-Match"] = etag;
71
- if (token) headers["Authorization"] = `Bearer ${token}`;
58
+ if (token) headers.Authorization = `Bearer ${token}`;
72
59
  const res = await fetch(
73
60
  `https://api.github.com/repos/${fullName}/releases?per_page=${PER_REPO}`,
74
61
  { headers },
@@ -4,7 +4,7 @@
4
4
  * The TUI view layer reads from SearchState and dispatches actions.
5
5
  * This module is testable without the OpenTUI renderer.
6
6
  */
7
- import type { Repo, SearchOptions, SortStrategy, SessionState } from "./types";
7
+ import type { Repo, SortStrategy, SessionState } from "./types";
8
8
  import type { TabName } from "./trending";
9
9
  import { tabSince } from "./trending";
10
10
  import {
@@ -24,7 +24,6 @@ import {
24
24
  touchSavedSearch,
25
25
  } from "./saved-searches";
26
26
  import { fetchDeepDive, buildDeepDiveText } from "./deepdive";
27
- import { fetchTopics } from "./explore";
28
27
  import { loadConfig } from "./config";
29
28
 
30
29
  export type Overlay =
@@ -131,8 +130,6 @@ export class SearchStateManager {
131
130
  timestamp: Date.now(),
132
131
  resultCount: response.repos.length,
133
132
  });
134
- } catch (err) {
135
- throw err;
136
133
  } finally {
137
134
  this.state.isLoading = false;
138
135
  }