pi-extmgr 0.1.22 → 0.1.24

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.
@@ -13,19 +13,20 @@ import {
13
13
  type SettingItem,
14
14
  } from "@mariozechner/pi-tui";
15
15
  import type { InstalledPackage, PackageExtensionEntry, State } from "../types/index.js";
16
- import { discoverPackageExtensions, setPackageExtensionState } from "../packages/extensions.js";
16
+ import {
17
+ applyPackageExtensionStateChanges,
18
+ discoverPackageExtensions,
19
+ validatePackageExtensionSettings,
20
+ } from "../packages/extensions.js";
17
21
  import { notify } from "../utils/notify.js";
18
22
  import { logExtensionToggle } from "../utils/history.js";
23
+ import { requireCustomUI, runCustomUI } from "../utils/mode.js";
19
24
  import { getPackageSourceKind } from "../utils/package-source.js";
25
+ import { getSettingsListSelectedIndex } from "../utils/settings-list.js";
20
26
  import { fileExists } from "../utils/fs.js";
21
27
  import { UI } from "../constants.js";
22
28
  import { getChangeMarker, getPackageIcon, getScopeIcon, getStatusIcon } from "./theme.js";
23
29
 
24
- interface SelectableList {
25
- selectedIndex?: number;
26
- handleInput?(data: string): void;
27
- }
28
-
29
30
  export interface PackageConfigRow {
30
31
  id: string;
31
32
  extensionPath: string;
@@ -36,16 +37,6 @@ export interface PackageConfigRow {
36
37
 
37
38
  type ConfigurePanelAction = { type: "cancel" } | { type: "save" };
38
39
 
39
- function getSelectedIndex(settingsList: unknown): number | undefined {
40
- if (settingsList && typeof settingsList === "object") {
41
- const selectable = settingsList as SelectableList;
42
- if (typeof selectable.selectedIndex === "number") {
43
- return selectable.selectedIndex;
44
- }
45
- }
46
- return undefined;
47
- }
48
-
49
40
  export async function buildPackageConfigRows(
50
41
  entries: PackageExtensionEntry[]
51
42
  ): Promise<PackageConfigRow[]> {
@@ -130,99 +121,116 @@ async function showConfigurePanel(
130
121
  rows: PackageConfigRow[],
131
122
  staged: Map<string, State>,
132
123
  ctx: ExtensionCommandContext
133
- ): Promise<ConfigurePanelAction> {
134
- return ctx.ui.custom<ConfigurePanelAction>((tui, theme, _keybindings, done) => {
135
- const container = new Container();
136
-
137
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
138
- container.addChild(
139
- new Text(theme.fg("accent", theme.bold(`Configure extensions: ${pkg.name}`)), 2, 0)
140
- );
141
- container.addChild(
142
- new Text(
143
- theme.fg(
144
- "muted",
145
- `${rows.length} extension path${rows.length === 1 ? "" : "s"} • Space/Enter toggle • S save • Esc cancel`
146
- ),
147
- 2,
148
- 0
149
- )
150
- );
151
- container.addChild(new Spacer(1));
152
-
153
- const settingsItems = buildSettingItems(rows, staged, pkg, theme);
154
- const rowById = new Map(rows.map((row) => [row.id, row]));
155
-
156
- const settingsList = new SettingsList(
157
- settingsItems,
158
- Math.min(rows.length + 2, UI.maxListHeight),
159
- getSettingsListTheme(),
160
- (id: string, newValue: string) => {
161
- const row = rowById.get(id);
162
- if (!row || !row.available) return;
163
-
164
- const state = newValue as State;
165
- staged.set(id, state);
166
-
167
- const settingsItem = settingsItems.find((item) => item.id === id);
168
- if (settingsItem) {
124
+ ): Promise<ConfigurePanelAction | undefined> {
125
+ return runCustomUI(ctx, "Package extension configuration", () =>
126
+ ctx.ui.custom<ConfigurePanelAction>((tui, theme, _keybindings, done) => {
127
+ const container = new Container();
128
+ const titleText = new Text("", 2, 0);
129
+ const subtitleText = new Text("", 2, 0);
130
+ const footerText = new Text("", 2, 0);
131
+
132
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
133
+ container.addChild(titleText);
134
+ container.addChild(subtitleText);
135
+ container.addChild(new Spacer(1));
136
+
137
+ const settingsItems = buildSettingItems(rows, staged, pkg, theme);
138
+ const rowById = new Map(rows.map((row) => [row.id, row]));
139
+ const syncThemedContent = (): void => {
140
+ titleText.setText(theme.fg("accent", theme.bold(`Configure extensions: ${pkg.name}`)));
141
+ subtitleText.setText(
142
+ theme.fg(
143
+ "muted",
144
+ `${rows.length} extension path${rows.length === 1 ? "" : "s"} • Space/Enter toggle • S save • Esc cancel`
145
+ )
146
+ );
147
+ footerText.setText(theme.fg("dim", "↑↓ Navigate | Space/Enter Toggle | S Save | Esc Back"));
148
+
149
+ for (const settingsItem of settingsItems) {
150
+ const row = rowById.get(settingsItem.id);
151
+ if (!row) continue;
152
+ const currentState = staged.get(row.id) ?? row.originalState;
169
153
  settingsItem.label = formatConfigRowLabel(
170
154
  row,
171
- state,
155
+ currentState,
172
156
  pkg,
173
157
  theme,
174
- state !== row.originalState
175
- );
176
- }
177
-
178
- tui.requestRender();
179
- },
180
- () => done({ type: "cancel" }),
181
- { enableSearch: rows.length > UI.searchThreshold }
182
- );
183
-
184
- container.addChild(settingsList);
185
- container.addChild(new Spacer(1));
186
- container.addChild(
187
- new Text(theme.fg("dim", "↑↓ Navigate | Space/Enter Toggle | S Save | Esc Back"), 2, 0)
188
- );
189
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
190
-
191
- return {
192
- render(width: number) {
193
- return container.render(width);
194
- },
195
- invalidate() {
196
- container.invalidate();
197
- },
198
- handleInput(data: string) {
199
- if (matchesKey(data, Key.ctrl("s")) || data === "s" || data === "S") {
200
- done({ type: "save" });
201
- return;
202
- }
203
-
204
- const selectedIndex = getSelectedIndex(settingsList) ?? 0;
205
- const selectedId = settingsItems[selectedIndex]?.id ?? settingsItems[0]?.id;
206
- const selectedRow = selectedId ? rowById.get(selectedId) : undefined;
207
-
208
- if (
209
- selectedRow &&
210
- !selectedRow.available &&
211
- (data === " " || data === "\r" || data === "\n")
212
- ) {
213
- notify(
214
- ctx,
215
- `${selectedRow.extensionPath} is missing on disk and cannot be toggled.`,
216
- "warning"
158
+ currentState !== row.originalState
217
159
  );
218
- return;
219
160
  }
161
+ };
162
+ syncThemedContent();
163
+
164
+ const settingsList = new SettingsList(
165
+ settingsItems,
166
+ Math.min(rows.length + 2, UI.maxListHeight),
167
+ getSettingsListTheme(),
168
+ (id: string, newValue: string) => {
169
+ const row = rowById.get(id);
170
+ if (!row || !row.available) return;
171
+
172
+ const state = newValue as State;
173
+ staged.set(id, state);
174
+
175
+ const settingsItem = settingsItems.find((item) => item.id === id);
176
+ if (settingsItem) {
177
+ settingsItem.label = formatConfigRowLabel(
178
+ row,
179
+ state,
180
+ pkg,
181
+ theme,
182
+ state !== row.originalState
183
+ );
184
+ }
185
+
186
+ tui.requestRender();
187
+ },
188
+ () => done({ type: "cancel" }),
189
+ { enableSearch: rows.length > UI.searchThreshold }
190
+ );
220
191
 
221
- settingsList.handleInput?.(data);
222
- tui.requestRender();
223
- },
224
- };
225
- });
192
+ container.addChild(settingsList);
193
+ container.addChild(new Spacer(1));
194
+ container.addChild(footerText);
195
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
196
+
197
+ return {
198
+ render(width: number) {
199
+ return container.render(width);
200
+ },
201
+ invalidate() {
202
+ container.invalidate();
203
+ syncThemedContent();
204
+ },
205
+ handleInput(data: string) {
206
+ if (matchesKey(data, Key.ctrl("s")) || data === "s" || data === "S") {
207
+ done({ type: "save" });
208
+ return;
209
+ }
210
+
211
+ const selectedIndex = getSettingsListSelectedIndex(settingsList) ?? 0;
212
+ const selectedId = settingsItems[selectedIndex]?.id ?? settingsItems[0]?.id;
213
+ const selectedRow = selectedId ? rowById.get(selectedId) : undefined;
214
+
215
+ if (
216
+ selectedRow &&
217
+ !selectedRow.available &&
218
+ (data === " " || data === "\r" || data === "\n")
219
+ ) {
220
+ notify(
221
+ ctx,
222
+ `${selectedRow.extensionPath} is missing on disk and cannot be toggled.`,
223
+ "warning"
224
+ );
225
+ return;
226
+ }
227
+
228
+ settingsList.handleInput?.(data);
229
+ tui.requestRender();
230
+ },
231
+ };
232
+ })
233
+ );
226
234
  }
227
235
 
228
236
  export async function applyPackageExtensionChanges(
@@ -232,40 +240,50 @@ export async function applyPackageExtensionChanges(
232
240
  cwd: string,
233
241
  pi: ExtensionAPI
234
242
  ): Promise<{ changed: number; errors: string[] }> {
235
- let changed = 0;
236
243
  const errors: string[] = [];
244
+ const changedRows = [...rows]
245
+ .sort((a, b) => a.extensionPath.localeCompare(b.extensionPath))
246
+ .flatMap((row) => {
247
+ const target = staged.get(row.id) ?? row.originalState;
248
+ if (target === row.originalState) {
249
+ return [];
250
+ }
237
251
 
238
- const sortedRows = [...rows].sort((a, b) => a.extensionPath.localeCompare(b.extensionPath));
252
+ if (!row.available) {
253
+ const error = `${row.extensionPath}: extension entrypoint is missing on disk`;
254
+ errors.push(error);
255
+ logExtensionToggle(pi, row.id, row.originalState, target, false, error);
256
+ return [];
257
+ }
239
258
 
240
- for (const row of sortedRows) {
241
- const target = staged.get(row.id) ?? row.originalState;
242
- if (target === row.originalState) continue;
259
+ return [{ row, target }];
260
+ });
243
261
 
244
- if (!row.available) {
245
- const error = `${row.extensionPath}: extension entrypoint is missing on disk`;
246
- errors.push(error);
247
- logExtensionToggle(pi, row.id, row.originalState, target, false, error);
248
- continue;
249
- }
262
+ if (changedRows.length === 0) {
263
+ return { changed: 0, errors };
264
+ }
250
265
 
251
- const result = await setPackageExtensionState(
252
- pkg.source,
253
- row.extensionPath,
254
- pkg.scope,
255
- target,
256
- cwd
257
- );
266
+ const result = await applyPackageExtensionStateChanges(
267
+ pkg.source,
268
+ pkg.scope,
269
+ changedRows.map(({ row, target }) => ({ extensionPath: row.extensionPath, target })),
270
+ cwd
271
+ );
258
272
 
259
- if (result.ok) {
260
- changed += 1;
261
- logExtensionToggle(pi, row.id, row.originalState, target, true);
262
- } else {
263
- errors.push(`${row.extensionPath}: ${result.error}`);
273
+ if (!result.ok) {
274
+ for (const { row, target } of changedRows) {
275
+ const error = `${row.extensionPath}: ${result.error}`;
276
+ errors.push(error);
264
277
  logExtensionToggle(pi, row.id, row.originalState, target, false, result.error);
265
278
  }
279
+ return { changed: 0, errors };
280
+ }
281
+
282
+ for (const { row, target } of changedRows) {
283
+ logExtensionToggle(pi, row.id, row.originalState, target, true);
266
284
  }
267
285
 
268
- return { changed, errors };
286
+ return { changed: changedRows.length, errors };
269
287
  }
270
288
 
271
289
  async function promptRestartForPackageConfig(ctx: ExtensionCommandContext): Promise<boolean> {
@@ -302,6 +320,16 @@ export async function configurePackageExtensions(
302
320
  ctx: ExtensionCommandContext,
303
321
  pi: ExtensionAPI
304
322
  ): Promise<{ changed: number; reloaded: boolean }> {
323
+ if (!requireCustomUI(ctx, "Package extension configuration")) {
324
+ return { changed: 0, reloaded: false };
325
+ }
326
+
327
+ const validation = await validatePackageExtensionSettings(pkg.scope, ctx.cwd);
328
+ if (!validation.ok) {
329
+ notify(ctx, validation.error, "error");
330
+ return { changed: 0, reloaded: false };
331
+ }
332
+
305
333
  const discovered = await discoverPackageExtensions([pkg], ctx.cwd);
306
334
  const rows = await buildPackageConfigRows(discovered);
307
335
 
@@ -314,6 +342,9 @@ export async function configurePackageExtensions(
314
342
 
315
343
  while (true) {
316
344
  const action = await showConfigurePanel(pkg, rows, staged, ctx);
345
+ if (!action) {
346
+ return { changed: 0, reloaded: false };
347
+ }
317
348
 
318
349
  if (action.type === "cancel") {
319
350
  const pending = getPendingChangeCount(rows, staged);
package/src/ui/remote.ts CHANGED
@@ -15,7 +15,9 @@ import {
15
15
  isCacheValid,
16
16
  } from "../packages/discovery.js";
17
17
  import { installPackage, installPackageLocally } from "../packages/install.js";
18
+ import { execNpm } from "../utils/npm-exec.js";
18
19
  import { notify } from "../utils/notify.js";
20
+ import { requireCustomUI, runCustomUI } from "../utils/mode.js";
19
21
 
20
22
  interface PackageInfoCacheEntry {
21
23
  timestamp: number;
@@ -64,8 +66,9 @@ class PackageInfoCache {
64
66
  }
65
67
 
66
68
  set(name: string, entry: Omit<PackageInfoCacheEntry, "timestamp">): void {
67
- // Evict oldest if at capacity
68
- if (this.cache.size >= this.maxSize) {
69
+ if (this.cache.has(name)) {
70
+ this.cache.delete(name);
71
+ } else if (this.cache.size >= this.maxSize) {
69
72
  const firstKey = this.cache.keys().next().value;
70
73
  if (firstKey) {
71
74
  this.cache.delete(firstKey);
@@ -81,10 +84,6 @@ class PackageInfoCache {
81
84
  clear(): void {
82
85
  this.cache.clear();
83
86
  }
84
-
85
- get size(): number {
86
- return this.cache.size;
87
- }
88
87
  }
89
88
 
90
89
  // Global LRU cache instance
@@ -93,6 +92,10 @@ const packageInfoCache = new PackageInfoCache(
93
92
  CACHE_LIMITS.packageInfoTTL
94
93
  );
95
94
 
95
+ export function clearRemotePackageInfoCache(): void {
96
+ packageInfoCache.clear();
97
+ }
98
+
96
99
  const REMOTE_MENU_CHOICES = {
97
100
  browse: "🔍 Browse pi packages",
98
101
  search: "🔎 Search packages",
@@ -143,9 +146,8 @@ async function buildPackageInfoText(
143
146
  }
144
147
 
145
148
  const [infoRes, weeklyDownloads] = await Promise.all([
146
- pi.exec("npm", ["view", packageName, "--json"], {
149
+ execNpm(pi, ["view", packageName, "--json"], ctx, {
147
150
  timeout: TIMEOUTS.npmView,
148
- cwd: ctx.cwd,
149
151
  }),
150
152
  fetchWeeklyDownloads(packageName),
151
153
  ]);
@@ -269,50 +271,63 @@ async function selectBrowseAction(
269
271
  items.push({ value: "nav:refresh", label: "🔄 Refresh search" });
270
272
  items.push({ value: "nav:menu", label: "← Back to menu" });
271
273
 
272
- return ctx.ui.custom<BrowseAction | undefined>((tui, theme, _keybindings, done) => {
273
- const container = new Container();
274
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
275
- container.addChild(new Text(theme.fg("accent", theme.bold(titleText)), 1, 0));
276
-
277
- const selectList = new SelectList(items, Math.min(items.length, 12), {
278
- selectedPrefix: (t) => theme.fg("accent", t),
279
- selectedText: (t) => theme.fg("accent", t),
280
- description: (t) => theme.fg("muted", t),
281
- scrollInfo: (t) => theme.fg("dim", t),
282
- noMatch: (t) => theme.fg("warning", t),
283
- });
284
-
285
- selectList.onSelect = (item) => {
286
- if (item.value === "nav:prev") {
287
- done({ type: "prev" });
288
- } else if (item.value === "nav:next") {
289
- done({ type: "next" });
290
- } else if (item.value === "nav:refresh") {
291
- done({ type: "refresh" });
292
- } else if (item.value === "nav:menu") {
293
- done({ type: "menu" });
294
- } else if (item.value.startsWith("pkg:")) {
295
- done({ type: "package", name: item.value.slice(4) });
296
- } else {
297
- done(undefined);
298
- }
299
- };
300
-
301
- selectList.onCancel = () => done(undefined);
302
-
303
- container.addChild(selectList);
304
- container.addChild(new Text(theme.fg("dim", "↑↓ wraps • enter select • esc cancel"), 1, 0));
305
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
306
-
307
- return {
308
- render: (w: number) => container.render(w),
309
- invalidate: () => container.invalidate(),
310
- handleInput: (data: string) => {
311
- selectList.handleInput(data);
312
- tui.requestRender();
313
- },
314
- };
315
- });
274
+ return runCustomUI(ctx, "Remote package browsing", () =>
275
+ ctx.ui.custom<BrowseAction>((tui, theme, _keybindings, done) => {
276
+ const container = new Container();
277
+ const title = new Text("", 1, 0);
278
+ const footer = new Text("", 1, 0);
279
+ const syncThemedContent = (): void => {
280
+ title.setText(theme.fg("accent", theme.bold(titleText)));
281
+ footer.setText(theme.fg("dim", "↑↓ wraps • enter select • esc cancel"));
282
+ };
283
+
284
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
285
+ container.addChild(title);
286
+
287
+ const selectList = new SelectList(items, Math.min(items.length, 12), {
288
+ selectedPrefix: (t) => theme.fg("accent", t),
289
+ selectedText: (t) => theme.fg("accent", t),
290
+ description: (t) => theme.fg("muted", t),
291
+ scrollInfo: (t) => theme.fg("dim", t),
292
+ noMatch: (t) => theme.fg("warning", t),
293
+ });
294
+
295
+ selectList.onSelect = (item) => {
296
+ if (item.value === "nav:prev") {
297
+ done({ type: "prev" });
298
+ } else if (item.value === "nav:next") {
299
+ done({ type: "next" });
300
+ } else if (item.value === "nav:refresh") {
301
+ done({ type: "refresh" });
302
+ } else if (item.value === "nav:menu") {
303
+ done({ type: "menu" });
304
+ } else if (item.value.startsWith("pkg:")) {
305
+ done({ type: "package", name: item.value.slice(4) });
306
+ } else {
307
+ done({ type: "cancel" });
308
+ }
309
+ };
310
+
311
+ selectList.onCancel = () => done({ type: "cancel" });
312
+
313
+ syncThemedContent();
314
+ container.addChild(selectList);
315
+ container.addChild(footer);
316
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
317
+
318
+ return {
319
+ render: (w: number) => container.render(w),
320
+ invalidate: () => {
321
+ container.invalidate();
322
+ syncThemedContent();
323
+ },
324
+ handleInput: (data: string) => {
325
+ selectList.handleInput(data);
326
+ tui.requestRender();
327
+ },
328
+ };
329
+ })
330
+ );
316
331
  }
317
332
 
318
333
  export async function browseRemotePackages(
@@ -321,6 +336,16 @@ export async function browseRemotePackages(
321
336
  pi: ExtensionAPI,
322
337
  offset = 0
323
338
  ): Promise<void> {
339
+ if (
340
+ !requireCustomUI(
341
+ ctx,
342
+ "Remote package browsing",
343
+ "Use `/extensions install <source>` to install directly outside the full interactive TUI."
344
+ )
345
+ ) {
346
+ return;
347
+ }
348
+
324
349
  // Check cache first
325
350
  let allPackages: NpmPackage[] = [];
326
351
 
@@ -375,8 +400,8 @@ export async function browseRemotePackages(
375
400
  showLoadMore
376
401
  );
377
402
 
378
- if (!result) {
379
- return; // User cancelled
403
+ if (!result || result.type === "cancel") {
404
+ return;
380
405
  }
381
406
 
382
407
  // Handle result