github-search-cli 0.6.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/query.ts ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Query layer: parse a raw user query into structured qualifiers/keywords and
3
+ * build the GitHub search syntax string.
4
+ *
5
+ * This layer is deliberately free of any I/O. It only transforms strings into
6
+ * the {@link ParsedQuery} shape and then into a GitHub `q` parameter. Keeping it
7
+ * pure makes it trivially unit-testable and lets a future AI query-rewriter sit
8
+ * in front of it without side effects.
9
+ */
10
+ import type { ParsedQuery, Qualifier, SearchOptions, SortStrategy } from "./types.ts";
11
+
12
+ /** Qualifier keys we explicitly understand and can also accept as CLI flags. */
13
+ export const KNOWN_QUALIFIERS = [
14
+ "language",
15
+ "stars",
16
+ "fork",
17
+ "archived",
18
+ "topic",
19
+ "user",
20
+ "org",
21
+ "repo",
22
+ "updated",
23
+ "pushed",
24
+ "visibility",
25
+ "in",
26
+ "size",
27
+ "license",
28
+ "created",
29
+ ] as const;
30
+
31
+ export type KnownQualifier = (typeof KNOWN_QUALIFIERS)[number];
32
+
33
+ /** Qualifier suggestions for Tab auto-complete. Maps a prefix to matching qualifiers. */
34
+ export const QUALIFIER_SUGGESTIONS: Record<string, string[]> = {
35
+ l: ["language:"],
36
+ s: ["stars:", "size:"],
37
+ t: ["topic:"],
38
+ u: ["user:"],
39
+ o: ["org:"],
40
+ r: ["repo:"],
41
+ c: ["created:"],
42
+ p: ["pushed:"],
43
+ v: ["visibility:"],
44
+ i: ["in:"],
45
+ f: ["fork:"],
46
+ a: ["archived:"],
47
+ };
48
+
49
+ /** Find matching qualifier suggestions for a partial word. */
50
+ export function suggestFor(input: string): string[] {
51
+ const trimmed = input.trim().toLowerCase();
52
+ if (trimmed.length === 0) return [];
53
+ // Single letter prefix
54
+ if (trimmed.length === 1 && QUALIFIER_SUGGESTIONS[trimmed]) {
55
+ return QUALIFIER_SUGGESTIONS[trimmed];
56
+ }
57
+ // Partial qualifier key (e.g. "lang" → "language:")
58
+ const matches = KNOWN_QUALIFIERS.filter(k => k.startsWith(trimmed));
59
+ return matches.map(k => k + ":");
60
+ }
61
+
62
+ /**
63
+ * Tokenize a raw query string while respecting double quotes so that phrases
64
+ * like `"machine learning"` or `repo:"my cool repo"` stay together as one
65
+ * token even when the quote is not at a token boundary.
66
+ */
67
+ export function tokenize(input: string): string[] {
68
+ const tokens: string[] = [];
69
+ let current = "";
70
+ let inQuotes = false;
71
+ for (let i = 0; i < input.length; i++) {
72
+ const ch = input[i];
73
+ if (ch === '"') {
74
+ inQuotes = !inQuotes;
75
+ current += ch; // keep the quote so callers can detect quoted values
76
+ } else if (/\s/.test(ch) && !inQuotes) {
77
+ if (current) {
78
+ tokens.push(current);
79
+ current = "";
80
+ }
81
+ } else {
82
+ current += ch;
83
+ }
84
+ }
85
+ if (current) {
86
+ // Strip quotes only when the whole token is wrapped in them
87
+ // (e.g. `"machine learning"`); leave `repo:"my repo"` intact for the
88
+ // qualifier parser to handle.
89
+ if (current.startsWith('"') && current.endsWith('"') && current.length >= 2) {
90
+ current = current.slice(1, -1);
91
+ }
92
+ tokens.push(current);
93
+ }
94
+ return tokens;
95
+ }
96
+
97
+ /**
98
+ * Parse a raw query string into a {@link ParsedQuery}.
99
+ *
100
+ * Rules:
101
+ * - `key:value` or `key:"quoted value"` becomes a qualifier.
102
+ * - `-key:value` becomes a negated qualifier.
103
+ * - Anything else is a free-text keyword (quotes are stripped).
104
+ * - The original string is preserved verbatim in `raw`.
105
+ */
106
+ export function parseQuery(raw: string): ParsedQuery {
107
+ const trimmed = raw.trim();
108
+ if (trimmed === "") {
109
+ return { keywords: [], qualifiers: [], raw };
110
+ }
111
+
112
+ const keywords: string[] = [];
113
+ const qualifiers: Qualifier[] = [];
114
+
115
+ for (const token of tokenize(trimmed)) {
116
+ const qualifier = matchQualifier(token);
117
+ if (qualifier) {
118
+ qualifiers.push(qualifier);
119
+ } else {
120
+ keywords.push(token);
121
+ }
122
+ }
123
+
124
+ return { keywords, qualifiers, raw: trimmed };
125
+ }
126
+
127
+ /** Match a single token against `[-]key:value` (value may be quoted). */
128
+ function matchQualifier(token: string): Qualifier | null {
129
+ const negated = token.startsWith("-");
130
+ const body = negated ? token.slice(1) : token;
131
+ const idx = body.indexOf(":");
132
+ if (idx <= 0) return null; // no key, or starts with ':'
133
+ const key = body.slice(0, idx).toLowerCase();
134
+ let value = body.slice(idx + 1);
135
+ if (value === "") return null;
136
+ // A qualifier key must be a plain word (letters only) so that URLs like
137
+ // `http://example.com` are treated as free-text keywords, not qualifiers.
138
+ if (!/^[a-z]+$/i.test(key)) return null;
139
+ // A value starting with `//` is a URL scheme, not a qualifier value.
140
+ if (value.startsWith("//")) return null;
141
+ // Strip surrounding quotes from a quoted value (e.g. `repo:"my repo"`).
142
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
143
+ value = value.slice(1, -1);
144
+ }
145
+ return { key, value, negated };
146
+ }
147
+
148
+ /** CLI flag filters that map onto qualifiers. */
149
+ export interface FlagFilters {
150
+ language?: string;
151
+ stars?: string;
152
+ org?: string;
153
+ user?: string;
154
+ topic?: string;
155
+ archived?: boolean;
156
+ fork?: boolean;
157
+ }
158
+
159
+ /**
160
+ * Merge flag-based filters into a parsed query. Flags are appended as
161
+ * qualifiers. Returns a new {@link ParsedQuery} (does not mutate the input).
162
+ */
163
+ export function applyFlagFilters(
164
+ query: ParsedQuery,
165
+ flags: FlagFilters,
166
+ ): ParsedQuery {
167
+ const qualifiers = [...query.qualifiers];
168
+
169
+ if (flags.language) qualifiers.push({ key: "language", value: flags.language, negated: false });
170
+ if (flags.stars) qualifiers.push({ key: "stars", value: flags.stars, negated: false });
171
+ if (flags.org) qualifiers.push({ key: "org", value: flags.org, negated: false });
172
+ if (flags.user) qualifiers.push({ key: "user", value: flags.user, negated: false });
173
+ if (flags.topic) qualifiers.push({ key: "topic", value: flags.topic, negated: false });
174
+ if (flags.archived !== undefined)
175
+ qualifiers.push({ key: "archived", value: flags.archived ? "true" : "false", negated: false });
176
+ if (flags.fork !== undefined)
177
+ qualifiers.push({ key: "fork", value: flags.fork ? "true" : "false", negated: false });
178
+
179
+ return { ...query, qualifiers };
180
+ }
181
+
182
+ /**
183
+ * Validate a parsed query for conflicting or nonsensical filters.
184
+ * Throws an Error describing the first conflict found.
185
+ */
186
+ export function validateQuery(query: ParsedQuery): void {
187
+ const has = (key: string) => query.qualifiers.some((q) => q.key === key && !q.negated);
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
+ }
195
+ if (has("visibility") && has("private")) {
196
+ throw new Error("Cannot combine `visibility:` and `private:` filters.");
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Build the GitHub search `q` string from a parsed query.
202
+ * Keywords are joined with spaces; qualifiers are emitted as `key:value`
203
+ * (or `-key:value` when negated). Quoted values are re-quoted when they
204
+ * contain spaces.
205
+ */
206
+ export function buildGitHubQuery(query: ParsedQuery): string {
207
+ const parts: string[] = [];
208
+
209
+ for (const kw of query.keywords) {
210
+ const alreadyQuoted = kw.startsWith('"') && kw.endsWith('"');
211
+ parts.push(kw.includes(" ") && !alreadyQuoted ? `"${kw}"` : kw);
212
+ }
213
+
214
+ for (const q of query.qualifiers) {
215
+ const needsQuote = q.value.includes(" ") && !q.value.startsWith('"');
216
+ const value = needsQuote ? `"${q.value}"` : q.value;
217
+ parts.push(`${q.negated ? "-" : ""}${q.key}:${value}`);
218
+ }
219
+
220
+ return parts.join(" ");
221
+ }
222
+
223
+ /**
224
+ * Map our sort strategy to the GitHub `sort` param and the in-query `sort:`
225
+ * qualifier. GitHub's repository search supports `stars`, `forks`, `updated`
226
+ * and (implicitly) best-match. We encode best-match by omitting the param.
227
+ */
228
+ export function githubSortParam(sort: SortStrategy): { sort?: string; order?: string } {
229
+ switch (sort) {
230
+ case "stars":
231
+ return { sort: "stars", order: "desc" };
232
+ case "forks":
233
+ return { sort: "forks", order: "desc" };
234
+ case "updated":
235
+ return { sort: "updated", order: "desc" };
236
+ case "best-match":
237
+ default:
238
+ return {}; // GitHub default relevance ranking
239
+ }
240
+ }
241
+
242
+ /** Build the full GitHub search URL for the given query + options. */
243
+ export function buildSearchUrl(query: ParsedQuery, options: SearchOptions): string {
244
+ const q = buildGitHubQuery(query);
245
+ const { sort, order } = githubSortParam(options.sort);
246
+ 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) });
248
+ if (sort) params.set("sort", sort);
249
+ if (order) params.set("order", order);
250
+ return `https://api.github.com/search/repositories?${params.toString()}`;
251
+ }
package/src/ranking.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Ranking layer: order normalized {@link Repo} results deterministically.
3
+ *
4
+ * All strategies are pure functions of the repo data plus a stable tie-breaker
5
+ * (repo id) so that identical inputs always produce identical output. A future
6
+ * AI reranker can be added as an additional strategy or as a post-step without
7
+ * changing the rest of the pipeline.
8
+ */
9
+ import type { Repo, SortStrategy } from "./types.ts";
10
+
11
+ /**
12
+ * Rank repos according to the chosen strategy.
13
+ * The sort is stable and deterministic: when two repos compare equal on the
14
+ * primary key, we fall back to descending stars, then descending id.
15
+ */
16
+ export function rankRepos(repos: Repo[], strategy: SortStrategy): Repo[] {
17
+ const copy = [...repos];
18
+ switch (strategy) {
19
+ case "stars":
20
+ copy.sort(byKey((r) => r.stars));
21
+ break;
22
+ case "forks":
23
+ copy.sort(byKey((r) => r.forks));
24
+ break;
25
+ case "updated":
26
+ copy.sort(byKey((r) => Date.parse(r.updatedAt)));
27
+ break;
28
+ case "best-match":
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);
33
+ break;
34
+ }
35
+ return copy;
36
+ }
37
+
38
+ /** Comparator factory: descending by `key`, then descending stars, then
39
+ * ascending id (stable, deterministic tie-break). */
40
+ function byKey(key: (r: Repo) => number): (a: Repo, b: Repo) => number {
41
+ return (a, b) => key(b) - key(a) || b.stars - a.stars || a.id - b.id;
42
+ }
43
+
44
+ /**
45
+ * A simple, explainable composite score used for the (optional) "best" blend.
46
+ * Not used by default but available for an AI/rerank layer later.
47
+ * Range is roughly 0..1 after normalization by the caller.
48
+ */
49
+ export function compositeScore(r: Repo): number {
50
+ // Log-scaled stars + recency bonus. Deterministic and transparent.
51
+ const starScore = Math.log10(r.stars + 1);
52
+ const recencyMs = Date.now() - Date.parse(r.pushedAt);
53
+ const recencyScore = Math.max(0, 1 - recencyMs / (1000 * 60 * 60 * 24 * 365));
54
+ return starScore * 0.7 + recencyScore * 0.3;
55
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Saved searches — named queries you can re-run anytime.
3
+ */
4
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
5
+ import { join } from "path";
6
+ import type { SavedSearch, SortStrategy } from "./types.ts";
7
+ import { stateDir } from "./config.ts";
8
+
9
+ const SAVED_FILE = join(stateDir(), "saved-searches.json");
10
+
11
+ function loadRaw(): SavedSearch[] {
12
+ mkdirSync(stateDir(), { recursive: true });
13
+ try {
14
+ return JSON.parse(readFileSync(SAVED_FILE, "utf-8")) as SavedSearch[];
15
+ } catch {
16
+ return [];
17
+ }
18
+ }
19
+
20
+ function saveRaw(searches: SavedSearch[]): void {
21
+ mkdirSync(stateDir(), { recursive: true });
22
+ writeFileSync(SAVED_FILE, JSON.stringify(searches, null, 2));
23
+ }
24
+
25
+ /** Get all saved searches, sorted by last run time (most recent first). */
26
+ export function getSavedSearches(): SavedSearch[] {
27
+ return loadRaw().sort((a, b) => (b.lastRunAt ?? b.createdAt) - (a.lastRunAt ?? a.createdAt));
28
+ }
29
+
30
+ /** Save a new search. Overwrites if name already exists. */
31
+ export function saveSearch(name: string, query: string, mode: "search" | "trending", sort: SortStrategy, limit: number, tab?: string): SavedSearch {
32
+ const searches = loadRaw();
33
+ const now = Date.now();
34
+ const existing = searches.findIndex((s) => s.name === name);
35
+ const entry: SavedSearch = { name, query, mode, sort, limit, tab, createdAt: now, lastRunAt: now };
36
+ if (existing >= 0) {
37
+ searches[existing] = { ...searches[existing], ...entry, createdAt: searches[existing].createdAt };
38
+ } else {
39
+ searches.push(entry);
40
+ }
41
+ saveRaw(searches);
42
+ return entry;
43
+ }
44
+
45
+ /** Delete a saved search by name. */
46
+ export function deleteSavedSearch(name: string): void {
47
+ saveRaw(loadRaw().filter((s) => s.name !== name));
48
+ }
49
+
50
+ /** Update lastRunAt for a saved search. */
51
+ export function touchSavedSearch(name: string): void {
52
+ const searches = loadRaw();
53
+ const found = searches.find((s) => s.name === name);
54
+ if (found) {
55
+ found.lastRunAt = Date.now();
56
+ saveRaw(searches);
57
+ }
58
+ }
package/src/session.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Session restore — save/restore UI state between restarts.
3
+ */
4
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
5
+ import { join } from "path";
6
+ import type { SessionState, SortStrategy } from "./types.ts";
7
+ import { stateDir } from "./config.ts";
8
+
9
+ const SESSION_FILE = join(stateDir(), "session.json");
10
+
11
+ export function saveSession(state: SessionState): void {
12
+ mkdirSync(stateDir(), { recursive: true });
13
+ try {
14
+ writeFileSync(SESSION_FILE, JSON.stringify(state));
15
+ } catch {
16
+ // non-critical
17
+ }
18
+ }
19
+
20
+ export function restoreSession(): SessionState | null {
21
+ try {
22
+ const raw = readFileSync(SESSION_FILE, "utf-8");
23
+ return JSON.parse(raw) as SessionState;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ export function clearSession(): void {
30
+ try {
31
+ writeFileSync(SESSION_FILE, "");
32
+ } catch {
33
+ // non-critical
34
+ }
35
+ }
package/src/share.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Share repo — copy formatted snippets to clipboard.
3
+ */
4
+ import type { Repo } from "./types.ts";
5
+
6
+ export type ShareFormat = "markdown" | "plain" | "gh-cli" | "short";
7
+
8
+ export function formatShare(repo: Repo, format: ShareFormat): string {
9
+ switch (format) {
10
+ case "markdown":
11
+ return `[${repo.fullName}](${repo.url}) — ${repo.description ?? ""}`.trim();
12
+ case "plain":
13
+ return `${repo.fullName} — ${repo.description ?? ""} — ${repo.url}`.trim();
14
+ case "gh-cli":
15
+ return `gh repo view ${repo.fullName}`;
16
+ case "short":
17
+ return repo.fullName;
18
+ }
19
+ }
20
+
21
+ /** Copy text to clipboard via platform command. */
22
+ export async function copyToClipboard(text: string): Promise<boolean> {
23
+ try {
24
+ const p = process.platform;
25
+ if (p === "win32") {
26
+ const proc = Bun.spawn(["clip"], { stdin: "pipe" });
27
+ proc.stdin.write(text);
28
+ proc.stdin.end();
29
+ await proc.exited;
30
+ } else if (p === "darwin") {
31
+ const proc = Bun.spawn(["pbcopy"], { stdin: "pipe" });
32
+ proc.stdin.write(text);
33
+ proc.stdin.end();
34
+ await proc.exited;
35
+ } else {
36
+ // Linux — try xclip, then wl-copy
37
+ const cmd = Bun.which("xclip") ? ["xclip", "-selection", "clipboard"] :
38
+ Bun.which("wl-copy") ? ["wl-copy"] : null;
39
+ if (!cmd) return false;
40
+ const proc = Bun.spawn(cmd, { stdin: "pipe" });
41
+ proc.stdin.write(text);
42
+ proc.stdin.end();
43
+ await proc.exited;
44
+ }
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
package/src/status.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Status bar state machine with spinner animation.
3
+ */
4
+ export type StatusType = "idle" | "searching" | "loading" | "success" | "error";
5
+
6
+ const SEARCH_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
7
+ const LOADING_SPINNER = ["▰▱▱▱▱", "▰▰▱▱▱", "▰▰▰▱▱", "▰▰▰▰▱", "▰▰▰▰▰"];
8
+
9
+ export class StatusManager {
10
+ private spinnerInterval: ReturnType<typeof setInterval> | null = null;
11
+ private spinnerIndex = 0;
12
+ private type: StatusType = "idle";
13
+ private message = "";
14
+ private onUpdate: (text: string) => void;
15
+
16
+ constructor(onUpdate: (text: string) => void) {
17
+ this.onUpdate = onUpdate;
18
+ }
19
+
20
+ set(type: StatusType, message: string): void {
21
+ this.stopSpinner();
22
+ this.type = type;
23
+ this.message = message;
24
+
25
+ if (type === "searching" || type === "loading") {
26
+ this.startSpinner(type === "searching" ? SEARCH_SPINNER : LOADING_SPINNER);
27
+ } else {
28
+ this.emit();
29
+ }
30
+ }
31
+
32
+ private startSpinner(frames: string[]): void {
33
+ this.spinnerIndex = 0;
34
+ this.emit(frames[0]);
35
+ this.spinnerInterval = setInterval(() => {
36
+ this.spinnerIndex = (this.spinnerIndex + 1) % frames.length;
37
+ this.emit(frames[this.spinnerIndex]);
38
+ }, 120);
39
+ }
40
+
41
+ private stopSpinner(): void {
42
+ if (this.spinnerInterval) {
43
+ clearInterval(this.spinnerInterval);
44
+ this.spinnerInterval = null;
45
+ }
46
+ }
47
+
48
+ private emit(spinner?: string): void {
49
+ const icon = this.type === "searching" ? "🔍"
50
+ : this.type === "loading" ? "📦"
51
+ : this.type === "success" ? "✓"
52
+ : this.type === "error" ? "⚠"
53
+ : " ";
54
+ const prefix = spinner ?? "";
55
+ this.onUpdate(`${icon} ${this.message} ${prefix}`.trim());
56
+ }
57
+
58
+ dispose(): void {
59
+ this.stopSpinner();
60
+ }
61
+ }
package/src/themes.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Theme system — 3 built-in palettes. Apply theme to TUI colors.
3
+ */
4
+ export interface Theme {
5
+ name: string;
6
+ bg: string;
7
+ surface: string;
8
+ text: string;
9
+ muted: string;
10
+ blue: string;
11
+ green: string;
12
+ yellow: string;
13
+ red: string;
14
+ teal: string;
15
+ purple: string;
16
+ orange: string;
17
+ border: string;
18
+ selectionBg: string;
19
+ selectionText: string;
20
+ }
21
+
22
+ const THEMES: Record<string, Theme> = {
23
+ "tokyo-night": {
24
+ name: "tokyo-night",
25
+ bg: "#1e1e2e", surface: "#181825", text: "#cdd6f4", muted: "#6c7086",
26
+ blue: "#89b4fa", green: "#a6e3a1", yellow: "#f9e2af", red: "#f38ba8",
27
+ teal: "#94e2d5", purple: "#cba6f7", orange: "#fab387",
28
+ border: "#45475a", selectionBg: "#2d5bcf", selectionText: "#ffffff",
29
+ },
30
+ dracula: {
31
+ name: "dracula",
32
+ bg: "#282a36", surface: "#21222c", text: "#f8f8f2", muted: "#6272a4",
33
+ blue: "#8be9fd", green: "#50fa7b", yellow: "#f1fa8c", red: "#ff5555",
34
+ teal: "#50fa7b", purple: "#bd93f9", orange: "#ffb86c",
35
+ border: "#44475a", selectionBg: "#bd93f9", selectionText: "#282a36",
36
+ },
37
+ monokai: {
38
+ name: "monokai",
39
+ bg: "#272822", surface: "#1e1f1c", text: "#f8f8f2", muted: "#75715e",
40
+ blue: "#66d9ef", green: "#a6e22e", yellow: "#e6db74", red: "#f92672",
41
+ teal: "#a6e22e", purple: "#ae81ff", orange: "#fd971f",
42
+ border: "#49483e", selectionBg: "#e6db74", selectionText: "#272822",
43
+ },
44
+ };
45
+
46
+ export function loadTheme(name: string): Theme {
47
+ return THEMES[name] ?? THEMES["tokyo-night"];
48
+ }
49
+
50
+ export function listThemes(): string[] {
51
+ return Object.keys(THEMES);
52
+ }
package/src/tips.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Startup tips — rotate through helpful hints.
3
+ */
4
+ const TIPS = [
5
+ "💡 Tip: Press ? to see all keyboard shortcuts",
6
+ "💡 Tip: Use Ctrl+R to recall past searches",
7
+ "💡 Tip: Press b to bookmark a repository",
8
+ "💡 Tip: Tab auto-completes qualifiers like language: and stars:",
9
+ "💡 Tip: Press Ctrl+E to export results as CSV or Markdown",
10
+ "💡 Tip: Set GITHUB_TOKEN for higher API rate limits",
11
+ "💡 Tip: Press c to compare up to 5 repos side-by-side",
12
+ "💡 Tip: Press d for a deep-dive into any repo",
13
+ "💡 Tip: Press E to browse popular GitHub topics",
14
+ "💡 Tip: Press Ctrl+P to copy a repo link to clipboard",
15
+ ];
16
+
17
+ let lastIndex = -1;
18
+
19
+ /** Get the next tip (never repeats the same tip twice in a row). */
20
+ 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];
27
+ }