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.
package/src/search.ts CHANGED
@@ -181,7 +181,7 @@ export function suggestFor(input: string): string[] {
181
181
  return QUALIFIER_SUGGESTIONS[trimmed];
182
182
  }
183
183
  const matches = KNOWN_QUALIFIERS.filter((k) => k.startsWith(trimmed));
184
- return matches.map((k) => k + ":");
184
+ return matches.map((k) => `${k}:`);
185
185
  }
186
186
 
187
187
  export function tokenize(input: string): string[] {
@@ -329,7 +329,6 @@ export function githubSortParam(sort: SortStrategy): {
329
329
  return { sort: "forks", order: "desc" };
330
330
  case "updated":
331
331
  return { sort: "updated", order: "desc" };
332
- case "best-match":
333
332
  default:
334
333
  return {};
335
334
  }
@@ -359,7 +358,6 @@ export function rankRepos(repos: Repo[], strategy: SortStrategy): Repo[] {
359
358
  case "updated":
360
359
  copy.sort(byKey((r) => Date.parse(r.updatedAt)));
361
360
  break;
362
- case "best-match":
363
361
  default:
364
362
  break;
365
363
  }
@@ -473,7 +471,7 @@ export class GitHubSearchAdapter implements SearchAdapter {
473
471
  if (
474
472
  !parsed ||
475
473
  typeof parsed !== "object" ||
476
- !Array.isArray((parsed as any).items)
474
+ !Array.isArray((parsed as { items?: unknown }).items)
477
475
  ) {
478
476
  this.logger.error(
479
477
  `[github] unexpected response shape: ${JSON.stringify(parsed).slice(0, 200)}`,
@@ -630,6 +628,7 @@ function trendingRepoToRepo(r: RawTrendingRepo): Repo {
630
628
  */
631
629
  export class TrendingAdapter implements SearchAdapter {
632
630
  readonly name = "trending";
631
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: part of the adapter constructor contract — callers pass a shared logger (search.ts:757) even though this adapter currently logs nothing itself.
633
632
  private readonly logger: Logger;
634
633
 
635
634
  constructor(logger: Logger = noopLogger) {
package/src/share.ts CHANGED
@@ -19,7 +19,7 @@ export function formatShare(repo: Repo, format: ShareFormat): string {
19
19
  }
20
20
 
21
21
  /** Copy text to clipboard via platform command. */
22
- import { spawn } from "child_process";
22
+ import { spawn } from "node:child_process";
23
23
 
24
24
  export async function copyToClipboard(text: string): Promise<boolean> {
25
25
  try {
@@ -46,7 +46,7 @@ export async function copyToClipboard(text: string): Promise<boolean> {
46
46
  }
47
47
 
48
48
  async function findLinuxClipboard(): Promise<string[] | null> {
49
- const { execFileSync } = await import("child_process");
49
+ const { execFileSync } = await import("node:child_process");
50
50
  try {
51
51
  execFileSync("xclip", ["-version"], { stdio: "ignore" });
52
52
  return ["xclip", "-selection", "clipboard"];
package/src/storage.ts CHANGED
@@ -12,8 +12,8 @@ import {
12
12
  writeFileSync,
13
13
  appendFileSync,
14
14
  unlinkSync,
15
- } from "fs";
16
- import { join } from "path";
15
+ } from "node:fs";
16
+ import { join } from "node:path";
17
17
  import { stateDir } from "./config";
18
18
 
19
19
  /** Ensure the state directory exists. */
@@ -43,7 +43,7 @@ export function appendJSONL(filename: string, obj: unknown): void {
43
43
  try {
44
44
  appendFileSync(
45
45
  join(stateDir(), filename),
46
- JSON.stringify(obj) + "\n",
46
+ `${JSON.stringify(obj)}\n`,
47
47
  "utf-8",
48
48
  );
49
49
  } catch {
@@ -119,7 +119,7 @@ export const memoryStorage = {
119
119
  },
120
120
  appendJSONL(filename: string, obj: unknown): void {
121
121
  const existing = memStore.get(filename) ?? "";
122
- memStore.set(filename, existing + JSON.stringify(obj) + "\n");
122
+ memStore.set(filename, `${existing + JSON.stringify(obj)}\n`);
123
123
  },
124
124
  readJSONL<T>(filename: string): T[] {
125
125
  const raw = memStore.get(filename);
package/src/themes.ts CHANGED
@@ -242,6 +242,7 @@ export async function detectTerminalBackground(
242
242
  // Reply: ESC ] 11 ; <spec> (BEL | ESC \)
243
243
  const onData = (d: Buffer) => {
244
244
  buffer += d.toString("latin1");
245
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matches raw ANSI OSC-11 escape bytes, which are control characters by definition
245
246
  const m = buffer.match(/\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)/);
246
247
  if (m) finish(parseOscColor(m[1]));
247
248
  };
@@ -35,7 +35,7 @@ function parseRepoLink(block: string): RepoLink | undefined {
35
35
  // `/login?...&return_to=...` href fails the bare-path requirement and the
36
36
  // stargazers/forks links don't carry the heading's class="Link".
37
37
  const anchor = block.match(
38
- /<a[^>]*?href="\/([A-Za-z0-9._-]+)\/([^\"?#]+)"[^>]*class="Link"/,
38
+ /<a[^>]*?href="\/([A-Za-z0-9._-]+)\/([^"?#]+)"[^>]*class="Link"/,
39
39
  );
40
40
  if (!anchor) return undefined;
41
41
  return { owner: anchor[1], name: anchor[2] };
@@ -43,7 +43,7 @@ function parseRepoLink(block: string): RepoLink | undefined {
43
43
 
44
44
  function parseStars(block: string): number {
45
45
  const starsMatch = block.match(
46
- /href="\/[^\/\"]+\/[^\/\"]+\/stargazers"[^>]*>.*?<\/svg>\s*(\d[\d,]*)/,
46
+ /href="\/[^/"]+\/[^/"]+\/stargazers"[^>]*>.*?<\/svg>\s*(\d[\d,]*)/,
47
47
  );
48
48
  return starsMatch ? parseInt(starsMatch[1].replace(/,/g, ""), 10) : 0;
49
49
  }
@@ -69,10 +69,9 @@ export function parseTrendingHtml(html: string): RawTrendingRepo[] {
69
69
  const flat = flattenHtml(html);
70
70
  const repos: RawTrendingRepo[] = [];
71
71
  const articleRe = /<article class="Box-row">(.*?)<\/article>/gi;
72
- let match: RegExpExecArray | null;
73
72
  let rank = 0;
74
73
 
75
- while ((match = articleRe.exec(flat)) !== null) {
74
+ for (const match of flat.matchAll(articleRe)) {
76
75
  rank++;
77
76
  const block = match[1];
78
77
  const link = parseRepoLink(block);
package/src/trending.ts CHANGED
@@ -118,13 +118,13 @@ export function formatRepoLine(repo: Repo, rank: number): StyledText {
118
118
  const rankStr = String(rank).padStart(2, "0");
119
119
  const full = `${repo.owner}/${repo.name}`;
120
120
  const lc = langColor(repo.language ?? "");
121
- const rc = rankColor(rank);
121
+ const _rc = rankColor(rank);
122
122
  const star = "★";
123
123
  const arrow = repo.score > 0 ? "▲" : repo.score < 0 ? "▼" : "—";
124
124
  const growth = `${arrow} ${fmtSigned(repo.score)} today`;
125
125
  const desc =
126
126
  (repo.description ?? "").length > 60
127
- ? (repo.description ?? "").slice(0, 57) + "..."
127
+ ? `${(repo.description ?? "").slice(0, 57)}...`
128
128
  : (repo.description ?? "");
129
129
  const line1 = t`${dim(fg(C.rankBg)(`[${rankStr}]`))} ${bold(fg("#c0caf5")(full))} ${fg(lc)(`● ${repo.language}`)} ${fg(C.gold)(`${star} ${fmtStars(repo.stars)}`)} ${bold(fg(C.green)(growth))}`;
130
130
  const line2 = t` ${dim(fg(C.muted)(desc))}`;
@@ -182,7 +182,7 @@ export async function launchTrending(): Promise<void> {
182
182
  let currentPeriod = "this week";
183
183
  let repos: Repo[] = [];
184
184
  let selectedRepoIdx = 0;
185
- let isLoading = false;
185
+ let _isLoading = false;
186
186
  // ── Fetch from github.com/trending ──
187
187
  async function loadTab(index: number) {
188
188
  if (index < 0 || index >= TAB_NAMES.length) return;
@@ -194,7 +194,7 @@ export async function launchTrending(): Promise<void> {
194
194
  : since === "weekly"
195
195
  ? "this week"
196
196
  : "this month";
197
- isLoading = true;
197
+ _isLoading = true;
198
198
  for (const rb of rowBoxes) scrollBox.remove(rb);
199
199
  rowBoxes.length = 0;
200
200
  rowTexts.length = 0;
@@ -233,7 +233,7 @@ export async function launchTrending(): Promise<void> {
233
233
  });
234
234
  scrollBox.add(errText);
235
235
  }
236
- isLoading = false;
236
+ _isLoading = false;
237
237
  renderTabBar();
238
238
  renderer.requestRender();
239
239
  }
@@ -359,7 +359,7 @@ export async function launchTrending(): Promise<void> {
359
359
  const lc = langColor(r.language ?? "");
360
360
  const desc =
361
361
  (r.description ?? "").length > 55
362
- ? (r.description ?? "").slice(0, 52) + "..."
362
+ ? `${(r.description ?? "").slice(0, 52)}...`
363
363
  : (r.description ?? "");
364
364
  const arrow = r.score > 0 ? "▲" : r.score < 0 ? "▼" : "—";
365
365
  const growthStr = `${arrow} ${fmtStars(Math.abs(r.score))} ${currentPeriod}`;
@@ -373,7 +373,7 @@ export async function launchTrending(): Promise<void> {
373
373
  fg: rc,
374
374
  height: 1,
375
375
  });
376
- const descStr = desc.length > 60 ? desc.slice(0, 57) + "..." : desc;
376
+ const descStr = desc.length > 60 ? `${desc.slice(0, 57)}...` : desc;
377
377
  const pad = " ";
378
378
  const line1 = t`${bold(fg("#c0caf5")(nameStr))}${fg(lc)(langStr)}${fg(C.muted)(starsStr)}${bold(fg(C.green)(growthStr))}`;
379
379
  const line2 = t`${fg(C.descText)(`${pad}${descStr}`)}`;
package/src/tui.ts CHANGED
@@ -38,6 +38,8 @@ import type {
38
38
  SortStrategy,
39
39
  Package,
40
40
  SessionState,
41
+ HistoryEntry,
42
+ SavedSearch,
41
43
  } from "./types";
42
44
  import { fetchOrgProfile, type OrgProfile } from "./org";
43
45
  import {
@@ -54,13 +56,7 @@ import {
54
56
  import { tabSince, TAB_NAMES, fmtStars } from "./trending";
55
57
  import { loadConfig, saveConfig } from "./config";
56
58
  import { buildHelpSections, HELP_KEYS_COLUMN } from "./help";
57
- import {
58
- SearchCliError,
59
- NetworkError,
60
- RateLimitError,
61
- BadQueryError,
62
- NoResultsError,
63
- } from "./errors";
59
+ import { SearchCliError, NoResultsError } from "./errors";
64
60
  import {
65
61
  appendHistory,
66
62
  readHistory,
@@ -68,12 +64,7 @@ import {
68
64
  clearHistory,
69
65
  rotateHistory,
70
66
  } from "./history";
71
- import {
72
- getBookmarks,
73
- isBookmarked,
74
- toggleBookmark,
75
- removeBookmark,
76
- } from "./bookmarks";
67
+ import { getBookmarks, toggleBookmark, removeBookmark } from "./bookmarks";
77
68
  import {
78
69
  getSavedSearches,
79
70
  saveSearch,
@@ -83,8 +74,9 @@ import {
83
74
  import { saveSession, restoreSession } from "./session";
84
75
  import { fetchDeepDive, buildDeepDiveText } from "./deepdive";
85
76
  import { buildComparisonTable } from "./compare";
86
- import { renderMarkdown } from "./markdown-render";
87
- import { fetchTopics, type TopicItem } from "./explore";
77
+ import { createMarkdownView } from "./markdown-view";
78
+ import { fetchImageBytes, readmeBaseUrl } from "./image-loader";
79
+ import { fetchTopics } from "./explore";
88
80
  import { exportToFile, type ExportFormat } from "./output";
89
81
  import {
90
82
  listThemes,
@@ -269,8 +261,8 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
269
261
  let currentPage = 1;
270
262
  let totalCount = 0;
271
263
  let deepDiveActive = false;
272
- let compareList: Repo[] = [];
273
- let quitArmed = false;
264
+ const compareList: Repo[] = [];
265
+ const _quitArmed = false;
274
266
 
275
267
  const header = new TextRenderable(renderer, {
276
268
  content:
@@ -1088,12 +1080,22 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1088
1080
  paddingLeft: 2,
1089
1081
  paddingRight: 2,
1090
1082
  });
1091
- const updateText = new TextRenderable(renderer, {
1083
+ // Version header only — the release notes below it are rendered markdown.
1084
+ const updateHeader = new TextRenderable(renderer, {
1092
1085
  content: "",
1093
1086
  fg: colors.accent,
1094
1087
  bg: colors.surfaceDim,
1095
1088
  });
1096
- // ponytail: notes render as plain text with \n; keep it a column so lines stack
1089
+ const updateNotes = createMarkdownView({
1090
+ renderer,
1091
+ colors,
1092
+ // Notes ship with the release and are mostly prose/lists; skip the
1093
+ // image pipeline in this narrow panel.
1094
+ images: false,
1095
+ background: colors.surfaceDim,
1096
+ });
1097
+ // ponytail: the notes are markdown now; keep the content a column so
1098
+ // blocks stack and the scroll box can measure them.
1097
1099
  const updateScroll = new ScrollBoxRenderable(renderer, {
1098
1100
  flexGrow: 1,
1099
1101
  backgroundColor: colors.surfaceDim,
@@ -1196,7 +1198,8 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1196
1198
  paddingX: 1,
1197
1199
  });
1198
1200
  updateBox.add(updateScroll);
1199
- updateScroll.add(updateText);
1201
+ updateScroll.add(updateHeader);
1202
+ updateScroll.add(updateNotes.renderable);
1200
1203
  updateBox.add(updateOptionsRow);
1201
1204
  updateBox.add(updateFooter);
1202
1205
  root.add(updateDim);
@@ -1259,7 +1262,8 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1259
1262
  function popMenuLevel(): boolean {
1260
1263
  if (menuStack.length <= 1) return false;
1261
1264
  const prev = menuStack[menuStack.length - 2];
1262
- const current = menuStack.pop()!;
1265
+ const current = menuStack.pop();
1266
+ if (!current) return false;
1263
1267
  leaderSelect.options = prev.entries.map((e) => ({
1264
1268
  name:
1265
1269
  e.type === "category" ? ` ${e.icon} ${e.name} \u2192` : ` ${e.name}`,
@@ -1283,7 +1287,10 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1283
1287
  function buildMenuHierarchy(): MenuCategory[] {
1284
1288
  const hasRepo = (() => {
1285
1289
  const opt = resultsSelect.getSelectedOption();
1286
- return opt?.value && typeof (opt.value as any)?.fullName === "string";
1290
+ return (
1291
+ opt?.value &&
1292
+ typeof (opt.value as { fullName?: unknown })?.fullName === "string"
1293
+ );
1287
1294
  })();
1288
1295
 
1289
1296
  const groups: MenuCategory[] = [];
@@ -1602,17 +1609,11 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1602
1609
  if (state.lastInstalledVersion === currentVersion) return;
1603
1610
  // We just upgraded — show notes for the new version
1604
1611
  const notes = fetchReleaseNotes(currentVersion);
1605
- // Box is 55% of the terminal; subtract border + padding so wrapped
1606
- // lines never clip horizontally.
1607
- const notesWidth = Math.max(
1608
- 30,
1609
- Math.floor((process.stdout.columns || 80) * 0.55) - 8,
1610
- );
1611
- const notesBlock = notes
1612
- ? renderMarkdown(notes, { width: notesWidth })
1613
- : " No release notes found for this version.";
1614
1612
  updateSelectedOption = 0;
1615
- updateText.content = ` Updated ghfind ${state.lastInstalledVersion} → ${currentVersion}\n\n${notesBlock}`;
1613
+ updateHeader.content = ` Updated ghfind ${state.lastInstalledVersion} → ${currentVersion}\n`;
1614
+ updateNotes.setContent(
1615
+ notes ?? "_No release notes found for this version._",
1616
+ );
1616
1617
  renderUpdateOptions();
1617
1618
  showOverlay("update");
1618
1619
  }
@@ -1630,14 +1631,8 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1630
1631
  if (latest) {
1631
1632
  updateSelectedOption = 0;
1632
1633
  const notes = fetchReleaseNotes(latest);
1633
- const notesWidth = Math.max(
1634
- 30,
1635
- Math.floor((process.stdout.columns || 80) * 0.55) - 8,
1636
- );
1637
- const notesBlock = notes
1638
- ? renderMarkdown(notes, { width: notesWidth })
1639
- : ` A new version is available\n\n Current: ${currentVersion}\n Latest: ${latest}\n\n Run "npm install -g ghfind"`;
1640
- updateText.content = ` A new version is available\n\n Current: ${currentVersion}\n Latest: ${latest}\n\n${notesBlock}`;
1634
+ updateHeader.content = ` A new version is available\n\n Current: ${currentVersion}\n Latest: ${latest}\n`;
1635
+ updateNotes.setContent(notes ?? "Run `npm install -g ghfind` to update.");
1641
1636
  renderUpdateOptions();
1642
1637
  showOverlay("update");
1643
1638
  } else {
@@ -1694,7 +1689,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1694
1689
  if (currentQueryInput) {
1695
1690
  const name =
1696
1691
  currentQueryInput.length > 40
1697
- ? currentQueryInput.slice(0, 37) + "..."
1692
+ ? `${currentQueryInput.slice(0, 37)}...`
1698
1693
  : currentQueryInput;
1699
1694
  saveSearch(
1700
1695
  name,
@@ -1857,15 +1852,22 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1857
1852
  },
1858
1853
  },
1859
1854
  });
1860
- const readmeText = new TextRenderable(renderer, {
1861
- content: "",
1862
- fg: colors.text,
1863
- bg: colors.bg,
1855
+ // Full markdown rendering (headings, tables, code, inline images).
1856
+ const readmeView = createMarkdownView({
1857
+ renderer,
1858
+ colors,
1859
+ loadImage: (url) => fetchImageBytes(url, { token: githubToken }),
1860
+ getImageWidth: () => Math.max(24, (process.stdout.columns || 80) - 12),
1864
1861
  });
1865
- readmeScroll.add(readmeText);
1862
+ readmeScroll.add(readmeView.renderable);
1866
1863
  readmeBox.add(readmeScroll);
1864
+
1865
+ const readmeFooterText = () =>
1866
+ ` ↑↓/jk scroll PgUp/PgDn page i images: ${
1867
+ readmeView.imagesEnabled ? "on" : "off"
1868
+ } Esc/q close`;
1867
1869
  const readmeFooter = new TextRenderable(renderer, {
1868
- content: " ↑↓/jk scroll Esc/q close",
1870
+ content: readmeFooterText(),
1869
1871
  fg: colors.muted,
1870
1872
  bg: colors.bg,
1871
1873
  height: 1,
@@ -1881,33 +1883,36 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1881
1883
  setStatus("No repo selected");
1882
1884
  return;
1883
1885
  }
1884
- readmeText.content = ` Loading README for ${repo.fullName}...`;
1886
+ readmeView.setContent(`Loading README for ${repo.fullName}…`);
1887
+ readmeFooter.content = readmeFooterText();
1888
+ readmeScroll.scrollTop = 0;
1885
1889
  showOverlay("readme");
1886
1890
  try {
1887
1891
  const headers: Record<string, string> = { "User-Agent": "ghfind/1.0" };
1888
1892
  if (githubToken) headers.Authorization = `Bearer ${githubToken}`;
1889
1893
  let text = "";
1890
- for (const path of [
1891
- `${repo.owner}/${repo.name}/main/README.md`,
1892
- `${repo.owner}/${repo.name}/master/README.md`,
1893
- `${repo.owner}/${repo.name}/main/README.rst`,
1894
- ]) {
1895
- const r = await fetch(`https://raw.githubusercontent.com/${path}`, {
1896
- headers,
1897
- });
1894
+ let sourceUrl = "";
1895
+ for (const [branch, file] of [
1896
+ ["main", "README.md"],
1897
+ ["master", "README.md"],
1898
+ ["main", "README.rst"],
1899
+ ] as const) {
1900
+ // The README's own URL doubles as the base for relative image paths.
1901
+ const url = readmeBaseUrl(repo.owner, repo.name, branch, file);
1902
+ const r = await fetch(url, { headers });
1898
1903
  if (r.ok) {
1899
1904
  text = await r.text();
1905
+ sourceUrl = url;
1900
1906
  break;
1901
1907
  }
1902
1908
  }
1903
1909
  if (!text) {
1904
- readmeText.content = ` (no README found for ${repo.fullName})`;
1910
+ readmeView.setContent(`_(no README found for ${repo.fullName})_`);
1905
1911
  } else {
1906
- const termW = Math.max(40, (process.stdout.columns || 80) - 4);
1907
- readmeText.content = renderMarkdown(text, { width: termW });
1912
+ readmeView.setContent(text, sourceUrl);
1908
1913
  }
1909
1914
  } catch {
1910
- readmeText.content = " Failed to load README";
1915
+ readmeView.setContent("_Failed to load README._");
1911
1916
  }
1912
1917
  renderer.requestRender();
1913
1918
  }
@@ -1925,16 +1930,11 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1925
1930
  statusBar.content = ` ${text}`;
1926
1931
  renderer.requestRender();
1927
1932
  });
1928
-
1929
- // ── Startup tip ──
1930
- if (config.theme !== undefined || true) {
1931
- // Show a tip after 2s idle
1932
- setTimeout(() => {
1933
- if (currentOverlay === "none") {
1934
- setStatus(nextTip());
1935
- }
1936
- }, 2000);
1937
- }
1933
+ setTimeout(() => {
1934
+ if (currentOverlay === "none") {
1935
+ setStatus(nextTip());
1936
+ }
1937
+ }, 2000);
1938
1938
 
1939
1939
  // ── Helper functions ────────────────────────────────────────────────
1940
1940
 
@@ -2097,7 +2097,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
2097
2097
  val >= 1000
2098
2098
  ? `${(val / 1000).toFixed(1).replace(/\.0$/, "")}k`
2099
2099
  : String(val);
2100
- label = label.padStart(5) + " ";
2100
+ label = `${label.padStart(5)} `;
2101
2101
  }
2102
2102
  lines.push(`${label}┆${chars.join("")}`);
2103
2103
  }
@@ -2515,6 +2515,24 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
2515
2515
  }
2516
2516
  });
2517
2517
 
2518
+ // Pasted content flows through "input"; purge JSON-shaped blobs that
2519
+ // slipped into the query (a search line is never raw JSON: objects/arrays
2520
+ // always arrive as paste payloads from API responses / deep-dive output).
2521
+ searchInput.on("input", () => {
2522
+ const v = searchInput.value;
2523
+ if (v.length > 0) {
2524
+ const t = v.trim();
2525
+ if (
2526
+ (t.startsWith("{") && t.endsWith("}")) ||
2527
+ (t.startsWith("[") && t.endsWith("]"))
2528
+ ) {
2529
+ searchInput.value = "";
2530
+ setStatus("Ignored JSON paste — search queries accept plain text");
2531
+ renderer.requestRender();
2532
+ }
2533
+ }
2534
+ });
2535
+
2518
2536
  // Navigate results → update detail pane
2519
2537
  resultsSelect.on("selectionChanged", () => {
2520
2538
  const opt = resultsSelect.getSelectedOption();
@@ -2616,6 +2634,14 @@ ${pack.description ?? ""}`;
2616
2634
 
2617
2635
  // README viewer: scrollable, Esc/q already handled above
2618
2636
  if (currentOverlay === "readme") {
2637
+ if (key.name === "i") {
2638
+ readmeView.setImagesEnabled(!readmeView.imagesEnabled);
2639
+ readmeFooter.content = readmeFooterText();
2640
+ setStatus(
2641
+ readmeView.imagesEnabled ? "README images on" : "README images off",
2642
+ );
2643
+ renderer.requestRender();
2644
+ }
2619
2645
  return;
2620
2646
  }
2621
2647
 
@@ -2624,7 +2650,8 @@ ${pack.description ?? ""}`;
2624
2650
  if (key.name === "d") {
2625
2651
  const sel = historySelect.getSelectedOption();
2626
2652
  if (sel?.value) {
2627
- deleteHistoryEntry((sel.value as any).index);
2653
+ const idx = (sel.value as { index?: number }).index;
2654
+ if (idx !== undefined) deleteHistoryEntry(idx);
2628
2655
  refreshHistory();
2629
2656
  setStatus("Deleted history entry");
2630
2657
  }
@@ -2641,8 +2668,9 @@ ${pack.description ?? ""}`;
2641
2668
  if (key.name === "enter" || key.name === "return") {
2642
2669
  const sel = historySelect.getSelectedOption();
2643
2670
  if (sel?.value) {
2644
- const entry = (sel.value as any).entry;
2671
+ const entry = (sel.value as { entry?: HistoryEntry }).entry;
2645
2672
  showOverlay("none");
2673
+ if (!entry) return;
2646
2674
  if (entry.mode === "trending") {
2647
2675
  if (entry.tab)
2648
2676
  trendingTab = entry.tab as (typeof TAB_NAMES)[number];
@@ -2663,7 +2691,10 @@ ${pack.description ?? ""}`;
2663
2691
  if (key.name === "d") {
2664
2692
  const sel = bookmarksSelect.getSelectedOption();
2665
2693
  if (sel?.value) {
2666
- removeBookmark((sel.value as any).repo.fullName);
2694
+ removeBookmark(
2695
+ (sel.value as { repo?: { fullName: string } }).repo?.fullName ??
2696
+ "",
2697
+ );
2667
2698
  refreshBookmarks();
2668
2699
  setStatus("Bookmark removed");
2669
2700
  }
@@ -2672,7 +2703,8 @@ ${pack.description ?? ""}`;
2672
2703
  }
2673
2704
  if (key.name === "enter" || key.name === "return") {
2674
2705
  const sel = bookmarksSelect.getSelectedOption();
2675
- if (sel?.value) openUrl((sel.value as any).repo.url);
2706
+ if (sel?.value)
2707
+ openUrl((sel.value as { repo?: { url?: string } }).repo?.url ?? "");
2676
2708
  return;
2677
2709
  }
2678
2710
  return;
@@ -2683,7 +2715,7 @@ ${pack.description ?? ""}`;
2683
2715
  if (key.name === "d") {
2684
2716
  const sel = savedSelect.getSelectedOption();
2685
2717
  if (sel?.value) {
2686
- deleteSavedSearch((sel.value as any).name);
2718
+ deleteSavedSearch((sel.value as { name?: string }).name ?? "");
2687
2719
  refreshSavedSearches();
2688
2720
  setStatus("Saved search deleted");
2689
2721
  }
@@ -2693,7 +2725,7 @@ ${pack.description ?? ""}`;
2693
2725
  if (key.name === "enter" || key.name === "return") {
2694
2726
  const sel = savedSelect.getSelectedOption();
2695
2727
  if (sel?.value) {
2696
- const s = sel.value as any;
2728
+ const s = sel.value as SavedSearch;
2697
2729
  showOverlay("none");
2698
2730
  touchSavedSearch(s.name);
2699
2731
  if (s.mode === "trending") {
@@ -2718,9 +2750,9 @@ ${pack.description ?? ""}`;
2718
2750
  if (key.name === "enter" || key.name === "return") {
2719
2751
  const sel = topicsSelect.getSelectedOption();
2720
2752
  if (sel?.value) {
2721
- const topic = sel.value as any;
2753
+ const topic = sel.value as { name?: string };
2722
2754
  showOverlay("none");
2723
- searchInput.value = `topic:${topic.name}`;
2755
+ searchInput.value = `topic:${topic.name ?? ""}`;
2724
2756
  showSearchMode();
2725
2757
  doSearch(`topic:${topic.name}`);
2726
2758
  }
@@ -2973,7 +3005,7 @@ ${pack.description ?? ""}`;
2973
3005
  // Number keys 1-5 switch trending tabs (only in trending mode)
2974
3006
  if (/^[1-5]$/.test(key.name)) {
2975
3007
  if (currentMode === "trending") {
2976
- const idx = parseInt(key.name) - 1;
3008
+ const idx = parseInt(key.name, 10) - 1;
2977
3009
  if (idx >= 0 && idx < TAB_NAMES.length) {
2978
3010
  trendingTab = TAB_NAMES[idx];
2979
3011
  loadTrending();
@@ -3025,16 +3057,16 @@ ${pack.description ?? ""}`;
3025
3057
  });
3026
3058
 
3027
3059
  // ── Update check (before start) ──────────────────────────────────────
3028
- const currentVersion = getVersion();
3060
+ const _currentVersion = getVersion();
3029
3061
  // ── Start ──────────────────────────────────────────────────────────
3030
3062
  // Check for updates (non-blocking — doesn't delay TUI startup)
3031
- if (!session || !session.mode) {
3063
+ if (!session?.mode) {
3032
3064
  showLanding(true);
3033
3065
  }
3034
3066
  checkForUpdateAndShow();
3035
3067
  checkPostUpgradePanel();
3036
3068
  renderer.start();
3037
- if (session && session.mode) {
3069
+ if (session?.mode) {
3038
3070
  if (currentMode === "trending") {
3039
3071
  loadTrending();
3040
3072
  } else {