github-search-cli 9.7.1 → 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/tui.ts CHANGED
@@ -74,7 +74,8 @@ import {
74
74
  import { saveSession, restoreSession } from "./session";
75
75
  import { fetchDeepDive, buildDeepDiveText } from "./deepdive";
76
76
  import { buildComparisonTable } from "./compare";
77
- import { renderMarkdown } from "./markdown-render";
77
+ import { createMarkdownView } from "./markdown-view";
78
+ import { fetchImageBytes, readmeBaseUrl } from "./image-loader";
78
79
  import { fetchTopics } from "./explore";
79
80
  import { exportToFile, type ExportFormat } from "./output";
80
81
  import {
@@ -1079,12 +1080,22 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1079
1080
  paddingLeft: 2,
1080
1081
  paddingRight: 2,
1081
1082
  });
1082
- const updateText = new TextRenderable(renderer, {
1083
+ // Version header only — the release notes below it are rendered markdown.
1084
+ const updateHeader = new TextRenderable(renderer, {
1083
1085
  content: "",
1084
1086
  fg: colors.accent,
1085
1087
  bg: colors.surfaceDim,
1086
1088
  });
1087
- // 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.
1088
1099
  const updateScroll = new ScrollBoxRenderable(renderer, {
1089
1100
  flexGrow: 1,
1090
1101
  backgroundColor: colors.surfaceDim,
@@ -1187,7 +1198,8 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1187
1198
  paddingX: 1,
1188
1199
  });
1189
1200
  updateBox.add(updateScroll);
1190
- updateScroll.add(updateText);
1201
+ updateScroll.add(updateHeader);
1202
+ updateScroll.add(updateNotes.renderable);
1191
1203
  updateBox.add(updateOptionsRow);
1192
1204
  updateBox.add(updateFooter);
1193
1205
  root.add(updateDim);
@@ -1597,17 +1609,11 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1597
1609
  if (state.lastInstalledVersion === currentVersion) return;
1598
1610
  // We just upgraded — show notes for the new version
1599
1611
  const notes = fetchReleaseNotes(currentVersion);
1600
- // Box is 55% of the terminal; subtract border + padding so wrapped
1601
- // lines never clip horizontally.
1602
- const notesWidth = Math.max(
1603
- 30,
1604
- Math.floor((process.stdout.columns || 80) * 0.55) - 8,
1605
- );
1606
- const notesBlock = notes
1607
- ? renderMarkdown(notes, { width: notesWidth })
1608
- : " No release notes found for this version.";
1609
1612
  updateSelectedOption = 0;
1610
- 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
+ );
1611
1617
  renderUpdateOptions();
1612
1618
  showOverlay("update");
1613
1619
  }
@@ -1625,14 +1631,8 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1625
1631
  if (latest) {
1626
1632
  updateSelectedOption = 0;
1627
1633
  const notes = fetchReleaseNotes(latest);
1628
- const notesWidth = Math.max(
1629
- 30,
1630
- Math.floor((process.stdout.columns || 80) * 0.55) - 8,
1631
- );
1632
- const notesBlock = notes
1633
- ? renderMarkdown(notes, { width: notesWidth })
1634
- : ` A new version is available\n\n Current: ${currentVersion}\n Latest: ${latest}\n\n Run "npm install -g ghfind"`;
1635
- 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.");
1636
1636
  renderUpdateOptions();
1637
1637
  showOverlay("update");
1638
1638
  } else {
@@ -1852,15 +1852,22 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1852
1852
  },
1853
1853
  },
1854
1854
  });
1855
- const readmeText = new TextRenderable(renderer, {
1856
- content: "",
1857
- fg: colors.text,
1858
- 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),
1859
1861
  });
1860
- readmeScroll.add(readmeText);
1862
+ readmeScroll.add(readmeView.renderable);
1861
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`;
1862
1869
  const readmeFooter = new TextRenderable(renderer, {
1863
- content: " ↑↓/jk scroll Esc/q close",
1870
+ content: readmeFooterText(),
1864
1871
  fg: colors.muted,
1865
1872
  bg: colors.bg,
1866
1873
  height: 1,
@@ -1876,33 +1883,36 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1876
1883
  setStatus("No repo selected");
1877
1884
  return;
1878
1885
  }
1879
- readmeText.content = ` Loading README for ${repo.fullName}...`;
1886
+ readmeView.setContent(`Loading README for ${repo.fullName}…`);
1887
+ readmeFooter.content = readmeFooterText();
1888
+ readmeScroll.scrollTop = 0;
1880
1889
  showOverlay("readme");
1881
1890
  try {
1882
1891
  const headers: Record<string, string> = { "User-Agent": "ghfind/1.0" };
1883
1892
  if (githubToken) headers.Authorization = `Bearer ${githubToken}`;
1884
1893
  let text = "";
1885
- for (const path of [
1886
- `${repo.owner}/${repo.name}/main/README.md`,
1887
- `${repo.owner}/${repo.name}/master/README.md`,
1888
- `${repo.owner}/${repo.name}/main/README.rst`,
1889
- ]) {
1890
- const r = await fetch(`https://raw.githubusercontent.com/${path}`, {
1891
- headers,
1892
- });
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 });
1893
1903
  if (r.ok) {
1894
1904
  text = await r.text();
1905
+ sourceUrl = url;
1895
1906
  break;
1896
1907
  }
1897
1908
  }
1898
1909
  if (!text) {
1899
- readmeText.content = ` (no README found for ${repo.fullName})`;
1910
+ readmeView.setContent(`_(no README found for ${repo.fullName})_`);
1900
1911
  } else {
1901
- const termW = Math.max(40, (process.stdout.columns || 80) - 4);
1902
- readmeText.content = renderMarkdown(text, { width: termW });
1912
+ readmeView.setContent(text, sourceUrl);
1903
1913
  }
1904
1914
  } catch {
1905
- readmeText.content = " Failed to load README";
1915
+ readmeView.setContent("_Failed to load README._");
1906
1916
  }
1907
1917
  renderer.requestRender();
1908
1918
  }
@@ -2505,6 +2515,24 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
2505
2515
  }
2506
2516
  });
2507
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
+
2508
2536
  // Navigate results → update detail pane
2509
2537
  resultsSelect.on("selectionChanged", () => {
2510
2538
  const opt = resultsSelect.getSelectedOption();
@@ -2606,6 +2634,14 @@ ${pack.description ?? ""}`;
2606
2634
 
2607
2635
  // README viewer: scrollable, Esc/q already handled above
2608
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
+ }
2609
2645
  return;
2610
2646
  }
2611
2647
 
@@ -1,211 +0,0 @@
1
- /**
2
- * Minimal markdown-to-terminal-text renderer.
3
- *
4
- * Renders headings, code blocks, lists, tables, blockquotes, horizontal rules,
5
- * bold/italic, and links into a plain-text representation suitable for a
6
- * TextRenderable in the TUI.
7
- */
8
-
9
- interface RenderOptions {
10
- /** Terminal width for wrapping and table layout. Default: 80. */
11
- width?: number;
12
- }
13
-
14
- /** Render markdown text to terminal-friendly plain text. */
15
- export function renderMarkdown(md: string, opts: RenderOptions = {}): string {
16
- const width = opts.width ?? 80;
17
- const lines = md.split("\n");
18
- const out: string[] = [];
19
- let inCodeBlock = false;
20
- let codeLang = "";
21
- let codeLines: string[] = [];
22
- let inTable = false;
23
- let tableRows: string[][] = [];
24
-
25
- function flushCode() {
26
- if (codeLines.length === 0) return;
27
- const maxLen = Math.max(...codeLines.map((l) => l.length), codeLang.length);
28
- const barW = Math.min(maxLen + 4, width - 2);
29
- out.push(`┌${"─".repeat(barW)}┐`);
30
- if (codeLang) out.push(`│ ${codeLang.padEnd(barW - 1)}│`);
31
- for (const cl of codeLines) {
32
- out.push(`│ ${cl.padEnd(barW - 1)}│`);
33
- }
34
- out.push(`└${"─".repeat(barW)}┘`);
35
- out.push("");
36
- codeLines = [];
37
- codeLang = "";
38
- }
39
-
40
- function flushTable() {
41
- if (tableRows.length === 0) return;
42
- const dataRows = tableRows.filter(
43
- (r) => !r.every((c) => /^[\s\-:|]+$/.test(c)),
44
- );
45
- if (dataRows.length === 0) {
46
- tableRows = [];
47
- return;
48
- }
49
- const colCount = Math.max(...dataRows.map((r) => r.length));
50
- const colW: number[] = [];
51
- for (let ci = 0; ci < colCount; ci++) {
52
- colW.push(Math.max(3, ...dataRows.map((r) => (r[ci] ?? "").length)));
53
- }
54
- const totalW = colW.reduce((a, b) => a + b, 0) + colCount * 3 - 1;
55
- if (totalW > width) {
56
- for (const row of dataRows) {
57
- out.push(` ${row.map((c) => c.trim()).join(" │ ")}`);
58
- }
59
- } else {
60
- const sep = `┌${colW.map((w) => "─".repeat(w + 2)).join("┬")}┐`;
61
- const mid = `├${colW.map((w) => "─".repeat(w + 2)).join("┼")}┤`;
62
- const end = `└${colW.map((w) => "─".repeat(w + 2)).join("┴")}┘`;
63
- out.push(sep);
64
- dataRows.forEach((row, i) => {
65
- const cells = colW.map((w, ci) => (row[ci] ?? "").trim().padEnd(w));
66
- out.push(`│ ${cells.map((c) => ` ${c} `).join("│")}│`);
67
- if (i === 0) out.push(mid);
68
- });
69
- out.push(end);
70
- }
71
- out.push("");
72
- tableRows = [];
73
- }
74
-
75
- function renderInline(line: string): string {
76
- // Bold
77
- line = line.replace(/\*\*(.+?)\*\*/g, "$1");
78
- // Italic (no asterisks)
79
- line = line.replace(/\*([^*]+?)\*/g, "$1");
80
- // Inline code
81
- line = line.replace(/`([^`]+)`/g, "⟨$1⟩");
82
- // Links [text](url)
83
- line = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)");
84
- // Images
85
- line = line.replace(/!\[([^\]]*)\]\([^)]+\)/g, "🖼 $1");
86
- return line;
87
- }
88
-
89
- function wrapLine(line: string, maxW: number): string[] {
90
- if (line.length <= maxW) return [line];
91
- const parts: string[] = [];
92
- let remaining = line;
93
- while (remaining.length > maxW) {
94
- let breakAt = maxW;
95
- const spaceIdx = remaining.lastIndexOf(" ", maxW);
96
- if (spaceIdx > maxW / 2) breakAt = spaceIdx;
97
- parts.push(remaining.slice(0, breakAt));
98
- remaining = remaining.slice(breakAt).replace(/^ /, "");
99
- }
100
- if (remaining) parts.push(remaining);
101
- return parts;
102
- }
103
-
104
- for (let i = 0; i < lines.length; i++) {
105
- const line = lines[i];
106
-
107
- // Code block boundaries
108
- if (/^```/.test(line)) {
109
- if (inCodeBlock) {
110
- flushCode();
111
- inCodeBlock = false;
112
- } else {
113
- inCodeBlock = true;
114
- codeLang = line.slice(3).trim();
115
- }
116
- continue;
117
- }
118
- if (inCodeBlock) {
119
- codeLines.push(line);
120
- continue;
121
- }
122
-
123
- // Table row
124
- if (line.includes("|")) {
125
- const cells = line
126
- .split("|")
127
- .map((c) => c.trim())
128
- .filter((_, idx, arr) => idx > 0 && idx < arr.length - 1);
129
- if (cells.length >= 2) {
130
- inTable = true;
131
- tableRows.push(cells);
132
- continue;
133
- }
134
- } else if (inTable) {
135
- flushTable();
136
- inTable = false;
137
- }
138
-
139
- // Horizontal rule
140
- if (/^(\s*[-*_]){3,}\s*$/.test(line)) {
141
- out.push("─".repeat(Math.min(width, 40)));
142
- out.push("");
143
- continue;
144
- }
145
-
146
- // Headings
147
- const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
148
- if (headingMatch) {
149
- const level = headingMatch[1].length;
150
- const text = renderInline(headingMatch[2]);
151
- const prefix = "█ ".repeat(level);
152
- out.push("");
153
- out.push(prefix + text);
154
- out.push("");
155
- continue;
156
- }
157
-
158
- // Blockquote
159
- const bqMatch = line.match(/^>\s?(.*)/);
160
- if (bqMatch) {
161
- const inner = renderInline(bqMatch[1]);
162
- for (const wl of wrapLine(inner, width - 4)) {
163
- out.push(`│ ${wl}`);
164
- }
165
- continue;
166
- }
167
-
168
- // Unordered list
169
- const ulMatch = line.match(/^(\s*)[-*+]\s+(.*)/);
170
- if (ulMatch) {
171
- const indent = ulMatch[1].length;
172
- const depth = Math.floor(indent / 2);
173
- const prefix = `${" ".repeat(depth)}• `;
174
- const inner = renderInline(ulMatch[2]);
175
- for (const wl of wrapLine(inner, width - prefix.length)) {
176
- out.push(prefix + wl);
177
- }
178
- continue;
179
- }
180
-
181
- // Ordered list
182
- const olMatch = line.match(/^(\s*)\d+\.\s+(.*)/);
183
- if (olMatch) {
184
- const indent = olMatch[1].length;
185
- const depth = Math.floor(indent / 2);
186
- const prefix = `${" ".repeat(depth)}◦ `;
187
- const inner = renderInline(olMatch[2]);
188
- for (const wl of wrapLine(inner, width - prefix.length)) {
189
- out.push(prefix + wl);
190
- }
191
- continue;
192
- }
193
-
194
- // Empty line
195
- if (line.trim() === "") {
196
- out.push("");
197
- continue;
198
- }
199
-
200
- // Paragraph
201
- const rendered = renderInline(line);
202
- for (const wl of wrapLine(rendered, width)) {
203
- out.push(wl);
204
- }
205
- }
206
-
207
- if (inCodeBlock) flushCode();
208
- if (inTable) flushTable();
209
-
210
- return out.join("\n");
211
- }