pi-extmgr 0.1.28 → 0.2.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/ui/remote.ts CHANGED
@@ -5,22 +5,42 @@ import {
5
5
  DynamicBorder,
6
6
  type ExtensionAPI,
7
7
  type ExtensionCommandContext,
8
+ type Theme,
8
9
  } from "@mariozechner/pi-coding-agent";
9
- import { Container, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui";
10
- import { CACHE_LIMITS, PAGE_SIZE, TIMEOUTS } from "../constants.js";
11
10
  import {
11
+ Container,
12
+ fuzzyMatch,
13
+ type Focusable,
14
+ getKeybindings,
15
+ Input,
16
+ Key,
17
+ matchesKey,
18
+ Spacer,
19
+ Text,
20
+ truncateToWidth,
21
+ wrapTextWithAnsi,
22
+ } from "@mariozechner/pi-tui";
23
+ import { CACHE_LIMITS, PAGE_SIZE, TIMEOUTS, UI } from "../constants.js";
24
+ import {
25
+ clearSearchCache,
12
26
  getSearchCache,
13
27
  isCacheValid,
14
28
  searchNpmPackages,
15
29
  setSearchCache,
16
30
  } from "../packages/discovery.js";
17
- import { installPackage, installPackageLocally } from "../packages/install.js";
18
- import { type BrowseAction, type NpmPackage } from "../types/index.js";
31
+ import {
32
+ installPackage,
33
+ installPackageLocallyWithOutcome,
34
+ installPackageWithOutcome,
35
+ } from "../packages/install.js";
36
+ import { type BrowseAction, type NpmPackage, type SearchCache } from "../types/index.js";
19
37
  import { parseChoiceByLabel, splitCommandArgs } from "../utils/command.js";
20
- import { dynamicTruncate, formatBytes, truncate } from "../utils/format.js";
38
+ import { formatBytes, normalizePackageSource, parseNpmSource, truncate } from "../utils/format.js";
21
39
  import { requireCustomUI, runCustomUI } from "../utils/mode.js";
40
+ import { fetchWithTimeout } from "../utils/network.js";
22
41
  import { notify } from "../utils/notify.js";
23
42
  import { execNpm } from "../utils/npm-exec.js";
43
+ import { getPackageSourceKind } from "../utils/package-source.js";
24
44
  import { runTaskWithLoader } from "./async-task.js";
25
45
 
26
46
  interface PackageInfoCacheEntry {
@@ -98,6 +118,30 @@ const packageInfoCache = new PackageInfoCache(
98
118
 
99
119
  export function clearRemotePackageInfoCache(): void {
100
120
  packageInfoCache.clear();
121
+ clearCommunityBrowseCache();
122
+ }
123
+
124
+ function getCommunityBrowseCache(): SearchCache | null {
125
+ const cache = getSearchCache();
126
+ if (!cache || cache.query !== COMMUNITY_BROWSE_QUERY) {
127
+ return null;
128
+ }
129
+
130
+ return isCacheValid(COMMUNITY_BROWSE_QUERY) ? cache : null;
131
+ }
132
+
133
+ function setCommunityBrowseCache(results: NpmPackage[]): void {
134
+ setSearchCache({
135
+ query: COMMUNITY_BROWSE_QUERY,
136
+ results,
137
+ timestamp: Date.now(),
138
+ });
139
+ }
140
+
141
+ function clearCommunityBrowseCache(): void {
142
+ if (getSearchCache()?.query === COMMUNITY_BROWSE_QUERY) {
143
+ clearSearchCache();
144
+ }
101
145
  }
102
146
 
103
147
  const REMOTE_MENU_CHOICES = {
@@ -113,6 +157,218 @@ const PACKAGE_DETAILS_CHOICES = {
113
157
  back: "Back to results",
114
158
  } as const;
115
159
 
160
+ const COMMUNITY_BROWSE_QUERY = "keywords:pi-package";
161
+
162
+ type RemoteBrowseSource = "community" | "npm";
163
+
164
+ type RemoteBrowseQueryPlan =
165
+ | {
166
+ kind: "browse";
167
+ rawQuery: typeof COMMUNITY_BROWSE_QUERY;
168
+ searchQuery: typeof COMMUNITY_BROWSE_QUERY;
169
+ displayQuery: "";
170
+ title: "Community packages";
171
+ }
172
+ | {
173
+ kind: "search";
174
+ rawQuery: string;
175
+ searchQuery: string;
176
+ displayQuery: string;
177
+ title: string;
178
+ exactPackageName?: string;
179
+ }
180
+ | {
181
+ kind: "unsupported";
182
+ rawQuery: string;
183
+ message: string;
184
+ };
185
+
186
+ function findExactPackageLookup(query: string): string | undefined {
187
+ if (!query || /\s/.test(query)) {
188
+ return undefined;
189
+ }
190
+
191
+ const parsed = parseNpmSource(normalizePackageSource(query));
192
+ if (!parsed?.name) {
193
+ return undefined;
194
+ }
195
+
196
+ if (query.startsWith("npm:") || Boolean(parsed.version) || parsed.name.startsWith("@")) {
197
+ return parsed.name.toLowerCase();
198
+ }
199
+
200
+ return undefined;
201
+ }
202
+
203
+ function buildUnsupportedSearchMessage(query: string, kind: "local" | "git"): string {
204
+ const label = truncate(query, 60);
205
+ const sourceLabel = kind === "local" ? "local path" : "git source";
206
+ return `"${label}" looks like a ${sourceLabel}. Remote browse searches npm package names and keywords. Use Install by source instead.`;
207
+ }
208
+
209
+ function createRemoteBrowseQueryPlan(query: string): RemoteBrowseQueryPlan {
210
+ const trimmed = query.trim();
211
+ if (!trimmed || trimmed === COMMUNITY_BROWSE_QUERY) {
212
+ return {
213
+ kind: "browse",
214
+ rawQuery: COMMUNITY_BROWSE_QUERY,
215
+ searchQuery: COMMUNITY_BROWSE_QUERY,
216
+ displayQuery: "",
217
+ title: "Community packages",
218
+ };
219
+ }
220
+
221
+ const sourceKind = getPackageSourceKind(trimmed);
222
+ if (sourceKind === "local" || sourceKind === "git") {
223
+ return {
224
+ kind: "unsupported",
225
+ rawQuery: trimmed,
226
+ message: buildUnsupportedSearchMessage(trimmed, sourceKind),
227
+ };
228
+ }
229
+
230
+ const exactPackageName = findExactPackageLookup(trimmed);
231
+ return {
232
+ kind: "search",
233
+ rawQuery: trimmed,
234
+ searchQuery: exactPackageName ?? trimmed,
235
+ displayQuery: trimmed,
236
+ title: "Remote packages",
237
+ ...(exactPackageName ? { exactPackageName } : {}),
238
+ };
239
+ }
240
+
241
+ function createCommunityBrowsePlan(
242
+ query: string
243
+ ): Exclude<RemoteBrowseQueryPlan, { kind: "unsupported" }> {
244
+ const trimmed = query.trim();
245
+ if (!trimmed || trimmed === COMMUNITY_BROWSE_QUERY) {
246
+ return {
247
+ kind: "browse",
248
+ rawQuery: COMMUNITY_BROWSE_QUERY,
249
+ searchQuery: COMMUNITY_BROWSE_QUERY,
250
+ displayQuery: "",
251
+ title: "Community packages",
252
+ };
253
+ }
254
+
255
+ return {
256
+ kind: "search",
257
+ rawQuery: trimmed,
258
+ searchQuery: COMMUNITY_BROWSE_QUERY,
259
+ displayQuery: trimmed,
260
+ title: "Community packages",
261
+ };
262
+ }
263
+
264
+ function resolveRemoteBrowseSource(query: string, source?: RemoteBrowseSource): RemoteBrowseSource {
265
+ if (source) {
266
+ return source;
267
+ }
268
+
269
+ const trimmed = query.trim();
270
+ return !trimmed || trimmed === COMMUNITY_BROWSE_QUERY ? "community" : "npm";
271
+ }
272
+
273
+ function getCommunitySearchFields(pkg: NpmPackage): {
274
+ primary: string[];
275
+ secondary: string[];
276
+ } {
277
+ return {
278
+ primary: [pkg.name, pkg.author ?? ""]
279
+ .map((value) => value.trim().toLowerCase())
280
+ .filter((value) => value.length > 0),
281
+ secondary: [pkg.description ?? "", ...(pkg.keywords ?? [])]
282
+ .map((value) => value.trim().toLowerCase())
283
+ .filter((value) => value.length > 0),
284
+ };
285
+ }
286
+
287
+ function scoreCommunityBrowseResult(pkg: NpmPackage, query: string): number | undefined {
288
+ const tokens = query
289
+ .trim()
290
+ .toLowerCase()
291
+ .split(/\s+/)
292
+ .filter((token) => token.length > 0);
293
+ if (tokens.length === 0) {
294
+ return 0;
295
+ }
296
+
297
+ const fields = getCommunitySearchFields(pkg);
298
+ let totalScore = 0;
299
+
300
+ for (const token of tokens) {
301
+ const primarySubstringScore = fields.primary.reduce<number | undefined>((best, field) => {
302
+ const index = field.indexOf(token);
303
+ if (index < 0) {
304
+ return best;
305
+ }
306
+ return best === undefined ? index : Math.min(best, index);
307
+ }, undefined);
308
+ if (primarySubstringScore !== undefined) {
309
+ totalScore += primarySubstringScore;
310
+ continue;
311
+ }
312
+
313
+ const secondarySubstringScore = fields.secondary.reduce<number | undefined>((best, field) => {
314
+ const index = field.indexOf(token);
315
+ if (index < 0) {
316
+ return best;
317
+ }
318
+ const score = 100 + index;
319
+ return best === undefined ? score : Math.min(best, score);
320
+ }, undefined);
321
+ if (secondarySubstringScore !== undefined) {
322
+ totalScore += secondarySubstringScore;
323
+ continue;
324
+ }
325
+
326
+ const primaryFuzzyScore = fields.primary.reduce<number | undefined>((best, field) => {
327
+ const match = fuzzyMatch(token, field);
328
+ if (!match.matches) {
329
+ return best;
330
+ }
331
+ const score = 200 + match.score;
332
+ return best === undefined ? score : Math.min(best, score);
333
+ }, undefined);
334
+ if (primaryFuzzyScore !== undefined) {
335
+ totalScore += primaryFuzzyScore;
336
+ continue;
337
+ }
338
+
339
+ return undefined;
340
+ }
341
+
342
+ return totalScore;
343
+ }
344
+
345
+ function filterCommunityBrowseResults(packages: NpmPackage[], query: string): NpmPackage[] {
346
+ const matches = packages
347
+ .map((pkg, index) => ({
348
+ pkg,
349
+ index,
350
+ score: scoreCommunityBrowseResult(pkg, query),
351
+ }))
352
+ .filter(
353
+ (match): match is { pkg: NpmPackage; index: number; score: number } =>
354
+ match.score !== undefined
355
+ );
356
+
357
+ matches.sort((a, b) => a.score - b.score || a.index - b.index);
358
+ return matches.map((match) => match.pkg);
359
+ }
360
+
361
+ function filterRemoteBrowseResults(
362
+ plan: Exclude<RemoteBrowseQueryPlan, { kind: "unsupported" }>,
363
+ packages: NpmPackage[]
364
+ ): NpmPackage[] {
365
+ if (plan.kind !== "search" || !plan.exactPackageName) {
366
+ return packages;
367
+ }
368
+
369
+ return packages.filter((pkg) => pkg.name.toLowerCase() === plan.exactPackageName);
370
+ }
371
+
116
372
  function createAbortError(): Error {
117
373
  const error = new Error("Operation cancelled");
118
374
  error.name = "AbortError";
@@ -134,15 +390,13 @@ async function fetchWeeklyDownloads(
134
390
  packageName: string,
135
391
  signal?: AbortSignal
136
392
  ): Promise<number | undefined> {
137
- const controller = new AbortController();
138
- const timer = setTimeout(() => controller.abort(), TIMEOUTS.weeklyDownloads);
139
- const combinedSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
140
-
141
393
  try {
142
394
  const encoded = encodeURIComponent(packageName);
143
- const res = await fetch(`https://api.npmjs.org/downloads/point/last-week/${encoded}`, {
144
- signal: combinedSignal,
145
- });
395
+ const res = await fetchWithTimeout(
396
+ `https://api.npmjs.org/downloads/point/last-week/${encoded}`,
397
+ TIMEOUTS.weeklyDownloads,
398
+ signal
399
+ );
146
400
 
147
401
  if (!res.ok) return undefined;
148
402
  const data = (await res.json()) as NpmDownloadsPoint;
@@ -152,8 +406,6 @@ async function fetchWeeklyDownloads(
152
406
  throw error;
153
407
  }
154
408
  return undefined;
155
- } finally {
156
- clearTimeout(timer);
157
409
  }
158
410
  }
159
411
 
@@ -238,7 +490,7 @@ export async function showRemote(
238
490
  return;
239
491
  case "browse":
240
492
  case "":
241
- await browseRemotePackages(ctx, "keywords:pi-package", pi);
493
+ await browseRemotePackages(ctx, COMMUNITY_BROWSE_QUERY, pi);
242
494
  return;
243
495
  }
244
496
 
@@ -256,7 +508,7 @@ async function showRemoteMenu(ctx: ExtensionCommandContext, pi: ExtensionAPI): P
256
508
 
257
509
  switch (choice) {
258
510
  case "browse":
259
- await browseRemotePackages(ctx, "keywords:pi-package", pi);
511
+ await browseRemotePackages(ctx, COMMUNITY_BROWSE_QUERY, pi);
260
512
  return;
261
513
  case "search":
262
514
  await promptSearch(ctx, pi);
@@ -269,9 +521,287 @@ async function showRemoteMenu(ctx: ExtensionCommandContext, pi: ExtensionAPI): P
269
521
  }
270
522
  }
271
523
 
524
+ function formatRemotePackageLabel(pkg: NpmPackage, theme: Theme): string {
525
+ const name = theme.bold(pkg.name);
526
+ const version = pkg.version ? theme.fg("dim", `@${pkg.version}`) : "";
527
+ return `${name}${version}`;
528
+ }
529
+
530
+ function formatRemotePackageDetails(
531
+ pkg: NpmPackage,
532
+ selectedNumber: number,
533
+ totalResults: number
534
+ ): string {
535
+ const parts = [
536
+ pkg.description || "No description",
537
+ pkg.author ? `by ${pkg.author}` : undefined,
538
+ `result ${selectedNumber} of ${totalResults}`,
539
+ pkg.keywords?.length ? `keywords: ${pkg.keywords.slice(0, 5).join(", ")}` : undefined,
540
+ pkg.date ? `updated ${pkg.date.slice(0, 10)}` : undefined,
541
+ ];
542
+
543
+ return parts.filter(Boolean).join(" • ");
544
+ }
545
+
546
+ class RemotePackageBrowser implements Focusable {
547
+ private readonly searchInput = new Input();
548
+ private selectedIndex = 0;
549
+ private searchActive = false;
550
+ private _focused = false;
551
+
552
+ constructor(
553
+ private readonly packages: NpmPackage[],
554
+ private readonly theme: Theme,
555
+ private readonly browseSource: RemoteBrowseSource,
556
+ private readonly queryLabel: string,
557
+ private readonly totalResults: number,
558
+ private readonly offset: number,
559
+ private readonly maxVisibleItems: number,
560
+ private readonly showPrevious: boolean,
561
+ private readonly showLoadMore: boolean,
562
+ private readonly onAction: (action: BrowseAction) => void
563
+ ) {
564
+ this.searchInput.setValue(queryLabel);
565
+ }
566
+
567
+ get focused(): boolean {
568
+ return this._focused;
569
+ }
570
+
571
+ set focused(value: boolean) {
572
+ this._focused = value;
573
+ this.searchInput.focused = value && this.searchActive;
574
+ }
575
+
576
+ invalidate(): void {
577
+ this.searchInput.invalidate();
578
+ }
579
+
580
+ handleBrowseInput(data: string): boolean {
581
+ const kb = getKeybindings();
582
+
583
+ if (this.searchActive) {
584
+ if (matchesKey(data, Key.enter)) {
585
+ this.searchActive = false;
586
+ this.searchInput.focused = false;
587
+ this.onAction({ type: "search", query: this.searchInput.getValue().trim() });
588
+ return true;
589
+ }
590
+
591
+ if (matchesKey(data, Key.escape)) {
592
+ this.searchActive = false;
593
+ this.searchInput.focused = false;
594
+ this.searchInput.setValue(this.queryLabel);
595
+ return true;
596
+ }
597
+
598
+ this.searchInput.handleInput(data);
599
+ return true;
600
+ }
601
+
602
+ if (data === "/" || matchesKey(data, Key.ctrl("f"))) {
603
+ this.searchActive = true;
604
+ this.searchInput.setValue("");
605
+ this.searchInput.focused = this._focused;
606
+ return true;
607
+ }
608
+
609
+ if (kb.matches(data, "tui.select.up")) {
610
+ this.moveSelection(-1);
611
+ return true;
612
+ }
613
+
614
+ if (kb.matches(data, "tui.select.down")) {
615
+ this.moveSelection(1);
616
+ return true;
617
+ }
618
+
619
+ if (kb.matches(data, "tui.select.pageUp")) {
620
+ this.moveSelection(-Math.max(1, this.maxVisibleItems - 1));
621
+ return true;
622
+ }
623
+
624
+ if (kb.matches(data, "tui.select.pageDown")) {
625
+ this.moveSelection(Math.max(1, this.maxVisibleItems - 1));
626
+ return true;
627
+ }
628
+
629
+ if (matchesKey(data, Key.home)) {
630
+ this.selectedIndex = 0;
631
+ return true;
632
+ }
633
+
634
+ if (matchesKey(data, Key.end)) {
635
+ this.selectedIndex = Math.max(0, this.packages.length - 1);
636
+ return true;
637
+ }
638
+
639
+ const selected = this.packages[this.selectedIndex];
640
+ if (selected && matchesKey(data, Key.enter)) {
641
+ this.onAction({ type: "package", name: selected.name });
642
+ return true;
643
+ }
644
+
645
+ if ((data === "p" || data === "P") && this.showPrevious) {
646
+ this.onAction({ type: "prev" });
647
+ return true;
648
+ }
649
+
650
+ if ((data === "n" || data === "N") && this.showLoadMore) {
651
+ this.onAction({ type: "next" });
652
+ return true;
653
+ }
654
+
655
+ if (data === "r" || data === "R") {
656
+ this.onAction({ type: "refresh" });
657
+ return true;
658
+ }
659
+
660
+ if (data === "i") {
661
+ this.onAction({ type: "install" });
662
+ return true;
663
+ }
664
+
665
+ if (data === "m" || data === "M") {
666
+ this.onAction({ type: "menu" });
667
+ return true;
668
+ }
669
+
670
+ if (matchesKey(data, Key.escape)) {
671
+ this.onAction({ type: "cancel" });
672
+ return true;
673
+ }
674
+
675
+ return false;
676
+ }
677
+
678
+ render(width: number): string[] {
679
+ const lines: string[] = [];
680
+
681
+ if (this.searchActive) {
682
+ lines.push(...this.searchInput.render(width));
683
+ lines.push("");
684
+ } else if (this.queryLabel) {
685
+ lines.push(
686
+ truncateToWidth(this.theme.fg("accent", ` Search: ${this.queryLabel}`), width, "")
687
+ );
688
+ lines.push("");
689
+ } else {
690
+ lines.push(
691
+ this.theme.fg(
692
+ "muted",
693
+ this.browseSource === "community"
694
+ ? " Browse community packages · / search to filter loaded packages"
695
+ : " Browse remote search results · / search to search npm packages"
696
+ )
697
+ );
698
+ lines.push("");
699
+ }
700
+
701
+ lines.push(truncateToWidth(this.buildSummaryLine(), width, ""));
702
+ lines.push("");
703
+
704
+ const { startIndex, endIndex } = this.getVisibleRange();
705
+ for (const pkg of this.packages.slice(startIndex, endIndex)) {
706
+ lines.push(this.renderPackageLine(pkg, width));
707
+ }
708
+
709
+ if (startIndex > 0 || endIndex < this.packages.length) {
710
+ lines.push("");
711
+ lines.push(
712
+ this.theme.fg(
713
+ "dim",
714
+ ` Showing ${startIndex + 1}-${endIndex} of ${this.packages.length} on this page`
715
+ )
716
+ );
717
+ }
718
+
719
+ const selected = this.packages[this.selectedIndex];
720
+ if (selected) {
721
+ lines.push("");
722
+ const detailText = formatRemotePackageDetails(
723
+ selected,
724
+ this.offset + this.selectedIndex + 1,
725
+ this.totalResults
726
+ );
727
+ for (const line of wrapTextWithAnsi(detailText, width - 4)) {
728
+ lines.push(this.theme.fg("dim", ` ${line}`));
729
+ }
730
+ }
731
+
732
+ lines.push("");
733
+ lines.push(truncateToWidth(this.buildFooterLine(), width, ""));
734
+ return lines;
735
+ }
736
+
737
+ private buildSummaryLine(): string {
738
+ const pageCount = Math.max(1, Math.ceil(this.totalResults / PAGE_SIZE));
739
+ const pageNumber = Math.floor(this.offset / PAGE_SIZE) + 1;
740
+ const rangeEnd = this.offset + this.packages.length;
741
+ const label = this.queryLabel
742
+ ? `Search: ${truncate(this.queryLabel, 40)}`
743
+ : "Community packages";
744
+ return ` ${this.theme.fg("accent", label)} • ${this.theme.fg("muted", `${this.offset + 1}-${rangeEnd} of ${this.totalResults}`)} • ${this.theme.fg("muted", `page ${pageNumber}/${pageCount}`)}`;
745
+ }
746
+
747
+ private buildFooterLine(): string {
748
+ const parts = ["Enter details", "/ search"];
749
+
750
+ if (this.showPrevious) {
751
+ parts.push("p prev");
752
+ }
753
+ if (this.showLoadMore) {
754
+ parts.push("n next");
755
+ }
756
+
757
+ parts.push("r refresh", "i install", "m menu", "Esc back");
758
+ return ` ${this.theme.fg("dim", parts.join(" · "))}`;
759
+ }
760
+
761
+ private renderPackageLine(pkg: NpmPackage, width: number): string {
762
+ const prefix =
763
+ this.packages[this.selectedIndex]?.name === pkg.name ? this.theme.fg("accent", "→ ") : " ";
764
+ return truncateToWidth(prefix + formatRemotePackageLabel(pkg, this.theme), width);
765
+ }
766
+
767
+ private moveSelection(delta: number): void {
768
+ if (this.packages.length === 0) {
769
+ this.selectedIndex = 0;
770
+ return;
771
+ }
772
+
773
+ const nextIndex = this.selectedIndex + delta;
774
+ if (nextIndex < 0) {
775
+ this.selectedIndex = 0;
776
+ return;
777
+ }
778
+
779
+ if (nextIndex >= this.packages.length) {
780
+ this.selectedIndex = this.packages.length - 1;
781
+ return;
782
+ }
783
+
784
+ this.selectedIndex = nextIndex;
785
+ }
786
+
787
+ private getVisibleRange(): { startIndex: number; endIndex: number } {
788
+ const maxVisible = Math.max(1, this.maxVisibleItems);
789
+ const startIndex = Math.max(
790
+ 0,
791
+ Math.min(
792
+ this.selectedIndex - Math.floor(maxVisible / 2),
793
+ Math.max(0, this.packages.length - maxVisible)
794
+ )
795
+ );
796
+ const endIndex = Math.min(startIndex + maxVisible, this.packages.length);
797
+ return { startIndex, endIndex };
798
+ }
799
+ }
800
+
272
801
  async function selectBrowseAction(
273
802
  ctx: ExtensionCommandContext,
274
- titleText: string,
803
+ plan: Exclude<RemoteBrowseQueryPlan, { kind: "unsupported" }>,
804
+ browseSource: RemoteBrowseSource,
275
805
  packages: NpmPackage[],
276
806
  offset: number,
277
807
  totalResults: number,
@@ -280,77 +810,56 @@ async function selectBrowseAction(
280
810
  ): Promise<BrowseAction | undefined> {
281
811
  if (!ctx.hasUI) return undefined;
282
812
 
283
- const items: SelectItem[] = packages.map((p) => ({
284
- value: `pkg:${p.name}`,
285
- label: `${p.name}${p.version ? ` @${p.version}` : ""}`,
286
- description: dynamicTruncate(p.description || "No description", 35),
287
- }));
288
-
289
- if (showPrevious) {
290
- items.push({ value: "nav:prev", label: "◀ Previous page" });
291
- }
292
- if (showLoadMore) {
293
- items.push({
294
- value: "nav:next",
295
- label: `▶ Next page (${offset + 1}-${offset + packages.length} of ${totalResults})`,
296
- });
297
- }
298
- items.push({ value: "nav:refresh", label: "🔄 Refresh search" });
299
- items.push({ value: "nav:menu", label: "← Back to menu" });
300
-
301
813
  return runCustomUI(ctx, "Remote package browsing", () =>
302
814
  ctx.ui.custom<BrowseAction>((tui, theme, _keybindings, done) => {
303
815
  const container = new Container();
304
- const title = new Text("", 1, 0);
305
- const footer = new Text("", 1, 0);
816
+ const title = new Text("", 2, 0);
817
+ const browser = new RemotePackageBrowser(
818
+ packages,
819
+ theme,
820
+ browseSource,
821
+ plan.displayQuery,
822
+ totalResults,
823
+ offset,
824
+ Math.max(4, Math.min(UI.maxListHeight, tui.terminal.rows - 10)),
825
+ showPrevious,
826
+ showLoadMore,
827
+ done
828
+ );
306
829
  const syncThemedContent = (): void => {
307
- title.setText(theme.fg("accent", theme.bold(titleText)));
308
- footer.setText(theme.fg("dim", "↑↓ wraps • enter select • esc cancel"));
830
+ title.setText(theme.fg("accent", theme.bold(plan.title)));
309
831
  };
310
832
 
833
+ syncThemedContent();
311
834
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
312
835
  container.addChild(title);
313
-
314
- const selectList = new SelectList(items, Math.min(items.length, 12), {
315
- selectedPrefix: (t) => theme.fg("accent", t),
316
- selectedText: (t) => theme.fg("accent", t),
317
- description: (t) => theme.fg("muted", t),
318
- scrollInfo: (t) => theme.fg("dim", t),
319
- noMatch: (t) => theme.fg("warning", t),
320
- });
321
-
322
- selectList.onSelect = (item) => {
323
- if (item.value === "nav:prev") {
324
- done({ type: "prev" });
325
- } else if (item.value === "nav:next") {
326
- done({ type: "next" });
327
- } else if (item.value === "nav:refresh") {
328
- done({ type: "refresh" });
329
- } else if (item.value === "nav:menu") {
330
- done({ type: "menu" });
331
- } else if (item.value.startsWith("pkg:")) {
332
- done({ type: "package", name: item.value.slice(4) });
333
- } else {
334
- done({ type: "cancel" });
335
- }
336
- };
337
-
338
- selectList.onCancel = () => done({ type: "cancel" });
339
-
340
- syncThemedContent();
341
- container.addChild(selectList);
342
- container.addChild(footer);
836
+ container.addChild(new Spacer(1));
837
+ container.addChild(browser);
343
838
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
344
839
 
840
+ let focused = false;
841
+
345
842
  return {
346
- render: (w: number) => container.render(w),
347
- invalidate: () => {
843
+ get focused() {
844
+ return focused;
845
+ },
846
+ set focused(value: boolean) {
847
+ focused = value;
848
+ browser.focused = value;
849
+ },
850
+ render(width: number) {
851
+ syncThemedContent();
852
+ return container.render(width);
853
+ },
854
+ invalidate() {
348
855
  container.invalidate();
856
+ browser.invalidate();
349
857
  syncThemedContent();
350
858
  },
351
- handleInput: (data: string) => {
352
- selectList.handleInput(data);
353
- tui.requestRender();
859
+ handleInput(data: string) {
860
+ if (browser.handleBrowseInput(data)) {
861
+ tui.requestRender();
862
+ }
354
863
  },
355
864
  };
356
865
  })
@@ -361,7 +870,8 @@ export async function browseRemotePackages(
361
870
  ctx: ExtensionCommandContext,
362
871
  query: string,
363
872
  pi: ExtensionAPI,
364
- offset = 0
873
+ offset = 0,
874
+ source?: RemoteBrowseSource
365
875
  ): Promise<void> {
366
876
  if (
367
877
  !requireCustomUI(
@@ -373,25 +883,49 @@ export async function browseRemotePackages(
373
883
  return;
374
884
  }
375
885
 
886
+ const browseSource = resolveRemoteBrowseSource(query, source);
887
+ const plan =
888
+ browseSource === "community"
889
+ ? createCommunityBrowsePlan(query)
890
+ : createRemoteBrowseQueryPlan(query);
891
+ if (plan.kind === "unsupported") {
892
+ notify(ctx, plan.message, "warning");
893
+ return;
894
+ }
895
+
896
+ const cacheQuery = browseSource === "community" ? COMMUNITY_BROWSE_QUERY : plan.rawQuery;
376
897
  let allPackages: NpmPackage[] | undefined;
377
898
 
378
- if (isCacheValid(query)) {
379
- const cache = getSearchCache();
899
+ if (browseSource === "community") {
900
+ const cache = getCommunityBrowseCache();
380
901
  if (cache) {
902
+ allPackages = filterCommunityBrowseResults(cache.results, plan.displayQuery);
903
+ }
904
+ } else if (isCacheValid(cacheQuery)) {
905
+ const cache = getSearchCache();
906
+ if (cache?.query === cacheQuery) {
381
907
  allPackages = cache.results;
382
908
  }
383
909
  }
384
910
 
385
911
  if (!allPackages) {
912
+ const searchLabel =
913
+ browseSource === "community"
914
+ ? "community packages"
915
+ : plan.displayQuery || "community packages";
386
916
  const results = await runTaskWithLoader(
387
917
  ctx,
388
918
  {
389
- title: "Remote Packages",
390
- message: `Searching npm for ${truncate(query, 40)}...`,
919
+ title: plan.title,
920
+ message: `Searching npm for ${truncate(searchLabel, 40)}...`,
391
921
  },
392
922
  async ({ signal, setMessage }) => {
393
- setMessage(`Searching npm for ${truncate(query, 40)}...`);
394
- return searchNpmPackages(query, ctx, { signal });
923
+ setMessage(`Searching npm for ${truncate(searchLabel, 40)}...`);
924
+ return searchNpmPackages(
925
+ browseSource === "community" ? COMMUNITY_BROWSE_QUERY : plan.searchQuery,
926
+ ctx,
927
+ { signal }
928
+ );
395
929
  }
396
930
  );
397
931
 
@@ -400,40 +934,44 @@ export async function browseRemotePackages(
400
934
  return;
401
935
  }
402
936
 
403
- allPackages = results;
404
- setSearchCache({
405
- query,
406
- results: allPackages,
407
- timestamp: Date.now(),
408
- });
937
+ if (browseSource === "community") {
938
+ setCommunityBrowseCache(results);
939
+ allPackages = filterCommunityBrowseResults(results, plan.displayQuery);
940
+ } else {
941
+ allPackages = filterRemoteBrowseResults(plan, results);
942
+ setSearchCache({
943
+ query: plan.rawQuery,
944
+ results: allPackages,
945
+ timestamp: Date.now(),
946
+ });
947
+ }
409
948
  }
410
949
 
411
- // Apply pagination from cached/filtered results
412
950
  const totalResults = allPackages.length;
413
951
  const packages = allPackages.slice(offset, offset + PAGE_SIZE);
952
+ const reloadQuery =
953
+ browseSource === "community" ? plan.displayQuery || COMMUNITY_BROWSE_QUERY : plan.rawQuery;
414
954
 
415
955
  if (packages.length === 0) {
416
- const msg = offset > 0 ? "No more packages to show." : `No packages found for: ${query}`;
956
+ const msg =
957
+ offset > 0
958
+ ? "No more packages to show."
959
+ : `No packages found for: ${plan.displayQuery || "community packages"}`;
417
960
  ctx.ui.notify(msg, "info");
418
961
 
419
962
  if (offset > 0) {
420
- await browseRemotePackages(ctx, query, pi, 0);
963
+ await browseRemotePackages(ctx, reloadQuery, pi, 0, browseSource);
421
964
  }
422
965
  return;
423
966
  }
424
967
 
425
- // Add navigation options
426
968
  const showLoadMore = totalResults >= PAGE_SIZE && offset + PAGE_SIZE < totalResults;
427
969
  const showPrevious = offset > 0;
428
970
 
429
- const titleText =
430
- offset > 0
431
- ? `Search Results (${offset + 1}-${offset + packages.length} of ${totalResults})`
432
- : `Search: ${truncate(query, 40)} (${totalResults})`;
433
-
434
971
  const result = await selectBrowseAction(
435
972
  ctx,
436
- titleText,
973
+ plan,
974
+ browseSource,
437
975
  packages,
438
976
  offset,
439
977
  totalResults,
@@ -445,23 +983,51 @@ export async function browseRemotePackages(
445
983
  return;
446
984
  }
447
985
 
448
- // Handle result
449
986
  switch (result.type) {
450
987
  case "prev":
451
- await browseRemotePackages(ctx, query, pi, Math.max(0, offset - PAGE_SIZE));
988
+ await browseRemotePackages(
989
+ ctx,
990
+ reloadQuery,
991
+ pi,
992
+ Math.max(0, offset - PAGE_SIZE),
993
+ browseSource
994
+ );
452
995
  return;
453
996
  case "next":
454
- await browseRemotePackages(ctx, query, pi, offset + PAGE_SIZE);
997
+ await browseRemotePackages(ctx, reloadQuery, pi, offset + PAGE_SIZE, browseSource);
455
998
  return;
456
999
  case "refresh":
457
- setSearchCache(null);
458
- await browseRemotePackages(ctx, query, pi, 0);
1000
+ if (browseSource === "community") {
1001
+ clearCommunityBrowseCache();
1002
+ } else {
1003
+ clearSearchCache();
1004
+ }
1005
+ await browseRemotePackages(ctx, reloadQuery, pi, 0, browseSource);
1006
+ return;
1007
+ case "search": {
1008
+ const nextQuery = result.query.trim();
1009
+ if (browseSource === "community") {
1010
+ await browseRemotePackages(ctx, nextQuery || COMMUNITY_BROWSE_QUERY, pi, 0, "community");
1011
+ return;
1012
+ }
1013
+ await browseRemotePackages(
1014
+ ctx,
1015
+ nextQuery || COMMUNITY_BROWSE_QUERY,
1016
+ pi,
1017
+ 0,
1018
+ nextQuery ? "npm" : undefined
1019
+ );
1020
+ return;
1021
+ }
1022
+ case "install":
1023
+ await promptInstall(ctx, pi);
1024
+ await browseRemotePackages(ctx, reloadQuery, pi, offset, browseSource);
459
1025
  return;
460
1026
  case "menu":
461
1027
  await showRemoteMenu(ctx, pi);
462
1028
  return;
463
1029
  case "package":
464
- await showPackageDetails(result.name, ctx, pi, query, offset);
1030
+ await showPackageDetails(result.name, ctx, pi, reloadQuery, offset, browseSource);
465
1031
  return;
466
1032
  }
467
1033
  }
@@ -471,7 +1037,8 @@ async function showPackageDetails(
471
1037
  ctx: ExtensionCommandContext,
472
1038
  pi: ExtensionAPI,
473
1039
  previousQuery: string,
474
- previousOffset: number
1040
+ previousOffset: number,
1041
+ browseSource?: RemoteBrowseSource
475
1042
  ): Promise<void> {
476
1043
  if (!ctx.hasUI) {
477
1044
  console.log(`Package: ${packageName}`);
@@ -484,12 +1051,30 @@ async function showPackageDetails(
484
1051
  );
485
1052
 
486
1053
  switch (choice) {
487
- case "installManaged":
488
- await installPackage(`npm:${packageName}`, ctx, pi);
1054
+ case "installManaged": {
1055
+ const outcome = await installPackageWithOutcome(`npm:${packageName}`, ctx, pi);
1056
+ if (outcome.reloaded) {
1057
+ return;
1058
+ }
1059
+ if (outcome.installed) {
1060
+ await browseRemotePackages(ctx, previousQuery, pi, previousOffset, browseSource);
1061
+ return;
1062
+ }
1063
+ await showPackageDetails(packageName, ctx, pi, previousQuery, previousOffset, browseSource);
489
1064
  return;
490
- case "installStandalone":
491
- await installPackageLocally(packageName, ctx, pi);
1065
+ }
1066
+ case "installStandalone": {
1067
+ const outcome = await installPackageLocallyWithOutcome(packageName, ctx, pi);
1068
+ if (outcome.reloaded) {
1069
+ return;
1070
+ }
1071
+ if (outcome.installed) {
1072
+ await browseRemotePackages(ctx, previousQuery, pi, previousOffset, browseSource);
1073
+ return;
1074
+ }
1075
+ await showPackageDetails(packageName, ctx, pi, previousQuery, previousOffset, browseSource);
492
1076
  return;
1077
+ }
493
1078
  case "viewInfo":
494
1079
  try {
495
1080
  const text = await runTaskWithLoader(
@@ -503,7 +1088,14 @@ async function showPackageDetails(
503
1088
 
504
1089
  if (!text) {
505
1090
  notify(ctx, `Loading ${packageName} details was cancelled.`, "info");
506
- await showPackageDetails(packageName, ctx, pi, previousQuery, previousOffset);
1091
+ await showPackageDetails(
1092
+ packageName,
1093
+ ctx,
1094
+ pi,
1095
+ previousQuery,
1096
+ previousOffset,
1097
+ browseSource
1098
+ );
507
1099
  return;
508
1100
  }
509
1101
 
@@ -512,10 +1104,10 @@ async function showPackageDetails(
512
1104
  const message = error instanceof Error ? error.message : String(error);
513
1105
  ctx.ui.notify(`Package: ${packageName}\n${message}`, "warning");
514
1106
  }
515
- await showPackageDetails(packageName, ctx, pi, previousQuery, previousOffset);
1107
+ await showPackageDetails(packageName, ctx, pi, previousQuery, previousOffset, browseSource);
516
1108
  return;
517
1109
  case "back":
518
- await browseRemotePackages(ctx, previousQuery, pi, previousOffset);
1110
+ await browseRemotePackages(ctx, previousQuery, pi, previousOffset, browseSource);
519
1111
  return;
520
1112
  default:
521
1113
  return;
@@ -523,7 +1115,7 @@ async function showPackageDetails(
523
1115
  }
524
1116
 
525
1117
  async function promptSearch(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
526
- const query = await ctx.ui.input("Search packages", "keywords:pi-package");
1118
+ const query = await ctx.ui.input("Search packages", "package name, keyword, or npm:@scope/pkg");
527
1119
  if (!query?.trim()) return;
528
1120
  await searchPackages(query.trim(), ctx, pi);
529
1121
  }