github-search-cli 9.2.1 → 9.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,216 @@
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(
53
+ Math.max(
54
+ 3,
55
+ ...dataRows.map((r) => (r[ci] ?? "").length),
56
+ ),
57
+ );
58
+ }
59
+ const totalW = colW.reduce((a, b) => a + b, 0) + colCount * 3 - 1;
60
+ if (totalW > width) {
61
+ for (const row of dataRows) {
62
+ out.push(" " + row.map((c) => c.trim()).join(" │ "));
63
+ }
64
+ } else {
65
+ const sep = "┌" + colW.map((w) => "─".repeat(w + 2)).join("┬") + "┐";
66
+ const mid = "├" + colW.map((w) => "─".repeat(w + 2)).join("┼") + "┤";
67
+ const end = "└" + colW.map((w) => "─".repeat(w + 2)).join("┴") + "┘";
68
+ out.push(sep);
69
+ dataRows.forEach((row, i) => {
70
+ const cells = colW.map((w, ci) => (row[ci] ?? "").trim().padEnd(w));
71
+ out.push("│ " + cells.map((c) => ` ${c} `).join("│") + "│");
72
+ if (i === 0) out.push(mid);
73
+ });
74
+ out.push(end);
75
+ }
76
+ out.push("");
77
+ tableRows = [];
78
+ }
79
+
80
+ function renderInline(line: string): string {
81
+ // Bold
82
+ line = line.replace(/\*\*(.+?)\*\*/g, "$1");
83
+ // Italic (no asterisks)
84
+ line = line.replace(/\*([^*]+?)\*/g, "$1");
85
+ // Inline code
86
+ line = line.replace(/`([^`]+)`/g, "⟨$1⟩");
87
+ // Links [text](url)
88
+ line = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)");
89
+ // Images
90
+ line = line.replace(/!\[([^\]]*)\]\([^)]+\)/g, "🖼 $1");
91
+ return line;
92
+ }
93
+
94
+ function wrapLine(line: string, maxW: number): string[] {
95
+ if (line.length <= maxW) return [line];
96
+ const parts: string[] = [];
97
+ let remaining = line;
98
+ while (remaining.length > maxW) {
99
+ let breakAt = maxW;
100
+ const spaceIdx = remaining.lastIndexOf(" ", maxW);
101
+ if (spaceIdx > maxW / 2) breakAt = spaceIdx;
102
+ parts.push(remaining.slice(0, breakAt));
103
+ remaining = remaining.slice(breakAt).replace(/^ /, "");
104
+ }
105
+ if (remaining) parts.push(remaining);
106
+ return parts;
107
+ }
108
+
109
+ for (let i = 0; i < lines.length; i++) {
110
+ let line = lines[i];
111
+
112
+ // Code block boundaries
113
+ if (/^```/.test(line)) {
114
+ if (inCodeBlock) {
115
+ flushCode();
116
+ inCodeBlock = false;
117
+ } else {
118
+ inCodeBlock = true;
119
+ codeLang = line.slice(3).trim();
120
+ }
121
+ continue;
122
+ }
123
+ if (inCodeBlock) {
124
+ codeLines.push(line);
125
+ continue;
126
+ }
127
+
128
+ // Table row
129
+ if (line.includes("|")) {
130
+ const cells = line
131
+ .split("|")
132
+ .map((c) => c.trim())
133
+ .filter((_, idx, arr) => idx > 0 && idx < arr.length - 1);
134
+ if (cells.length >= 2) {
135
+ inTable = true;
136
+ tableRows.push(cells);
137
+ continue;
138
+ }
139
+ } else if (inTable) {
140
+ flushTable();
141
+ inTable = false;
142
+ }
143
+
144
+ // Horizontal rule
145
+ if (/^(\s*[-*_]){3,}\s*$/.test(line)) {
146
+ out.push("─".repeat(Math.min(width, 40)));
147
+ out.push("");
148
+ continue;
149
+ }
150
+
151
+ // Headings
152
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
153
+ if (headingMatch) {
154
+ const level = headingMatch[1].length;
155
+ const text = renderInline(headingMatch[2]);
156
+ const prefix = "█ ".repeat(level);
157
+ out.push("");
158
+ out.push(prefix + text);
159
+ out.push("");
160
+ continue;
161
+ }
162
+
163
+ // Blockquote
164
+ const bqMatch = line.match(/^>\s?(.*)/);
165
+ if (bqMatch) {
166
+ const inner = renderInline(bqMatch[1]);
167
+ for (const wl of wrapLine(inner, width - 4)) {
168
+ out.push("│ " + wl);
169
+ }
170
+ continue;
171
+ }
172
+
173
+ // Unordered list
174
+ const ulMatch = line.match(/^(\s*)[-*+]\s+(.*)/);
175
+ if (ulMatch) {
176
+ const indent = ulMatch[1].length;
177
+ const depth = Math.floor(indent / 2);
178
+ const prefix = " ".repeat(depth) + "• ";
179
+ const inner = renderInline(ulMatch[2]);
180
+ for (const wl of wrapLine(inner, width - prefix.length)) {
181
+ out.push(prefix + wl);
182
+ }
183
+ continue;
184
+ }
185
+
186
+ // Ordered list
187
+ const olMatch = line.match(/^(\s*)\d+\.\s+(.*)/);
188
+ if (olMatch) {
189
+ const indent = olMatch[1].length;
190
+ const depth = Math.floor(indent / 2);
191
+ const prefix = " ".repeat(depth) + "◦ ";
192
+ const inner = renderInline(olMatch[2]);
193
+ for (const wl of wrapLine(inner, width - prefix.length)) {
194
+ out.push(prefix + wl);
195
+ }
196
+ continue;
197
+ }
198
+
199
+ // Empty line
200
+ if (line.trim() === "") {
201
+ out.push("");
202
+ continue;
203
+ }
204
+
205
+ // Paragraph
206
+ const rendered = renderInline(line);
207
+ for (const wl of wrapLine(rendered, width)) {
208
+ out.push(wl);
209
+ }
210
+ }
211
+
212
+ if (inCodeBlock) flushCode();
213
+ if (inTable) flushTable();
214
+
215
+ return out.join("\n");
216
+ }
@@ -3,7 +3,7 @@ import { readJSON, writeJSON } from "./storage";
3
3
 
4
4
  export interface Notification {
5
5
  id: number;
6
- type: "info" | "warning" | "rate-limit" | "watch-change" | "upgrade";
6
+ type: "info" | "warning" | "rate-limit" | "watch-change" | "upgrade" | "release";
7
7
  icon: string;
8
8
  message: string;
9
9
  timestamp: number;
@@ -39,6 +39,7 @@ function iconFor(type: Notification["type"]): string {
39
39
  case "rate-limit": return "🚫";
40
40
  case "watch-change": return "🔔";
41
41
  case "upgrade": return "📦";
42
+ case "release": return "🚀";
42
43
  }
43
44
  }
44
45
 
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Release tracker — checks bookmarked repos for new GitHub releases.
3
+ *
4
+ * Decisions (map 003):
5
+ * - Signal: new GitHub releases only (`GET /repos/{o}/{r}/releases?per_page=5`).
6
+ * - Seen state: `lastSeenAt` per bookmark; "new" = published after it.
7
+ * - Rate budget: etag conditional requests + token when present.
8
+ * - Prereleases shown (tagged `[pre]`), drafts never.
9
+ *
10
+ * ponytail: sequential fetches — fine for ≤100 bookmarks at launch;
11
+ * parallelize with p-limit if users report slow startup.
12
+ */
13
+ import type { Bookmark } from "./types";
14
+ import { readJSON, writeJSON } from "./storage";
15
+ import { getBookmarks, getLastSeen, markSeen } from "./bookmarks";
16
+ import { addNotification } from "./notifications";
17
+ import { loadConfig } from "./config";
18
+
19
+ const RELEASES_FILE = "release-cache.json";
20
+ const PER_REPO = 5;
21
+
22
+ export interface TrackedRelease {
23
+ id: number;
24
+ tagName: string;
25
+ name: string;
26
+ url: string;
27
+ publishedAt: string;
28
+ prerelease: boolean;
29
+ }
30
+
31
+ /** Persisted cache: etag + last-known releases per repo. Only what the feed needs. */
32
+ interface RepoReleaseState {
33
+ etag?: string;
34
+ checkedAt: number;
35
+ releases: TrackedRelease[];
36
+ }
37
+
38
+ type ReleaseCache = Record<string, RepoReleaseState>;
39
+
40
+ function loadCache(): ReleaseCache {
41
+ return readJSON<ReleaseCache>(RELEASES_FILE, {});
42
+ }
43
+
44
+ function saveCache(cache: ReleaseCache): void {
45
+ writeJSON(RELEASES_FILE, cache);
46
+ }
47
+
48
+ function toTracked(raw: Array<Record<string, unknown>>): TrackedRelease[] {
49
+ return raw
50
+ .filter((r) => r.draft !== true) // drafts: never shown
51
+ .map((r) => ({
52
+ id: r.id as number,
53
+ tagName: r.tag_name as string,
54
+ name: (r.name as string) || (r.tag_name as string),
55
+ url: r.html_url as string,
56
+ publishedAt: (r.published_at as string) || "",
57
+ prerelease: r.prerelease === true,
58
+ }));
59
+ }
60
+
61
+ /** Fetch /releases for one repo using its cached etag. */
62
+ async function fetchReleases(
63
+ fullName: string,
64
+ etag: string | undefined,
65
+ token: string | undefined,
66
+ ): Promise<{ status: number; etag?: string; releases?: TrackedRelease[] }> {
67
+ const headers: Record<string, string> = { Accept: "application/vnd.github+json" };
68
+ if (etag) headers["If-None-Match"] = etag;
69
+ if (token) headers["Authorization"] = `Bearer ${token}`;
70
+ const res = await fetch(`https://api.github.com/repos/${fullName}/releases?per_page=${PER_REPO}`, { headers });
71
+ if (res.status === 304 || !res.ok) return { status: res.status };
72
+ return {
73
+ status: 200,
74
+ etag: res.headers.get("etag") ?? undefined,
75
+ releases: toTracked((await res.json()) as Array<Record<string, unknown>>),
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Check all bookmarks for releases newer than their lastSeenAt.
81
+ * Returns one result per bookmark so callers can render feeds or counts.
82
+ */
83
+ export async function checkReleases(
84
+ opts: { token?: string } = {},
85
+ ): Promise<Array<{ fullName: string; newReleases: TrackedRelease[]; error?: string }>> {
86
+ const bookmarks = getBookmarks();
87
+ const cache = loadCache();
88
+ const config = loadConfig();
89
+ const token = opts.token ?? config.githubToken ?? process.env.GITHUB_TOKEN;
90
+ const results: Array<{ fullName: string; newReleases: TrackedRelease[]; error?: string }> = [];
91
+
92
+ for (const bm of bookmarks) {
93
+ try {
94
+ const prev = cache[bm.repo.fullName];
95
+ const lastSeen = getLastSeen(bm.repo.fullName);
96
+ const isNew = (r: TrackedRelease) =>
97
+ !r.publishedAt || new Date(r.publishedAt).getTime() > lastSeen;
98
+
99
+ const { status, etag, releases } = await fetchReleases(bm.repo.fullName, prev?.etag, token);
100
+
101
+ if (status === 304 && prev) {
102
+ // Unchanged upstream; recompute against lastSeenAt (it may have moved).
103
+ results.push({ fullName: bm.repo.fullName, newReleases: prev.releases.filter(isNew) });
104
+ continue;
105
+ }
106
+ if (status !== 200 || !releases) {
107
+ results.push({ fullName: bm.repo.fullName, newReleases: [], error: `HTTP ${status}` });
108
+ continue;
109
+ }
110
+ cache[bm.repo.fullName] = { checkedAt: Date.now(), etag, releases };
111
+ results.push({ fullName: bm.repo.fullName, newReleases: releases.filter(isNew) });
112
+ } catch (err) {
113
+ results.push({ fullName: bm.repo.fullName, newReleases: [], error: (err as Error).message });
114
+ }
115
+ }
116
+
117
+ saveCache(cache);
118
+ return results;
119
+ }
120
+
121
+ /** Check + push unseen releases into the notification center, then mark them seen. Returns per-repo results. */
122
+ export async function checkAndNotify(opts: { token?: string } = {}): Promise<Array<{ fullName: string; newReleases: TrackedRelease[]; error?: string }>> {
123
+ const results = await checkReleases(opts);
124
+ for (const r of results) {
125
+ for (const rel of r.newReleases) {
126
+ addNotification(
127
+ "release",
128
+ `${r.fullName} released ${rel.tagName}${rel.prerelease ? " [pre]" : ""}`,
129
+ );
130
+ }
131
+ if (r.newReleases.length > 0) markSeen(r.fullName);
132
+ }
133
+ return results;
134
+ }
135
+
136
+ /** All cached releases across bookmarks, newest first (for the Releases panel / CLI). */
137
+ export function allCachedReleases(): Array<TrackedRelease & { fullName: string }> {
138
+ const cache = loadCache();
139
+ const bookmarks = getBookmarks();
140
+ return Object.entries(cache)
141
+ .filter(([fullName]) => bookmarks.some((b) => b.repo.fullName === fullName))
142
+ .flatMap(([fullName, s]) => s.releases.map((r) => ({ ...r, fullName })))
143
+ .sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());
144
+ }
package/src/tips.ts CHANGED
@@ -1,6 +1,26 @@
1
1
  /**
2
2
  * Startup tips — rotate through helpful hints.
3
3
  */
4
+
5
+ /** Rotates tips without repeating the same one twice in a row. */
6
+ export class TipRotator {
7
+ private tips: string[];
8
+ private lastIndex = -1;
9
+
10
+ constructor(tips?: string[]) {
11
+ this.tips = tips ?? TIPS;
12
+ }
13
+
14
+ next(): string {
15
+ let idx: number;
16
+ do {
17
+ idx = Math.floor(Math.random() * this.tips.length);
18
+ } while (idx === this.lastIndex && this.tips.length > 1);
19
+ this.lastIndex = idx;
20
+ return this.tips[idx];
21
+ }
22
+ }
23
+
4
24
  const TIPS = [
5
25
  "💡 Tip: Press ? to see all keyboard shortcuts",
6
26
  "💡 Tip: Use Ctrl+R to recall past searches",
@@ -14,14 +34,9 @@ const TIPS = [
14
34
  "💡 Tip: Press Ctrl+P to copy a repo link to clipboard",
15
35
  ];
16
36
 
17
- let lastIndex = -1;
37
+ export const defaultRotator = new TipRotator();
18
38
 
19
39
  /** Get the next tip (never repeats the same tip twice in a row). */
20
40
  export function nextTip(): string {
21
- let idx: number;
22
- do {
23
- idx = Math.floor(Math.random() * TIPS.length);
24
- } while (idx === lastIndex && TIPS.length > 1);
25
- lastIndex = idx;
26
- return TIPS[idx];
41
+ return defaultRotator.next();
27
42
  }
package/src/toast.ts ADDED
@@ -0,0 +1,69 @@
1
+ /** Toast popup — absolute-positioned card pinned top-right, auto-hides. */
2
+ import { BoxRenderable, TextRenderable } from "@opentui/core";
3
+
4
+ /** Only the color keys the toast reads (lazily, so runtime theme swaps apply). */
5
+ export interface ToastColors {
6
+ green: string;
7
+ yellow: string;
8
+ text: string;
9
+ surfaceDim: string;
10
+ }
11
+
12
+ export function createToast(
13
+ renderer: Parameters<BoxRenderable["constructor"]>[0],
14
+ root: { add(child: unknown): unknown },
15
+ colors: ToastColors,
16
+ ) {
17
+ let timer: ReturnType<typeof setTimeout> | undefined;
18
+ const box = new BoxRenderable(renderer, {
19
+ visible: false,
20
+ position: "absolute",
21
+ top: 1,
22
+ right: 2,
23
+ height: 3,
24
+ border: true,
25
+ borderStyle: "rounded",
26
+ borderColor: colors.green,
27
+ backgroundColor: colors.surfaceDim,
28
+ zIndex: 100,
29
+ });
30
+ const text = new TextRenderable(renderer, {
31
+ content: "",
32
+ color: colors.text,
33
+ backgroundColor: colors.surfaceDim,
34
+ height: 1,
35
+ });
36
+ box.add(text);
37
+ root.add(box);
38
+
39
+ return {
40
+ /** Show a message; accent picks the border color. Re-showing resets the timer. */
41
+ show(msg: string, ms = 2000, accent?: string): void {
42
+ text.content = msg;
43
+ box.borderColor = accent ?? colors.green;
44
+ // Message + paddingX(2) + side borders(2)
45
+ box.width = msg.length + 4;
46
+ box.visible = true;
47
+ renderer.requestRender();
48
+ clearTimeout(timer);
49
+ timer = setTimeout(() => {
50
+ box.visible = false;
51
+ renderer.requestRender();
52
+ }, ms);
53
+ },
54
+ hide(): void {
55
+ clearTimeout(timer);
56
+ if (!box.visible) return;
57
+ box.visible = false;
58
+ renderer.requestRender();
59
+ },
60
+ get visible(): boolean {
61
+ return box.visible;
62
+ },
63
+ get borderColor(): string {
64
+ return box.borderColor as string;
65
+ },
66
+ };
67
+ }
68
+
69
+ export type Toast = ReturnType<typeof createToast>;
package/src/tui.ts CHANGED
@@ -79,6 +79,7 @@ import {
79
79
  import { saveSession, restoreSession } from "./session";
80
80
  import { fetchDeepDive, buildDeepDiveText } from "./deepdive";
81
81
  import { buildComparisonTable } from "./compare";
82
+ import { renderMarkdown } from "./markdown-render";
82
83
  import { fetchTopics, type TopicItem } from "./explore";
83
84
  import { exportToFile, type ExportFormat } from "./output";
84
85
  import { listThemes, loadTheme } from "./themes";
@@ -112,6 +113,9 @@ import {
112
113
  snoozeUpdateNotices,
113
114
  suppressUpdateNotices,
114
115
  performUpdate,
116
+ fetchReleaseNotes,
117
+ readUpdateState,
118
+ recordPreUpdateState,
115
119
  } from "./update-check";
116
120
  import { debugLog } from "./storage";
117
121
  const colors = {
@@ -1526,6 +1530,33 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1526
1530
  showOverlay("leader");
1527
1531
  }
1528
1532
 
1533
+ // ponytail: post-upgrade panel — fires when lastInstalledVersion != current version
1534
+ // (no network, just reads bundled release notes for the new version)
1535
+ function checkPostUpgradePanel() {
1536
+ const state = readUpdateState();
1537
+ if (state.suppressed) return;
1538
+ if (!cachedVersion) {
1539
+ try {
1540
+ const pkg = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf-8"));
1541
+ cachedVersion = pkg.version;
1542
+ } catch {
1543
+ return;
1544
+ }
1545
+ }
1546
+ const currentVersion = cachedVersion;
1547
+ if (!state.lastInstalledVersion) return;
1548
+ if (state.lastInstalledVersion === currentVersion) return;
1549
+ // We just upgraded — show notes for the new version
1550
+ const notes = fetchReleaseNotes(currentVersion);
1551
+ const notesBlock = notes
1552
+ ? renderMarkdown(notes, { width: 60 })
1553
+ : " No release notes found for this version.";
1554
+ updateSelectedOption = 0;
1555
+ updateText.content = ` Updated ghfind ${state.lastInstalledVersion} → ${currentVersion}\n\n${notesBlock}`;
1556
+ renderUpdateOptions();
1557
+ showOverlay("update");
1558
+ }
1559
+
1529
1560
  async function checkForUpdateAndShow(force: boolean = false) {
1530
1561
  if (!force && !shouldCheckUpdate()) return;
1531
1562
  if (cachedVersion === null) {
@@ -1543,7 +1574,11 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1543
1574
  markUpdateChecked(latest ?? undefined);
1544
1575
  if (latest) {
1545
1576
  updateSelectedOption = 0;
1546
- updateText.content = ` A new version is available\n\n Current: ${currentVersion}\n Latest: ${latest}\n\n Run "npm install -g ghfind"`;
1577
+ const notes = fetchReleaseNotes(latest);
1578
+ const notesBlock = notes
1579
+ ? renderMarkdown(notes, { width: 60 })
1580
+ : ` A new version is available\n\n Current: ${currentVersion}\n Latest: ${latest}\n\n Run "npm install -g ghfind"`;
1581
+ updateText.content = ` A new version is available\n\n Current: ${currentVersion}\n Latest: ${latest}\n\n${notesBlock}`;
1547
1582
  renderUpdateOptions();
1548
1583
  showOverlay("update");
1549
1584
  } else {
@@ -1709,17 +1744,8 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1709
1744
  if (!text) {
1710
1745
  readmeText.content = ` (no README found for ${repo.fullName})`;
1711
1746
  } else {
1712
- // ponytail: just strip markdown formatting, show full text
1713
- const stripped = text
1714
- .replace(/```[\s\S]*?```/g, "[code block]")
1715
- .replace(/#{1,6}\s/g, "")
1716
- .replace(/\*\*(.+?)\*\*/g, "$1")
1717
- .replace(/\[(.+?)\]\(.+?\)/g, "$1")
1718
- .replace(/`([^`]+)`/g, "$1")
1719
- .split("\n")
1720
- .map((l) => l.trimEnd())
1721
- .join("\n");
1722
- readmeText.content = stripped;
1747
+ const termW = Math.max(40, (process.stdout.columns || 80) - 4);
1748
+ readmeText.content = renderMarkdown(text, { width: termW });
1723
1749
  }
1724
1750
  } catch {
1725
1751
  readmeText.content = " Failed to load README";
@@ -2585,6 +2611,8 @@ ${pack.description ?? ""}`;
2585
2611
  const action = ["now", "later", "never"][updateSelectedOption] as "now" | "later" | "never" | undefined;
2586
2612
  if (action === "now") {
2587
2613
  showOverlay("none");
2614
+ // ponytail: record the running version so the next launch detects the upgrade
2615
+ recordPreUpdateState(cachedVersion ?? "");
2588
2616
  performUpdate().then((ok) => {
2589
2617
  if (ok) {
2590
2618
  setStatus("✓ Update complete — restart ghfind");
@@ -2646,6 +2674,8 @@ ${pack.description ?? ""}`;
2646
2674
  showLeaderMenu();
2647
2675
  }
2648
2676
  return;
2677
+ }
2678
+
2649
2679
  // 't' toggles theme and persists to config
2650
2680
  if (key.name === "t" && !searchInput.focused) {
2651
2681
  const current = config.theme;
@@ -2659,7 +2689,6 @@ ${pack.description ?? ""}`;
2659
2689
  renderer.requestRender();
2660
2690
  return;
2661
2691
  }
2662
- }
2663
2692
 
2664
2693
  // 'c' toggles compare on selected repo (when not typing in search)
2665
2694
  if (key.name === "c" && !searchInput.focused) {
@@ -2779,6 +2808,7 @@ ${pack.description ?? ""}`;
2779
2808
  showLanding(true);
2780
2809
  }
2781
2810
  checkForUpdateAndShow();
2811
+ checkPostUpgradePanel();
2782
2812
  renderer.start();
2783
2813
  if (session && session.mode) {
2784
2814
  if (currentMode === "trending") {
package/src/types.ts CHANGED
@@ -104,6 +104,8 @@ export interface Bookmark {
104
104
  savedAt: number;
105
105
  tags: string[];
106
106
  notes?: string;
107
+ /** Releases published after this are "unseen". Set when the Releases panel is viewed. */
108
+ lastSeenAt?: number;
107
109
  }
108
110
 
109
111
  /** A named, saved search. */
@@ -120,7 +122,7 @@ export interface SavedSearch {
120
122
 
121
123
  /** Session state restored on startup. */
122
124
  export interface SessionState {
123
- mode: "search" | "trending" | "packages";
125
+ mode: "search" | "trending" | "packages" | "bookmarks";
124
126
  query: string;
125
127
  sort: SortStrategy;
126
128
  limit: number;