github-search-cli 9.7.1 → 9.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1538,7 +1538,7 @@ var cached = null;
1538
1538
  function getVersion() {
1539
1539
  if (cached) return cached;
1540
1540
  if (true) {
1541
- cached = "9.7.1";
1541
+ cached = "9.8.1";
1542
1542
  return cached;
1543
1543
  }
1544
1544
  try {
@@ -2309,6 +2309,12 @@ function removeBookmark(fullName) {
2309
2309
  const bookmarks = loadRaw().filter((b) => b.repo.fullName !== fullName);
2310
2310
  saveRaw(bookmarks);
2311
2311
  }
2312
+ function searchBookmarks(query) {
2313
+ const q = query.toLowerCase();
2314
+ return getBookmarks().filter(
2315
+ (b) => b.repo.fullName.toLowerCase().includes(q) || (b.repo.description ?? "").toLowerCase().includes(q) || b.tags.some((t3) => t3.toLowerCase().includes(q))
2316
+ );
2317
+ }
2312
2318
  function getLastSeen(fullName) {
2313
2319
  return loadRaw().find((b) => b.repo.fullName === fullName)?.lastSeenAt ?? 0;
2314
2320
  }
@@ -2382,8 +2388,8 @@ var PER_REPO = 5;
2382
2388
  function loadCache() {
2383
2389
  return readJSON(RELEASES_FILE, {});
2384
2390
  }
2385
- function saveCache(cache) {
2386
- writeJSON(RELEASES_FILE, cache);
2391
+ function saveCache(cache2) {
2392
+ writeJSON(RELEASES_FILE, cache2);
2387
2393
  }
2388
2394
  function toTracked(raw) {
2389
2395
  return raw.filter((r) => r.draft !== true).map((r) => ({
@@ -2414,13 +2420,13 @@ async function fetchReleases(fullName, etag, token) {
2414
2420
  }
2415
2421
  async function checkReleases(opts = {}) {
2416
2422
  const bookmarks = getBookmarks();
2417
- const cache = loadCache();
2423
+ const cache2 = loadCache();
2418
2424
  const config = loadConfig();
2419
2425
  const token = opts.token ?? config.githubToken ?? process.env.GITHUB_TOKEN;
2420
2426
  const results2 = [];
2421
2427
  for (const bm of bookmarks) {
2422
2428
  try {
2423
- const prev = cache[bm.repo.fullName];
2429
+ const prev = cache2[bm.repo.fullName];
2424
2430
  const lastSeen = getLastSeen(bm.repo.fullName);
2425
2431
  const isNew = (r) => !r.publishedAt || new Date(r.publishedAt).getTime() > lastSeen;
2426
2432
  const { status, etag, releases } = await fetchReleases(
@@ -2443,7 +2449,7 @@ async function checkReleases(opts = {}) {
2443
2449
  });
2444
2450
  continue;
2445
2451
  }
2446
- cache[bm.repo.fullName] = { checkedAt: Date.now(), etag, releases };
2452
+ cache2[bm.repo.fullName] = { checkedAt: Date.now(), etag, releases };
2447
2453
  results2.push({
2448
2454
  fullName: bm.repo.fullName,
2449
2455
  newReleases: releases.filter(isNew)
@@ -2456,7 +2462,7 @@ async function checkReleases(opts = {}) {
2456
2462
  });
2457
2463
  }
2458
2464
  }
2459
- saveCache(cache);
2465
+ saveCache(cache2);
2460
2466
  return results2;
2461
2467
  }
2462
2468
  async function checkAndNotify(opts = {}) {
@@ -2473,9 +2479,9 @@ async function checkAndNotify(opts = {}) {
2473
2479
  return results2;
2474
2480
  }
2475
2481
  function allCachedReleases() {
2476
- const cache = loadCache();
2482
+ const cache2 = loadCache();
2477
2483
  const bookmarks = getBookmarks();
2478
- return Object.entries(cache).filter(([fullName]) => bookmarks.some((b) => b.repo.fullName === fullName)).flatMap(([fullName, s]) => s.releases.map((r) => ({ ...r, fullName }))).sort(
2484
+ return Object.entries(cache2).filter(([fullName]) => bookmarks.some((b) => b.repo.fullName === fullName)).flatMap(([fullName, s]) => s.releases.map((r) => ({ ...r, fullName }))).sort(
2479
2485
  (a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
2480
2486
  );
2481
2487
  }
@@ -2946,15 +2952,15 @@ function buildDeepDiveText(data) {
2946
2952
  }
2947
2953
 
2948
2954
  // src/cli.ts
2949
- import { readFileSync as readFileSync6 } from "fs";
2950
- import { join as join6, dirname as dirname4 } from "path";
2955
+ import { readFileSync as readFileSync7 } from "fs";
2956
+ import { join as join7, dirname as dirname4 } from "path";
2951
2957
  import { fileURLToPath as fileURLToPath4 } from "url";
2952
2958
 
2953
2959
  // src/tui.ts
2954
2960
  import {
2955
2961
  createCliRenderer as createCliRenderer3,
2956
- BoxRenderable as BoxRenderable4,
2957
- TextRenderable as TextRenderable4,
2962
+ BoxRenderable as BoxRenderable5,
2963
+ TextRenderable as TextRenderable5,
2958
2964
  InputRenderable,
2959
2965
  SelectRenderable,
2960
2966
  ScrollBoxRenderable as ScrollBoxRenderable3
@@ -3059,7 +3065,7 @@ function buildHelpSections() {
3059
3065
  { keys: "Limit", action: "Cycle limit: 10 \u2192 25 \u2192 50 \u2192 100" },
3060
3066
  { keys: "Refresh", action: "Re-run current search" },
3061
3067
  { keys: "Graph", action: "Toggle commit activity chart" },
3062
- { keys: "Readme", action: "Full README viewer" },
3068
+ { keys: "Readme", action: "Full markdown README viewer" },
3063
3069
  { keys: "Deep-dive", action: "Languages + contributors + excerpt" },
3064
3070
  { keys: "Bookmark", action: "Save / unsave selected repo" },
3065
3071
  { keys: "Compare", action: "Add to / remove from comparison" },
@@ -3099,6 +3105,7 @@ function buildHelpSections() {
3099
3105
  { keys: "d", action: "Delete entry (history, saved)" },
3100
3106
  { keys: "\u2191 \u2193 / j k", action: "Navigate overlay list" },
3101
3107
  { keys: "\u2190 \u2192 / h l", action: "Navigate tabs / panels" },
3108
+ { keys: "i", action: "Toggle inline images (README viewer)" },
3102
3109
  {
3103
3110
  keys: "PgUp/PgDn",
3104
3111
  action: "Scroll update notes; Home/End jump to ends"
@@ -3260,170 +3267,777 @@ function restoreSession() {
3260
3267
  return readJSON(SESSION_FILE, null);
3261
3268
  }
3262
3269
 
3263
- // src/markdown-render.ts
3264
- function renderMarkdown(md, opts = {}) {
3265
- const width = opts.width ?? 80;
3266
- const lines = md.split("\n");
3267
- const out = [];
3268
- let inCodeBlock = false;
3269
- let codeLang = "";
3270
- let codeLines = [];
3271
- let inTable = false;
3272
- let tableRows = [];
3273
- function flushCode() {
3274
- if (codeLines.length === 0) return;
3275
- const maxLen = Math.max(...codeLines.map((l) => l.length), codeLang.length);
3276
- const barW = Math.min(maxLen + 4, width - 2);
3277
- out.push(`\u250C${"\u2500".repeat(barW)}\u2510`);
3278
- if (codeLang) out.push(`\u2502 ${codeLang.padEnd(barW - 1)}\u2502`);
3279
- for (const cl of codeLines) {
3280
- out.push(`\u2502 ${cl.padEnd(barW - 1)}\u2502`);
3281
- }
3282
- out.push(`\u2514${"\u2500".repeat(barW)}\u2518`);
3283
- out.push("");
3284
- codeLines = [];
3285
- codeLang = "";
3286
- }
3287
- function flushTable() {
3288
- if (tableRows.length === 0) return;
3289
- const dataRows = tableRows.filter(
3290
- (r) => !r.every((c) => /^[\s\-:|]+$/.test(c))
3270
+ // src/markdown-view.ts
3271
+ import {
3272
+ BoxRenderable as BoxRenderable3,
3273
+ MarkdownRenderable,
3274
+ RGBA,
3275
+ StyledText as StyledText3,
3276
+ SyntaxStyle,
3277
+ TextRenderable as TextRenderable3,
3278
+ createTextAttributes
3279
+ } from "@opentui/core";
3280
+
3281
+ // src/png.ts
3282
+ import { inflateSync } from "zlib";
3283
+ var PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
3284
+ function channelsForColorType(colorType) {
3285
+ switch (colorType) {
3286
+ case 0:
3287
+ return 1;
3288
+ // greyscale
3289
+ case 2:
3290
+ return 3;
3291
+ // truecolour
3292
+ case 3:
3293
+ return 1;
3294
+ // palette index
3295
+ case 4:
3296
+ return 2;
3297
+ // greyscale + alpha
3298
+ case 6:
3299
+ return 4;
3300
+ // truecolour + alpha
3301
+ default:
3302
+ throw new Error(`Unsupported PNG colour type ${colorType}`);
3303
+ }
3304
+ }
3305
+ function paeth(a, b, c) {
3306
+ const p = a + b - c;
3307
+ const pa = Math.abs(p - a);
3308
+ const pb = Math.abs(p - b);
3309
+ const pc = Math.abs(p - c);
3310
+ if (pa <= pb && pa <= pc) return a;
3311
+ if (pb <= pc) return b;
3312
+ return c;
3313
+ }
3314
+ function unfilter(raw, height, bytesPerRow, bytesPerPixel) {
3315
+ const out = new Uint8Array(height * bytesPerRow);
3316
+ let src = 0;
3317
+ for (let y = 0; y < height; y++) {
3318
+ const filter = raw[src++];
3319
+ const rowStart = y * bytesPerRow;
3320
+ const prevStart = rowStart - bytesPerRow;
3321
+ for (let x = 0; x < bytesPerRow; x++) {
3322
+ const value = raw[src + x];
3323
+ const left = x >= bytesPerPixel ? out[rowStart + x - bytesPerPixel] : 0;
3324
+ const up = y > 0 ? out[prevStart + x] : 0;
3325
+ const upLeft = y > 0 && x >= bytesPerPixel ? out[prevStart + x - bytesPerPixel] : 0;
3326
+ let result;
3327
+ switch (filter) {
3328
+ case 0:
3329
+ result = value;
3330
+ break;
3331
+ case 1:
3332
+ result = value + left;
3333
+ break;
3334
+ case 2:
3335
+ result = value + up;
3336
+ break;
3337
+ case 3:
3338
+ result = value + (left + up >> 1);
3339
+ break;
3340
+ case 4:
3341
+ result = value + paeth(left, up, upLeft);
3342
+ break;
3343
+ default:
3344
+ throw new Error(`Unsupported PNG filter type ${filter}`);
3345
+ }
3346
+ out[rowStart + x] = result & 255;
3347
+ }
3348
+ src += bytesPerRow;
3349
+ }
3350
+ return out;
3351
+ }
3352
+ function sampleAt(row, offsetInRow, index, bitDepth) {
3353
+ if (bitDepth === 8) return row[offsetInRow + index];
3354
+ if (bitDepth === 16) return row[offsetInRow + index * 2];
3355
+ const bitOffset = index * bitDepth;
3356
+ const byte = row[offsetInRow + (bitOffset >> 3)];
3357
+ const shift = 8 - bitDepth - (bitOffset & 7);
3358
+ return byte >> shift & (1 << bitDepth) - 1;
3359
+ }
3360
+ function decodePng(bytes) {
3361
+ if (bytes.length < 8) throw new Error("PNG too short");
3362
+ for (let i = 0; i < PNG_SIGNATURE.length; i++) {
3363
+ if (bytes[i] !== PNG_SIGNATURE[i]) throw new Error("Not a PNG file");
3364
+ }
3365
+ let width = 0;
3366
+ let height = 0;
3367
+ let bitDepth = 8;
3368
+ let colorType = 6;
3369
+ let interlace = 0;
3370
+ let palette = null;
3371
+ let transparency = null;
3372
+ const idat = [];
3373
+ let offset = 8;
3374
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
3375
+ while (offset + 8 <= bytes.length) {
3376
+ const length = view.getUint32(offset);
3377
+ const type = String.fromCharCode(
3378
+ bytes[offset + 4],
3379
+ bytes[offset + 5],
3380
+ bytes[offset + 6],
3381
+ bytes[offset + 7]
3291
3382
  );
3292
- if (dataRows.length === 0) {
3293
- tableRows = [];
3294
- return;
3295
- }
3296
- const colCount = Math.max(...dataRows.map((r) => r.length));
3297
- const colW = [];
3298
- for (let ci = 0; ci < colCount; ci++) {
3299
- colW.push(Math.max(3, ...dataRows.map((r) => (r[ci] ?? "").length)));
3383
+ const dataStart = offset + 8;
3384
+ const dataEnd = dataStart + length;
3385
+ if (dataEnd > bytes.length) throw new Error("Truncated PNG chunk");
3386
+ switch (type) {
3387
+ case "IHDR": {
3388
+ width = view.getUint32(dataStart);
3389
+ height = view.getUint32(dataStart + 4);
3390
+ bitDepth = bytes[dataStart + 8];
3391
+ colorType = bytes[dataStart + 9];
3392
+ interlace = bytes[dataStart + 12];
3393
+ break;
3394
+ }
3395
+ case "PLTE":
3396
+ palette = bytes.subarray(dataStart, dataEnd);
3397
+ break;
3398
+ case "tRNS":
3399
+ transparency = bytes.subarray(dataStart, dataEnd);
3400
+ break;
3401
+ case "IDAT":
3402
+ idat.push(bytes.subarray(dataStart, dataEnd));
3403
+ break;
3404
+ default:
3405
+ break;
3300
3406
  }
3301
- const totalW = colW.reduce((a, b) => a + b, 0) + colCount * 3 - 1;
3302
- if (totalW > width) {
3303
- for (const row of dataRows) {
3304
- out.push(` ${row.map((c) => c.trim()).join(" \u2502 ")}`);
3407
+ offset = dataEnd + 4;
3408
+ if (type === "IEND") break;
3409
+ }
3410
+ if (width === 0 || height === 0) throw new Error("PNG has no IHDR");
3411
+ if (interlace !== 0) throw new Error("Interlaced PNG is not supported");
3412
+ const channels = channelsForColorType(colorType);
3413
+ const bitsPerPixel = channels * bitDepth;
3414
+ const bytesPerRow = Math.ceil(width * bitsPerPixel / 8);
3415
+ const bytesPerPixel = Math.max(1, Math.ceil(bitsPerPixel / 8));
3416
+ const compressed = idat.length === 1 ? idat[0] : concat(idat);
3417
+ const raw = new Uint8Array(inflateSync(compressed));
3418
+ if (raw.length < height * (bytesPerRow + 1)) {
3419
+ throw new Error("Truncated PNG pixel data");
3420
+ }
3421
+ const rows = unfilter(raw, height, bytesPerRow, bytesPerPixel);
3422
+ const out = new Uint8Array(width * height * 4);
3423
+ const maxSample = (1 << Math.min(bitDepth, 8)) - 1;
3424
+ const scale = bitDepth === 16 ? 1 / 257 : 255 / maxSample;
3425
+ for (let y = 0; y < height; y++) {
3426
+ const row = rows.subarray(y * bytesPerRow, (y + 1) * bytesPerRow);
3427
+ for (let x = 0; x < width; x++) {
3428
+ const to8 = (sample) => bitDepth === 8 || bitDepth === 16 ? sample : Math.round(sample * scale);
3429
+ let r;
3430
+ let g;
3431
+ let b;
3432
+ let a = 255;
3433
+ switch (colorType) {
3434
+ case 0: {
3435
+ const grey = to8(sampleAt(row, 0, x, bitDepth));
3436
+ r = g = b = grey;
3437
+ break;
3438
+ }
3439
+ case 2:
3440
+ r = to8(sampleAt(row, 0, x * 3, bitDepth));
3441
+ g = to8(sampleAt(row, 0, x * 3 + 1, bitDepth));
3442
+ b = to8(sampleAt(row, 0, x * 3 + 2, bitDepth));
3443
+ break;
3444
+ case 3: {
3445
+ const index = sampleAt(row, 0, x, bitDepth);
3446
+ const p = index * 3;
3447
+ if (!palette || p + 2 >= palette.length)
3448
+ throw new Error("PNG palette entry missing");
3449
+ r = palette[p];
3450
+ g = palette[p + 1];
3451
+ b = palette[p + 2];
3452
+ if (transparency && index < transparency.length)
3453
+ a = transparency[index];
3454
+ break;
3455
+ }
3456
+ case 4: {
3457
+ const grey = to8(sampleAt(row, 0, x * 2, bitDepth));
3458
+ r = g = b = grey;
3459
+ a = to8(sampleAt(row, 0, x * 2 + 1, bitDepth));
3460
+ break;
3461
+ }
3462
+ default: {
3463
+ r = to8(sampleAt(row, 0, x * 4, bitDepth));
3464
+ g = to8(sampleAt(row, 0, x * 4 + 1, bitDepth));
3465
+ b = to8(sampleAt(row, 0, x * 4 + 2, bitDepth));
3466
+ a = to8(sampleAt(row, 0, x * 4 + 3, bitDepth));
3467
+ break;
3468
+ }
3305
3469
  }
3306
- } else {
3307
- const sep = `\u250C${colW.map((w) => "\u2500".repeat(w + 2)).join("\u252C")}\u2510`;
3308
- const mid = `\u251C${colW.map((w) => "\u2500".repeat(w + 2)).join("\u253C")}\u2524`;
3309
- const end = `\u2514${colW.map((w) => "\u2500".repeat(w + 2)).join("\u2534")}\u2518`;
3310
- out.push(sep);
3311
- dataRows.forEach((row, i) => {
3312
- const cells = colW.map((w, ci) => (row[ci] ?? "").trim().padEnd(w));
3313
- out.push(`\u2502 ${cells.map((c) => ` ${c} `).join("\u2502")}\u2502`);
3314
- if (i === 0) out.push(mid);
3315
- });
3316
- out.push(end);
3317
- }
3318
- out.push("");
3319
- tableRows = [];
3320
- }
3321
- function renderInline(line) {
3322
- line = line.replace(/\*\*(.+?)\*\*/g, "$1");
3323
- line = line.replace(/\*([^*]+?)\*/g, "$1");
3324
- line = line.replace(/`([^`]+)`/g, "\u27E8$1\u27E9");
3325
- line = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)");
3326
- line = line.replace(/!\[([^\]]*)\]\([^)]+\)/g, "\u{1F5BC} $1");
3327
- return line;
3328
- }
3329
- function wrapLine(line, maxW) {
3330
- if (line.length <= maxW) return [line];
3331
- const parts = [];
3332
- let remaining = line;
3333
- while (remaining.length > maxW) {
3334
- let breakAt = maxW;
3335
- const spaceIdx = remaining.lastIndexOf(" ", maxW);
3336
- if (spaceIdx > maxW / 2) breakAt = spaceIdx;
3337
- parts.push(remaining.slice(0, breakAt));
3338
- remaining = remaining.slice(breakAt).replace(/^ /, "");
3339
- }
3340
- if (remaining) parts.push(remaining);
3341
- return parts;
3342
- }
3343
- for (let i = 0; i < lines.length; i++) {
3344
- const line = lines[i];
3345
- if (/^```/.test(line)) {
3346
- if (inCodeBlock) {
3347
- flushCode();
3348
- inCodeBlock = false;
3349
- } else {
3350
- inCodeBlock = true;
3351
- codeLang = line.slice(3).trim();
3470
+ if (colorType === 0 && transparency && transparency.length >= 2) {
3471
+ const key = transparency[0] << 8 | transparency[1];
3472
+ const sample = sampleAt(row, 0, x, bitDepth);
3473
+ if (sample === key) a = 0;
3352
3474
  }
3353
- continue;
3354
- }
3355
- if (inCodeBlock) {
3356
- codeLines.push(line);
3357
- continue;
3475
+ const o = (y * width + x) * 4;
3476
+ out[o] = r;
3477
+ out[o + 1] = g;
3478
+ out[o + 2] = b;
3479
+ out[o + 3] = a;
3358
3480
  }
3359
- if (line.includes("|")) {
3360
- const cells = line.split("|").map((c) => c.trim()).filter((_, idx, arr) => idx > 0 && idx < arr.length - 1);
3361
- if (cells.length >= 2) {
3362
- inTable = true;
3363
- tableRows.push(cells);
3481
+ }
3482
+ return { width, height, data: out };
3483
+ }
3484
+ function concat(chunks) {
3485
+ let total = 0;
3486
+ for (const c of chunks) total += c.length;
3487
+ const merged = new Uint8Array(total);
3488
+ let offset = 0;
3489
+ for (const c of chunks) {
3490
+ merged.set(c, offset);
3491
+ offset += c.length;
3492
+ }
3493
+ return merged;
3494
+ }
3495
+
3496
+ // src/image-render.ts
3497
+ var DEFAULT_CELL_ASPECT = 0.5;
3498
+ var HALF_BLOCK = "\u2580";
3499
+ function fitImageToCells(imgWidth, imgHeight, opts) {
3500
+ const cellAspect = opts.cellAspect ?? DEFAULT_CELL_ASPECT;
3501
+ const minCols = Math.max(1, Math.min(opts.maxCols, 8));
3502
+ if (imgWidth <= 0 || imgHeight <= 0) {
3503
+ return { cols: minCols, rows: 1 };
3504
+ }
3505
+ const rowsPerCol = cellAspect * (imgHeight / imgWidth);
3506
+ let cols = Math.max(minCols, Math.floor(opts.maxCols));
3507
+ let rows = Math.max(1, Math.round(cols * rowsPerCol));
3508
+ if (rows > opts.maxRows) {
3509
+ rows = Math.max(1, opts.maxRows);
3510
+ cols = Math.max(
3511
+ minCols,
3512
+ Math.min(cols, Math.floor(rows / Math.max(rowsPerCol, 1e-6)))
3513
+ );
3514
+ }
3515
+ return { cols, rows };
3516
+ }
3517
+ function pixelAt(img, x, y, background) {
3518
+ const o = (y * img.width + x) * 4;
3519
+ const a = img.data[o + 3] / 255;
3520
+ if (a >= 1) return [img.data[o], img.data[o + 1], img.data[o + 2]];
3521
+ const mix = (channel, bg) => Math.round(channel * a + bg * (1 - a));
3522
+ return [
3523
+ mix(img.data[o], background[0]),
3524
+ mix(img.data[o + 1], background[1]),
3525
+ mix(img.data[o + 2], background[2])
3526
+ ];
3527
+ }
3528
+ function toHalfBlocks(img, opts = {}) {
3529
+ const background = opts.background ?? [0, 0, 0];
3530
+ const cells = [];
3531
+ for (let row = 0; row * 2 < img.height; row++) {
3532
+ const cellsRow = [];
3533
+ const topY = row * 2;
3534
+ const bottomY = topY + 1;
3535
+ for (let x = 0; x < img.width; x++) {
3536
+ const top = pixelAt(img, x, topY, background);
3537
+ const bottom = bottomY < img.height ? pixelAt(img, x, bottomY, background) : top;
3538
+ cellsRow.push({ top, bottom });
3539
+ }
3540
+ cells.push(cellsRow);
3541
+ }
3542
+ return { cols: img.width, rows: cells.length, cells };
3543
+ }
3544
+ function toImageRuns(image) {
3545
+ return image.cells.map((cells) => {
3546
+ const runs = [];
3547
+ for (const cell of cells) {
3548
+ const last = runs[runs.length - 1];
3549
+ if (last && last.top[0] === cell.top[0] && last.top[1] === cell.top[1] && last.top[2] === cell.top[2] && last.bottom[0] === cell.bottom[0] && last.bottom[1] === cell.bottom[1] && last.bottom[2] === cell.bottom[2]) {
3550
+ last.text += HALF_BLOCK;
3364
3551
  continue;
3365
3552
  }
3366
- } else if (inTable) {
3367
- flushTable();
3368
- inTable = false;
3553
+ runs.push({ text: HALF_BLOCK, top: cell.top, bottom: cell.bottom });
3369
3554
  }
3370
- if (/^(\s*[-*_]){3,}\s*$/.test(line)) {
3371
- out.push("\u2500".repeat(Math.min(width, 40)));
3372
- out.push("");
3555
+ return runs;
3556
+ });
3557
+ }
3558
+ var MAX_SOURCE_PIXELS = 2e7;
3559
+ async function rasterizeImage(bytes, opts) {
3560
+ const ImageCtor = globalThis.Bun?.Image;
3561
+ if (!ImageCtor) return null;
3562
+ const source = new ImageCtor(bytes, { maxPixels: MAX_SOURCE_PIXELS });
3563
+ try {
3564
+ const meta = await source.metadata();
3565
+ const { cols, rows } = fitImageToCells(meta.width, meta.height, opts);
3566
+ const png = await source.resize(cols, rows * 2, { fit: "fill" }).png({ compressionLevel: 1 }).bytes();
3567
+ return toHalfBlocks(decodePng(png), { background: opts.background });
3568
+ } catch {
3569
+ return null;
3570
+ }
3571
+ }
3572
+
3573
+ // src/image-loader.ts
3574
+ var MAX_IMAGE_BYTES = 6 * 1024 * 1024;
3575
+ var BADGE_HOST = /(^|\.)(shields\.io|badgen\.net|badge\.fury\.io|codecov\.io|coveralls\.io|travis-ci\.(org|com)|snyk\.io|sonarcloud\.io|david-dm\.(org|io))$/i;
3576
+ var RAW_HOSTS = /* @__PURE__ */ new Set([
3577
+ "raw.githubusercontent.com",
3578
+ "camo.githubusercontent.com",
3579
+ "user-images.githubusercontent.com",
3580
+ "private-user-images.githubusercontent.com",
3581
+ "avatars.githubusercontent.com",
3582
+ "objects.githubusercontent.com"
3583
+ ]);
3584
+ var RASTER_EXT = /\.(png|jpe?g|webp|gif|bmp|avif|heic|tiff?)(?:[?#]|$)/i;
3585
+ var CACHE_LIMIT = 24;
3586
+ var MAX_CONCURRENT_IMAGE_FETCHES = 4;
3587
+ var cache = /* @__PURE__ */ new Map();
3588
+ var activeFetches = 0;
3589
+ var waiting = [];
3590
+ function releaseSlot() {
3591
+ activeFetches--;
3592
+ waiting.shift()?.();
3593
+ }
3594
+ async function withSlot(job) {
3595
+ if (activeFetches >= MAX_CONCURRENT_IMAGE_FETCHES) {
3596
+ await new Promise((resolve) => waiting.push(resolve));
3597
+ }
3598
+ activeFetches++;
3599
+ try {
3600
+ return await job();
3601
+ } finally {
3602
+ releaseSlot();
3603
+ }
3604
+ }
3605
+ function decodeDataUri(uri) {
3606
+ const match = /^data:([^;,]+)?(;base64)?,(.*)$/is.exec(uri);
3607
+ if (!match) return null;
3608
+ const mime = (match[1] ?? "").toLowerCase();
3609
+ if (mime.includes("svg")) return null;
3610
+ const payload = match[3];
3611
+ try {
3612
+ if (match[2]) return new Uint8Array(Buffer.from(payload, "base64"));
3613
+ return new Uint8Array(Buffer.from(decodeURIComponent(payload), "binary"));
3614
+ } catch {
3615
+ return null;
3616
+ }
3617
+ }
3618
+ function toRawGitHubUrl(url) {
3619
+ if (url.hostname.toLowerCase() !== "github.com") return url;
3620
+ const match = /^\/([^/]+)\/([^/]+)\/(?:blob|raw)\/(.+)$/.exec(url.pathname);
3621
+ if (!match) return url;
3622
+ const [, owner, repo, rest] = match;
3623
+ return new URL(
3624
+ `https://raw.githubusercontent.com/${owner}/${repo}/${rest}${url.search}`
3625
+ );
3626
+ }
3627
+ function resolveRootRelative(path, base) {
3628
+ let parsed;
3629
+ try {
3630
+ parsed = new URL(base);
3631
+ } catch {
3632
+ return null;
3633
+ }
3634
+ if (parsed.hostname.toLowerCase() !== "raw.githubusercontent.com") {
3635
+ return null;
3636
+ }
3637
+ const segments = parsed.pathname.split("/").filter(Boolean);
3638
+ if (segments.length < 3) return null;
3639
+ return new URL(`${parsed.origin}/${segments.slice(0, 3).join("/")}${path}`);
3640
+ }
3641
+ function resolveReadmeImageUrl(raw, base) {
3642
+ const cleaned = raw.trim();
3643
+ if (!cleaned) return null;
3644
+ if (/^data:/i.test(cleaned)) {
3645
+ return isRenderableImageUrl(cleaned) ? cleaned : null;
3646
+ }
3647
+ let url = null;
3648
+ try {
3649
+ url = cleaned.startsWith("/") ? resolveRootRelative(cleaned, base) : new URL(cleaned, base);
3650
+ } catch {
3651
+ return null;
3652
+ }
3653
+ if (!url) return null;
3654
+ url = toRawGitHubUrl(url);
3655
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
3656
+ url.hash = "";
3657
+ const resolved = url.toString();
3658
+ return isRenderableImageUrl(resolved) ? resolved : null;
3659
+ }
3660
+ function isRenderableImageUrl(url) {
3661
+ if (/^data:/i.test(url)) return !/svg/i.test(url.slice(0, 64));
3662
+ let parsed;
3663
+ try {
3664
+ parsed = new URL(url);
3665
+ } catch {
3666
+ return false;
3667
+ }
3668
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
3669
+ if (BADGE_HOST.test(parsed.hostname)) return false;
3670
+ const path = parsed.pathname.toLowerCase();
3671
+ if (path.endsWith(".svg") || path.endsWith(".svgz")) return false;
3672
+ if (parsed.hostname.toLowerCase().endsWith("github.com")) {
3673
+ if (/^\/user-attachments\//.test(path)) return true;
3674
+ if (!RAW_HOSTS.has(parsed.hostname)) {
3675
+ return /\.(png|jpe?g|webp|gif|bmp|avif|heic|tiff?)$/i.test(path);
3676
+ }
3677
+ }
3678
+ if (/\.[a-z0-9]{1,5}$/.test(path) && !RASTER_EXT.test(path)) return false;
3679
+ return true;
3680
+ }
3681
+ function isGitHubHost(hostname) {
3682
+ return /(^|\.)githubusercontent\.com$|(^|\.)github\.com$/i.test(hostname);
3683
+ }
3684
+ async function fetchImageBytes(url, opts = {}) {
3685
+ const cached2 = cache.get(url);
3686
+ if (cached2) return cached2;
3687
+ const pending = withSlot(() => loadImageBytes(url, opts));
3688
+ cache.set(url, pending);
3689
+ while (cache.size > CACHE_LIMIT) {
3690
+ const oldest = cache.keys().next().value;
3691
+ if (oldest === void 0) break;
3692
+ cache.delete(oldest);
3693
+ }
3694
+ return pending;
3695
+ }
3696
+ async function loadImageBytes(url, opts) {
3697
+ if (/^data:/i.test(url)) return decodeDataUri(url);
3698
+ const fetchImpl = opts.fetchImpl ?? fetch;
3699
+ const timeoutMs = opts.timeoutMs ?? 1e4;
3700
+ const controller = new AbortController();
3701
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
3702
+ try {
3703
+ const headers = {
3704
+ "User-Agent": "ghfind/1.0",
3705
+ Accept: "image/*,*/*;q=0.5"
3706
+ };
3707
+ let host = "";
3708
+ try {
3709
+ host = new URL(url).hostname;
3710
+ } catch {
3711
+ return null;
3712
+ }
3713
+ if (opts.token && isGitHubHost(host)) {
3714
+ headers.Authorization = `Bearer ${opts.token}`;
3715
+ }
3716
+ const res = await fetchImpl(url, { headers, signal: controller.signal });
3717
+ if (!res.ok) return null;
3718
+ const contentType = res.headers.get("content-type") ?? "";
3719
+ if (contentType && !/^image\//i.test(contentType)) {
3720
+ const githubHost = isGitHubHost(host);
3721
+ if (!githubHost || !/octet-stream/i.test(contentType)) return null;
3722
+ }
3723
+ if (/svg/i.test(contentType)) return null;
3724
+ const declared = Number(res.headers.get("content-length") ?? "0");
3725
+ if (declared > MAX_IMAGE_BYTES) return null;
3726
+ const buffer = new Uint8Array(await res.arrayBuffer());
3727
+ if (buffer.byteLength === 0 || buffer.byteLength > MAX_IMAGE_BYTES) {
3728
+ return null;
3729
+ }
3730
+ return buffer;
3731
+ } catch {
3732
+ return null;
3733
+ } finally {
3734
+ clearTimeout(timer);
3735
+ }
3736
+ }
3737
+ function readmeBaseUrl(owner, repo, branch, path = "README.md") {
3738
+ return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
3739
+ }
3740
+
3741
+ // src/markdown-images.ts
3742
+ var IMG_TAG = /<img\b[^>]*?src\s*=\s*["']([^"']+)["'][^>]*>/gi;
3743
+ var ALT_ATTR = /\balt\s*=\s*["']([^"']*)["']/i;
3744
+ var HTML_TAG = /<\/?[a-z][^>]*>/gi;
3745
+ function imagesFromHtml(raw) {
3746
+ if (!raw || !/<img\b/i.test(raw)) return [];
3747
+ const withoutTags = raw.replace(IMG_TAG, "").replace(/<\/?(?:p|div|center|br|a|picture|source)\b[^>]*>/gi, "").trim();
3748
+ if (withoutTags) return [];
3749
+ const refs = [];
3750
+ for (const match of raw.matchAll(IMG_TAG)) {
3751
+ const tag = match[0];
3752
+ const alt = ALT_ATTR.exec(tag)?.[1] ?? "";
3753
+ refs.push({ href: match[1], alt });
3754
+ }
3755
+ return refs;
3756
+ }
3757
+ function inlinePlainText(tokens) {
3758
+ if (!tokens) return "";
3759
+ let out = "";
3760
+ for (const token of tokens) {
3761
+ if (token.type === "image") continue;
3762
+ if (token.type === "html") {
3763
+ out += (token.raw ?? "").replace(HTML_TAG, "");
3373
3764
  continue;
3374
3765
  }
3375
- const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
3376
- if (headingMatch) {
3377
- const level = headingMatch[1].length;
3378
- const text = renderInline(headingMatch[2]);
3379
- const prefix = "\u2588 ".repeat(level);
3380
- out.push("");
3381
- out.push(prefix + text);
3382
- out.push("");
3766
+ if (token.type === "br") {
3767
+ out += "\n";
3383
3768
  continue;
3384
3769
  }
3385
- const bqMatch = line.match(/^>\s?(.*)/);
3386
- if (bqMatch) {
3387
- const inner = renderInline(bqMatch[1]);
3388
- for (const wl of wrapLine(inner, width - 4)) {
3389
- out.push(`\u2502 ${wl}`);
3390
- }
3770
+ if (token.text && (!token.tokens || token.tokens.length === 0)) {
3771
+ out += token.text;
3391
3772
  continue;
3392
3773
  }
3393
- const ulMatch = line.match(/^(\s*)[-*+]\s+(.*)/);
3394
- if (ulMatch) {
3395
- const indent = ulMatch[1].length;
3396
- const depth = Math.floor(indent / 2);
3397
- const prefix = `${" ".repeat(depth)}\u2022 `;
3398
- const inner = renderInline(ulMatch[2]);
3399
- for (const wl of wrapLine(inner, width - prefix.length)) {
3400
- out.push(prefix + wl);
3401
- }
3774
+ if (token.tokens?.length) {
3775
+ out += inlinePlainText(token.tokens);
3402
3776
  continue;
3403
3777
  }
3404
- const olMatch = line.match(/^(\s*)\d+\.\s+(.*)/);
3405
- if (olMatch) {
3406
- const indent = olMatch[1].length;
3407
- const depth = Math.floor(indent / 2);
3408
- const prefix = `${" ".repeat(depth)}\u25E6 `;
3409
- const inner = renderInline(olMatch[2]);
3410
- for (const wl of wrapLine(inner, width - prefix.length)) {
3411
- out.push(prefix + wl);
3778
+ if (token.raw) out += token.raw.replace(HTML_TAG, "");
3779
+ }
3780
+ return out;
3781
+ }
3782
+ function extractImageRefs(token) {
3783
+ const images = [];
3784
+ const collect = (inline2) => {
3785
+ if (inline2.type === "image") {
3786
+ if (inline2.href)
3787
+ images.push({ href: inline2.href, alt: inline2.text ?? "" });
3788
+ return true;
3789
+ }
3790
+ if (inline2.type === "html") {
3791
+ const refs = imagesFromHtml(inline2.raw);
3792
+ if (refs.length > 0) {
3793
+ images.push(...refs);
3794
+ return true;
3412
3795
  }
3413
- continue;
3796
+ return !(inline2.raw ?? "").replace(HTML_TAG, "").trim();
3414
3797
  }
3415
- if (line.trim() === "") {
3416
- out.push("");
3417
- continue;
3798
+ if (inline2.type === "text" || inline2.type === "escape") {
3799
+ return !(inline2.text ?? "").trim();
3800
+ }
3801
+ return false;
3802
+ };
3803
+ if (token.type === "image" && token.href) {
3804
+ return [{ href: token.href, alt: token.text ?? "" }];
3805
+ }
3806
+ if (token.type === "html") {
3807
+ const refs = imagesFromHtml(token.raw);
3808
+ return refs.length > 0 ? refs : null;
3809
+ }
3810
+ const inline = token.tokens ?? [];
3811
+ if (inline.length === 0) return null;
3812
+ for (const child of inline) {
3813
+ if (!collect(child)) return null;
3814
+ }
3815
+ return images.length > 0 ? images : null;
3816
+ }
3817
+
3818
+ // src/markdown-view.ts
3819
+ var MAX_IMAGE_ROWS = 24;
3820
+ var RASTER_CACHE_LIMIT = 32;
3821
+ function createMarkdownStyle(colors2) {
3822
+ const surface = colors2.surface ?? colors2.bg;
3823
+ return SyntaxStyle.fromStyles({
3824
+ default: { fg: colors2.text ?? "#ffffff" },
3825
+ conceal: { fg: colors2.muted ?? "#888888" },
3826
+ "markup.heading": { fg: colors2.accent ?? colors2.blue, bold: true },
3827
+ "markup.strong": { fg: colors2.text ?? "#ffffff", bold: true },
3828
+ "markup.italic": { fg: colors2.text ?? "#ffffff", italic: true },
3829
+ "markup.strikethrough": { fg: colors2.muted ?? "#888888", dim: true },
3830
+ "markup.raw": { fg: colors2.green ?? "#a6e3a1", bg: surface },
3831
+ "markup.raw.block": { fg: colors2.green ?? "#a6e3a1" },
3832
+ "markup.link": { fg: colors2.accent ?? colors2.blue },
3833
+ "markup.link.label": {
3834
+ fg: colors2.accent ?? colors2.blue,
3835
+ underline: true
3836
+ },
3837
+ "markup.link.url": { fg: colors2.muted ?? "#888888", underline: true },
3838
+ "markup.quote": { fg: colors2.muted ?? "#888888", italic: true },
3839
+ "markup.list": { fg: colors2.blue ?? colors2.accent }
3840
+ });
3841
+ }
3842
+ function hexToRgb(hex) {
3843
+ const fallback = [0, 0, 0];
3844
+ if (!hex) return fallback;
3845
+ const match = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
3846
+ if (!match) return fallback;
3847
+ const value = Number.parseInt(match[1], 16);
3848
+ return [value >> 16 & 255, value >> 8 & 255, value & 255];
3849
+ }
3850
+ function rgba(color) {
3851
+ return RGBA.fromValues(color[0], color[1], color[2], 255);
3852
+ }
3853
+ function imageStyledText(image) {
3854
+ const chunks = [];
3855
+ toImageRuns(image).forEach((runs, rowIndex) => {
3856
+ if (rowIndex > 0) chunks.push({ __isChunk: true, text: "\n" });
3857
+ for (const run2 of runs) {
3858
+ chunks.push({
3859
+ __isChunk: true,
3860
+ text: run2.text,
3861
+ fg: rgba(run2.top),
3862
+ bg: rgba(run2.bottom)
3863
+ });
3864
+ }
3865
+ });
3866
+ return new StyledText3(chunks);
3867
+ }
3868
+ function createMarkdownView(opts) {
3869
+ const { renderer, colors: colors2 } = opts;
3870
+ const maxImageRows = opts.maxImageRows ?? MAX_IMAGE_ROWS;
3871
+ const rasterCache = /* @__PURE__ */ new Map();
3872
+ let imagesEnabled = opts.images ?? true;
3873
+ let imageBase = opts.imageBase;
3874
+ const text = () => colors2.text ?? "#ffffff";
3875
+ const bg = () => opts.background ?? colors2.bg ?? "#000000";
3876
+ const muted = () => colors2.muted ?? "#888888";
3877
+ const renderable = new MarkdownRenderable(renderer, {
3878
+ content: "",
3879
+ syntaxStyle: createMarkdownStyle(colors2),
3880
+ fg: text(),
3881
+ bg: bg(),
3882
+ conceal: true,
3883
+ streaming: true,
3884
+ internalBlockMode: "top-level",
3885
+ // Borderless columns are the top-level default; boxed tables read better
3886
+ // for the multi-column tables READMEs use.
3887
+ tableOptions: { style: "grid" },
3888
+ width: "100%"
3889
+ });
3890
+ const caption = (label, color = muted()) => new TextRenderable3(renderer, {
3891
+ content: ` ${label}`,
3892
+ fg: color,
3893
+ bg: bg(),
3894
+ wrapMode: "word",
3895
+ flexShrink: 0,
3896
+ width: "100%"
3897
+ });
3898
+ const rasterize = (url) => {
3899
+ const maxCols = Math.max(8, Math.floor(opts.getImageWidth?.() ?? 100));
3900
+ const key = `${maxCols}|${url}`;
3901
+ const cached2 = rasterCache.get(key);
3902
+ if (cached2) return cached2;
3903
+ const pending = (async () => {
3904
+ const loader = opts.loadImage;
3905
+ if (!loader) return null;
3906
+ const bytes = await loader(url);
3907
+ if (!bytes) return null;
3908
+ return rasterizeImage(bytes, {
3909
+ maxCols,
3910
+ maxRows: maxImageRows,
3911
+ background: hexToRgb(bg())
3912
+ });
3913
+ })();
3914
+ rasterCache.set(key, pending);
3915
+ while (rasterCache.size > RASTER_CACHE_LIMIT) {
3916
+ const oldest = rasterCache.keys().next().value;
3917
+ if (oldest === void 0) break;
3918
+ rasterCache.delete(oldest);
3919
+ }
3920
+ return pending;
3921
+ };
3922
+ const imageNode = (image) => {
3923
+ const label = `\u{1F5BC} ${image.alt.trim() || "image"}`;
3924
+ const url = imageBase ? resolveReadmeImageUrl(image.href, imageBase) : null;
3925
+ if (!imagesEnabled || !url || !opts.loadImage) return caption(label);
3926
+ const container = new BoxRenderable3(renderer, {
3927
+ flexDirection: "column",
3928
+ flexShrink: 0,
3929
+ width: "100%"
3930
+ });
3931
+ const placeholder = caption(`${label} (loading\u2026)`);
3932
+ container.add(placeholder);
3933
+ void rasterize(url).then(
3934
+ (cells) => {
3935
+ if (container.isDestroyed || !container.parent) return;
3936
+ container.remove(placeholder);
3937
+ placeholder.destroy();
3938
+ container.add(caption(label));
3939
+ if (cells) {
3940
+ container.add(
3941
+ new TextRenderable3(renderer, {
3942
+ content: imageStyledText(cells),
3943
+ bg: bg(),
3944
+ wrapMode: "none",
3945
+ selectable: false,
3946
+ flexShrink: 0
3947
+ })
3948
+ );
3949
+ }
3950
+ renderable.requestRender();
3951
+ },
3952
+ (err) => {
3953
+ void err;
3954
+ }
3955
+ );
3956
+ return container;
3957
+ };
3958
+ const headingNode = (token) => {
3959
+ const heading = inlinePlainText(token.tokens).trim();
3960
+ if (!heading) return void 0;
3961
+ const depth = token.depth ?? 1;
3962
+ const color = depth <= 2 ? colors2.accent : colors2.teal ?? colors2.accent;
3963
+ return new TextRenderable3(renderer, {
3964
+ content: new StyledText3([
3965
+ {
3966
+ __isChunk: true,
3967
+ text: heading,
3968
+ fg: RGBA.fromHex(color ?? "#ffffff"),
3969
+ attributes: createTextAttributes({
3970
+ bold: true,
3971
+ underline: depth === 1
3972
+ })
3973
+ }
3974
+ ]),
3975
+ bg: bg(),
3976
+ wrapMode: "word",
3977
+ flexShrink: 0,
3978
+ width: "100%"
3979
+ });
3980
+ };
3981
+ const buildRenderNode = () => (token, _context) => {
3982
+ if (token.type === "heading") return headingNode(token);
3983
+ const images = extractImageRefs(token);
3984
+ if (images && images.length > 0) {
3985
+ const box = new BoxRenderable3(renderer, {
3986
+ flexDirection: "column",
3987
+ flexShrink: 0,
3988
+ width: "100%",
3989
+ marginTop: 1
3990
+ });
3991
+ for (const image of images) box.add(imageNode(image));
3992
+ return box;
3418
3993
  }
3419
- const rendered = renderInline(line);
3420
- for (const wl of wrapLine(rendered, width)) {
3421
- out.push(wl);
3994
+ return void 0;
3995
+ };
3996
+ renderable.renderNode = buildRenderNode();
3997
+ return {
3998
+ renderable,
3999
+ get imagesEnabled() {
4000
+ return imagesEnabled;
4001
+ },
4002
+ setContent(markdown, base) {
4003
+ if (base !== void 0) imageBase = base;
4004
+ renderable.syntaxStyle = createMarkdownStyle(colors2);
4005
+ renderable.fg = text();
4006
+ renderable.bg = bg();
4007
+ renderable.content = markdown;
4008
+ renderable.streaming = false;
4009
+ renderable.requestRender();
4010
+ },
4011
+ setImagesEnabled(enabled) {
4012
+ if (imagesEnabled === enabled) return;
4013
+ imagesEnabled = enabled;
4014
+ renderable.renderNode = buildRenderNode();
4015
+ renderable.requestRender();
3422
4016
  }
4017
+ };
4018
+ }
4019
+
4020
+ // src/readme.ts
4021
+ var CANDIDATES = [
4022
+ ["main", "README.md"],
4023
+ ["master", "README.md"],
4024
+ ["main", "README.rst"]
4025
+ ];
4026
+ async function fetchReadme(owner, repo, opts = {}) {
4027
+ const fetchImpl = opts.fetchImpl ?? fetch;
4028
+ const headers = { "User-Agent": "ghfind/1.0" };
4029
+ if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
4030
+ const attempts = CANDIDATES.map(async ([branch, file]) => {
4031
+ const url = readmeBaseUrl(owner, repo, branch, file);
4032
+ const res = await fetchImpl(url, { headers });
4033
+ if (!res.ok) throw new Error(`no readme at ${url}`);
4034
+ return { text: await res.text(), sourceUrl: url };
4035
+ });
4036
+ try {
4037
+ return await Promise.any(attempts);
4038
+ } catch {
4039
+ return null;
3423
4040
  }
3424
- if (inCodeBlock) flushCode();
3425
- if (inTable) flushTable();
3426
- return out.join("\n");
3427
4041
  }
3428
4042
 
3429
4043
  // src/explore.ts
@@ -3746,7 +4360,7 @@ function fetchReleaseNotes(version) {
3746
4360
  }
3747
4361
 
3748
4362
  // src/landing.ts
3749
- import { BoxRenderable as BoxRenderable3, TextRenderable as TextRenderable3 } from "@opentui/core";
4363
+ import { BoxRenderable as BoxRenderable4, TextRenderable as TextRenderable4 } from "@opentui/core";
3750
4364
  function landingKeysActive(mode, overlay) {
3751
4365
  return mode === "landing" && overlay === "none";
3752
4366
  }
@@ -3878,13 +4492,13 @@ async function launchBrowser(theme_override) {
3878
4492
  let deepDiveActive = false;
3879
4493
  const compareList = [];
3880
4494
  const _quitArmed = false;
3881
- const header = new TextRenderable4(renderer, {
4495
+ const header = new TextRenderable5(renderer, {
3882
4496
  content: " ghfind \u2014 GitHub repo browser [/]search [Space]menu [\u2192]open [?]help [q]uit",
3883
4497
  bg: colors.bg,
3884
4498
  fg: colors.muted,
3885
4499
  height: 1
3886
4500
  });
3887
- const trendingTabBox = new BoxRenderable4(renderer, {
4501
+ const trendingTabBox = new BoxRenderable5(renderer, {
3888
4502
  flexDirection: "row",
3889
4503
  height: 1,
3890
4504
  backgroundColor: colors.surface,
@@ -3899,7 +4513,7 @@ async function launchBrowser(theme_override) {
3899
4513
  TAB_NAMES.forEach((name, i) => {
3900
4514
  const isActive = name === trendingTab;
3901
4515
  const label = ` ${name} `;
3902
- const tt = new TextRenderable4(renderer, {
4516
+ const tt = new TextRenderable5(renderer, {
3903
4517
  content: label,
3904
4518
  fg: isActive ? colors.bg : colors.muted,
3905
4519
  bg: isActive ? colors.blue : colors.bg,
@@ -3908,7 +4522,7 @@ async function launchBrowser(theme_override) {
3908
4522
  trendingTabTexts.push(tt);
3909
4523
  trendingTabBox.add(tt);
3910
4524
  if (i < TAB_NAMES.length - 1) {
3911
- const sp = new TextRenderable4(renderer, {
4525
+ const sp = new TextRenderable5(renderer, {
3912
4526
  content: " ",
3913
4527
  fg: colors.muted,
3914
4528
  bg: colors.bg,
@@ -3921,7 +4535,7 @@ async function launchBrowser(theme_override) {
3921
4535
  }
3922
4536
  root.add(header);
3923
4537
  root.add(trendingTabBox);
3924
- const searchBox = new BoxRenderable4(renderer, {
4538
+ const searchBox = new BoxRenderable5(renderer, {
3925
4539
  visible: true,
3926
4540
  width: "100%",
3927
4541
  flexDirection: "row",
@@ -3940,7 +4554,7 @@ async function launchBrowser(theme_override) {
3940
4554
  });
3941
4555
  searchBox.add(searchInput);
3942
4556
  root.add(searchBox);
3943
- const toolbarText = new TextRenderable4(renderer, {
4557
+ const toolbarText = new TextRenderable5(renderer, {
3944
4558
  content: formatToolbar(currentSort, currentLimit, totalCount),
3945
4559
  visible: true,
3946
4560
  // hidden in trending mode
@@ -3949,14 +4563,14 @@ async function launchBrowser(theme_override) {
3949
4563
  paddingX: 1
3950
4564
  });
3951
4565
  root.add(toolbarText);
3952
- const body = new BoxRenderable4(renderer, {
4566
+ const body = new BoxRenderable5(renderer, {
3953
4567
  flexDirection: "row",
3954
4568
  flexGrow: 1,
3955
4569
  gap: 1,
3956
4570
  paddingY: 1,
3957
4571
  paddingX: 1
3958
4572
  });
3959
- const resultsBox = new BoxRenderable4(renderer, {
4573
+ const resultsBox = new BoxRenderable5(renderer, {
3960
4574
  border: true,
3961
4575
  borderColor: colors.border,
3962
4576
  title: " Results ",
@@ -3985,7 +4599,7 @@ async function launchBrowser(theme_override) {
3985
4599
  itemSpacing: 0
3986
4600
  });
3987
4601
  resultsBox.add(resultsSelect);
3988
- const detailBox = new BoxRenderable4(renderer, {
4602
+ const detailBox = new BoxRenderable5(renderer, {
3989
4603
  width: "50%",
3990
4604
  flexDirection: "column",
3991
4605
  paddingX: 1,
@@ -3993,7 +4607,7 @@ async function launchBrowser(theme_override) {
3993
4607
  focusable: false,
3994
4608
  backgroundColor: colors.surface
3995
4609
  });
3996
- const detailText = new TextRenderable4(renderer, {
4610
+ const detailText = new TextRenderable5(renderer, {
3997
4611
  content: "",
3998
4612
  fg: colors.text,
3999
4613
  wrapMode: "none"
@@ -4002,7 +4616,7 @@ async function launchBrowser(theme_override) {
4002
4616
  body.add(resultsBox);
4003
4617
  body.add(detailBox);
4004
4618
  root.add(body);
4005
- const landingBox = new BoxRenderable4(renderer, {
4619
+ const landingBox = new BoxRenderable5(renderer, {
4006
4620
  visible: false,
4007
4621
  flexGrow: 1,
4008
4622
  flexDirection: "column",
@@ -4010,7 +4624,7 @@ async function launchBrowser(theme_override) {
4010
4624
  justifyContent: "center"
4011
4625
  });
4012
4626
  root.add(landingBox);
4013
- const landingBanner = new TextRenderable4(renderer, {
4627
+ const landingBanner = new TextRenderable5(renderer, {
4014
4628
  content: [
4015
4629
  " \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557\u2588\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2557 ",
4016
4630
  "\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557",
@@ -4060,7 +4674,7 @@ async function launchBrowser(theme_override) {
4060
4674
  const landingCards = [];
4061
4675
  for (let i = 0; i < landingOptions.length; i++) {
4062
4676
  const opt = landingOptions[i];
4063
- const card = new BoxRenderable4(renderer, {
4677
+ const card = new BoxRenderable5(renderer, {
4064
4678
  flexDirection: "column",
4065
4679
  height: 5,
4066
4680
  width: "60%",
@@ -4072,18 +4686,18 @@ async function launchBrowser(theme_override) {
4072
4686
  });
4073
4687
  landingCards.push(card);
4074
4688
  landingBox.add(card);
4075
- const spacerTop = new TextRenderable4(renderer, { content: "", height: 1 });
4076
- const titleLine = new TextRenderable4(renderer, {
4689
+ const spacerTop = new TextRenderable5(renderer, { content: "", height: 1 });
4690
+ const titleLine = new TextRenderable5(renderer, {
4077
4691
  content: opt.title,
4078
4692
  fg: i === 0 ? colors.accent : colors.text,
4079
4693
  height: 1
4080
4694
  });
4081
- const spacerBot = new TextRenderable4(renderer, { content: "", height: 1 });
4695
+ const spacerBot = new TextRenderable5(renderer, { content: "", height: 1 });
4082
4696
  card.add(spacerTop);
4083
4697
  card.add(titleLine);
4084
4698
  card.add(spacerBot);
4085
4699
  }
4086
- const landingHint = new TextRenderable4(renderer, {
4700
+ const landingHint = new TextRenderable5(renderer, {
4087
4701
  content: " \u2191\u2193 Navigate \u21E9 Select [b]ookmarks [?]help [q]uit",
4088
4702
  fg: colors.muted,
4089
4703
  bg: colors.bg,
@@ -4226,7 +4840,7 @@ async function launchBrowser(theme_override) {
4226
4840
  renderer.requestRender();
4227
4841
  }
4228
4842
  const helpSections = buildHelpSections();
4229
- const helpBox = new BoxRenderable4(renderer, {
4843
+ const helpBox = new BoxRenderable5(renderer, {
4230
4844
  visible: false,
4231
4845
  flexGrow: 1,
4232
4846
  backgroundColor: colors.bg
@@ -4248,7 +4862,7 @@ async function launchBrowser(theme_override) {
4248
4862
  }
4249
4863
  });
4250
4864
  for (const section of helpSections) {
4251
- const sectionBox = new BoxRenderable4(renderer, {
4865
+ const sectionBox = new BoxRenderable5(renderer, {
4252
4866
  title: section.title,
4253
4867
  titleColor: colors[section.titleColor] ?? section.titleColor,
4254
4868
  flexDirection: "column",
@@ -4260,7 +4874,7 @@ async function launchBrowser(theme_override) {
4260
4874
  for (const row of section.rows) {
4261
4875
  const keys = row.keys.padEnd(HELP_KEYS_COLUMN);
4262
4876
  sectionBox.add(
4263
- new TextRenderable4(renderer, {
4877
+ new TextRenderable5(renderer, {
4264
4878
  content: ` ${keys} ${row.action}`,
4265
4879
  fg: colors.text
4266
4880
  })
@@ -4268,7 +4882,7 @@ async function launchBrowser(theme_override) {
4268
4882
  }
4269
4883
  if (section.note) {
4270
4884
  sectionBox.add(
4271
- new TextRenderable4(renderer, {
4885
+ new TextRenderable5(renderer, {
4272
4886
  content: ` ${section.note}`,
4273
4887
  fg: colors.muted
4274
4888
  })
@@ -4278,7 +4892,7 @@ async function launchBrowser(theme_override) {
4278
4892
  }
4279
4893
  helpBox.add(helpScroll);
4280
4894
  root.add(helpBox);
4281
- const historyBox = new BoxRenderable4(renderer, {
4895
+ const historyBox = new BoxRenderable5(renderer, {
4282
4896
  visible: false,
4283
4897
  flexGrow: 1,
4284
4898
  backgroundColor: colors.bg,
@@ -4313,7 +4927,7 @@ async function launchBrowser(theme_override) {
4313
4927
  }
4314
4928
  historySelect.setSelectedIndex(0);
4315
4929
  }
4316
- const bookmarksBox = new BoxRenderable4(renderer, {
4930
+ const bookmarksBox = new BoxRenderable5(renderer, {
4317
4931
  visible: false,
4318
4932
  flexGrow: 1,
4319
4933
  backgroundColor: colors.bg,
@@ -4348,7 +4962,7 @@ async function launchBrowser(theme_override) {
4348
4962
  }
4349
4963
  bookmarksSelect.setSelectedIndex(0);
4350
4964
  }
4351
- const savedBox = new BoxRenderable4(renderer, {
4965
+ const savedBox = new BoxRenderable5(renderer, {
4352
4966
  visible: false,
4353
4967
  flexGrow: 1,
4354
4968
  backgroundColor: colors.bg,
@@ -4368,7 +4982,7 @@ async function launchBrowser(theme_override) {
4368
4982
  });
4369
4983
  savedBox.add(savedSelect);
4370
4984
  root.add(savedBox);
4371
- const topicsBox = new BoxRenderable4(renderer, {
4985
+ const topicsBox = new BoxRenderable5(renderer, {
4372
4986
  visible: false,
4373
4987
  flexGrow: 1,
4374
4988
  backgroundColor: colors.bg,
@@ -4403,7 +5017,7 @@ async function launchBrowser(theme_override) {
4403
5017
  }
4404
5018
  topicsSelect.setSelectedIndex(0);
4405
5019
  }
4406
- const exportBox = new BoxRenderable4(renderer, {
5020
+ const exportBox = new BoxRenderable5(renderer, {
4407
5021
  visible: false,
4408
5022
  flexGrow: 1,
4409
5023
  backgroundColor: colors.bg,
@@ -4440,28 +5054,28 @@ async function launchBrowser(theme_override) {
4440
5054
  });
4441
5055
  exportBox.add(exportSelect);
4442
5056
  root.add(exportBox);
4443
- const compareBox = new BoxRenderable4(renderer, {
5057
+ const compareBox = new BoxRenderable5(renderer, {
4444
5058
  visible: false,
4445
5059
  flexGrow: 1,
4446
5060
  backgroundColor: colors.bg,
4447
5061
  title: " Comparison ",
4448
5062
  titleColor: colors.yellow
4449
5063
  });
4450
- const compareText = new TextRenderable4(renderer, {
5064
+ const compareText = new TextRenderable5(renderer, {
4451
5065
  content: "",
4452
5066
  fg: colors.text,
4453
5067
  bg: colors.bg
4454
5068
  });
4455
5069
  compareBox.add(compareText);
4456
5070
  root.add(compareBox);
4457
- const notifsBox = new BoxRenderable4(renderer, {
5071
+ const notifsBox = new BoxRenderable5(renderer, {
4458
5072
  visible: false,
4459
5073
  flexGrow: 1,
4460
5074
  backgroundColor: colors.bg,
4461
5075
  title: " Notifications ",
4462
5076
  titleColor: colors.yellow
4463
5077
  });
4464
- const notifsText = new TextRenderable4(renderer, {
5078
+ const notifsText = new TextRenderable5(renderer, {
4465
5079
  content: "(no notifications)",
4466
5080
  fg: colors.text,
4467
5081
  bg: colors.bg
@@ -4476,7 +5090,7 @@ async function launchBrowser(theme_override) {
4476
5090
  notifsText.content = notifs2.map((n) => ` ${n.icon} ${n.message}`).join("\n");
4477
5091
  }
4478
5092
  }
4479
- const shareBox = new BoxRenderable4(renderer, {
5093
+ const shareBox = new BoxRenderable5(renderer, {
4480
5094
  visible: false,
4481
5095
  flexGrow: 1,
4482
5096
  backgroundColor: colors.bg,
@@ -4535,7 +5149,7 @@ async function launchBrowser(theme_override) {
4535
5149
  }
4536
5150
  savedSelect.setSelectedIndex(0);
4537
5151
  }
4538
- const leaderDim = new BoxRenderable4(renderer, {
5152
+ const leaderDim = new BoxRenderable5(renderer, {
4539
5153
  visible: false,
4540
5154
  position: "absolute",
4541
5155
  top: 0,
@@ -4546,7 +5160,7 @@ async function launchBrowser(theme_override) {
4546
5160
  // 60% opacity black (hex alpha: 0x99 ≈ 153/255)
4547
5161
  });
4548
5162
  root.add(leaderDim);
4549
- const leaderBox = new BoxRenderable4(renderer, {
5163
+ const leaderBox = new BoxRenderable5(renderer, {
4550
5164
  visible: false,
4551
5165
  position: "absolute",
4552
5166
  width: "80%",
@@ -4578,7 +5192,7 @@ async function launchBrowser(theme_override) {
4578
5192
  selectedDescriptionColor: colors.selectionText,
4579
5193
  itemSpacing: 0
4580
5194
  });
4581
- const leaderFooter = new TextRenderable4(renderer, {
5195
+ const leaderFooter = new TextRenderable5(renderer, {
4582
5196
  content: " \u2191\u2193 select Enter run Esc/q close ",
4583
5197
  fg: colors.muted,
4584
5198
  bg: colors.surfaceDim,
@@ -4589,7 +5203,7 @@ async function launchBrowser(theme_override) {
4589
5203
  leaderBox.add(leaderFooter);
4590
5204
  root.add(leaderDim);
4591
5205
  root.add(leaderBox);
4592
- const updateDim = new BoxRenderable4(renderer, {
5206
+ const updateDim = new BoxRenderable5(renderer, {
4593
5207
  visible: false,
4594
5208
  position: "absolute",
4595
5209
  top: 0,
@@ -4599,7 +5213,7 @@ async function launchBrowser(theme_override) {
4599
5213
  backgroundColor: "#00000088"
4600
5214
  });
4601
5215
  root.add(updateDim);
4602
- const updateBox = new BoxRenderable4(renderer, {
5216
+ const updateBox = new BoxRenderable5(renderer, {
4603
5217
  visible: false,
4604
5218
  position: "absolute",
4605
5219
  width: "55%",
@@ -4618,11 +5232,19 @@ async function launchBrowser(theme_override) {
4618
5232
  paddingLeft: 2,
4619
5233
  paddingRight: 2
4620
5234
  });
4621
- const updateText = new TextRenderable4(renderer, {
5235
+ const updateHeader = new TextRenderable5(renderer, {
4622
5236
  content: "",
4623
5237
  fg: colors.accent,
4624
5238
  bg: colors.surfaceDim
4625
5239
  });
5240
+ const updateNotes = createMarkdownView({
5241
+ renderer,
5242
+ colors,
5243
+ // Notes ship with the release and are mostly prose/lists; skip the
5244
+ // image pipeline in this narrow panel.
5245
+ images: false,
5246
+ background: colors.surfaceDim
5247
+ });
4626
5248
  const updateScroll = new ScrollBoxRenderable3(renderer, {
4627
5249
  flexGrow: 1,
4628
5250
  backgroundColor: colors.surfaceDim,
@@ -4642,14 +5264,14 @@ async function launchBrowser(theme_override) {
4642
5264
  }
4643
5265
  });
4644
5266
  let updateSelectedOption = 0;
4645
- const updateOptionsRow = new BoxRenderable4(renderer, {
5267
+ const updateOptionsRow = new BoxRenderable5(renderer, {
4646
5268
  flexDirection: "row",
4647
5269
  gap: 1,
4648
5270
  backgroundColor: colors.surfaceDim,
4649
5271
  height: 3,
4650
5272
  width: "100%"
4651
5273
  });
4652
- const updateNowBox = new BoxRenderable4(renderer, {
5274
+ const updateNowBox = new BoxRenderable5(renderer, {
4653
5275
  border: true,
4654
5276
  borderColor: updateSelectedOption === 0 ? colors.yellow : colors.border,
4655
5277
  backgroundColor: colors.surfaceDim,
@@ -4658,13 +5280,13 @@ async function launchBrowser(theme_override) {
4658
5280
  paddingLeft: 1,
4659
5281
  paddingRight: 1
4660
5282
  });
4661
- const updateNowText = new TextRenderable4(renderer, {
5283
+ const updateNowText = new TextRenderable5(renderer, {
4662
5284
  content: " Update Now ",
4663
5285
  fg: updateSelectedOption === 0 ? colors.yellow : colors.text,
4664
5286
  bg: colors.surfaceDim
4665
5287
  });
4666
5288
  updateNowBox.add(updateNowText);
4667
- const laterBox = new BoxRenderable4(renderer, {
5289
+ const laterBox = new BoxRenderable5(renderer, {
4668
5290
  border: true,
4669
5291
  borderColor: updateSelectedOption === 1 ? colors.yellow : colors.border,
4670
5292
  backgroundColor: colors.surfaceDim,
@@ -4673,13 +5295,13 @@ async function launchBrowser(theme_override) {
4673
5295
  paddingLeft: 1,
4674
5296
  paddingRight: 1
4675
5297
  });
4676
- const laterText = new TextRenderable4(renderer, {
5298
+ const laterText = new TextRenderable5(renderer, {
4677
5299
  content: " Later",
4678
5300
  fg: updateSelectedOption === 1 ? colors.yellow : colors.text,
4679
5301
  bg: colors.surfaceDim
4680
5302
  });
4681
5303
  laterBox.add(laterText);
4682
- const neverBox = new BoxRenderable4(renderer, {
5304
+ const neverBox = new BoxRenderable5(renderer, {
4683
5305
  border: true,
4684
5306
  borderColor: updateSelectedOption === 2 ? colors.yellow : colors.border,
4685
5307
  backgroundColor: colors.surfaceDim,
@@ -4688,7 +5310,7 @@ async function launchBrowser(theme_override) {
4688
5310
  paddingLeft: 1,
4689
5311
  paddingRight: 1
4690
5312
  });
4691
- const neverText = new TextRenderable4(renderer, {
5313
+ const neverText = new TextRenderable5(renderer, {
4692
5314
  content: " Don't show again",
4693
5315
  fg: updateSelectedOption === 2 ? colors.yellow : colors.text,
4694
5316
  bg: colors.surfaceDim
@@ -4705,7 +5327,7 @@ async function launchBrowser(theme_override) {
4705
5327
  }
4706
5328
  updateOptionsRow.add(updateNowBox);
4707
5329
  updateOptionsRow.add(laterBox);
4708
- const updateFooter = new TextRenderable4(renderer, {
5330
+ const updateFooter = new TextRenderable5(renderer, {
4709
5331
  content: " \u2191\u2193/jk/PgUp/PgDn scroll \u2190\u2192 select Enter Esc/q close",
4710
5332
  fg: colors.muted,
4711
5333
  bg: colors.surfaceDim,
@@ -4713,7 +5335,8 @@ async function launchBrowser(theme_override) {
4713
5335
  paddingX: 1
4714
5336
  });
4715
5337
  updateBox.add(updateScroll);
4716
- updateScroll.add(updateText);
5338
+ updateScroll.add(updateHeader);
5339
+ updateScroll.add(updateNotes.renderable);
4717
5340
  updateBox.add(updateOptionsRow);
4718
5341
  updateBox.add(updateFooter);
4719
5342
  root.add(updateDim);
@@ -5061,15 +5684,12 @@ async function launchBrowser(theme_override) {
5061
5684
  if (!state.lastInstalledVersion) return;
5062
5685
  if (state.lastInstalledVersion === currentVersion) return;
5063
5686
  const notes = fetchReleaseNotes(currentVersion);
5064
- const notesWidth = Math.max(
5065
- 30,
5066
- Math.floor((process.stdout.columns || 80) * 0.55) - 8
5067
- );
5068
- const notesBlock = notes ? renderMarkdown(notes, { width: notesWidth }) : " No release notes found for this version.";
5069
5687
  updateSelectedOption = 0;
5070
- updateText.content = ` Updated ghfind ${state.lastInstalledVersion} \u2192 ${currentVersion}
5071
-
5072
- ${notesBlock}`;
5688
+ updateHeader.content = ` Updated ghfind ${state.lastInstalledVersion} \u2192 ${currentVersion}
5689
+ `;
5690
+ updateNotes.setContent(
5691
+ notes ?? "_No release notes found for this version._"
5692
+ );
5073
5693
  renderUpdateOptions();
5074
5694
  showOverlay("update");
5075
5695
  }
@@ -5084,22 +5704,12 @@ ${notesBlock}`;
5084
5704
  if (latest) {
5085
5705
  updateSelectedOption = 0;
5086
5706
  const notes = fetchReleaseNotes(latest);
5087
- const notesWidth = Math.max(
5088
- 30,
5089
- Math.floor((process.stdout.columns || 80) * 0.55) - 8
5090
- );
5091
- const notesBlock = notes ? renderMarkdown(notes, { width: notesWidth }) : ` A new version is available
5092
-
5093
- Current: ${currentVersion}
5094
- Latest: ${latest}
5095
-
5096
- Run "npm install -g ghfind"`;
5097
- updateText.content = ` A new version is available
5707
+ updateHeader.content = ` A new version is available
5098
5708
 
5099
5709
  Current: ${currentVersion}
5100
5710
  Latest: ${latest}
5101
-
5102
- ${notesBlock}`;
5711
+ `;
5712
+ updateNotes.setContent(notes ?? "Run `npm install -g ghfind` to update.");
5103
5713
  renderUpdateOptions();
5104
5714
  showOverlay("update");
5105
5715
  } else {
@@ -5182,7 +5792,7 @@ ${notesBlock}`;
5182
5792
  renderer.requestRender();
5183
5793
  }
5184
5794
  }
5185
- const orgBox = new BoxRenderable4(renderer, {
5795
+ const orgBox = new BoxRenderable5(renderer, {
5186
5796
  visible: false,
5187
5797
  flexGrow: 1,
5188
5798
  backgroundColor: colors.surface,
@@ -5205,14 +5815,14 @@ ${notesBlock}`;
5205
5815
  }
5206
5816
  }
5207
5817
  });
5208
- const orgText = new TextRenderable4(renderer, {
5818
+ const orgText = new TextRenderable5(renderer, {
5209
5819
  content: "",
5210
5820
  fg: colors.text,
5211
5821
  bg: colors.bg
5212
5822
  });
5213
5823
  orgScroll.add(orgText);
5214
5824
  orgBox.add(orgScroll);
5215
- const orgFooter = new TextRenderable4(renderer, {
5825
+ const orgFooter = new TextRenderable5(renderer, {
5216
5826
  content: " \u2191\u2193/jk scroll Esc/q close",
5217
5827
  fg: colors.muted,
5218
5828
  bg: colors.bg,
@@ -5278,7 +5888,7 @@ ${notesBlock}`;
5278
5888
  }
5279
5889
  renderer.requestRender();
5280
5890
  }
5281
- const readmeBox = new BoxRenderable4(renderer, {
5891
+ const readmeBox = new BoxRenderable5(renderer, {
5282
5892
  visible: false,
5283
5893
  flexGrow: 1,
5284
5894
  backgroundColor: colors.surface,
@@ -5301,15 +5911,17 @@ ${notesBlock}`;
5301
5911
  }
5302
5912
  }
5303
5913
  });
5304
- const readmeText = new TextRenderable4(renderer, {
5305
- content: "",
5306
- fg: colors.text,
5307
- bg: colors.bg
5914
+ const readmeView = createMarkdownView({
5915
+ renderer,
5916
+ colors,
5917
+ loadImage: (url) => fetchImageBytes(url, { token: githubToken }),
5918
+ getImageWidth: () => Math.max(24, (process.stdout.columns || 80) - 12)
5308
5919
  });
5309
- readmeScroll.add(readmeText);
5920
+ readmeScroll.add(readmeView.renderable);
5310
5921
  readmeBox.add(readmeScroll);
5311
- const readmeFooter = new TextRenderable4(renderer, {
5312
- content: " \u2191\u2193/jk scroll Esc/q close",
5922
+ const readmeFooterText = () => ` \u2191\u2193/jk scroll PgUp/PgDn page i images: ${readmeView.imagesEnabled ? "on" : "off"} Esc/q close`;
5923
+ const readmeFooter = new TextRenderable5(renderer, {
5924
+ content: readmeFooterText(),
5313
5925
  fg: colors.muted,
5314
5926
  bg: colors.bg,
5315
5927
  height: 1,
@@ -5324,37 +5936,25 @@ ${notesBlock}`;
5324
5936
  setStatus("No repo selected");
5325
5937
  return;
5326
5938
  }
5327
- readmeText.content = ` Loading README for ${repo.fullName}...`;
5939
+ readmeView.setContent(`Loading README for ${repo.fullName}\u2026`);
5940
+ readmeFooter.content = readmeFooterText();
5941
+ readmeScroll.scrollTop = 0;
5328
5942
  showOverlay("readme");
5329
5943
  try {
5330
- const headers = { "User-Agent": "ghfind/1.0" };
5331
- if (githubToken) headers.Authorization = `Bearer ${githubToken}`;
5332
- let text = "";
5333
- for (const path of [
5334
- `${repo.owner}/${repo.name}/main/README.md`,
5335
- `${repo.owner}/${repo.name}/master/README.md`,
5336
- `${repo.owner}/${repo.name}/main/README.rst`
5337
- ]) {
5338
- const r = await fetch(`https://raw.githubusercontent.com/${path}`, {
5339
- headers
5340
- });
5341
- if (r.ok) {
5342
- text = await r.text();
5343
- break;
5344
- }
5345
- }
5346
- if (!text) {
5347
- readmeText.content = ` (no README found for ${repo.fullName})`;
5944
+ const readme = await fetchReadme(repo.owner, repo.name, {
5945
+ token: githubToken
5946
+ });
5947
+ if (readme) {
5948
+ readmeView.setContent(readme.text, readme.sourceUrl);
5348
5949
  } else {
5349
- const termW = Math.max(40, (process.stdout.columns || 80) - 4);
5350
- readmeText.content = renderMarkdown(text, { width: termW });
5950
+ readmeView.setContent(`_(no README found for ${repo.fullName})_`);
5351
5951
  }
5352
5952
  } catch {
5353
- readmeText.content = " Failed to load README";
5953
+ readmeView.setContent("_Failed to load README._");
5354
5954
  }
5355
5955
  renderer.requestRender();
5356
5956
  }
5357
- const statusBar = new TextRenderable4(renderer, {
5957
+ const statusBar = new TextRenderable5(renderer, {
5358
5958
  content: " Ready. Press Enter to search.",
5359
5959
  fg: colors.muted,
5360
5960
  height: 1,
@@ -5835,6 +6435,17 @@ ${notesBlock}`;
5835
6435
  doSearch(searchInput.value);
5836
6436
  }
5837
6437
  });
6438
+ searchInput.on("input", () => {
6439
+ const v = searchInput.value;
6440
+ if (v.length > 0) {
6441
+ const t3 = v.trim();
6442
+ if (t3.startsWith("{") && t3.endsWith("}") || t3.startsWith("[") && t3.endsWith("]")) {
6443
+ searchInput.value = "";
6444
+ setStatus("Ignored JSON paste \u2014 search queries accept plain text");
6445
+ renderer.requestRender();
6446
+ }
6447
+ }
6448
+ });
5838
6449
  resultsSelect.on("selectionChanged", () => {
5839
6450
  const opt = resultsSelect.getSelectedOption();
5840
6451
  if (currentMode === "packages") {
@@ -5917,6 +6528,14 @@ ${pack.description ?? ""}`;
5917
6528
  return;
5918
6529
  }
5919
6530
  if (currentOverlay === "readme") {
6531
+ if (key.name === "i") {
6532
+ readmeView.setImagesEnabled(!readmeView.imagesEnabled);
6533
+ readmeFooter.content = readmeFooterText();
6534
+ setStatus(
6535
+ readmeView.imagesEnabled ? "README images on" : "README images off"
6536
+ );
6537
+ renderer.requestRender();
6538
+ }
5920
6539
  return;
5921
6540
  }
5922
6541
  if (currentOverlay === "history") {
@@ -6350,6 +6969,12 @@ reported in text output and via the error message.
6350
6969
  ghfind deep-dive owner/repo --json
6351
6970
  Text output (no flag) prints the same sections formatted for the terminal.
6352
6971
 
6972
+ ## Finding skills
6973
+ ghfind skill search QUERY # skills installed on this machine
6974
+ ghfind skill search QUERY --remote # the public skills.sh ecosystem
6975
+ Results carry path+description (local) or install command (registry, read-only --
6976
+ nothing is installed). The MCP server exposes the same search as
6977
+ ghfind_skill_search.
6353
6978
  ## Rate limits
6354
6979
 
6355
6980
  60 req/hr unauthenticated, 5,000 with GITHUB_TOKEN (env var or \`ghfind login\`).
@@ -6361,6 +6986,14 @@ Bookmarks live in ghfind's local state, not GitHub. An agent can read the
6361
6986
  release feed with:
6362
6987
  ghfind --releases --json
6363
6988
  Marking releases seen and editing bookmarks is a TUI action; skip it.
6989
+ ## Local state (read-only)
6990
+ ghfind bookmarks [QUERY] --json # saved repos, newest first
6991
+ ghfind history [QUERY] --json # past searches
6992
+ ghfind saved --json # named searches
6993
+ ghfind topics --json # popular GitHub topics
6994
+ ghfind readme owner/repo # print a repo README
6995
+ ghfind share owner/repo --as gh-cli|markdown|plain|short
6996
+ These never write local state; skip them when nothing needs reading.
6364
6997
 
6365
6998
  ## Token efficiency
6366
6999
 
@@ -6400,6 +7033,233 @@ function formatSkillCatalog() {
6400
7033
  ].join("\n");
6401
7034
  }
6402
7035
 
7036
+ // src/skill-finder.ts
7037
+ import { readdirSync, readFileSync as readFileSync6 } from "fs";
7038
+ import { homedir as homedir3 } from "os";
7039
+ import { delimiter, join as join6 } from "path";
7040
+ var REGISTRY_SEARCH_ENDPOINT = "https://skills.sh/api/search";
7041
+ var MIN_QUERY_LENGTH = 2;
7042
+ var MAX_SKILL_RESULTS = 100;
7043
+ function displayPath(path) {
7044
+ const home = homedir3();
7045
+ const normalized = path.replace(/\\/g, "/");
7046
+ const homeNorm = home.replace(/\\/g, "/").replace(/\/$/, "");
7047
+ if (normalized === homeNorm) return "~";
7048
+ if (normalized.startsWith(`${homeNorm}/`)) {
7049
+ return `~${normalized.slice(homeNorm.length)}`;
7050
+ }
7051
+ return normalized;
7052
+ }
7053
+ function defaultSkillRoots(cwd = process.cwd()) {
7054
+ const home = homedir3();
7055
+ return [
7056
+ join6(home, ".codex", "skills"),
7057
+ join6(home, ".agents", "skills"),
7058
+ join6(cwd, ".codex", "skills"),
7059
+ join6(cwd, ".github", "skills")
7060
+ ];
7061
+ }
7062
+ function resolveSkillRoots(cwd = process.cwd()) {
7063
+ const override = process.env.GHFIND_SKILL_ROOTS;
7064
+ const paths = override?.trim() ? override.split(delimiter).filter((p) => p.trim()).map((p) => p.trim()) : defaultSkillRoots(cwd);
7065
+ return paths.map((path) => ({ path, label: displayPath(path) }));
7066
+ }
7067
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git"]);
7068
+ var MAX_DEPTH = 4;
7069
+ function unquote(value) {
7070
+ const trimmed = value.trim();
7071
+ if (trimmed.length >= 2 && (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'"))) {
7072
+ return trimmed.slice(1, -1).trim();
7073
+ }
7074
+ return trimmed;
7075
+ }
7076
+ function parseSkillFrontmatter(text) {
7077
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
7078
+ if (!match) return null;
7079
+ const lines = match[1].split(/\r?\n/);
7080
+ const fields = {};
7081
+ for (let i = 0; i < lines.length; i++) {
7082
+ const field = /^([A-Za-z_][\w-]*):[ \t]*(.*)$/.exec(lines[i]);
7083
+ if (!field) continue;
7084
+ const key = field[1].toLowerCase();
7085
+ const inline = field[2].trim();
7086
+ if (inline === ">" || inline === "|" || /^[>|][-+]?$/.test(inline)) {
7087
+ const literal = inline.startsWith("|");
7088
+ const parts = [];
7089
+ while (i + 1 < lines.length && /^[ \t]+\S/.test(lines[i + 1])) {
7090
+ parts.push(lines[++i].trim());
7091
+ }
7092
+ fields[key] = literal ? parts.join("\n") : parts.join(" ");
7093
+ } else {
7094
+ fields[key] = unquote(inline);
7095
+ }
7096
+ }
7097
+ const name = fields.name ?? "";
7098
+ if (!name) return null;
7099
+ return { name, description: fields.description ?? "" };
7100
+ }
7101
+ function scanLocalSkills(roots = resolveSkillRoots()) {
7102
+ const byName = /* @__PURE__ */ new Map();
7103
+ for (const root of roots) {
7104
+ for (const dir of walkDirs(root.path)) {
7105
+ const entries = listDir(dir);
7106
+ if (!entries) continue;
7107
+ if (!entries.some((e) => e.isFile() && e.name === "SKILL.md")) continue;
7108
+ const file = join6(dir, "SKILL.md");
7109
+ try {
7110
+ const parsed = parseSkillFrontmatter(readFileSync6(file, "utf-8"));
7111
+ if (!parsed || byName.has(parsed.name)) continue;
7112
+ byName.set(parsed.name, {
7113
+ name: parsed.name,
7114
+ description: parsed.description.replace(/\s+/g, " ").trim(),
7115
+ path: file,
7116
+ origin: root.label
7117
+ });
7118
+ } catch {
7119
+ }
7120
+ }
7121
+ }
7122
+ return [...byName.values()];
7123
+ }
7124
+ function listDir(dir) {
7125
+ try {
7126
+ return readdirSync(dir, { withFileTypes: true });
7127
+ } catch {
7128
+ return null;
7129
+ }
7130
+ }
7131
+ function* walkDirs(root) {
7132
+ const queue = [[root, 0]];
7133
+ while (queue.length > 0) {
7134
+ const [dir, depth] = queue.shift();
7135
+ const entries = listDir(dir);
7136
+ if (!entries) continue;
7137
+ yield dir;
7138
+ if (depth >= MAX_DEPTH) continue;
7139
+ for (const entry of entries) {
7140
+ if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
7141
+ queue.push([join6(dir, entry.name), depth + 1]);
7142
+ }
7143
+ }
7144
+ }
7145
+ function localMatchTier(hit, needle) {
7146
+ const name = hit.name.toLowerCase();
7147
+ if (name === needle) return 0;
7148
+ if (name.startsWith(needle)) return 1;
7149
+ if (name.includes(needle)) return 2;
7150
+ if (hit.description.toLowerCase().includes(needle)) return 3;
7151
+ return null;
7152
+ }
7153
+ function rankLocalSkills(hits, query, limit = MAX_SKILL_RESULTS) {
7154
+ const needle = query.trim().toLowerCase();
7155
+ return hits.map((hit) => ({ hit, tier: localMatchTier(hit, needle) })).filter(
7156
+ (entry) => entry.tier !== null
7157
+ ).sort((a, b) => a.tier - b.tier || a.hit.name.localeCompare(b.hit.name)).slice(0, clampLimit(limit)).map((entry) => entry.hit);
7158
+ }
7159
+ function clampLimit(limit) {
7160
+ const n = Math.trunc(Number(limit));
7161
+ if (!Number.isFinite(n)) return MAX_SKILL_RESULTS;
7162
+ return Math.min(Math.max(n, 1), MAX_SKILL_RESULTS);
7163
+ }
7164
+ function assertSearchableQuery(query) {
7165
+ const trimmed = query.trim();
7166
+ if (trimmed.length < MIN_QUERY_LENGTH) {
7167
+ throw new Error(
7168
+ `Query must be at least ${MIN_QUERY_LENGTH} characters (got "${trimmed}").`
7169
+ );
7170
+ }
7171
+ return trimmed;
7172
+ }
7173
+ function searchLocalSkills(query, opts = {}) {
7174
+ const trimmed = assertSearchableQuery(query);
7175
+ const skills = rankLocalSkills(
7176
+ scanLocalSkills(opts.roots ?? resolveSkillRoots()),
7177
+ trimmed,
7178
+ opts.limit ?? MAX_SKILL_RESULTS
7179
+ );
7180
+ return { query: trimmed, source: "local", count: skills.length, skills };
7181
+ }
7182
+ function normalizeRegistrySkill(raw) {
7183
+ if (typeof raw !== "object" || raw === null) return null;
7184
+ const obj = raw;
7185
+ const source = typeof obj.source === "string" ? obj.source : "";
7186
+ const skillId = typeof obj.skillId === "string" ? obj.skillId : "";
7187
+ if (!source || !skillId) return null;
7188
+ const name = typeof obj.name === "string" && obj.name.trim() ? obj.name : skillId;
7189
+ const installs = typeof obj.installs === "number" ? obj.installs : 0;
7190
+ const id = typeof obj.id === "string" && obj.id ? obj.id : `${source}/${skillId}`;
7191
+ return {
7192
+ id,
7193
+ source,
7194
+ skillId,
7195
+ name,
7196
+ installs,
7197
+ installCommand: `npx skills add ${source}@${skillId}`,
7198
+ url: `https://skills.sh/${source}/${skillId}`
7199
+ };
7200
+ }
7201
+ async function searchRegistrySkills(query, opts = {}) {
7202
+ const trimmed = assertSearchableQuery(query);
7203
+ const limit = clampLimit(opts.limit ?? MAX_SKILL_RESULTS);
7204
+ const fetchImpl = opts.fetchImpl ?? fetch;
7205
+ const url = `${REGISTRY_SEARCH_ENDPOINT}?q=${encodeURIComponent(trimmed)}&limit=${limit}`;
7206
+ let res;
7207
+ try {
7208
+ res = await fetchImpl(url, {
7209
+ headers: { Accept: "application/json", "User-Agent": "ghfind/1.0" }
7210
+ });
7211
+ } catch (err) {
7212
+ throw new Error(
7213
+ `Could not reach skills.sh: ${err instanceof Error ? err.message : String(err)}`
7214
+ );
7215
+ }
7216
+ if (!res.ok) {
7217
+ throw new Error(
7218
+ `skills.sh returned HTTP ${res.status} for "${trimmed}". Retry, or search locally with --local.`
7219
+ );
7220
+ }
7221
+ let payload;
7222
+ try {
7223
+ payload = await res.json();
7224
+ } catch {
7225
+ throw new Error("skills.sh returned a response that was not JSON.");
7226
+ }
7227
+ const items = payload?.skills ?? [];
7228
+ const skills = items.map(normalizeRegistrySkill).filter((s) => s !== null).slice(0, limit);
7229
+ return { query: trimmed, source: "registry", count: skills.length, skills };
7230
+ }
7231
+ async function searchSkills(query, opts = {}) {
7232
+ if ((opts.source ?? "local") === "registry") {
7233
+ return searchRegistrySkills(query, opts);
7234
+ }
7235
+ return searchLocalSkills(query, opts);
7236
+ }
7237
+ function formatSkillSearchJson(result) {
7238
+ return JSON.stringify(result, null, 2);
7239
+ }
7240
+ function shortDescription(description, width = 100) {
7241
+ if (description.length <= width) return description;
7242
+ return `${description.slice(0, width - 1).trimEnd()}\u2026`;
7243
+ }
7244
+ function formatSkillSearchText(result) {
7245
+ if (result.count === 0) {
7246
+ return result.source === "local" ? `No installed skills match "${result.query}". Try --remote to search the skills.sh ecosystem.` : `No skills.sh entries match "${result.query}".`;
7247
+ }
7248
+ if (result.source === "local") {
7249
+ return result.skills.map(
7250
+ (s) => `${s.name} (${s.origin})
7251
+ ${shortDescription(s.description)}`
7252
+ ).join("\n");
7253
+ }
7254
+ return result.skills.map(
7255
+ (s) => `${s.name} ${s.installs.toLocaleString()} installs [${s.source}]
7256
+ ${s.installCommand}`
7257
+ ).join("\n");
7258
+ }
7259
+ function formatSkillSearchNames(result) {
7260
+ return result.skills.map((s) => s.name).join("\n");
7261
+ }
7262
+
6403
7263
  // src/mcp-server.ts
6404
7264
  var SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
6405
7265
  var LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0];
@@ -6462,6 +7322,7 @@ function asMcpToolError(err) {
6462
7322
  }
6463
7323
  var TRENDING_QUERY = { keywords: [], qualifiers: [], raw: "trending" };
6464
7324
  var REPO_SORTS = ["best-match", "stars", "updated", "forks"];
7325
+ var SKILL_SOURCES = ["local", "registry"];
6465
7326
  var TRENDING_SINCE = ["daily", "weekly", "monthly"];
6466
7327
  var PKG_SORTS = ["best-match", "score", "downloads", "name"];
6467
7328
  var TOOLS = [
@@ -6806,6 +7667,52 @@ Not found: ${missing.join(", ")}` : "");
6806
7667
  data: { name: "ghfind-cli" }
6807
7668
  };
6808
7669
  }
7670
+ },
7671
+ {
7672
+ name: "ghfind_skill_search",
7673
+ title: "Search agent skills",
7674
+ description: "Search installed agent skills (SKILL.md files under the user's skill directories) or the public skills.sh ecosystem by keyword, and return matching skills with their descriptions. Read-only: it never installs anything - registry hits include the `npx skills add` command for the user to run deliberately. Use when the user asks to find a skill for a task, or asks what skills are available.",
7675
+ inputSchema: {
7676
+ type: "object",
7677
+ properties: {
7678
+ query: {
7679
+ type: "string",
7680
+ description: 'Keyword to match against skill names and descriptions, e.g. "changelog" or "testing" (2+ characters).'
7681
+ },
7682
+ source: {
7683
+ type: "string",
7684
+ description: "Where to search: 'local' for skills installed on this machine (default), 'registry' for the public skills.sh ecosystem.",
7685
+ enum: [...SKILL_SOURCES]
7686
+ },
7687
+ limit: {
7688
+ type: "number",
7689
+ description: "Max results, 1-100 (default 20).",
7690
+ minimum: 1,
7691
+ maximum: 100
7692
+ }
7693
+ },
7694
+ required: ["query"]
7695
+ },
7696
+ async run(args) {
7697
+ const source = coerceEnum(
7698
+ args.source,
7699
+ SKILL_SOURCES,
7700
+ "local"
7701
+ );
7702
+ const limit = coerceInt(args.limit, 20, 1, 100);
7703
+ const result = await searchSkills(
7704
+ String(args.query ?? ""),
7705
+ { source, limit }
7706
+ );
7707
+ const where = result.source === "local" ? "installed skills on this machine" : "the public skills.sh ecosystem";
7708
+ const header = `${result.count} match(es) for "${result.query}" in ${where}:`;
7709
+ return {
7710
+ text: `${header}
7711
+
7712
+ ${formatSkillSearchText(result)}`,
7713
+ data: { ...result }
7714
+ };
7715
+ }
6809
7716
  }
6810
7717
  ];
6811
7718
  function toolSummaries() {
@@ -6884,7 +7791,7 @@ async function processServerLine(line, state, config) {
6884
7791
  title: "ghfind \u2014 GitHub search for agents",
6885
7792
  version: getVersion()
6886
7793
  },
6887
- instructions: "ghfind gives agents GitHub search: repositories, trending, npm packages, org/user profiles, repo comparison and deep-dives. Call ghfind_skill for the full CLI guide. Unauthenticated requests are rate-limited to 60/hr; pass a token or set GITHUB_TOKEN for 5,000/hr."
7794
+ instructions: "ghfind gives agents GitHub search: repositories, trending, npm packages, org/user profiles, repo comparison and deep-dives. ghfind_skill_search finds agent skills, either installed on this machine or in the public skills.sh ecosystem. Call ghfind_skill for the full CLI guide. Unauthenticated requests are rate-limited to 60/hr; pass a token or set GITHUB_TOKEN for 5,000/hr."
6888
7795
  })
6889
7796
  );
6890
7797
  return;
@@ -6988,7 +7895,7 @@ async function runMcpServer(options = {}) {
6988
7895
  // src/cli.ts
6989
7896
  process.env.GHFIND_CLI_RUN = "1";
6990
7897
  var __dirname3 = dirname4(fileURLToPath4(import.meta.url));
6991
- var PKG_DIR3 = join6(__dirname3, "..");
7898
+ var PKG_DIR3 = join7(__dirname3, "..");
6992
7899
  function parseArgs(args) {
6993
7900
  const flags = {
6994
7901
  query: "",
@@ -7012,7 +7919,16 @@ function parseArgs(args) {
7012
7919
  registry: "npm",
7013
7920
  compare: [],
7014
7921
  doctor: false,
7015
- mcp: false
7922
+ mcp: false,
7923
+ bookmarks: false,
7924
+ history: false,
7925
+ topics: false,
7926
+ saved: false,
7927
+ remote: false,
7928
+ local: false,
7929
+ copy: false,
7930
+ raw: false,
7931
+ limitExplicit: false
7016
7932
  };
7017
7933
  const queryParts = [];
7018
7934
  for (let i = 0; i < args.length; i++) {
@@ -7069,6 +7985,7 @@ function parseArgs(args) {
7069
7985
  break;
7070
7986
  case "--limit": {
7071
7987
  const parsed = parseInt(args[++i], 10);
7988
+ flags.limitExplicit = true;
7072
7989
  if (Number.isNaN(parsed) || parsed < 1 || parsed > 100) {
7073
7990
  console.error(`Invalid limit: must be 1-100. Using default 50.`);
7074
7991
  flags.limit = 50;
@@ -7101,9 +8018,56 @@ function parseArgs(args) {
7101
8018
  case "--completion":
7102
8019
  flags.completion = args[++i];
7103
8020
  break;
8021
+ case "--remote":
8022
+ flags.remote = true;
8023
+ break;
8024
+ case "--local":
8025
+ flags.local = true;
8026
+ break;
8027
+ case "--copy":
8028
+ flags.copy = true;
8029
+ break;
8030
+ case "--raw":
8031
+ flags.raw = true;
8032
+ break;
8033
+ case "--as":
8034
+ flags.sharedAs = args[++i];
8035
+ break;
7104
8036
  case "mcp":
7105
8037
  flags.mcp = true;
7106
8038
  break;
8039
+ case "bookmarks":
8040
+ flags.bookmarks = true;
8041
+ break;
8042
+ case "history":
8043
+ flags.history = true;
8044
+ break;
8045
+ case "topics":
8046
+ flags.topics = true;
8047
+ break;
8048
+ case "saved":
8049
+ flags.saved = true;
8050
+ break;
8051
+ case "readme": {
8052
+ const next = args[i + 1];
8053
+ if (next && !next.startsWith("-")) {
8054
+ flags.readme = next;
8055
+ i++;
8056
+ } else {
8057
+ flags.readme = "";
8058
+ }
8059
+ break;
8060
+ }
8061
+ case "share": {
8062
+ const next = args[i + 1];
8063
+ if (next && !next.startsWith("-")) {
8064
+ flags.share = next;
8065
+ i++;
8066
+ } else {
8067
+ flags.share = "";
8068
+ }
8069
+ break;
8070
+ }
7107
8071
  case "deep-dive": {
7108
8072
  const next = args[i + 1];
7109
8073
  if (next && !next.startsWith("-")) {
@@ -7114,6 +8078,17 @@ function parseArgs(args) {
7114
8078
  }
7115
8079
  case "skill": {
7116
8080
  const next = args[i + 1];
8081
+ if (next === "search") {
8082
+ i++;
8083
+ const query = args[i + 1];
8084
+ if (query && !query.startsWith("-")) {
8085
+ flags.skillSearch = query;
8086
+ i++;
8087
+ } else {
8088
+ flags.skillSearch = "";
8089
+ }
8090
+ break;
8091
+ }
7117
8092
  if (next && !next.startsWith("-")) {
7118
8093
  flags.skill = next;
7119
8094
  i++;
@@ -7192,6 +8167,13 @@ Usage:
7192
8167
  ghfind pkg <query> Search npm packages, text list
7193
8168
  ghfind mcp Run the MCP server for AI agents (stdio)
7194
8169
  ghfind skill [name] Print the agent skill guide (or catalog)
8170
+ ghfind bookmarks [query] Saved repos, newest first
8171
+ ghfind history [query] Past searches, newest first
8172
+ ghfind topics Browse popular GitHub topics
8173
+ ghfind saved Named searches saved in the TUI
8174
+ ghfind readme <owner/repo> Print a repository README
8175
+ ghfind share a/b --as gh-cli Share snippet (--copy to clipboard)
8176
+ ghfind skill search <query> Find skills locally, or --remote
7195
8177
  ghfind --watch <query> Watch mode (poll every Ns)
7196
8178
  ghfind init Run setup wizard
7197
8179
  ghfind login Import the gh CLI token or paste one
@@ -7216,14 +8198,19 @@ Options:
7216
8198
  --releases New-release feed for bookmarks
7217
8199
  --interval <s> Watch interval in seconds (default: 300)
7218
8200
  --completion <shell> Generate completions
8201
+ --remote skill search: query skills.sh instead of this machine
8202
+ --local skill search: force the local scan (default)
8203
+ --as <fmt> share format: markdown|plain|gh-cli|short
8204
+ --copy share: also copy the snippet to the clipboard
8205
+ --raw readme: print the raw markdown with no header
7219
8206
  `);
7220
8207
  return;
7221
8208
  }
7222
8209
  if (flags.completion) {
7223
8210
  const shell = flags.completion;
7224
8211
  try {
7225
- const content = readFileSync6(
7226
- join6(PKG_DIR3, "completions", `ghfind.${shell}`),
8212
+ const content = readFileSync7(
8213
+ join7(PKG_DIR3, "completions", `ghfind.${shell}`),
7227
8214
  "utf-8"
7228
8215
  );
7229
8216
  console.log(content);
@@ -7245,10 +8232,38 @@ Options:
7245
8232
  await runMcpServer({ token: flags.token });
7246
8233
  return;
7247
8234
  }
8235
+ if (flags.skillSearch !== void 0) {
8236
+ await runSkillSearch(flags);
8237
+ return;
8238
+ }
7248
8239
  if (flags.skill !== void 0) {
7249
8240
  printAgentSkill(flags.skill);
7250
8241
  return;
7251
8242
  }
8243
+ if (flags.bookmarks) {
8244
+ runBookmarks(flags);
8245
+ return;
8246
+ }
8247
+ if (flags.history) {
8248
+ runHistory(flags);
8249
+ return;
8250
+ }
8251
+ if (flags.saved) {
8252
+ runSavedSearches(flags);
8253
+ return;
8254
+ }
8255
+ if (flags.topics) {
8256
+ await runTopics(flags);
8257
+ return;
8258
+ }
8259
+ if (flags.readme !== void 0) {
8260
+ await runReadme(flags);
8261
+ return;
8262
+ }
8263
+ if (flags.share !== void 0) {
8264
+ await runShare(flags);
8265
+ return;
8266
+ }
7252
8267
  if (flags.pkg) {
7253
8268
  const interactive = !flags.query && !flags.json && !flags.csv && !flags.markdown && !flags.count && !flags.format;
7254
8269
  if (interactive) {
@@ -7382,6 +8397,233 @@ async function runDeepDive(flags, outputFormat) {
7382
8397
  process.exit(1);
7383
8398
  }
7384
8399
  }
8400
+ async function runSkillSearch(flags) {
8401
+ const query = flags.skillSearch ?? "";
8402
+ if (!query.trim()) {
8403
+ console.error(
8404
+ "Usage: ghfind skill search <query> [--remote] [--limit <n>] [--json]"
8405
+ );
8406
+ process.exit(1);
8407
+ }
8408
+ if (flags.remote && flags.local) {
8409
+ console.error(
8410
+ "Contradictory flags: pass either --remote or --local, not both."
8411
+ );
8412
+ process.exit(1);
8413
+ }
8414
+ const source = flags.remote ? "registry" : "local";
8415
+ try {
8416
+ const result = await searchSkills(query, {
8417
+ source,
8418
+ limit: flags.limit
8419
+ });
8420
+ if (flags.json) {
8421
+ console.log(formatSkillSearchJson(result));
8422
+ } else if (flags.format === "names") {
8423
+ console.log(formatSkillSearchNames(result));
8424
+ } else {
8425
+ console.log(formatSkillSearchText(result));
8426
+ }
8427
+ } catch (err) {
8428
+ console.error(err instanceof Error ? err.message : String(err));
8429
+ process.exit(1);
8430
+ }
8431
+ }
8432
+ function runBookmarks(flags) {
8433
+ const query = flags.query.trim();
8434
+ const bookmarks = query ? searchBookmarks(query) : getBookmarks();
8435
+ if (flags.json) {
8436
+ console.log(JSON.stringify(bookmarks, null, 2));
8437
+ return;
8438
+ }
8439
+ if (bookmarks.length === 0) {
8440
+ console.log(
8441
+ query ? `No bookmarks match "${query}".` : "No bookmarks yet. Press Space \u2192 Bookmark in the TUI to save one."
8442
+ );
8443
+ return;
8444
+ }
8445
+ if (flags.csv || flags.markdown) {
8446
+ const header = ["full_name", "stars", "language", "tags", "saved_at"];
8447
+ const rows = bookmarks.map((b) => [
8448
+ b.repo.fullName,
8449
+ String(b.repo.stars),
8450
+ b.repo.language ?? "",
8451
+ b.tags.join(" "),
8452
+ new Date(b.savedAt).toISOString().slice(0, 10)
8453
+ ]);
8454
+ console.log(
8455
+ flags.csv ? rowsToCsv(header, rows) : rowsToMarkdown(header, rows)
8456
+ );
8457
+ return;
8458
+ }
8459
+ for (const b of bookmarks) {
8460
+ const tags = b.tags.length > 0 ? ` [${b.tags.join(", ")}]` : "";
8461
+ console.log(
8462
+ `${b.repo.fullName} \u2014 ${b.repo.stars.toLocaleString()} ${b.repo.language ?? "n/a"}${tags}`
8463
+ );
8464
+ }
8465
+ }
8466
+ function runHistory(flags) {
8467
+ const limit = flags.limitExplicit ? flags.limit : 20;
8468
+ const query = flags.query.trim().toLowerCase();
8469
+ const entries = readHistory().filter((e) => !query || e.query.toLowerCase().includes(query)).slice(0, limit);
8470
+ if (flags.json) {
8471
+ console.log(JSON.stringify(entries, null, 2));
8472
+ return;
8473
+ }
8474
+ if (entries.length === 0) {
8475
+ console.log(
8476
+ query ? `No history matches "${query}".` : "No search history yet."
8477
+ );
8478
+ return;
8479
+ }
8480
+ if (flags.csv || flags.markdown) {
8481
+ const header = ["query", "mode", "results", "when"];
8482
+ const rows = entries.map((e) => [
8483
+ e.query,
8484
+ e.mode,
8485
+ String(e.resultCount),
8486
+ new Date(e.timestamp).toISOString()
8487
+ ]);
8488
+ console.log(
8489
+ flags.csv ? rowsToCsv(header, rows) : rowsToMarkdown(header, rows)
8490
+ );
8491
+ return;
8492
+ }
8493
+ for (const e of entries) {
8494
+ const when = new Date(e.timestamp).toISOString().slice(0, 10);
8495
+ console.log(
8496
+ `${e.query} \u2014 ${e.mode} \u2014 ${e.resultCount} results \u2014 ${when}`
8497
+ );
8498
+ }
8499
+ }
8500
+ function runSavedSearches(flags) {
8501
+ const saved = getSavedSearches();
8502
+ if (flags.json) {
8503
+ console.log(JSON.stringify(saved, null, 2));
8504
+ return;
8505
+ }
8506
+ if (saved.length === 0) {
8507
+ console.log("No saved searches yet. Save one from the TUI command menu.");
8508
+ return;
8509
+ }
8510
+ if (flags.csv || flags.markdown) {
8511
+ const header = ["name", "query", "mode", "limit"];
8512
+ const rows = saved.map((s) => [s.name, s.query, s.mode, String(s.limit)]);
8513
+ console.log(
8514
+ flags.csv ? rowsToCsv(header, rows) : rowsToMarkdown(header, rows)
8515
+ );
8516
+ return;
8517
+ }
8518
+ for (const s of saved) {
8519
+ console.log(`${s.name} \u2014 ${s.query} \u2014 ${s.mode} \u2014 limit ${s.limit}`);
8520
+ }
8521
+ }
8522
+ function rowsToCsv(header, rows) {
8523
+ const quote = (value) => /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
8524
+ return [header, ...rows].map((row) => row.map(quote).join(",")).join("\n");
8525
+ }
8526
+ function rowsToMarkdown(header, rows) {
8527
+ return [
8528
+ `| ${header.join(" | ")} |`,
8529
+ `|${header.map(() => "---").join("|")}|`,
8530
+ ...rows.map((row) => `| ${row.join(" | ")} |`)
8531
+ ].join("\n");
8532
+ }
8533
+ function truncate2(text, width) {
8534
+ if (text.length <= width) return text;
8535
+ return `${text.slice(0, width - 1).trimEnd()}\u2026`;
8536
+ }
8537
+ function plainText(html) {
8538
+ return html.replace(/<\/p>|<br\s*\/?>/gi, " ").replace(/<[^>]*>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
8539
+ }
8540
+ async function runTopics(flags) {
8541
+ try {
8542
+ const topics = (await fetchTopics()).slice(0, flags.limit);
8543
+ if (flags.json) {
8544
+ console.log(JSON.stringify(topics, null, 2));
8545
+ return;
8546
+ }
8547
+ if (topics.length === 0) {
8548
+ console.log("No topics returned.");
8549
+ return;
8550
+ }
8551
+ for (const t3 of topics) {
8552
+ const description = truncate2(plainText(t3.description), 140);
8553
+ console.log(description ? `${t3.name} ${description}` : t3.name);
8554
+ }
8555
+ } catch (err) {
8556
+ console.error(err instanceof Error ? err.message : String(err));
8557
+ process.exit(1);
8558
+ }
8559
+ }
8560
+ async function runReadme(flags) {
8561
+ const ref = flags.readme ?? "";
8562
+ if (!ref) {
8563
+ console.error(
8564
+ "Usage: ghfind readme <owner/repo> [--json] [--raw] [--token <t>]"
8565
+ );
8566
+ process.exit(1);
8567
+ }
8568
+ try {
8569
+ const { owner, name } = repoFromRef(ref);
8570
+ const readme = await fetchReadme(owner, name, { token: flags.token });
8571
+ if (!readme) {
8572
+ console.error(`No README found for ${owner}/${name}.`);
8573
+ process.exit(1);
8574
+ }
8575
+ if (flags.json) {
8576
+ console.log(
8577
+ JSON.stringify(
8578
+ { owner, name, sourceUrl: readme.sourceUrl, text: readme.text },
8579
+ null,
8580
+ 2
8581
+ )
8582
+ );
8583
+ return;
8584
+ }
8585
+ if (!flags.raw) {
8586
+ console.log(`# ${owner}/${name} README (${readme.sourceUrl})`);
8587
+ console.log("");
8588
+ }
8589
+ console.log(readme.text);
8590
+ } catch (err) {
8591
+ console.error(err instanceof Error ? err.message : String(err));
8592
+ process.exit(1);
8593
+ }
8594
+ }
8595
+ async function runShare(flags) {
8596
+ const ref = flags.share ?? "";
8597
+ if (!ref) {
8598
+ console.error(
8599
+ "Usage: ghfind share <owner/repo> [--as markdown|plain|gh-cli|short] [--copy]"
8600
+ );
8601
+ process.exit(1);
8602
+ }
8603
+ const SHARE_FORMATS = ["markdown", "plain", "gh-cli", "short"];
8604
+ const as = flags.sharedAs ?? "markdown";
8605
+ if (!SHARE_FORMATS.includes(as)) {
8606
+ console.error(
8607
+ `Invalid --as value: "${flags.sharedAs}" (use markdown|plain|gh-cli|short).`
8608
+ );
8609
+ process.exit(1);
8610
+ }
8611
+ try {
8612
+ repoFromRef(ref);
8613
+ const repo = await resolveRepoFromRef(ref, flags.token);
8614
+ const text = formatShare(repo, as);
8615
+ console.log(text);
8616
+ if (flags.copy) {
8617
+ const copied = await copyToClipboard(text);
8618
+ console.error(
8619
+ copied ? "Copied to clipboard." : "Could not copy to the clipboard on this platform."
8620
+ );
8621
+ }
8622
+ } catch (err) {
8623
+ console.error(err instanceof Error ? err.message : String(err));
8624
+ process.exit(1);
8625
+ }
8626
+ }
7385
8627
  async function runPackageSearch(flags) {
7386
8628
  if (flags.registry !== "npm") {
7387
8629
  console.error(`Registry "${flags.registry}" not yet supported (only npm).`);