github-search-cli 9.1.0 → 9.2.1

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,305 @@
1
+ #!/usr/bin/env node
2
+ /** package-browser.ts — "Packages" browse/list view for the ghfind CLI. */
3
+ import {
4
+ createCliRenderer,
5
+ BoxRenderable,
6
+ TextRenderable,
7
+ ScrollBoxRenderable,
8
+ StyledText,
9
+ bold,
10
+ fg,
11
+ dim,
12
+ t,
13
+ } from "@opentui/core";
14
+ import type { TextChunk, CliRenderer } from "@opentui/core";
15
+ import type { Package } from "./types";
16
+ import { openUrl } from "./open-url";
17
+ import { createPackageSearch } from "./package";
18
+ const C = {
19
+ bg: "#1a1b26",
20
+ surface: "#16161e",
21
+ text: "#a9b1d6",
22
+ muted: "#565f89",
23
+ cyan: "#7dcfff",
24
+ purple: "#bb9af7",
25
+ green: "#9ece6a",
26
+ gold: "#e0af68",
27
+ orange: "#ff9e64",
28
+ red: "#f7768e",
29
+ blue: "#7aa2f7",
30
+ lightBlue: "#b4f9f8",
31
+ selectionBg: "#3b6ce8",
32
+ selectionText: "#c0caf5",
33
+ border: "#363b54",
34
+ descText: "#6c7086", // lighter muted for descriptions
35
+ nameText: "#c0caf5", // bright for package names
36
+ };
37
+ // ─── Number formatting ────────────────────────────────────────────────
38
+ const INT_FMT = new Intl.NumberFormat("en-US");
39
+ /** 523219736 → "523,219,736" (full downloads tally, no compaction) */
40
+ function fmtCount(n: number): string {
41
+ return INT_FMT.format(Math.trunc(n));
42
+ }
43
+ // ─── Row formatting ───────────────────────────────────────────────────
44
+ /** Format one package as a 2-line StyledText (name line + description line). */
45
+ function formatPackageLine(pack: Package): StyledText {
46
+ const nameStr = `${pack.name}@${pack.version}`.padEnd(28).slice(0, 28);
47
+ const dlStr = `↓ ${fmtCount(pack.downloads)}`.padEnd(20).slice(0, 20);
48
+ const scoreStr = `score ${pack.score.toFixed(2)}`.padEnd(12).slice(0, 12);
49
+ const desc = (pack.description ?? "").length > 60
50
+ ? (pack.description ?? "").slice(0, 57) + "..."
51
+ : (pack.description ?? "");
52
+ const line1 = t`${bold(fg(C.nameText)(nameStr))}${fg(C.cyan)(dlStr)}${fg(C.gold)(scoreStr)}`;
53
+ const line2 = t`${dim(fg(C.descText)(` ${desc}`))}`;
54
+ const newline: TextChunk = { text: "\n", __isChunk: true as const };
55
+ const chunks: TextChunk[] = [...line1.chunks, newline, ...line2.chunks];
56
+ return new StyledText(chunks);
57
+ }
58
+ // ─── Main TUI entry ───────────────────────────────────────────────────
59
+ export async function launchPackageBrowser(): Promise<void> {
60
+ let renderer: CliRenderer;
61
+ try {
62
+ renderer = await createCliRenderer();
63
+ } catch (err) {
64
+ console.error(
65
+ "Failed to start the interactive browser.\n",
66
+ `Reason: ${err instanceof Error ? err.message : String(err)}\n`,
67
+ );
68
+ process.exit(1);
69
+ return;
70
+ }
71
+ const root = renderer.root;
72
+ root.flexDirection = "column";
73
+ // ── State ──
74
+ let query = "";
75
+ let dirty = false; // input edited since last search → Enter re-runs the search
76
+ let packages: Package[] = [];
77
+ let selectedIdx = 0;
78
+ let isLoading = false;
79
+ // ── Outer frame ──
80
+ const outerBox = new BoxRenderable(renderer, {
81
+ flexGrow: 1,
82
+ flexDirection: "column",
83
+ backgroundColor: C.bg,
84
+ border: true,
85
+ borderColor: C.border,
86
+ borderStyle: "rounded",
87
+ paddingX: 0,
88
+ paddingY: 0,
89
+ });
90
+ // ── Header bar ──
91
+ const headerBox = new BoxRenderable(renderer, {
92
+ height: 1,
93
+ backgroundColor: C.bg,
94
+ paddingX: 1,
95
+ });
96
+ const headerText = new TextRenderable(renderer, {
97
+ content: t`${bold(fg(C.purple)("⟠ tulipsearch"))}${fg(C.muted)(" — Packages")}`,
98
+ fg: C.purple,
99
+ bg: C.bg,
100
+ height: 1,
101
+ });
102
+ headerBox.add(headerText);
103
+ outerBox.add(headerBox);
104
+ // ── Query input line ──
105
+ const inputBox = new BoxRenderable(renderer, {
106
+ height: 1,
107
+ flexDirection: "row",
108
+ backgroundColor: C.surface,
109
+ paddingX: 1,
110
+ });
111
+ let inputText: TextRenderable | null = null;
112
+ function renderInput() {
113
+ if (inputText) inputBox.remove(inputText);
114
+ const cursor = isLoading ? " " : "▊";
115
+ inputText = new TextRenderable(renderer, {
116
+ content: t`${fg(C.cyan)("❯ ")}${query}${dim(fg(C.muted)(cursor))}`,
117
+ fg: C.text,
118
+ bg: C.surface,
119
+ height: 1,
120
+ });
121
+ inputBox.add(inputText);
122
+ }
123
+ // ── Status line (loading / error / result count) ──
124
+ const statusBox = new BoxRenderable(renderer, {
125
+ height: 1,
126
+ backgroundColor: C.bg,
127
+ paddingX: 1,
128
+ });
129
+ let statusText: TextRenderable | null = null;
130
+ function setStatus(cfg: { content: StyledText; color?: string }) {
131
+ if (statusText) statusBox.remove(statusText);
132
+ statusText = new TextRenderable(renderer, {
133
+ content: cfg.content,
134
+ fg: cfg.color ?? C.text,
135
+ bg: C.bg,
136
+ height: 1,
137
+ });
138
+ statusBox.add(statusText);
139
+ }
140
+ // ── Package list (scrollable) ──
141
+ const scrollBox = new ScrollBoxRenderable(renderer, {
142
+ flexGrow: 1,
143
+ backgroundColor: C.bg,
144
+ scrollY: true,
145
+ scrollX: false,
146
+ viewportCulling: true,
147
+ });
148
+ const rowBoxes: BoxRenderable[] = [];
149
+ const rowTexts: TextRenderable[] = [];
150
+ function clearRows() {
151
+ for (const rb of rowBoxes) scrollBox.remove(rb);
152
+ rowBoxes.length = 0;
153
+ rowTexts.length = 0;
154
+ }
155
+ function rebuildList() {
156
+ clearRows();
157
+ packages.forEach((p, i) => {
158
+ const isEven = i % 2 === 0;
159
+ const isSelected = i === selectedIdx;
160
+ const rowBg = isSelected ? C.selectionBg : (isEven ? C.bg : C.surface);
161
+ const rowText = new TextRenderable(renderer, {
162
+ content: formatPackageLine(p),
163
+ bg: rowBg,
164
+ height: 2,
165
+ });
166
+ const rowBox = new BoxRenderable(renderer, {
167
+ flexDirection: "row",
168
+ backgroundColor: rowBg,
169
+ });
170
+ rowBox.add(rowText);
171
+ rowBoxes.push(rowBox);
172
+ rowTexts.push(rowText);
173
+ scrollBox.add(rowBox);
174
+ });
175
+ }
176
+ // ── Footer bar ──
177
+ const footerBox = new BoxRenderable(renderer, {
178
+ height: 1,
179
+ backgroundColor: C.bg,
180
+ paddingX: 1,
181
+ });
182
+ const footerText = new TextRenderable(renderer, {
183
+ content: " type query ↵ search ↑↓ navigate ↵ open q/esc quit",
184
+ fg: C.muted,
185
+ bg: C.bg,
186
+ height: 1,
187
+ });
188
+ footerBox.add(footerText);
189
+ outerBox.add(footerBox);
190
+ root.add(outerBox);
191
+ outerBox.add(inputBox);
192
+ outerBox.add(statusBox);
193
+ const divider = new TextRenderable(renderer, {
194
+ content: "─".repeat(80),
195
+ fg: C.border,
196
+ bg: C.bg,
197
+ height: 1,
198
+ });
199
+ outerBox.add(divider);
200
+ outerBox.add(scrollBox);
201
+ // ── Fetch ──
202
+ async function runSearch() {
203
+ const q = query.trim();
204
+ if (!q) return;
205
+ isLoading = true;
206
+ dirty = false;
207
+ clearRows();
208
+ setStatus({ content: t`${dim(fg(C.muted)(` Loading packages for "${q}"...`))}` });
209
+ renderer.requestRender();
210
+ try {
211
+ const searcher = createPackageSearch();
212
+ const res = await searcher.searchPackage(q, 25);
213
+ packages = res.packages;
214
+ selectedIdx = 0;
215
+ rebuildList();
216
+ setStatus({
217
+ content: t`${fg(C.muted)(` ${packages.length} of ${fmtCount(res.totalCount)} results for `)}${fg(C.cyan)(`"${q}"`)}`,
218
+ });
219
+ } catch (err) {
220
+ packages = [];
221
+ clearRows();
222
+ const msg = err instanceof Error ? err.message : String(err);
223
+ setStatus({
224
+ content: t`${fg(C.red)(` Error: ${msg.slice(0, 70)} — press ↵ to retry`)}`,
225
+ });
226
+ }
227
+ isLoading = false;
228
+ renderInput();
229
+ renderer.requestRender();
230
+ }
231
+ // ── Helpers ──
232
+ function openSelectedPackage() {
233
+ if (selectedIdx < 0 || selectedIdx >= packages.length) return;
234
+ const p = packages[selectedIdx];
235
+ openUrl(p.url);
236
+ }
237
+ function moveSelection(delta: number) {
238
+ const oldIdx = selectedIdx;
239
+ const newIdx = Math.max(0, Math.min(packages.length - 1, oldIdx + delta));
240
+ if (newIdx === oldIdx) return;
241
+ selectedIdx = newIdx;
242
+ const setBg = (idx: number, selected: boolean) => {
243
+ if (idx < 0 || idx >= rowBoxes.length) return;
244
+ const bg = selected ? C.selectionBg : (idx % 2 === 0 ? C.bg : C.surface);
245
+ rowBoxes[idx].backgroundColor = bg;
246
+ if (rowTexts[idx]) rowTexts[idx].bg = bg;
247
+ };
248
+ setBg(oldIdx, false);
249
+ setBg(newIdx, true);
250
+ renderer.requestRender();
251
+ }
252
+ // ── Global key events ──
253
+ renderer.keyInput.on("keypress", (key: import("@opentui/core").KeyEvent) => {
254
+ const name = key.name || "";
255
+ if (name === "q" || name === "escape") {
256
+ process.exit(0);
257
+ return;
258
+ }
259
+ if (name === "backspace") {
260
+ query = query.slice(0, -1);
261
+ dirty = true;
262
+ renderInput();
263
+ renderer.requestRender();
264
+ return;
265
+ }
266
+ if (name === "space") {
267
+ query += " ";
268
+ dirty = true;
269
+ renderInput();
270
+ renderer.requestRender();
271
+ return;
272
+ }
273
+ // Printable single-char inputs edit the query.
274
+ if (name.length === 1) {
275
+ query += name;
276
+ dirty = true;
277
+ renderInput();
278
+ renderer.requestRender();
279
+ return;
280
+ }
281
+ if (name === "up" || name === "k") {
282
+ moveSelection(-1);
283
+ return;
284
+ }
285
+ if (name === "down" || name === "j") {
286
+ moveSelection(1);
287
+ return;
288
+ }
289
+ if (name === "enter" || name === "return") {
290
+ if (dirty || packages.length === 0) {
291
+ runSearch();
292
+ } else {
293
+ openSelectedPackage();
294
+ }
295
+ return;
296
+ }
297
+ });
298
+ // ── Start ──
299
+ renderer.start();
300
+ renderInput();
301
+ setStatus({
302
+ content: t`${fg(C.muted)(" Type a package query above and press ↵ to search")}`,
303
+ });
304
+ renderer.requestRender();
305
+ }
package/src/package.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * package.ts — package-registry search provider (prototype for ticket 002-008).
3
+ *
4
+ * npm-only for the MVP (ticket 002-005). Query is passed through verbatim to npm's
5
+ * native `/-/v1/search` (ticket 002-006) and the response normalized to the parallel
6
+ * `Package` shape (ticket 002-007). Rough on purpose — this is the artifact to react to.
7
+ */
8
+ import type { Package } from "./types";
9
+
10
+ export interface PackageSearchResult {
11
+ totalCount: number;
12
+ packages: Package[];
13
+ }
14
+
15
+ /** The npm `/-/v1/search` object shape we actually consume (research 002-001). */
16
+ interface NpmSearchObject {
17
+ package: {
18
+ name?: string;
19
+ scope?: string;
20
+ version?: string;
21
+ description?: string | null;
22
+ keywords?: string[];
23
+ date?: string;
24
+ links?: { npm?: string; homepage?: string; repository?: string };
25
+ publisher?: { username?: string };
26
+ author?: { name?: string };
27
+ };
28
+ score?: { final?: number; detail?: { quality?: number; popularity?: number; maintenance?: number } };
29
+ searchScore?: number;
30
+ downloads?: { npm?: number; total?: number; monthly?: number };
31
+ }
32
+
33
+ export function normalizeNpmObject(obj: NpmSearchObject): Package {
34
+ const p = obj.package;
35
+ const name = p.name ?? "";
36
+ return {
37
+ name,
38
+ version: p.version ?? "",
39
+ description: p.description ?? null,
40
+ url: p.links?.npm ?? `https://www.npmjs.com/package/${name}`,
41
+ downloads: obj.downloads?.monthly ?? 0,
42
+ score: obj.score?.final ?? obj.searchScore ?? 0,
43
+ publisher: p.publisher?.username ?? p.author?.name ?? null,
44
+ keywords: p.keywords ?? [],
45
+ registry: "npm",
46
+ };
47
+ }
48
+
49
+ export function createPackageSearch() {
50
+ return {
51
+ async searchPackage(rawQuery: string, limit: number): Promise<PackageSearchResult> {
52
+ const size = Math.min(Math.max(Math.trunc(limit) || 50, 1), 250); // npm caps size at 250
53
+ const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(rawQuery)}&size=${size}`;
54
+ const res = await fetch(url, {
55
+ headers: { "User-Agent": "ghfind", Accept: "application/json" },
56
+ });
57
+ if (!res.ok) {
58
+ throw new Error(`npm search failed: HTTP ${res.status}`);
59
+ }
60
+ const data = (await res.json()) as { objects?: NpmSearchObject[]; total?: number };
61
+ const packages = (data.objects ?? []).map(normalizeNpmObject);
62
+ return { totalCount: data.total ?? packages.length, packages };
63
+ },
64
+ };
65
+ }
66
+
67
+ /** Sort strategies for package results (npm registry order IS best-match). */
68
+ export type PackageSortMode = "best-match" | "score" | "downloads" | "name";
69
+
70
+ /** Sort modes in cycle order for the TUI Sort menu. */
71
+ export const PACKAGE_SORT_MODES: { key: PackageSortMode; label: string }[] = [
72
+ { key: "best-match", label: "best-match" },
73
+ { key: "downloads", label: "downloads" },
74
+ { key: "score", label: "score" },
75
+ { key: "name", label: "name" },
76
+ ];
77
+
78
+ /**
79
+ * Sort fetched packages. "best-match" is a no-op: it preserves the registry's
80
+ * relevance order, which is the canonical npm ranking.
81
+ */
82
+ export function sortPackages(pkgs: Package[], sort: PackageSortMode): Package[] {
83
+ if (sort === "best-match" || pkgs.length < 2) return pkgs;
84
+ const arr = [...pkgs];
85
+ if (sort === "score") {
86
+ arr.sort((a, b) => b.score - a.score);
87
+ } else if (sort === "downloads") {
88
+ arr.sort((a, b) => b.downloads - a.downloads);
89
+ } else {
90
+ arr.sort((a, b) => a.name.localeCompare(b.name));
91
+ }
92
+ return arr;
93
+ }
94
+
95
+ // ── Package output formatters (parallel to output.ts for repo) ─────────
96
+ // Entity model (ticket 002-007): json/text/count/limit are shared generics
97
+ // (json uses JSON.stringify on Package[]); csv/markdown/urls/names get package
98
+ // variants here. git-only formats (ssh-urls, clone-commands, ids) stay repo-only.
99
+
100
+ function csvEscape(val: unknown): string {
101
+ const str = String(val ?? "");
102
+ if (str.includes('"') || str.includes(",") || str.includes("\n")) {
103
+ return `"${str.replace(/"/g, '""')}"`;
104
+ }
105
+ return str;
106
+ }
107
+
108
+ export function formatPackagesJson(pkgs: Package[]): string {
109
+ return JSON.stringify(pkgs, null, 2);
110
+ }
111
+
112
+ export function formatPackagesCsv(pkgs: Package[]): string {
113
+ const header = "rank,name,version,downloads,score,url";
114
+ const rows = pkgs.map(
115
+ (p, i) =>
116
+ `${i + 1},${csvEscape(p.name)},${p.version},${p.downloads},${p.score.toFixed(2)},${csvEscape(p.url)}`,
117
+ );
118
+ return [header, ...rows].join("\n");
119
+ }
120
+
121
+ export function formatPackagesMarkdown(pkgs: Package[]): string {
122
+ const header = "| # | Package | Version | Downloads | Score |";
123
+ const sep = "|---|---------|---------|-----------|-------|";
124
+ const rows = pkgs.map(
125
+ (p, i) =>
126
+ `| ${i + 1} | [${p.name}@${p.version}](${p.url}) | ${p.version} | ${p.downloads.toLocaleString()} | ${p.score.toFixed(2)} |`,
127
+ );
128
+ return [header, sep, ...rows].join("\n");
129
+ }
130
+
131
+ export function formatPackagesUrls(pkgs: Package[]): string {
132
+ return pkgs.map((p) => p.url).join("\n");
133
+ }
134
+
135
+ export function formatPackagesNames(pkgs: Package[]): string {
136
+ return pkgs.map((p) => p.name).join("\n");
137
+ }
package/src/search.ts CHANGED
@@ -612,6 +612,7 @@ export class SearchModule implements SearchProvider {
612
612
  options.sort,
613
613
  String(options.limit),
614
614
  String(options.page ?? 1),
615
+ options.trendingSince ?? "none",
615
616
  );
616
617
  const cached = SearchModule.cache.get(cacheKey);
617
618
  if (cached) {
package/src/themes.ts CHANGED
@@ -38,48 +38,72 @@ export interface Theme {
38
38
  warning: string;
39
39
  /** Error state color */
40
40
  error: string;
41
+ /** Modal/overlay panel background (darker than surface) */
42
+ surfaceDim: string;
43
+ /** Primary accent for titles and highlights */
44
+ accent: string;
45
+ /** Dimmed accent for secondary accents */
46
+ accentDim: string;
47
+ /** Border for floating/modal panels */
48
+ borderAccent: string;
41
49
  }
42
50
 
43
51
  const THEMES: Record<string, Theme> = {
44
- "premium-dark": {
45
- name: "premium-dark",
46
- bg: "#3D3B3B", surface: "#4a4848", surfaceAlt: "#525050",
47
- text: "#e0e4f0", muted: "#a8a6a6",
48
- blue: "#89b4fa", green: "#a6e3a1", yellow: "#f9e2af", red: "#f38ba8",
49
- teal: "#94e2d5", purple: "#cba6f7", orange: "#fab387",
50
- border: "#1C1C1C", borderAlt: "#2a2a2a", separator: "#1C1C1C",
51
- selectionBg: "#2A2A9C", selectionText: "#ffffff",
52
- success: "#a6e3a1", warning: "#f9e2af", error: "#f38ba8",
53
- },
54
52
  "tokyo-night": {
55
53
  name: "tokyo-night",
56
- bg: "#3D3B3B", surface: "#4a4848", surfaceAlt: "#525050",
57
- text: "#e0e4f0", muted: "#a8a6a6",
58
- blue: "#89b4fa", green: "#a6e3a1", yellow: "#f9e2af", red: "#f38ba8",
59
- teal: "#94e2d5", purple: "#cba6f7", orange: "#fab387",
60
- border: "#1C1C1C", borderAlt: "#2a2a2a", separator: "#1C1C1C",
61
- selectionBg: "#2A2A9C", selectionText: "#ffffff",
62
- success: "#a6e3a1", warning: "#f9e2af", error: "#f38ba8",
54
+ // Genuine Tokyo Night palette — cool blue-violet mood
55
+ bg: "#1a1b26",
56
+ surface: "#24283b",
57
+ surfaceAlt: "#292e42",
58
+ text: "#c0caf5",
59
+ muted: "#565f89",
60
+ blue: "#7aa2f7",
61
+ green: "#9ece6a",
62
+ yellow: "#e0af68",
63
+ red: "#f7768e",
64
+ teal: "#73daca",
65
+ purple: "#bb9af7",
66
+ orange: "#ff9e64",
67
+ border: "#24283b",
68
+ borderAlt: "#1f2335",
69
+ separator: "#1f2335",
70
+ selectionBg: "#2d3f76",
71
+ selectionText: "#c0caf5",
72
+ success: "#9ece6a",
73
+ warning: "#e0af68",
74
+ error: "#f7768e",
75
+ surfaceDim: "#16161e",
76
+ accent: "#7aa2f7",
77
+ accentDim: "#5d7abf",
78
+ borderAccent: "#1f2335",
63
79
  },
64
- dracula: {
65
- name: "dracula",
66
- bg: "#3D3B3B", surface: "#4a4848", surfaceAlt: "#525050",
67
- text: "#f8f8f2", muted: "#a8a6a6",
68
- blue: "#8be9fd", green: "#50fa7b", yellow: "#f1fa8c", red: "#ff5555",
69
- teal: "#50fa7b", purple: "#bd93f9", orange: "#ffb86c",
70
- border: "#1C1C1C", borderAlt: "#2a2a2a", separator: "#1C1C1C",
71
- selectionBg: "#2A2A9C", selectionText: "#ffffff",
72
- success: "#50fa7b", warning: "#f1fa8c", error: "#ff5555",
73
- },
74
- monokai: {
75
- name: "monokai",
76
- bg: "#3D3B3B", surface: "#4a4848", surfaceAlt: "#525050",
77
- text: "#f8f8f2", muted: "#a8a6a6",
78
- blue: "#66d9ef", green: "#a6e22e", yellow: "#e6db74", red: "#f92672",
79
- teal: "#a6e22e", purple: "#ae81ff", orange: "#fd971f",
80
- border: "#1C1C1C", borderAlt: "#2a2a2a", separator: "#1C1C1C",
81
- selectionBg: "#2A2A9C", selectionText: "#f8f8f2",
82
- success: "#a6e22e", warning: "#e6db74", error: "#f92672",
80
+ "premium-dark": {
81
+ name: "premium-dark",
82
+ // Warm counter-mood to Tokyo Night — amber/rust accent
83
+ bg: "#1e1e2e",
84
+ surface: "#262637",
85
+ surfaceAlt: "#2e2e42",
86
+ text: "#cdd6f4",
87
+ muted: "#6c7086",
88
+ blue: "#89b4fa",
89
+ green: "#a6e3a1",
90
+ yellow: "#f9e2af",
91
+ red: "#f38ba8",
92
+ teal: "#94e2d5",
93
+ purple: "#cba6f7",
94
+ orange: "#fab387",
95
+ border: "#313244",
96
+ borderAlt: "#2a2a3d",
97
+ separator: "#2a2a3d",
98
+ selectionBg: "#45475a",
99
+ selectionText: "#cdd6f4",
100
+ success: "#a6e3a1",
101
+ warning: "#f9e2af",
102
+ error: "#f38ba8",
103
+ surfaceDim: "#181825",
104
+ accent: "#f9e2af",
105
+ accentDim: "#c9a87a",
106
+ borderAccent: "#313244",
83
107
  },
84
108
  };
85
109
 
package/src/trending.ts CHANGED
@@ -253,6 +253,14 @@ export async function launchTrending(): Promise<void> {
253
253
  paddingX: 1,
254
254
  });
255
255
  const tabTexts: TextRenderable[] = [];
256
+ const underlineBox = new BoxRenderable(renderer, {
257
+ height: 1,
258
+ backgroundColor: C.bg,
259
+ paddingX: 1,
260
+ });
261
+ function tabWidth(name: string): number {
262
+ return name.length + 2; // " name " padding
263
+ }
256
264
  function renderTabBar() {
257
265
  for (const t of tabTexts) tabBox.remove(t);
258
266
  tabTexts.length = 0;
@@ -261,8 +269,8 @@ export async function launchTrending(): Promise<void> {
261
269
  const label = ` ${name} `;
262
270
  const tt = new TextRenderable(renderer, {
263
271
  content: isActive ? t`${bold(fg(C.bg)(label))}` : t`${fg(C.muted)(label)}`,
264
- color: isActive ? C.bg : C.muted,
265
- backgroundColor: isActive ? C.cyan : C.bg,
272
+ fg: isActive ? C.bg : C.muted,
273
+ bg: isActive ? C.cyan : C.bg,
266
274
  height: 1,
267
275
  });
268
276
  tabTexts.push(tt);
@@ -270,17 +278,35 @@ export async function launchTrending(): Promise<void> {
270
278
  if (i < TAB_NAMES.length - 1) {
271
279
  const sp = new TextRenderable(renderer, {
272
280
  content: " ",
273
- color: C.muted,
274
- backgroundColor: C.bg,
281
+ fg: C.muted,
282
+ bg: C.bg,
275
283
  height: 1,
276
284
  });
277
285
  tabTexts.push(sp);
278
286
  tabBox.add(sp);
279
287
  }
280
288
  });
289
+ // ── cyan underline bar under active tab ──
290
+ let offset = 1; // paddingX
291
+ for (let i = 0; i < selectedTab; i++) {
292
+ offset += tabWidth(TAB_NAMES[i]) + 2; // tab + separator
293
+ }
294
+ const w = tabWidth(TAB_NAMES[selectedTab]);
295
+ const dots = "┉".repeat(w);
296
+ underlineBox.remove(underlineText);
297
+ underlineText.content = t`${fg(C.cyan)(dots)}`;
298
+ underlineText.left = offset;
281
299
  }
300
+ const underlineText = new TextRenderable(renderer, {
301
+ content: "",
302
+ fg: C.cyan,
303
+ bg: C.bg,
304
+ height: 1,
305
+ });
306
+ underlineBox.add(underlineText);
282
307
  renderTabBar();
283
308
  outerBox.add(tabBox);
309
+ outerBox.add(underlineBox);
284
310
  const divider = new TextRenderable(renderer, {
285
311
  content: "─".repeat(80),
286
312
  color: C.border,
@@ -357,7 +383,7 @@ export async function launchTrending(): Promise<void> {
357
383
  paddingX: 1,
358
384
  });
359
385
  const footerText = new TextRenderable(renderer, {
360
- content: " ↑↓ navigate ↵ open / search 1-5 tab q quit r refresh",
386
+ content: " ↑↓ navigate ↵ open / search 1-3 tab q quit r refresh",
361
387
  color: C.muted,
362
388
  height: 1,
363
389
  });