github-search-cli 0.6.1 → 0.7.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/help.ts CHANGED
@@ -1,46 +1,120 @@
1
- /**
2
- * Help screen: full keyboard shortcut reference for the TUI.
3
- */
4
- export function buildHelpText(): string {
5
- return [
6
- "┌─ search-cli Help ──────────────────────────── [q] close ─┐",
7
- "│ │",
8
- "│ NAVIGATION │",
9
- "│ ↑ ↓ / j k Move selection │",
10
- "│ Enter / o Open repo in browser │",
11
- "│ / Focus search input │",
12
- "│ t Toggle search / trending │",
13
- "│ ? / Ctrl+H Toggle this help screen │",
14
- "│ q Quit │",
15
- "│ │",
16
- "│ SEARCH MODE │",
17
- "│ s Cycle sort: best-match → stars → ... │",
18
- "│ l Cycle limit: 10 → 25 → 50 → 100 │",
19
- "│ r Refresh / re-run last search │",
20
- "│ g Toggle activity graph fullscreen │",
21
- "│ Tab Auto-complete qualifier │",
22
- "│ │",
23
- "│ TRENDING MODE │",
24
- "│ 1-5 Switch tab: Today → Week → Month → ...│",
25
- "│ ← → / h l Navigate tabs │",
26
- "│ r Refresh │",
27
- "│ │",
28
- "│ SEARCH SYNTAX │",
29
- "│ keywords Free-text search │",
30
- "│ key:value Qualifier filter │",
31
- "│ -key:value Negated qualifier │",
32
- "│ \"phrase\" Exact phrase match │",
33
- "│ │",
34
- "│ QUALIFIERS │",
35
- "│ language: Stars: Fork: Archived: Topic: │",
36
- "│ user: Org: Repo: Updated: Pushed: │",
37
- "│ visibility: License: Created: Size: In: │",
38
- "│ │",
39
- "│ RATE LIMITS │",
40
- "│ No token 10 req/min │",
41
- "│ GITHUB_TOKEN 30 req/min (or your plan limit) │",
42
- "│ │",
43
- "│ Set GITHUB_TOKEN in your environment or add to config. │",
44
- "└───────────────────────────────────────────────────────────┘",
45
- ].join("\n");
46
- }
1
+ /**
2
+ * Help screen — structured sections rendered by OpenTUI components.
3
+ *
4
+ * Each section has a title (with category color) and formatted rows.
5
+ * The TUI builds these into Box + TextRenderable components inside a ScrollBox.
6
+ */
7
+
8
+ export interface HelpRow {
9
+ keys: string;
10
+ action: string;
11
+ }
12
+
13
+ export interface HelpSection {
14
+ title: string;
15
+ /** Theme color key or hex — used for Box title color */
16
+ titleColor: string;
17
+ rows: HelpRow[];
18
+ note?: string;
19
+ }
20
+
21
+ export function buildHelpSections(): HelpSection[] {
22
+ return [
23
+ {
24
+ title: " Navigation ",
25
+ titleColor: "blue",
26
+ rows: [
27
+ { keys: "↑ ↓ / j k", action: "Move selection" },
28
+ { keys: "Enter / o", action: "Open repo in browser" },
29
+ { keys: "/", action: "Focus search input" },
30
+ { keys: "t", action: "Toggle search / trending" },
31
+ { keys: "? / Ctrl+H", action: "Toggle this help screen" },
32
+ { keys: "q", action: "Quit" },
33
+ ],
34
+ },
35
+ {
36
+ title: " Search Mode ",
37
+ titleColor: "green",
38
+ rows: [
39
+ { keys: "s", action: "Cycle sort: best-match → stars → …" },
40
+ { keys: "l", action: "Cycle limit: 10 → 25 → 50 → 100" },
41
+ { keys: "r", action: "Refresh / re-run last search" },
42
+ { keys: "g", action: "Toggle activity graph fullscreen" },
43
+ { keys: "Tab", action: "Auto-complete qualifier" },
44
+ { keys: "b", action: "Bookmark / unbookmark selected repo" },
45
+ ],
46
+ },
47
+ {
48
+ title: " Trending Mode ",
49
+ titleColor: "yellow",
50
+ rows: [
51
+ { keys: "1-5", action: "Switch tab: Today → Week → Month → …" },
52
+ { keys: "← → / h l", action: "Navigate tabs" },
53
+ { keys: "r", action: "Refresh" },
54
+ ],
55
+ },
56
+ {
57
+ title: " Power Tools ",
58
+ titleColor: "purple",
59
+ rows: [
60
+ { keys: "d", action: "Deep-dive: README + commit activity" },
61
+ { keys: "c", action: "Add/remove repo from comparison" },
62
+ { keys: "C (Shift+c)", action: "Show comparison view" },
63
+ { keys: "E (Shift+e)", action: "Topic explorer" },
64
+ { keys: "B (Shift+b)", action: "Bookmarks panel" },
65
+ { keys: "PageDown / Ctrl+F", action: "Next page of results" },
66
+ { keys: "PageUp / Ctrl+B", action: "Scroll to top of results" },
67
+ ],
68
+ },
69
+ {
70
+ title: " Overlays ",
71
+ titleColor: "teal",
72
+ rows: [
73
+ { keys: "Ctrl+R", action: "Search history" },
74
+ { keys: "Ctrl+O", action: "Saved searches" },
75
+ { keys: "Ctrl+E", action: "Export results" },
76
+ { keys: "Ctrl+N", action: "Notifications" },
77
+ { keys: "Ctrl+S", action: "Save current search" },
78
+ { keys: "Ctrl+P", action: "Share selected repo" },
79
+ ],
80
+ },
81
+ {
82
+ title: " Search Syntax ",
83
+ titleColor: "teal",
84
+ rows: [
85
+ { keys: "keywords", action: "Free-text search" },
86
+ { keys: "key:value", action: "Qualifier filter" },
87
+ { keys: "-key:value", action: "Negated qualifier" },
88
+ { keys: '"phrase"', action: "Exact phrase match" },
89
+ ],
90
+ },
91
+ {
92
+ title: " Qualifiers ",
93
+ titleColor: "muted",
94
+ rows: [
95
+ { keys: "language:", action: "Filter by language" },
96
+ { keys: "stars:>N", action: "Minimum stars" },
97
+ { keys: "fork:true", action: "Include forks" },
98
+ { keys: "user:", action: "User's repos" },
99
+ { keys: "org:", action: "Organization repos" },
100
+ { keys: "repo:", action: "Specific repo" },
101
+ { keys: "topic:", action: "Filter by topic" },
102
+ { keys: "license:", action: "License type" },
103
+ { keys: "pushed:>YYYY-MM-DD", action: "Last push date" },
104
+ { keys: "archived:false", action: "Exclude archived" },
105
+ ],
106
+ },
107
+ {
108
+ title: " Rate Limits ",
109
+ titleColor: "orange",
110
+ rows: [
111
+ { keys: "No token", action: "10 req / min" },
112
+ { keys: "GITHUB_TOKEN", action: "30 req / min (or your plan limit)" },
113
+ ],
114
+ note: "Set GITHUB_TOKEN in your environment or add to config.",
115
+ },
116
+ ];
117
+ }
118
+
119
+ /** Column at which the action description starts (right-aligns keys). */
120
+ export const HELP_KEYS_COLUMN = 24;
package/src/init.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { createInterface } from "readline";
7
7
  import { saveConfig } from "./config.ts";
8
+ import { listThemes } from "./themes.ts";
8
9
  import type { Config, SortStrategy } from "./types.ts";
9
10
 
10
11
  function ask(query: string): Promise<string> {
@@ -24,15 +25,22 @@ export async function runInitWizard(): Promise<void> {
24
25
  console.log("");
25
26
 
26
27
  const token = await ask(" GitHub token (optional, press Enter to skip): ");
28
+ if (token && !token.startsWith("ghp_") && !token.startsWith("github_pat_") && !token.startsWith("gho_")) {
29
+ console.log(" ⚠ Token doesn't look like a GitHub token (should start with ghp_, gho_, or github_pat_)");
30
+ }
31
+
27
32
  const sortStr = await ask(" Default sort [best-match/stars/updated/forks] (best-match): ");
28
33
  const limitStr = await ask(" Default result limit [10-100] (50): ");
29
34
 
35
+ const themes = listThemes();
36
+ const themeStr = await ask(` Theme [${themes.join("/")}] (tokyo-night): `);
37
+
30
38
  const config: Config = {
31
39
  defaultSort: (["best-match", "stars", "updated", "forks"].includes(sortStr)
32
40
  ? sortStr
33
41
  : "best-match") as SortStrategy,
34
42
  defaultLimit: Math.min(100, Math.max(10, parseInt(limitStr) || 50)),
35
- theme: "tokyo-night",
43
+ theme: themes.includes(themeStr) ? themeStr : "tokyo-night",
36
44
  cacheTtlSeconds: 300,
37
45
  defaultTab: "search",
38
46
  };
package/src/normalizer.ts CHANGED
@@ -37,26 +37,26 @@ export interface GitHubSearchEnvelope {
37
37
 
38
38
  /** Convert a single GitHub API item into a {@link Repo}. */
39
39
  export function normalizeRepo(item: GitHubApiItem): Repo {
40
- const owner = item.owner?.login ?? item.full_name.split("/")[0] ?? "";
40
+ const owner = item.owner?.login ?? item.full_name?.split("/")[0] ?? "";
41
41
  return {
42
- id: item.id,
43
- fullName: item.full_name,
44
- name: item.name,
42
+ id: item.id ?? 0,
43
+ fullName: item.full_name ?? "",
44
+ name: item.name ?? "",
45
45
  owner,
46
- description: item.description,
47
- url: item.html_url,
48
- stars: item.stargazers_count,
49
- forks: item.forks_count,
50
- watchers: item.watchers_count,
51
- language: item.language,
46
+ description: item.description ?? null,
47
+ url: item.html_url ?? "",
48
+ stars: item.stargazers_count ?? 0,
49
+ forks: item.forks_count ?? 0,
50
+ watchers: item.watchers_count ?? 0,
51
+ language: item.language ?? null,
52
52
  topics: item.topics ?? [],
53
- archived: item.archived,
54
- isFork: item.fork,
55
- private: item.private,
56
- createdAt: item.created_at,
57
- updatedAt: item.updated_at,
58
- pushedAt: item.pushed_at,
59
- score: item.score,
53
+ archived: item.archived ?? false,
54
+ isFork: item.fork ?? false,
55
+ private: item.private ?? false,
56
+ createdAt: item.created_at ?? "",
57
+ updatedAt: item.updated_at ?? "",
58
+ pushedAt: item.pushed_at ?? "",
59
+ score: item.score ?? 0,
60
60
  };
61
61
  }
62
62
 
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Open a URL in the user's default browser — platform-aware.
3
+ *
4
+ * ponytail: three platforms, no abstraction. Add more browsers/flags
5
+ * only if the open/xdg-open pattern fails somewhere.
6
+ */
7
+
8
+ export function openUrl(url: string): void {
9
+ try {
10
+ const p = process.platform;
11
+ if (p === "win32") Bun.spawn(["cmd", "/c", "start", "", url]);
12
+ else if (p === "darwin") Bun.spawn(["open", url]);
13
+ else Bun.spawn(["xdg-open", url]);
14
+ } catch {
15
+ // non-critical — user can copy the URL manually
16
+ }
17
+ }
package/src/output.ts CHANGED
@@ -5,9 +5,10 @@
5
5
  */
6
6
  import type { Repo, SearchResponse } from "./types.ts";
7
7
 
8
- /** Format a number compactly (e.g. 1234 -> "1.2k"). */
8
+ /** Format a number compactly (e.g. 1234 -> "1.2k", 999999 -> "1M"). */
9
9
  export function formatCount(n: number): string {
10
10
  if (n < 1000) return String(n);
11
+ if (n >= 999_500) return "1M"; // ponytail: round 999500+ up before showing 1000k
11
12
  if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
12
13
  return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
13
14
  }
package/src/pipe.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * Pipe-friendly format modifiers — output repo data as lines for shell piping.
3
3
  */
4
4
  import type { Repo } from "./types.ts";
5
+ import { openUrl } from "./open-url.ts";
5
6
 
6
7
  export type FormatLine = "urls" | "names" | "ssh-urls" | "clone-commands" | "ids";
7
8
 
@@ -56,10 +57,4 @@ export async function pipeExec(repos: Repo[], target: string): Promise<void> {
56
57
  }
57
58
  }
58
59
 
59
- async function openUrl(url: string): Promise<void> {
60
- const p = process.platform;
61
- const args = p === "win32" ? ["cmd", "/c", "start", "", url]
62
- : p === "darwin" ? ["open", url]
63
- : ["xdg-open", url];
64
- Bun.spawn(args);
65
- }
60
+
package/src/provider.ts CHANGED
@@ -89,15 +89,22 @@ export class GitHubSearchProvider implements SearchProvider {
89
89
  },
90
90
  });
91
91
 
92
- rateLimitRemaining = Number(res.headers.get("x-ratelimit-remaining") ?? NaN);
93
- if (res.status === 403 && rateLimitRemaining === 0) {
94
- if (this.tokens.length > 0 && attempt < this.tokens.length) {
95
- // Token exhausted, try next
96
- continue;
92
+ const remainingStr = res.headers.get("x-ratelimit-remaining");
93
+ rateLimitRemaining = remainingStr !== null ? Number(remainingStr) : undefined;
94
+ if (res.status === 403) {
95
+ if (remainingStr === "0" || remainingStr === null) {
96
+ // Rate limited
97
+ if (this.tokens.length > 0 && attempt < this.tokens.length) {
98
+ continue;
99
+ }
100
+ this.logger.warn("[github] rate limit exceeded");
101
+ rateLimited = true;
102
+ throw new RateLimitError(!!token);
97
103
  }
98
- this.logger.warn("[github] rate limit exceeded");
99
- rateLimited = true;
100
- throw new RateLimitError(!!token);
104
+ // Non-rate-limit 403 (e.g. private repo, permissions)
105
+ const body = await res.text().catch(() => "");
106
+ this.logger.error(`[github] non-rate-limit 403: ${body.slice(0, 200)}`);
107
+ throw new NetworkError();
101
108
  }
102
109
  if (!res.ok) {
103
110
  const body = await res.text().catch(() => "");
@@ -105,7 +112,23 @@ export class GitHubSearchProvider implements SearchProvider {
105
112
  throw new NetworkError();
106
113
  }
107
114
 
108
- const env = (await res.json()) as GitHubSearchEnvelope;
115
+ // Validate response is valid JSON before trusting the cast
116
+ const raw = await res.text();
117
+ let parsed: any;
118
+ try {
119
+ parsed = JSON.parse(raw);
120
+ } catch {
121
+ const snippet = raw.slice(0, 200);
122
+ this.logger.error(`[github] invalid JSON response: ${snippet}`);
123
+ throw new ParseError("GitHub API", snippet.includes("<!DOCTYPE")
124
+ ? "GitHub returned an HTML page (maintenance or CAPTCHA?)"
125
+ : "Invalid JSON response");
126
+ }
127
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.items)) {
128
+ this.logger.error(`[github] unexpected response shape: ${JSON.stringify(parsed).slice(0, 200)}`);
129
+ throw new ParseError("GitHub API", "Unexpected response shape — missing items array");
130
+ }
131
+ const env = parsed as GitHubSearchEnvelope;
109
132
  totalCount = env.total_count;
110
133
  const repos = normalizeEnvelope(env);
111
134
  all.push(...repos);
package/src/query.ts CHANGED
@@ -186,12 +186,9 @@ export function applyFlagFilters(
186
186
  export function validateQuery(query: ParsedQuery): void {
187
187
  const has = (key: string) => query.qualifiers.some((q) => q.key === key && !q.negated);
188
188
 
189
- if (has("fork") && has("archived")) {
190
- throw new Error("Cannot combine `fork:` and `archived:` filters (a fork can still be archived, but the combination is usually unintended).");
191
- }
192
- if (has("user") && has("org")) {
193
- throw new Error("Cannot combine `user:` and `org:` filters (a repo belongs to one or the other).");
194
- }
189
+ // ponytail: removed fork+archived and user+org validation — both are valid
190
+ // GitHub search queries (a fork can be archived; user: acts as author/committer
191
+ // not owner, so user+org is a valid scoping query).
195
192
  if (has("visibility") && has("private")) {
196
193
  throw new Error("Cannot combine `visibility:` and `private:` filters.");
197
194
  }
@@ -244,7 +241,8 @@ export function buildSearchUrl(query: ParsedQuery, options: SearchOptions): stri
244
241
  const q = buildGitHubQuery(query);
245
242
  const { sort, order } = githubSortParam(options.sort);
246
243
  const perPage = Math.min(Math.max(options.limit, 1), 100); // GitHub max page size
247
- const params = new URLSearchParams({ q: q || " ", per_page: String(perPage) });
244
+ // ponytail: per_page is set by appendPage in provider, not here
245
+ const params = new URLSearchParams({ q: q || " " });
248
246
  if (sort) params.set("sort", sort);
249
247
  if (order) params.set("order", order);
250
248
  return `https://api.github.com/search/repositories?${params.toString()}`;
package/src/ranking.ts CHANGED
@@ -27,9 +27,11 @@ export function rankRepos(repos: Repo[], strategy: SortStrategy): Repo[] {
27
27
  break;
28
28
  case "best-match":
29
29
  default:
30
- // GitHub already returns best-match order; keep it but apply a stable
31
- // tie-breaker so the output is reproducible.
32
- copy.sort((a, b) => b.score - a.score || b.stars - a.stars || a.id - b.id);
30
+ // GitHub already returns best-match order; preserve it unchanged.
31
+ // Re-sorting by score would be redundant for first-page results and
32
+ // misleading for paginated (page 2 repos would intermix with page 1).
33
+ // ponytail: if deterministic output is needed, sort by id:
34
+ // copy.sort((a, b) => a.id - b.id);
33
35
  break;
34
36
  }
35
37
  return copy;
package/src/session.ts CHANGED
@@ -12,8 +12,10 @@ export function saveSession(state: SessionState): void {
12
12
  mkdirSync(stateDir(), { recursive: true });
13
13
  try {
14
14
  writeFileSync(SESSION_FILE, JSON.stringify(state));
15
- } catch {
16
- // non-critical
15
+ } catch (err) {
16
+ if (process.env.DEBUG) {
17
+ console.error(`[search-cli] Failed to save session: ${err instanceof Error ? err.message : String(err)}`);
18
+ }
17
19
  }
18
20
  }
19
21
 
package/src/trending.ts CHANGED
@@ -19,7 +19,9 @@ import {
19
19
  t,
20
20
  } from "@opentui/core";
21
21
  import type { Repo } from "./types.ts";
22
- import { NetworkError, ParseError } from "./errors.ts";
22
+ import { openUrl } from "./open-url.ts";
23
+ import { NetworkError } from "./errors.ts";
24
+ import type { GitHubSearchEnvelope } from "./normalizer.ts";
23
25
 
24
26
  // ─── Tokyo Night palette ──────────────────────────────────────────────
25
27
  const C = {
@@ -80,12 +82,12 @@ function rankColor(rank: number): string {
80
82
  }
81
83
 
82
84
  // ─── Tab definitions ──────────────────────────────────────────────────
83
- export const TAB_NAMES = ["Today", "This Week", "This Month", "This Year", "All Time"] as const;
85
+ export const TAB_NAMES = ["Today", "This Week", "This Month"] as const;
84
86
  export type TabName = (typeof TAB_NAMES)[number];
85
87
 
86
- /** Map TabName to GitHub trending ?since= parameter.
87
- * Note: GitHub trending only offers daily/weekly/monthly.
88
- * "This Year" and "All Time" fall back to monthly.
88
+ /** Map TabName to GitHub API date range.
89
+ * We use the search API with `created:>DATE&sort=stars` so only
90
+ * daily/weekly/monthly ranges are meaningful.
89
91
  */
90
92
  export function tabSince(tab: TabName): "daily" | "weekly" | "monthly" {
91
93
  switch (tab) {
@@ -109,64 +111,53 @@ export interface TrendingRepo {
109
111
  // ─── Mock data ────────────────────────────────────────────────────────
110
112
 
111
113
  /**
112
- * Fetch real trending repos from github.com/trending.
113
- * Parses the HTML to extract owner, name, description, language, total stars,
114
- * and — crucially — stars gained in the period (daily/weekly/monthly).
114
+ * Fetch trending repos from the GitHub search API instead of HTML scraping.
115
+ * Uses `created:>DATE&sort=stars` queries since the API doesn't expose a
116
+ * dedicated trending endpoint.
117
+ *
118
+ * Note: `starsToday` is always 0 because the API doesn't provide period growth.
119
+ * The growth indicator is hidden when 0.
115
120
  */
116
121
  export async function fetchTrendingRepos(since: "daily" | "weekly" | "monthly"): Promise<TrendingRepo[]> {
117
- const url = `https://github.com/trending?since=${since}`;
122
+ const now = Date.now();
123
+ const msPerDay = 86_400_000;
124
+ const days = since === "daily" ? 1 : since === "weekly" ? 7 : 30;
125
+ const date = new Date(now - days * msPerDay).toISOString().slice(0, 10);
126
+
127
+ const url = `https://api.github.com/search/repositories?q=created:>${date}&sort=stars&order=desc&per_page=25`;
118
128
  let res: Response;
119
129
  try {
120
- res = await fetch(url, { headers: { "User-Agent": "search-cli/1.0" } });
130
+ res = await fetch(url, {
131
+ headers: {
132
+ Accept: "application/vnd.github+json",
133
+ "User-Agent": "search-cli",
134
+ },
135
+ });
121
136
  } catch {
122
137
  throw new NetworkError();
123
138
  }
124
- if (!res.ok) throw new ParseError("GitHub trending", `HTTP ${res.status}`);
125
- const html = await res.text();
126
-
127
- const repos: TrendingRepo[] = [];
128
- const articles = html.split('<article class="Box-row">');
129
- // first element is everything before the first article
130
-
131
- for (let i = 1; i < articles.length; i++) {
132
- const block = articles[i];
133
- const rank = i; // 1-based
134
-
135
- // Owner/name from the h2 anchor
136
- const h2 = block.match(/<h2[^>]*>[\s\S]*?<a[^>]*href="\/([^"/]+)\/([^"/]+?)"/);
137
- if (!h2) continue;
138
- const owner = h2[1];
139
- const name = h2[2];
140
-
141
- // Description from <p>
142
- const p = block.match(/<p[^>]*>([\s\S]*?)<\/p>/);
143
- const description = p ? p[1].replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim() : "";
144
-
145
- // Language
146
- const lang = block.match(/programmingLanguage"[^>]*>([^<]+)</);
147
- const language = lang ? lang[1].trim() : "";
148
-
149
- // Total stars (text after star SVG and before </a>)
150
- const stargazerBlock = block.match(/stargazers[^>]*>[\s\S]*?<\/svg>\s*([\d,]+)\s*<\/a>/);
151
- const stars = stargazerBlock ? parseInt(stargazerBlock[1].replace(/,/g, "")) : 0;
152
-
153
- // Stars gained this period (e.g. "8,795 stars this week")
154
- const periodS = block.match(new RegExp(`([\\d,]+)\\s+stars\\s+this\\s+${since === "daily" ? "day" : since === "weekly" ? "week" : "month"}`));
155
- const starsToday = periodS ? parseInt(periodS[1].replace(/,/g, "")) : 0;
156
-
157
- repos.push({ rank, owner, name, stars, starsToday, language, description });
158
- }
159
-
160
- return repos;
139
+ if (!res.ok) throw new NetworkError();
140
+ const data = (await res.json()) as GitHubSearchEnvelope;
141
+
142
+ return data.items.map((item, i) => ({
143
+ rank: i + 1,
144
+ owner: item.owner?.login ?? "?",
145
+ name: item.name,
146
+ stars: item.stargazers_count,
147
+ starsToday: 0, // API doesn't provide period growth
148
+ language: item.language ?? "",
149
+ description: item.description ?? "",
150
+ }));
161
151
  }
162
152
 
163
153
 
164
154
 
165
155
  // ─── Number formatting ────────────────────────────────────────────────
166
156
 
167
- /** 1234 → "1.2k", 1234567 → "1.2M" */
157
+ /** 1234 → "1.2k", 1234567 → "1.2M", 999999 → "1M" */
168
158
  export function fmtStars(n: number): string {
169
159
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
160
+ if (n >= 999_500) return "1M"; // ponytail: round 999500+ up before showing 1000k
170
161
  if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
171
162
  return String(n);
172
163
  }
@@ -237,8 +228,6 @@ export function getTrendingQuery(tab: TabName): string {
237
228
  Today: 1,
238
229
  "This Week": 7,
239
230
  "This Month": 30,
240
- "This Year": 365,
241
- "All Time": 0,
242
231
  };
243
232
  const days = ranges[tab];
244
233
  if (days === 0) return "sort:stars";
@@ -517,8 +506,8 @@ export async function launchTrending(): Promise<void> {
517
506
  return;
518
507
  }
519
508
 
520
- // Number keys 1-5 switch tabs
521
- if (/^[1-5]$/.test(name)) {
509
+ // Number keys 1-3 switch tabs
510
+ if (/^[1-3]$/.test(name)) {
522
511
  switchTab(parseInt(name, 10) - 1);
523
512
  return;
524
513
  }
@@ -569,19 +558,6 @@ export async function launchTrending(): Promise<void> {
569
558
  loadTab(selectedTab);
570
559
  }
571
560
 
572
- // ── Utilities ─────────────────────────────────────────────────────────
573
-
574
- function openUrl(url: string): void {
575
- try {
576
- const p = process.platform;
577
- if (p === "win32") Bun.spawn(["cmd", "/c", "start", "", url]);
578
- else if (p === "darwin") Bun.spawn(["open", url]);
579
- else Bun.spawn(["xdg-open", url]);
580
- } catch {
581
- // non-critical
582
- }
583
- }
584
-
585
561
  // ── Auto-run ──────────────────────────────────────────────────────────
586
562
  if (import.meta.main) {
587
563
  launchTrending();