pi-extmgr 0.1.27 → 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.
Files changed (41) hide show
  1. package/README.md +21 -10
  2. package/package.json +21 -16
  3. package/src/commands/auto-update.ts +5 -5
  4. package/src/commands/cache.ts +1 -1
  5. package/src/commands/history.ts +5 -34
  6. package/src/commands/install.ts +2 -2
  7. package/src/commands/registry.ts +7 -7
  8. package/src/commands/types.ts +1 -1
  9. package/src/constants.ts +0 -8
  10. package/src/extensions/discovery.ts +125 -42
  11. package/src/index.ts +15 -15
  12. package/src/packages/catalog.ts +9 -8
  13. package/src/packages/discovery.ts +56 -19
  14. package/src/packages/extensions.ts +65 -103
  15. package/src/packages/install.ts +104 -74
  16. package/src/packages/management.ts +78 -65
  17. package/src/types/index.ts +20 -11
  18. package/src/ui/async-task.ts +101 -65
  19. package/src/ui/footer.ts +47 -31
  20. package/src/ui/help.ts +17 -13
  21. package/src/ui/package-config.ts +36 -48
  22. package/src/ui/remote.ts +714 -119
  23. package/src/ui/theme.ts +2 -2
  24. package/src/ui/unified.ts +964 -371
  25. package/src/utils/auto-update.ts +44 -39
  26. package/src/utils/cache.ts +208 -37
  27. package/src/utils/command.ts +1 -1
  28. package/src/utils/duration.ts +132 -0
  29. package/src/utils/format.ts +4 -33
  30. package/src/utils/fs.ts +8 -4
  31. package/src/utils/history.ts +47 -9
  32. package/src/utils/mode.ts +2 -2
  33. package/src/utils/notify.ts +1 -15
  34. package/src/utils/npm-exec.ts +1 -1
  35. package/src/utils/package-source.ts +35 -7
  36. package/src/utils/path-identity.ts +7 -0
  37. package/src/utils/relative-path-selection.ts +100 -0
  38. package/src/utils/settings.ts +11 -61
  39. package/src/utils/status.ts +12 -10
  40. package/src/utils/ui-helpers.ts +2 -2
  41. package/src/utils/retry.ts +0 -49
package/src/ui/remote.ts CHANGED
@@ -1,23 +1,46 @@
1
1
  /**
2
2
  * Remote package browsing UI
3
3
  */
4
- import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
5
- import { DynamicBorder } from "@mariozechner/pi-coding-agent";
6
- import { Container, SelectList, Text, type SelectItem } from "@mariozechner/pi-tui";
7
- import type { BrowseAction, NpmPackage } from "../types/index.js";
8
- import { PAGE_SIZE, TIMEOUTS, CACHE_LIMITS } from "../constants.js";
9
- import { truncate, dynamicTruncate, formatBytes } from "../utils/format.js";
10
- import { parseChoiceByLabel, splitCommandArgs } from "../utils/command.js";
11
4
  import {
12
- searchNpmPackages,
5
+ DynamicBorder,
6
+ type ExtensionAPI,
7
+ type ExtensionCommandContext,
8
+ type Theme,
9
+ } from "@mariozechner/pi-coding-agent";
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,
13
26
  getSearchCache,
14
- setSearchCache,
15
27
  isCacheValid,
28
+ searchNpmPackages,
29
+ setSearchCache,
16
30
  } from "../packages/discovery.js";
17
- import { installPackage, installPackageLocally } from "../packages/install.js";
18
- import { execNpm } from "../utils/npm-exec.js";
19
- import { notify } from "../utils/notify.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";
37
+ import { parseChoiceByLabel, splitCommandArgs } from "../utils/command.js";
38
+ import { formatBytes, normalizePackageSource, parseNpmSource, truncate } from "../utils/format.js";
20
39
  import { requireCustomUI, runCustomUI } from "../utils/mode.js";
40
+ import { fetchWithTimeout } from "../utils/network.js";
41
+ import { notify } from "../utils/notify.js";
42
+ import { execNpm } from "../utils/npm-exec.js";
43
+ import { getPackageSourceKind } from "../utils/package-source.js";
21
44
  import { runTaskWithLoader } from "./async-task.js";
22
45
 
23
46
  interface PackageInfoCacheEntry {
@@ -95,6 +118,30 @@ const packageInfoCache = new PackageInfoCache(
95
118
 
96
119
  export function clearRemotePackageInfoCache(): void {
97
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
+ }
98
145
  }
99
146
 
100
147
  const REMOTE_MENU_CHOICES = {
@@ -110,6 +157,218 @@ const PACKAGE_DETAILS_CHOICES = {
110
157
  back: "Back to results",
111
158
  } as const;
112
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
+
113
372
  function createAbortError(): Error {
114
373
  const error = new Error("Operation cancelled");
115
374
  error.name = "AbortError";
@@ -131,15 +390,13 @@ async function fetchWeeklyDownloads(
131
390
  packageName: string,
132
391
  signal?: AbortSignal
133
392
  ): Promise<number | undefined> {
134
- const controller = new AbortController();
135
- const timer = setTimeout(() => controller.abort(), TIMEOUTS.weeklyDownloads);
136
- const combinedSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
137
-
138
393
  try {
139
394
  const encoded = encodeURIComponent(packageName);
140
- const res = await fetch(`https://api.npmjs.org/downloads/point/last-week/${encoded}`, {
141
- signal: combinedSignal,
142
- });
395
+ const res = await fetchWithTimeout(
396
+ `https://api.npmjs.org/downloads/point/last-week/${encoded}`,
397
+ TIMEOUTS.weeklyDownloads,
398
+ signal
399
+ );
143
400
 
144
401
  if (!res.ok) return undefined;
145
402
  const data = (await res.json()) as NpmDownloadsPoint;
@@ -149,8 +406,6 @@ async function fetchWeeklyDownloads(
149
406
  throw error;
150
407
  }
151
408
  return undefined;
152
- } finally {
153
- clearTimeout(timer);
154
409
  }
155
410
  }
156
411
 
@@ -235,7 +490,7 @@ export async function showRemote(
235
490
  return;
236
491
  case "browse":
237
492
  case "":
238
- await browseRemotePackages(ctx, "keywords:pi-package", pi);
493
+ await browseRemotePackages(ctx, COMMUNITY_BROWSE_QUERY, pi);
239
494
  return;
240
495
  }
241
496
 
@@ -253,7 +508,7 @@ async function showRemoteMenu(ctx: ExtensionCommandContext, pi: ExtensionAPI): P
253
508
 
254
509
  switch (choice) {
255
510
  case "browse":
256
- await browseRemotePackages(ctx, "keywords:pi-package", pi);
511
+ await browseRemotePackages(ctx, COMMUNITY_BROWSE_QUERY, pi);
257
512
  return;
258
513
  case "search":
259
514
  await promptSearch(ctx, pi);
@@ -266,9 +521,287 @@ async function showRemoteMenu(ctx: ExtensionCommandContext, pi: ExtensionAPI): P
266
521
  }
267
522
  }
268
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
+
269
801
  async function selectBrowseAction(
270
802
  ctx: ExtensionCommandContext,
271
- titleText: string,
803
+ plan: Exclude<RemoteBrowseQueryPlan, { kind: "unsupported" }>,
804
+ browseSource: RemoteBrowseSource,
272
805
  packages: NpmPackage[],
273
806
  offset: number,
274
807
  totalResults: number,
@@ -277,77 +810,56 @@ async function selectBrowseAction(
277
810
  ): Promise<BrowseAction | undefined> {
278
811
  if (!ctx.hasUI) return undefined;
279
812
 
280
- const items: SelectItem[] = packages.map((p) => ({
281
- value: `pkg:${p.name}`,
282
- label: `${p.name}${p.version ? ` @${p.version}` : ""}`,
283
- description: dynamicTruncate(p.description || "No description", 35),
284
- }));
285
-
286
- if (showPrevious) {
287
- items.push({ value: "nav:prev", label: "◀ Previous page" });
288
- }
289
- if (showLoadMore) {
290
- items.push({
291
- value: "nav:next",
292
- label: `▶ Next page (${offset + 1}-${offset + packages.length} of ${totalResults})`,
293
- });
294
- }
295
- items.push({ value: "nav:refresh", label: "🔄 Refresh search" });
296
- items.push({ value: "nav:menu", label: "← Back to menu" });
297
-
298
813
  return runCustomUI(ctx, "Remote package browsing", () =>
299
814
  ctx.ui.custom<BrowseAction>((tui, theme, _keybindings, done) => {
300
815
  const container = new Container();
301
- const title = new Text("", 1, 0);
302
- 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
+ );
303
829
  const syncThemedContent = (): void => {
304
- title.setText(theme.fg("accent", theme.bold(titleText)));
305
- footer.setText(theme.fg("dim", "↑↓ wraps • enter select • esc cancel"));
830
+ title.setText(theme.fg("accent", theme.bold(plan.title)));
306
831
  };
307
832
 
833
+ syncThemedContent();
308
834
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
309
835
  container.addChild(title);
310
-
311
- const selectList = new SelectList(items, Math.min(items.length, 12), {
312
- selectedPrefix: (t) => theme.fg("accent", t),
313
- selectedText: (t) => theme.fg("accent", t),
314
- description: (t) => theme.fg("muted", t),
315
- scrollInfo: (t) => theme.fg("dim", t),
316
- noMatch: (t) => theme.fg("warning", t),
317
- });
318
-
319
- selectList.onSelect = (item) => {
320
- if (item.value === "nav:prev") {
321
- done({ type: "prev" });
322
- } else if (item.value === "nav:next") {
323
- done({ type: "next" });
324
- } else if (item.value === "nav:refresh") {
325
- done({ type: "refresh" });
326
- } else if (item.value === "nav:menu") {
327
- done({ type: "menu" });
328
- } else if (item.value.startsWith("pkg:")) {
329
- done({ type: "package", name: item.value.slice(4) });
330
- } else {
331
- done({ type: "cancel" });
332
- }
333
- };
334
-
335
- selectList.onCancel = () => done({ type: "cancel" });
336
-
337
- syncThemedContent();
338
- container.addChild(selectList);
339
- container.addChild(footer);
836
+ container.addChild(new Spacer(1));
837
+ container.addChild(browser);
340
838
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
341
839
 
840
+ let focused = false;
841
+
342
842
  return {
343
- render: (w: number) => container.render(w),
344
- 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() {
345
855
  container.invalidate();
856
+ browser.invalidate();
346
857
  syncThemedContent();
347
858
  },
348
- handleInput: (data: string) => {
349
- selectList.handleInput(data);
350
- tui.requestRender();
859
+ handleInput(data: string) {
860
+ if (browser.handleBrowseInput(data)) {
861
+ tui.requestRender();
862
+ }
351
863
  },
352
864
  };
353
865
  })
@@ -358,7 +870,8 @@ export async function browseRemotePackages(
358
870
  ctx: ExtensionCommandContext,
359
871
  query: string,
360
872
  pi: ExtensionAPI,
361
- offset = 0
873
+ offset = 0,
874
+ source?: RemoteBrowseSource
362
875
  ): Promise<void> {
363
876
  if (
364
877
  !requireCustomUI(
@@ -370,25 +883,49 @@ export async function browseRemotePackages(
370
883
  return;
371
884
  }
372
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;
373
897
  let allPackages: NpmPackage[] | undefined;
374
898
 
375
- if (isCacheValid(query)) {
376
- const cache = getSearchCache();
899
+ if (browseSource === "community") {
900
+ const cache = getCommunityBrowseCache();
377
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) {
378
907
  allPackages = cache.results;
379
908
  }
380
909
  }
381
910
 
382
911
  if (!allPackages) {
912
+ const searchLabel =
913
+ browseSource === "community"
914
+ ? "community packages"
915
+ : plan.displayQuery || "community packages";
383
916
  const results = await runTaskWithLoader(
384
917
  ctx,
385
918
  {
386
- title: "Remote Packages",
387
- message: `Searching npm for ${truncate(query, 40)}...`,
919
+ title: plan.title,
920
+ message: `Searching npm for ${truncate(searchLabel, 40)}...`,
388
921
  },
389
922
  async ({ signal, setMessage }) => {
390
- setMessage(`Searching npm for ${truncate(query, 40)}...`);
391
- 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
+ );
392
929
  }
393
930
  );
394
931
 
@@ -397,40 +934,44 @@ export async function browseRemotePackages(
397
934
  return;
398
935
  }
399
936
 
400
- allPackages = results;
401
- setSearchCache({
402
- query,
403
- results: allPackages,
404
- timestamp: Date.now(),
405
- });
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
+ }
406
948
  }
407
949
 
408
- // Apply pagination from cached/filtered results
409
950
  const totalResults = allPackages.length;
410
951
  const packages = allPackages.slice(offset, offset + PAGE_SIZE);
952
+ const reloadQuery =
953
+ browseSource === "community" ? plan.displayQuery || COMMUNITY_BROWSE_QUERY : plan.rawQuery;
411
954
 
412
955
  if (packages.length === 0) {
413
- 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"}`;
414
960
  ctx.ui.notify(msg, "info");
415
961
 
416
962
  if (offset > 0) {
417
- await browseRemotePackages(ctx, query, pi, 0);
963
+ await browseRemotePackages(ctx, reloadQuery, pi, 0, browseSource);
418
964
  }
419
965
  return;
420
966
  }
421
967
 
422
- // Add navigation options
423
968
  const showLoadMore = totalResults >= PAGE_SIZE && offset + PAGE_SIZE < totalResults;
424
969
  const showPrevious = offset > 0;
425
970
 
426
- const titleText =
427
- offset > 0
428
- ? `Search Results (${offset + 1}-${offset + packages.length} of ${totalResults})`
429
- : `Search: ${truncate(query, 40)} (${totalResults})`;
430
-
431
971
  const result = await selectBrowseAction(
432
972
  ctx,
433
- titleText,
973
+ plan,
974
+ browseSource,
434
975
  packages,
435
976
  offset,
436
977
  totalResults,
@@ -442,23 +983,51 @@ export async function browseRemotePackages(
442
983
  return;
443
984
  }
444
985
 
445
- // Handle result
446
986
  switch (result.type) {
447
987
  case "prev":
448
- 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
+ );
449
995
  return;
450
996
  case "next":
451
- await browseRemotePackages(ctx, query, pi, offset + PAGE_SIZE);
997
+ await browseRemotePackages(ctx, reloadQuery, pi, offset + PAGE_SIZE, browseSource);
452
998
  return;
453
999
  case "refresh":
454
- setSearchCache(null);
455
- 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);
456
1025
  return;
457
1026
  case "menu":
458
1027
  await showRemoteMenu(ctx, pi);
459
1028
  return;
460
1029
  case "package":
461
- await showPackageDetails(result.name, ctx, pi, query, offset);
1030
+ await showPackageDetails(result.name, ctx, pi, reloadQuery, offset, browseSource);
462
1031
  return;
463
1032
  }
464
1033
  }
@@ -468,7 +1037,8 @@ async function showPackageDetails(
468
1037
  ctx: ExtensionCommandContext,
469
1038
  pi: ExtensionAPI,
470
1039
  previousQuery: string,
471
- previousOffset: number
1040
+ previousOffset: number,
1041
+ browseSource?: RemoteBrowseSource
472
1042
  ): Promise<void> {
473
1043
  if (!ctx.hasUI) {
474
1044
  console.log(`Package: ${packageName}`);
@@ -481,12 +1051,30 @@ async function showPackageDetails(
481
1051
  );
482
1052
 
483
1053
  switch (choice) {
484
- case "installManaged":
485
- 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);
486
1064
  return;
487
- case "installStandalone":
488
- 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);
489
1076
  return;
1077
+ }
490
1078
  case "viewInfo":
491
1079
  try {
492
1080
  const text = await runTaskWithLoader(
@@ -500,7 +1088,14 @@ async function showPackageDetails(
500
1088
 
501
1089
  if (!text) {
502
1090
  notify(ctx, `Loading ${packageName} details was cancelled.`, "info");
503
- await showPackageDetails(packageName, ctx, pi, previousQuery, previousOffset);
1091
+ await showPackageDetails(
1092
+ packageName,
1093
+ ctx,
1094
+ pi,
1095
+ previousQuery,
1096
+ previousOffset,
1097
+ browseSource
1098
+ );
504
1099
  return;
505
1100
  }
506
1101
 
@@ -509,10 +1104,10 @@ async function showPackageDetails(
509
1104
  const message = error instanceof Error ? error.message : String(error);
510
1105
  ctx.ui.notify(`Package: ${packageName}\n${message}`, "warning");
511
1106
  }
512
- await showPackageDetails(packageName, ctx, pi, previousQuery, previousOffset);
1107
+ await showPackageDetails(packageName, ctx, pi, previousQuery, previousOffset, browseSource);
513
1108
  return;
514
1109
  case "back":
515
- await browseRemotePackages(ctx, previousQuery, pi, previousOffset);
1110
+ await browseRemotePackages(ctx, previousQuery, pi, previousOffset, browseSource);
516
1111
  return;
517
1112
  default:
518
1113
  return;
@@ -520,7 +1115,7 @@ async function showPackageDetails(
520
1115
  }
521
1116
 
522
1117
  async function promptSearch(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
523
- 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");
524
1119
  if (!query?.trim()) return;
525
1120
  await searchPackages(query.trim(), ctx, pi);
526
1121
  }