github-search-cli 9.1.1 → 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.
@@ -1,305 +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
- }
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
+ }