github-search-cli 9.4.2 → 9.4.3

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +26 -4
  2. package/README.md +8 -3
  3. package/bin/ghfind.js +98 -89
  4. package/completions/ghfind.bash +1 -1
  5. package/completions/ghfind.fish +3 -1
  6. package/completions/ghfind.zsh +6 -0
  7. package/dist/{chunk-IVUU2KGR.js → chunk-GYIL4S5X.js} +4 -1
  8. package/dist/cli.js +761 -342
  9. package/dist/{config-GNZOQRTH.js → config-OCIPF2IF.js} +1 -1
  10. package/package.json +6 -3
  11. package/release-notes/9.3.0.md +32 -29
  12. package/scripts/build.ts +34 -19
  13. package/scripts/generate-svg-demo.js +218 -168
  14. package/scripts/postinstall.js +176 -176
  15. package/src/bookmarks.ts +69 -69
  16. package/src/cli.ts +17 -1
  17. package/src/compare.ts +69 -47
  18. package/src/config.ts +70 -65
  19. package/src/deepdive.ts +149 -118
  20. package/src/doctor.ts +287 -287
  21. package/src/errors.ts +62 -59
  22. package/src/explore.ts +34 -29
  23. package/src/export.ts +73 -63
  24. package/src/help.ts +157 -157
  25. package/src/history.ts +69 -55
  26. package/src/init.ts +60 -55
  27. package/src/landing.ts +106 -104
  28. package/src/login.ts +186 -0
  29. package/src/markdown-render.ts +1 -6
  30. package/src/notifications.ts +75 -59
  31. package/src/open-url.ts +16 -16
  32. package/src/org.ts +43 -15
  33. package/src/output.ts +153 -137
  34. package/src/package-browser.ts +317 -305
  35. package/src/package.ts +149 -137
  36. package/src/pipe.ts +60 -54
  37. package/src/releases.ts +182 -144
  38. package/src/saved-searches.ts +72 -50
  39. package/src/search-state.ts +283 -229
  40. package/src/search.ts +678 -653
  41. package/src/session.ts +26 -24
  42. package/src/share.ts +61 -54
  43. package/src/status.ts +68 -61
  44. package/src/storage.ts +136 -106
  45. package/src/themes.ts +259 -116
  46. package/src/tips.ts +42 -42
  47. package/src/toast.ts +70 -69
  48. package/src/trending-parser.ts +104 -0
  49. package/src/trending.ts +481 -462
  50. package/src/tui.ts +149 -153
  51. package/src/types.ts +160 -158
  52. package/src/watch.ts +95 -74
@@ -1,305 +1,317 @@
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
+ import { detectTerminalBackground, deriveSurfaceLayers } from "./themes";
19
+ const C = {
20
+ bg: "#1a1b26",
21
+ surface: "#16161e",
22
+ text: "#a9b1d6",
23
+ muted: "#565f89",
24
+ cyan: "#7dcfff",
25
+ purple: "#bb9af7",
26
+ green: "#9ece6a",
27
+ gold: "#e0af68",
28
+ orange: "#ff9e64",
29
+ red: "#f7768e",
30
+ blue: "#7aa2f7",
31
+ lightBlue: "#b4f9f8",
32
+ selectionBg: "#3b6ce8",
33
+ selectionText: "#c0caf5",
34
+ border: "#363b54",
35
+ descText: "#6c7086", // lighter muted for descriptions
36
+ nameText: "#c0caf5", // bright for package names
37
+ };
38
+ // ─── Number formatting ────────────────────────────────────────────────
39
+ const INT_FMT = new Intl.NumberFormat("en-US");
40
+ /** 523219736 → "523,219,736" (full downloads tally, no compaction) */
41
+ function fmtCount(n: number): string {
42
+ return INT_FMT.format(Math.trunc(n));
43
+ }
44
+ // ─── Row formatting ───────────────────────────────────────────────────
45
+ /** Format one package as a 2-line StyledText (name line + description line). */
46
+ function formatPackageLine(pack: Package): StyledText {
47
+ const nameStr = `${pack.name}@${pack.version}`.padEnd(28).slice(0, 28);
48
+ const dlStr = `↓ ${fmtCount(pack.downloads)}`.padEnd(20).slice(0, 20);
49
+ const scoreStr = `score ${pack.score.toFixed(2)}`.padEnd(12).slice(0, 12);
50
+ const desc =
51
+ (pack.description ?? "").length > 60
52
+ ? (pack.description ?? "").slice(0, 57) + "..."
53
+ : (pack.description ?? "");
54
+ const line1 = t`${bold(fg(C.nameText)(nameStr))}${fg(C.cyan)(dlStr)}${fg(C.gold)(scoreStr)}`;
55
+ const line2 = t`${dim(fg(C.descText)(` ${desc}`))}`;
56
+ const newline: TextChunk = { text: "\n", __isChunk: true as const };
57
+ const chunks: TextChunk[] = [...line1.chunks, newline, ...line2.chunks];
58
+ return new StyledText(chunks);
59
+ }
60
+ // ─── Main TUI entry ───────────────────────────────────────────────────
61
+ export async function launchPackageBrowser(): Promise<void> {
62
+ // Match the app backdrop to the terminal's own background (OSC 11), before
63
+ // the renderer takes over stdin. Falls back to the local palette default.
64
+ // Surface and border layers are blended toward that background as well.
65
+ const terminalBg = await detectTerminalBackground();
66
+ if (terminalBg) {
67
+ C.bg = terminalBg;
68
+ Object.assign(C, deriveSurfaceLayers(terminalBg));
69
+ }
70
+ let renderer: CliRenderer;
71
+ try {
72
+ renderer = await createCliRenderer();
73
+ } catch (err) {
74
+ console.error(
75
+ "Failed to start the interactive browser.\n",
76
+ `Reason: ${err instanceof Error ? err.message : String(err)}\n`,
77
+ );
78
+ process.exit(1);
79
+ return;
80
+ }
81
+ const root = renderer.root;
82
+ root.flexDirection = "column";
83
+ // ── State ──
84
+ let query = "";
85
+ let dirty = false; // input edited since last search → Enter re-runs the search
86
+ let packages: Package[] = [];
87
+ let selectedIdx = 0;
88
+ let isLoading = false;
89
+ // ── Outer frame ──
90
+ const outerBox = new BoxRenderable(renderer, {
91
+ flexGrow: 1,
92
+ flexDirection: "column",
93
+ backgroundColor: C.bg,
94
+ border: true,
95
+ borderColor: C.border,
96
+ borderStyle: "rounded",
97
+ paddingX: 0,
98
+ paddingY: 0,
99
+ });
100
+ // ── Header bar ──
101
+ const headerBox = new BoxRenderable(renderer, {
102
+ height: 1,
103
+ backgroundColor: C.bg,
104
+ paddingX: 1,
105
+ });
106
+ const headerText = new TextRenderable(renderer, {
107
+ content: t`${bold(fg(C.purple)("⟠ tulipsearch"))}${fg(C.muted)(" — Packages")}`,
108
+ fg: C.purple,
109
+ bg: C.bg,
110
+ height: 1,
111
+ });
112
+ headerBox.add(headerText);
113
+ outerBox.add(headerBox);
114
+ // ── Query input line ──
115
+ const inputBox = new BoxRenderable(renderer, {
116
+ height: 1,
117
+ flexDirection: "row",
118
+ backgroundColor: C.surface,
119
+ paddingX: 1,
120
+ });
121
+ let inputText: TextRenderable | null = null;
122
+ function renderInput() {
123
+ if (inputText) inputBox.remove(inputText);
124
+ const cursor = isLoading ? " " : "▊";
125
+ inputText = new TextRenderable(renderer, {
126
+ content: t`${fg(C.cyan)("❯ ")}${query}${dim(fg(C.muted)(cursor))}`,
127
+ fg: C.text,
128
+ bg: C.surface,
129
+ height: 1,
130
+ });
131
+ inputBox.add(inputText);
132
+ }
133
+ // ── Status line (loading / error / result count) ──
134
+ const statusBox = new BoxRenderable(renderer, {
135
+ height: 1,
136
+ backgroundColor: C.bg,
137
+ paddingX: 1,
138
+ });
139
+ let statusText: TextRenderable | null = null;
140
+ function setStatus(cfg: { content: StyledText; color?: string }) {
141
+ if (statusText) statusBox.remove(statusText);
142
+ statusText = new TextRenderable(renderer, {
143
+ content: cfg.content,
144
+ fg: cfg.color ?? C.text,
145
+ bg: C.bg,
146
+ height: 1,
147
+ });
148
+ statusBox.add(statusText);
149
+ }
150
+ // ── Package list (scrollable) ──
151
+ const scrollBox = new ScrollBoxRenderable(renderer, {
152
+ flexGrow: 1,
153
+ backgroundColor: C.bg,
154
+ scrollY: true,
155
+ scrollX: false,
156
+ viewportCulling: true,
157
+ });
158
+ const rowBoxes: BoxRenderable[] = [];
159
+ const rowTexts: TextRenderable[] = [];
160
+ function clearRows() {
161
+ for (const rb of rowBoxes) scrollBox.remove(rb);
162
+ rowBoxes.length = 0;
163
+ rowTexts.length = 0;
164
+ }
165
+ function rebuildList() {
166
+ clearRows();
167
+ packages.forEach((p, i) => {
168
+ const isEven = i % 2 === 0;
169
+ const isSelected = i === selectedIdx;
170
+ const rowBg = isSelected ? C.selectionBg : isEven ? C.bg : C.surface;
171
+ const rowText = new TextRenderable(renderer, {
172
+ content: formatPackageLine(p),
173
+ bg: rowBg,
174
+ height: 2,
175
+ });
176
+ const rowBox = new BoxRenderable(renderer, {
177
+ flexDirection: "row",
178
+ backgroundColor: rowBg,
179
+ });
180
+ rowBox.add(rowText);
181
+ rowBoxes.push(rowBox);
182
+ rowTexts.push(rowText);
183
+ scrollBox.add(rowBox);
184
+ });
185
+ }
186
+ // ── Footer bar ──
187
+ const footerBox = new BoxRenderable(renderer, {
188
+ height: 1,
189
+ backgroundColor: C.bg,
190
+ paddingX: 1,
191
+ });
192
+ const footerText = new TextRenderable(renderer, {
193
+ content: " type query ↵ search ↑↓ navigate ↵ open q/esc quit",
194
+ fg: C.muted,
195
+ bg: C.bg,
196
+ height: 1,
197
+ });
198
+ footerBox.add(footerText);
199
+ outerBox.add(footerBox);
200
+ root.add(outerBox);
201
+ outerBox.add(inputBox);
202
+ outerBox.add(statusBox);
203
+ const divider = new TextRenderable(renderer, {
204
+ content: "─".repeat(80),
205
+ fg: C.border,
206
+ bg: C.bg,
207
+ height: 1,
208
+ });
209
+ outerBox.add(divider);
210
+ outerBox.add(scrollBox);
211
+ // ── Fetch ──
212
+ async function runSearch() {
213
+ const q = query.trim();
214
+ if (!q) return;
215
+ isLoading = true;
216
+ dirty = false;
217
+ clearRows();
218
+ setStatus({
219
+ content: t`${dim(fg(C.muted)(` Loading packages for "${q}"...`))}`,
220
+ });
221
+ renderer.requestRender();
222
+ try {
223
+ const searcher = createPackageSearch();
224
+ const res = await searcher.searchPackage(q, 25);
225
+ packages = res.packages;
226
+ selectedIdx = 0;
227
+ rebuildList();
228
+ setStatus({
229
+ content: t`${fg(C.muted)(` ${packages.length} of ${fmtCount(res.totalCount)} results for `)}${fg(C.cyan)(`"${q}"`)}`,
230
+ });
231
+ } catch (err) {
232
+ packages = [];
233
+ clearRows();
234
+ const msg = err instanceof Error ? err.message : String(err);
235
+ setStatus({
236
+ content: t`${fg(C.red)(` Error: ${msg.slice(0, 70)} — press ↵ to retry`)}`,
237
+ });
238
+ }
239
+ isLoading = false;
240
+ renderInput();
241
+ renderer.requestRender();
242
+ }
243
+ // ── Helpers ──
244
+ function openSelectedPackage() {
245
+ if (selectedIdx < 0 || selectedIdx >= packages.length) return;
246
+ const p = packages[selectedIdx];
247
+ openUrl(p.url);
248
+ }
249
+ function moveSelection(delta: number) {
250
+ const oldIdx = selectedIdx;
251
+ const newIdx = Math.max(0, Math.min(packages.length - 1, oldIdx + delta));
252
+ if (newIdx === oldIdx) return;
253
+ selectedIdx = newIdx;
254
+ const setBg = (idx: number, selected: boolean) => {
255
+ if (idx < 0 || idx >= rowBoxes.length) return;
256
+ const bg = selected ? C.selectionBg : idx % 2 === 0 ? C.bg : C.surface;
257
+ rowBoxes[idx].backgroundColor = bg;
258
+ if (rowTexts[idx]) rowTexts[idx].bg = bg;
259
+ };
260
+ setBg(oldIdx, false);
261
+ setBg(newIdx, true);
262
+ renderer.requestRender();
263
+ }
264
+ // ── Global key events ──
265
+ renderer.keyInput.on("keypress", (key: import("@opentui/core").KeyEvent) => {
266
+ const name = key.name || "";
267
+ if (name === "q" || name === "escape") {
268
+ process.exit(0);
269
+ return;
270
+ }
271
+ if (name === "backspace") {
272
+ query = query.slice(0, -1);
273
+ dirty = true;
274
+ renderInput();
275
+ renderer.requestRender();
276
+ return;
277
+ }
278
+ if (name === "space") {
279
+ query += " ";
280
+ dirty = true;
281
+ renderInput();
282
+ renderer.requestRender();
283
+ return;
284
+ }
285
+ // Printable single-char inputs edit the query.
286
+ if (name.length === 1) {
287
+ query += name;
288
+ dirty = true;
289
+ renderInput();
290
+ renderer.requestRender();
291
+ return;
292
+ }
293
+ if (name === "up" || name === "k") {
294
+ moveSelection(-1);
295
+ return;
296
+ }
297
+ if (name === "down" || name === "j") {
298
+ moveSelection(1);
299
+ return;
300
+ }
301
+ if (name === "enter" || name === "return") {
302
+ if (dirty || packages.length === 0) {
303
+ runSearch();
304
+ } else {
305
+ openSelectedPackage();
306
+ }
307
+ return;
308
+ }
309
+ });
310
+ // ── Start ──
311
+ renderer.start();
312
+ renderInput();
313
+ setStatus({
314
+ content: t`${fg(C.muted)(" Type a package query above and press ↵ to search")}`,
315
+ });
316
+ renderer.requestRender();
317
+ }