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/unified.ts CHANGED
@@ -2,19 +2,28 @@
2
2
  * Unified extension manager UI
3
3
  * Displays local extensions and installed packages in one view
4
4
  */
5
- import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
6
- import type { Theme } from "@mariozechner/pi-coding-agent";
7
- import { getSettingsListTheme, DynamicBorder } from "@mariozechner/pi-coding-agent";
5
+ import { homedir } from "node:os";
6
+ import { relative } from "node:path";
7
+ import {
8
+ DynamicBorder,
9
+ type ExtensionAPI,
10
+ type ExtensionCommandContext,
11
+ type Theme,
12
+ } from "@mariozechner/pi-coding-agent";
8
13
  import {
9
14
  Container,
10
- SettingsList,
11
- Text,
12
- Spacer,
13
- type SettingItem,
14
- matchesKey,
15
+ type Focusable,
16
+ fuzzyMatch,
17
+ getKeybindings,
18
+ Input,
15
19
  Key,
20
+ matchesKey,
21
+ Spacer,
22
+ Text,
23
+ truncateToWidth,
24
+ wrapTextWithAnsi,
16
25
  } from "@mariozechner/pi-tui";
17
- import type { UnifiedItem, State, UnifiedAction, InstalledPackage } from "../types/index.js";
26
+ import { UI } from "../constants.js";
18
27
  import {
19
28
  discoverExtensions,
20
29
  removeLocalExtension,
@@ -22,34 +31,34 @@ import {
22
31
  } from "../extensions/discovery.js";
23
32
  import { getInstalledPackages } from "../packages/discovery.js";
24
33
  import {
25
- updatePackageWithOutcome,
26
34
  removePackageWithOutcome,
27
- updatePackagesWithOutcome,
28
35
  showInstalledPackagesList,
36
+ updatePackagesWithOutcome,
37
+ updatePackageWithOutcome,
29
38
  } from "../packages/management.js";
30
- import { showRemote } from "./remote.js";
31
- import { showHelp } from "./help.js";
32
- import { runTaskWithLoader } from "./async-task.js";
33
- import { formatEntry as formatExtEntry, dynamicTruncate, formatBytes } from "../utils/format.js";
34
39
  import {
35
- getStatusIcon,
36
- getPackageIcon,
37
- getScopeIcon,
38
- getChangeMarker,
39
- formatSize,
40
- } from "./theme.js";
41
- import { buildFooterState, buildFooterShortcuts, getPendingToggleChangeCount } from "./footer.js";
42
- import { logExtensionDelete, logExtensionToggle } from "../utils/history.js";
40
+ type InstalledPackage,
41
+ type LocalUnifiedItem,
42
+ type State,
43
+ type UnifiedAction,
44
+ type UnifiedItem,
45
+ } from "../types/index.js";
43
46
  import { getKnownUpdates, promptAutoUpdateWizard } from "../utils/auto-update.js";
44
- import { updateExtmgrStatus } from "../utils/status.js";
45
47
  import { parseChoiceByLabel } from "../utils/command.js";
48
+ import { formatBytes, formatEntry as formatExtEntry } from "../utils/format.js";
49
+ import { logExtensionDelete, logExtensionToggle } from "../utils/history.js";
50
+ import { hasCustomUI, runCustomUI } from "../utils/mode.js";
46
51
  import { notify } from "../utils/notify.js";
47
- import { confirmReload } from "../utils/ui-helpers.js";
52
+ import { normalizePathIdentity } from "../utils/path-identity.js";
48
53
  import { getPackageSourceKind, normalizePackageIdentity } from "../utils/package-source.js";
49
- import { hasCustomUI, runCustomUI } from "../utils/mode.js";
50
- import { getSettingsListSelectedIndex } from "../utils/settings-list.js";
51
- import { UI } from "../constants.js";
54
+ import { updateExtmgrStatus } from "../utils/status.js";
55
+ import { confirmReload, formatListOutput } from "../utils/ui-helpers.js";
56
+ import { runTaskWithLoader } from "./async-task.js";
57
+ import { buildFooterShortcuts, buildFooterState, getPendingToggleChangeCount } from "./footer.js";
58
+ import { showHelp } from "./help.js";
52
59
  import { configurePackageExtensions } from "./package-config.js";
60
+ import { showRemote } from "./remote.js";
61
+ import { getChangeMarker, getPackageIcon, getScopeIcon, getStatusIcon } from "./theme.js";
53
62
 
54
63
  async function showInteractiveFallback(
55
64
  ctx: ExtensionCommandContext,
@@ -146,202 +155,108 @@ async function showInteractiveOnce(
146
155
  // Staged changes tracking for local extensions.
147
156
  const staged = new Map<string, State>();
148
157
  const byId = new Map(items.map((item) => [item.id, item]));
158
+ let managerState: UnifiedManagerViewState | undefined;
149
159
 
150
- const result = await runCustomUI(
151
- ctx,
152
- "The unified extensions manager",
153
- () =>
154
- ctx.ui.custom<UnifiedAction>((tui, theme, _keybindings, done) => {
155
- const container = new Container();
156
-
157
- const titleText = new Text("", 2, 0);
158
- const subtitleText = new Text("", 2, 0);
159
- const quickText = new Text("", 2, 0);
160
- const footerState = buildFooterState(items);
161
- const footerText = new Text("", 2, 0);
162
-
163
- // Header
164
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
165
- container.addChild(titleText);
166
- container.addChild(subtitleText);
167
- container.addChild(quickText);
168
- container.addChild(new Spacer(1));
169
-
170
- // Build settings items
171
- const settingsItems = buildSettingsItems(items, staged, theme);
172
- const syncThemedContent = (): void => {
173
- titleText.setText(theme.fg("accent", theme.bold("Extensions Manager")));
174
- subtitleText.setText(
175
- theme.fg(
176
- "muted",
177
- `${items.length} item${items.length === 1 ? "" : "s"} • Space/Enter toggle local • Enter/A actions • c configure pkg extensions • u update pkg • x remove selected`
178
- )
179
- );
180
- quickText.setText(
181
- theme.fg(
182
- "dim",
183
- "Quick: i Install | f Search | U Update all | t Auto-update | p Palette"
184
- )
185
- );
186
- footerText.setText(theme.fg("dim", buildFooterShortcuts(footerState)));
187
-
188
- for (const settingsItem of settingsItems) {
189
- const item = byId.get(settingsItem.id);
190
- if (!item) continue;
191
-
192
- if (item.type === "local") {
193
- const currentState = staged.get(item.id) ?? item.state!;
194
- const changed = staged.has(item.id) && currentState !== item.originalState;
195
- settingsItem.label = formatUnifiedItemLabel(item, currentState, theme, changed);
196
- } else {
197
- settingsItem.label = formatUnifiedItemLabel(item, "enabled", theme, false);
198
- }
199
- }
200
- };
201
- syncThemedContent();
202
-
203
- const settingsList = new SettingsList(
204
- settingsItems,
205
- Math.min(items.length + 2, UI.maxListHeight),
206
- getSettingsListTheme(),
207
- (id: string, newValue: string) => {
208
- const item = byId.get(id);
209
- if (!item || item.type !== "local") return;
210
-
211
- const state = newValue as State;
212
- staged.set(id, state);
213
-
214
- const settingsItem = settingsItems.find((x) => x.id === id);
215
- if (settingsItem) {
216
- const changed = state !== item.originalState;
217
- settingsItem.label = formatUnifiedItemLabel(item, state, theme, changed);
218
- }
219
- tui.requestRender();
220
- },
221
- () => done({ type: "cancel" })
222
- );
223
-
224
- container.addChild(settingsList);
225
- container.addChild(new Spacer(1));
226
-
227
- // Footer with keyboard shortcuts
228
- container.addChild(footerText);
229
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
230
-
231
- return {
232
- render(width: number) {
233
- return container.render(width);
234
- },
235
- invalidate() {
236
- container.invalidate();
237
- syncThemedContent();
238
- },
239
- handleInput(data: string) {
240
- const selIdx = getSettingsListSelectedIndex(settingsList) ?? 0;
241
- const selectedId = settingsItems[selIdx]?.id ?? settingsItems[0]?.id;
242
- const selectedItem = selectedId ? byId.get(selectedId) : undefined;
243
-
244
- if (matchesKey(data, Key.ctrl("s")) || data === "s" || data === "S") {
245
- done({ type: "apply" });
246
- return;
247
- }
248
-
249
- // Enter on a package opens its action menu (fewer clicks)
250
- if (
251
- (data === "\r" || data === "\n") &&
252
- selectedId &&
253
- selectedItem?.type === "package"
254
- ) {
255
- done({ type: "action", itemId: selectedId, action: "menu" });
256
- return;
257
- }
258
-
259
- if (data === "a" || data === "A") {
260
- if (selectedId) {
261
- done({ type: "action", itemId: selectedId, action: "menu" });
262
- }
263
- return;
264
- }
265
-
266
- // Quick actions (global)
267
- if (data === "i") {
268
- done({ type: "quick", action: "install" });
269
- return;
270
- }
271
- if (data === "f") {
272
- done({ type: "quick", action: "search" });
273
- return;
274
- }
275
- if (data === "U") {
276
- done({ type: "quick", action: "update-all" });
277
- return;
278
- }
279
- if (data === "t" || data === "T") {
280
- done({ type: "quick", action: "auto-update" });
281
- return;
282
- }
283
-
284
- // Fast actions on selected row
285
- if (selectedId && selectedItem?.type === "package") {
286
- if (data === "u") {
287
- done({ type: "action", itemId: selectedId, action: "update" });
288
- return;
289
- }
290
- if (data === "x" || data === "X") {
291
- done({ type: "action", itemId: selectedId, action: "remove" });
292
- return;
293
- }
294
- if (data === "v" || data === "V") {
295
- done({ type: "action", itemId: selectedId, action: "details" });
296
- return;
297
- }
298
- if (data === "c" || data === "C") {
299
- done({ type: "action", itemId: selectedId, action: "configure" });
300
- return;
301
- }
302
- }
160
+ while (true) {
161
+ let nextManagerState = managerState;
303
162
 
304
- if (selectedId && selectedItem?.type === "local") {
305
- if (data === "x" || data === "X") {
306
- done({ type: "action", itemId: selectedId, action: "remove" });
307
- return;
163
+ const result = await runCustomUI(
164
+ ctx,
165
+ "The unified extensions manager",
166
+ () =>
167
+ ctx.ui.custom<UnifiedAction>((tui, theme, _keybindings, done) => {
168
+ const container = new Container();
169
+
170
+ const titleText = new Text("", 2, 0);
171
+ const statsText = new Text("", 2, 0);
172
+ const footerText = new Text("", 2, 0);
173
+ let browser!: UnifiedManagerBrowser;
174
+ const complete = (action: UnifiedAction): void => {
175
+ nextManagerState = browser.getViewState();
176
+ done(action);
177
+ };
178
+ browser = new UnifiedManagerBrowser(
179
+ items,
180
+ staged,
181
+ theme,
182
+ ctx.cwd,
183
+ Math.max(4, Math.min(UI.maxListHeight, tui.terminal.rows - 12)),
184
+ complete,
185
+ managerState
186
+ );
187
+ let lastWidth = tui.terminal.columns;
188
+
189
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
190
+ container.addChild(titleText);
191
+ container.addChild(statsText);
192
+ container.addChild(new Spacer(1));
193
+ container.addChild(browser);
194
+ container.addChild(new Spacer(1));
195
+ container.addChild(footerText);
196
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
197
+
198
+ const syncThemedContent = (width = lastWidth): void => {
199
+ lastWidth = width;
200
+ titleText.setText(theme.fg("accent", theme.bold("Extensions Manager")));
201
+ statsText.setText(
202
+ buildManagerSummary(items, staged, byId, theme, {
203
+ visibleItems: browser.getVisibleItems(),
204
+ filter: browser.getFilter(),
205
+ searchQuery: browser.getSearchQuery(),
206
+ })
207
+ );
208
+ footerText.setText(
209
+ theme.fg(
210
+ "dim",
211
+ buildFooterShortcuts(buildFooterState(staged, byId, browser.getSelectedItem()))
212
+ )
213
+ );
214
+ };
215
+
216
+ syncThemedContent();
217
+
218
+ let focused = false;
219
+
220
+ return {
221
+ get focused() {
222
+ return focused;
223
+ },
224
+ set focused(value: boolean) {
225
+ focused = value;
226
+ browser.focused = value;
227
+ },
228
+ render(width: number) {
229
+ syncThemedContent(width);
230
+ return container.render(width);
231
+ },
232
+ invalidate() {
233
+ container.invalidate();
234
+ browser.invalidate();
235
+ syncThemedContent(lastWidth);
236
+ },
237
+ handleInput(data: string) {
238
+ if (browser.handleManagerInput(data)) {
239
+ tui.requestRender();
308
240
  }
309
- }
310
-
311
- if (data === "r" || data === "R") {
312
- done({ type: "remote" });
313
- return;
314
- }
315
- if (data === "?" || data === "h" || data === "H") {
316
- done({ type: "help" });
317
- return;
318
- }
319
- if (data === "m" || data === "M" || data === "p" || data === "P") {
320
- done({ type: "menu" });
321
- return;
322
- }
323
- settingsList.handleInput?.(data);
324
- tui.requestRender();
325
- },
326
- };
327
- }),
328
- "Showing read-only local and installed package lists instead."
329
- );
330
-
331
- if (!result) {
332
- await showInteractiveFallback(ctx, pi);
333
- return true;
334
- }
241
+ },
242
+ };
243
+ }),
244
+ "Showing read-only local and installed package lists instead."
245
+ );
335
246
 
336
- return await handleUnifiedAction(result, items, staged, byId, ctx, pi);
337
- }
247
+ if (!result) {
248
+ await showInteractiveFallback(ctx, pi);
249
+ return true;
250
+ }
338
251
 
339
- function normalizePathForDuplicateCheck(value: string): string {
340
- const normalized = value.replace(/\\/g, "/");
341
- const looksWindowsPath =
342
- /^[a-zA-Z]:\//.test(normalized) || normalized.startsWith("//") || value.includes("\\");
252
+ const outcome = await handleUnifiedAction(result, items, staged, byId, ctx, pi);
253
+ if (outcome === "resume") {
254
+ managerState = nextManagerState;
255
+ continue;
256
+ }
343
257
 
344
- return looksWindowsPath ? normalized.toLowerCase() : normalized;
258
+ return outcome;
259
+ }
345
260
  }
346
261
 
347
262
  export function buildUnifiedItems(
@@ -354,7 +269,8 @@ export function buildUnifiedItems(
354
269
 
355
270
  // Add local extensions
356
271
  for (const entry of localEntries) {
357
- localPaths.add(normalizePathForDuplicateCheck(entry.activePath));
272
+ const currentPath = entry.state === "disabled" ? entry.disabledPath : entry.activePath;
273
+ localPaths.add(normalizePathIdentity(currentPath));
358
274
  items.push({
359
275
  type: "local",
360
276
  id: entry.id,
@@ -369,10 +285,8 @@ export function buildUnifiedItems(
369
285
  }
370
286
 
371
287
  for (const pkg of installedPackages) {
372
- const pkgSourceNormalized = normalizePathForDuplicateCheck(pkg.source);
373
- const pkgResolvedNormalized = pkg.resolvedPath
374
- ? normalizePathForDuplicateCheck(pkg.resolvedPath)
375
- : "";
288
+ const pkgSourceNormalized = normalizePathIdentity(pkg.source);
289
+ const pkgResolvedNormalized = pkg.resolvedPath ? normalizePathIdentity(pkg.resolvedPath) : "";
376
290
 
377
291
  let isDuplicate = false;
378
292
  for (const localPath of localPaths) {
@@ -380,16 +294,7 @@ export function buildUnifiedItems(
380
294
  isDuplicate = true;
381
295
  break;
382
296
  }
383
- if (
384
- pkgResolvedNormalized &&
385
- (localPath.startsWith(`${pkgResolvedNormalized}/`) ||
386
- pkgResolvedNormalized.startsWith(localPath))
387
- ) {
388
- isDuplicate = true;
389
- break;
390
- }
391
- const localDir = localPath.split("/").slice(0, -1).join("/");
392
- if (pkgResolvedNormalized && pkgResolvedNormalized === localDir) {
297
+ if (pkgResolvedNormalized && localPath.startsWith(`${pkgResolvedNormalized}/`)) {
393
298
  isDuplicate = true;
394
299
  break;
395
300
  }
@@ -400,9 +305,9 @@ export function buildUnifiedItems(
400
305
  type: "package",
401
306
  id: `pkg:${pkg.source}`,
402
307
  displayName: pkg.name,
403
- summary: pkg.description || `${pkg.source} (${pkg.scope})`,
404
308
  scope: pkg.scope,
405
309
  source: pkg.source,
310
+ resolvedPath: pkg.resolvedPath,
406
311
  version: pkg.version,
407
312
  description: pkg.description,
408
313
  size: pkg.size,
@@ -425,48 +330,90 @@ export function buildUnifiedItems(
425
330
  return items;
426
331
  }
427
332
 
428
- function buildSettingsItems(
333
+ function buildManagerSummary(
429
334
  items: UnifiedItem[],
430
335
  staged: Map<string, State>,
431
- theme: Theme
432
- ): SettingItem[] {
433
- return items.map((item) => {
434
- if (item.type === "local") {
435
- const currentState = staged.get(item.id) ?? item.state!;
436
- const changed = staged.has(item.id) && staged.get(item.id) !== item.originalState;
437
- return {
438
- id: item.id,
439
- label: formatUnifiedItemLabel(item, currentState, theme, changed),
440
- currentValue: currentState,
441
- values: ["enabled", "disabled"],
442
- };
443
- }
336
+ byId: Map<string, UnifiedItem>,
337
+ theme: Theme,
338
+ options?: {
339
+ visibleItems?: readonly UnifiedItem[];
340
+ filter?: UnifiedFilter;
341
+ searchQuery?: string;
342
+ }
343
+ ): string {
344
+ const summaryItems = options?.visibleItems ?? items;
345
+ const filtered =
346
+ Boolean(options?.searchQuery) ||
347
+ options?.filter === "local" ||
348
+ options?.filter === "packages" ||
349
+ options?.filter === "updates" ||
350
+ options?.filter === "disabled";
351
+ const localCount = summaryItems.filter((item) => item.type === "local").length;
352
+ const packageCount = summaryItems.length - localCount;
353
+ const updateCount = summaryItems.filter(
354
+ (item) => item.type === "package" && item.updateAvailable
355
+ ).length;
356
+ const pendingCount = getPendingToggleChangeCount(staged, byId);
357
+ const parts = [
358
+ filtered
359
+ ? theme.fg("accent", `showing ${summaryItems.length} of ${items.length}`)
360
+ : theme.fg("muted", `${items.length} item${items.length === 1 ? "" : "s"}`),
361
+ theme.fg("muted", `${localCount} local`),
362
+ ];
363
+
364
+ if (packageCount > 0) {
365
+ parts.push(theme.fg("muted", `${packageCount} package${packageCount === 1 ? "" : "s"}`));
366
+ }
444
367
 
445
- return {
446
- id: item.id,
447
- label: formatUnifiedItemLabel(item, "enabled", theme, false),
448
- currentValue: "enabled",
449
- values: ["enabled"],
450
- };
451
- });
368
+ if (updateCount > 0) {
369
+ parts.push(theme.fg("warning", `${updateCount} update${updateCount === 1 ? "" : "s"}`));
370
+ }
371
+
372
+ if (pendingCount > 0) {
373
+ parts.push(theme.fg("warning", `${pendingCount} unsaved`));
374
+ }
375
+
376
+ return parts.join(" • ");
377
+ }
378
+
379
+ type UnifiedFilter = "all" | "local" | "packages" | "updates" | "disabled";
380
+
381
+ interface UnifiedManagerViewState {
382
+ filter: UnifiedFilter;
383
+ searchQuery: string;
384
+ selectedItemId?: string;
385
+ }
386
+
387
+ const UNIFIED_FILTER_OPTIONS: Array<{ id: UnifiedFilter; key: string; label: string }> = [
388
+ { id: "all", key: "1", label: "All" },
389
+ { id: "local", key: "2", label: "Local" },
390
+ { id: "packages", key: "3", label: "Packages" },
391
+ { id: "updates", key: "4", label: "Updates" },
392
+ { id: "disabled", key: "5", label: "Disabled" },
393
+ ];
394
+
395
+ function getCurrentUnifiedItemState(
396
+ item: UnifiedItem,
397
+ staged: Map<string, State>
398
+ ): State | undefined {
399
+ return item.type === "local" ? (staged.get(item.id) ?? item.state) : undefined;
452
400
  }
453
401
 
454
402
  function formatUnifiedItemLabel(
455
403
  item: UnifiedItem,
456
- state: State,
404
+ state: State | undefined,
457
405
  theme: Theme,
458
406
  changed = false
459
407
  ): string {
460
408
  if (item.type === "local") {
461
- const statusIcon = getStatusIcon(theme, state === "enabled" ? "enabled" : "disabled");
409
+ const statusIcon = getStatusIcon(theme, state ?? item.state);
462
410
  const scopeIcon = getScopeIcon(theme, item.scope);
463
411
  const changeMarker = getChangeMarker(theme, changed);
464
412
  const name = theme.bold(item.displayName);
465
- const summary = theme.fg("dim", item.summary);
466
- return `${statusIcon} [${scopeIcon}] ${name} - ${summary}${changeMarker}`;
413
+ return `${statusIcon} [${scopeIcon}] ${name}${changeMarker}`;
467
414
  }
468
415
 
469
- const sourceKind = getPackageSourceKind(item.source ?? "");
416
+ const sourceKind = getPackageSourceKind(item.source);
470
417
  const pkgIcon = getPackageIcon(
471
418
  theme,
472
419
  sourceKind === "npm" || sourceKind === "git" || sourceKind === "local" ? sourceKind : "local"
@@ -474,34 +421,635 @@ function formatUnifiedItemLabel(
474
421
  const scopeIcon = getScopeIcon(theme, item.scope);
475
422
  const name = theme.bold(item.displayName);
476
423
  const version = item.version ? theme.fg("dim", `@${item.version}`) : "";
424
+ const size = item.size !== undefined ? theme.fg("dim", ` • ${formatBytes(item.size)}`) : "";
477
425
  const updateBadge = item.updateAvailable ? ` ${theme.fg("warning", "[update]")}` : "";
478
426
 
479
- // Build info parts
480
- const infoParts: string[] = [];
481
-
482
- // Show description if available
483
- // Reserved space: icon (2) + scope (3) + name (~25) + version (~10) + separator (3) = ~43 chars
484
- if (item.description) {
485
- infoParts.push(dynamicTruncate(item.description, 43));
486
- } else if (sourceKind === "npm") {
487
- infoParts.push("npm");
488
- } else if (sourceKind === "git") {
489
- infoParts.push("git");
490
- } else {
491
- infoParts.push("local");
427
+ return `${pkgIcon} [${scopeIcon}] ${name}${version}${size}${updateBadge}`;
428
+ }
429
+
430
+ function getLocalItemCurrentPath(item: LocalUnifiedItem, state?: State): string {
431
+ return (state ?? item.state) === "enabled" ? item.activePath : item.disabledPath;
432
+ }
433
+
434
+ function formatUnifiedItemDescription(
435
+ item: UnifiedItem,
436
+ state: State | undefined,
437
+ changed: boolean,
438
+ cwd: string
439
+ ): string {
440
+ if (item.type === "local") {
441
+ const details = [
442
+ item.summary,
443
+ "local extension",
444
+ item.scope,
445
+ changed ? `staged → ${state ?? item.state}` : (state ?? item.state),
446
+ compactDisplayPath(getLocalItemCurrentPath(item, state), cwd),
447
+ ];
448
+
449
+ return details.filter(Boolean).join(" • ");
450
+ }
451
+
452
+ const sourceKind = getPackageSourceKind(item.source);
453
+ const source = sourceKind === "local" ? compactDisplayPath(item.source, cwd) : item.source;
454
+ const details = [
455
+ item.description || "No description",
456
+ `${sourceKind === "unknown" ? "package" : `${sourceKind} package`}`,
457
+ item.scope,
458
+ source,
459
+ item.updateAvailable ? "update available" : undefined,
460
+ item.size !== undefined ? formatBytes(item.size) : undefined,
461
+ ];
462
+
463
+ return details.filter(Boolean).join(" • ");
464
+ }
465
+
466
+ function compactDisplayPath(filePath: string, cwd: string): string {
467
+ const normalizedPath = filePath.replace(/\\/g, "/");
468
+ const normalizedHome = homedir().replace(/\\/g, "/");
469
+
470
+ if (normalizedPath === normalizedHome) {
471
+ return "~";
492
472
  }
493
473
 
494
- // Show size if available
495
- if (item.size !== undefined) {
496
- infoParts.push(formatSize(theme, item.size));
474
+ if (normalizedPath.startsWith(`${normalizedHome}/`)) {
475
+ return `~/${normalizedPath.slice(normalizedHome.length + 1)}`;
497
476
  }
498
477
 
499
- const summary = theme.fg("dim", infoParts.join(" "));
500
- return `${pkgIcon} [${scopeIcon}] ${name}${version}${updateBadge} - ${summary}`;
478
+ const relativePath = relative(cwd, filePath).replace(/\\/g, "/");
479
+ if (
480
+ relativePath &&
481
+ relativePath !== ".." &&
482
+ !relativePath.startsWith("../") &&
483
+ !isAbsoluteDisplayPath(relativePath)
484
+ ) {
485
+ return `./${relativePath}`;
486
+ }
487
+
488
+ return normalizedPath;
501
489
  }
502
490
 
503
- function getToggleItemsForApply(items: UnifiedItem[]): UnifiedItem[] {
504
- return items.filter((item) => item.type === "local");
491
+ function isAbsoluteDisplayPath(value: string): boolean {
492
+ return /^([a-zA-Z]:\/|\/|\\\\)/.test(value);
493
+ }
494
+
495
+ function matchesUnifiedFilter(
496
+ item: UnifiedItem,
497
+ filter: UnifiedFilter,
498
+ staged: Map<string, State>
499
+ ): boolean {
500
+ switch (filter) {
501
+ case "all":
502
+ return true;
503
+ case "local":
504
+ return item.type === "local";
505
+ case "packages":
506
+ return item.type === "package";
507
+ case "updates":
508
+ return item.type === "package" && Boolean(item.updateAvailable);
509
+ case "disabled":
510
+ return item.type === "local" && getCurrentUnifiedItemState(item, staged) === "disabled";
511
+ }
512
+ }
513
+
514
+ function getUnifiedItemSearchFields(
515
+ item: UnifiedItem,
516
+ staged: Map<string, State>,
517
+ cwd: string
518
+ ): { primary: string[]; secondary: string[] } {
519
+ if (item.type === "local") {
520
+ const state = getCurrentUnifiedItemState(item, staged) ?? item.state;
521
+ return {
522
+ primary: [item.displayName, compactDisplayPath(getLocalItemCurrentPath(item, state), cwd)],
523
+ secondary: [item.summary],
524
+ };
525
+ }
526
+
527
+ const source =
528
+ getPackageSourceKind(item.source) === "local"
529
+ ? compactDisplayPath(item.source, cwd)
530
+ : item.source;
531
+ return {
532
+ primary: [item.displayName, source],
533
+ secondary: [item.version ?? "", item.description ?? ""],
534
+ };
535
+ }
536
+
537
+ function scoreUnifiedItemSearchMatch(
538
+ item: UnifiedItem,
539
+ query: string,
540
+ staged: Map<string, State>,
541
+ cwd: string
542
+ ): number | undefined {
543
+ const tokens = query
544
+ .trim()
545
+ .toLowerCase()
546
+ .split(/\s+/)
547
+ .filter((token) => token.length > 0);
548
+ if (tokens.length === 0) {
549
+ return 0;
550
+ }
551
+
552
+ const fields = getUnifiedItemSearchFields(item, staged, cwd);
553
+ const primary = fields.primary
554
+ .map((value) => value.trim().toLowerCase())
555
+ .filter((value) => value.length > 0);
556
+ const secondary = fields.secondary
557
+ .map((value) => value.trim().toLowerCase())
558
+ .filter((value) => value.length > 0);
559
+
560
+ let totalScore = 0;
561
+
562
+ for (const token of tokens) {
563
+ const primarySubstringScore = primary.reduce<number | undefined>((best, field) => {
564
+ const index = field.indexOf(token);
565
+ if (index < 0) {
566
+ return best;
567
+ }
568
+ return best === undefined ? index : Math.min(best, index);
569
+ }, undefined);
570
+ if (primarySubstringScore !== undefined) {
571
+ totalScore += primarySubstringScore;
572
+ continue;
573
+ }
574
+
575
+ const secondarySubstringScore = secondary.reduce<number | undefined>((best, field) => {
576
+ const index = field.indexOf(token);
577
+ if (index < 0) {
578
+ return best;
579
+ }
580
+ const score = 100 + index;
581
+ return best === undefined ? score : Math.min(best, score);
582
+ }, undefined);
583
+ if (secondarySubstringScore !== undefined) {
584
+ totalScore += secondarySubstringScore;
585
+ continue;
586
+ }
587
+
588
+ const primaryFuzzyScore = primary.reduce<number | undefined>((best, field) => {
589
+ const match = fuzzyMatch(token, field);
590
+ if (!match.matches) {
591
+ return best;
592
+ }
593
+ const score = 200 + match.score;
594
+ return best === undefined ? score : Math.min(best, score);
595
+ }, undefined);
596
+ if (primaryFuzzyScore !== undefined) {
597
+ totalScore += primaryFuzzyScore;
598
+ continue;
599
+ }
600
+
601
+ return undefined;
602
+ }
603
+
604
+ return totalScore;
605
+ }
606
+
607
+ function searchUnifiedItems(
608
+ items: UnifiedItem[],
609
+ query: string,
610
+ staged: Map<string, State>,
611
+ cwd: string
612
+ ): UnifiedItem[] {
613
+ const matches = items
614
+ .map((item, index) => ({
615
+ item,
616
+ index,
617
+ score: scoreUnifiedItemSearchMatch(item, query, staged, cwd),
618
+ }))
619
+ .filter(
620
+ (match): match is { item: UnifiedItem; index: number; score: number } =>
621
+ match.score !== undefined
622
+ );
623
+
624
+ matches.sort((a, b) => a.score - b.score || a.index - b.index);
625
+ return matches.map((match) => match.item);
626
+ }
627
+
628
+ class UnifiedManagerBrowser implements Focusable {
629
+ private readonly searchInput = new Input();
630
+ private readonly filteredItems: UnifiedItem[] = [];
631
+ private selectedIndex = 0;
632
+ private filter: UnifiedFilter = "all";
633
+ private searchActive = false;
634
+ private _focused = false;
635
+
636
+ constructor(
637
+ private readonly items: UnifiedItem[],
638
+ private readonly staged: Map<string, State>,
639
+ private readonly theme: Theme,
640
+ private readonly cwd: string,
641
+ private readonly maxVisibleItems: number,
642
+ private readonly onAction: (action: UnifiedAction) => void,
643
+ initialState?: UnifiedManagerViewState
644
+ ) {
645
+ if (initialState) {
646
+ this.filter = initialState.filter;
647
+ this.searchInput.setValue(initialState.searchQuery);
648
+ this.refreshVisibleItems(initialState.selectedItemId);
649
+ return;
650
+ }
651
+
652
+ this.refreshVisibleItems();
653
+ }
654
+
655
+ get focused(): boolean {
656
+ return this._focused;
657
+ }
658
+
659
+ set focused(value: boolean) {
660
+ this._focused = value;
661
+ this.searchInput.focused = value && this.searchActive;
662
+ }
663
+
664
+ getSelectedItem(): UnifiedItem | undefined {
665
+ return this.filteredItems[this.selectedIndex];
666
+ }
667
+
668
+ getVisibleItems(): readonly UnifiedItem[] {
669
+ return this.filteredItems;
670
+ }
671
+
672
+ getFilter(): UnifiedFilter {
673
+ return this.filter;
674
+ }
675
+
676
+ getSearchQuery(): string {
677
+ return this.searchInput.getValue().trim();
678
+ }
679
+
680
+ getViewState(): UnifiedManagerViewState {
681
+ const selectedItemId = this.getSelectedItem()?.id;
682
+ return {
683
+ filter: this.filter,
684
+ searchQuery: this.getSearchQuery(),
685
+ ...(selectedItemId ? { selectedItemId } : {}),
686
+ };
687
+ }
688
+
689
+ invalidate(): void {
690
+ this.searchInput.invalidate();
691
+ }
692
+
693
+ handleInput(data: string): void {
694
+ this.handleManagerInput(data);
695
+ }
696
+
697
+ handleManagerInput(data: string): boolean {
698
+ const kb = getKeybindings();
699
+
700
+ if (this.searchActive) {
701
+ if (matchesKey(data, Key.enter)) {
702
+ this.searchActive = false;
703
+ this.searchInput.focused = false;
704
+ return true;
705
+ }
706
+
707
+ if (matchesKey(data, Key.escape)) {
708
+ this.searchInput.setValue("");
709
+ this.searchActive = false;
710
+ this.searchInput.focused = false;
711
+ this.refreshVisibleItems();
712
+ return true;
713
+ }
714
+
715
+ this.searchInput.handleInput(data);
716
+ this.refreshVisibleItems();
717
+ return true;
718
+ }
719
+
720
+ if (data === "/" || matchesKey(data, Key.ctrl("f"))) {
721
+ this.searchActive = true;
722
+ this.searchInput.focused = this._focused;
723
+ return true;
724
+ }
725
+
726
+ if (matchesKey(data, Key.escape) && this.getSearchQuery()) {
727
+ this.searchInput.setValue("");
728
+ this.refreshVisibleItems();
729
+ return true;
730
+ }
731
+
732
+ if (matchesKey(data, Key.shift("tab"))) {
733
+ this.cycleFilter(-1);
734
+ return true;
735
+ }
736
+
737
+ if (matchesKey(data, Key.tab)) {
738
+ this.cycleFilter(1);
739
+ return true;
740
+ }
741
+
742
+ const directFilter = UNIFIED_FILTER_OPTIONS.find((option) => option.key === data)?.id;
743
+ if (directFilter) {
744
+ this.setFilter(directFilter);
745
+ return true;
746
+ }
747
+
748
+ if (kb.matches(data, "tui.select.up")) {
749
+ this.moveSelection(-1);
750
+ return true;
751
+ }
752
+
753
+ if (kb.matches(data, "tui.select.down")) {
754
+ this.moveSelection(1);
755
+ return true;
756
+ }
757
+
758
+ if (kb.matches(data, "tui.select.pageUp")) {
759
+ this.moveSelection(-Math.max(1, this.maxVisibleItems - 1));
760
+ return true;
761
+ }
762
+
763
+ if (kb.matches(data, "tui.select.pageDown")) {
764
+ this.moveSelection(Math.max(1, this.maxVisibleItems - 1));
765
+ return true;
766
+ }
767
+
768
+ if (matchesKey(data, Key.home)) {
769
+ this.selectedIndex = 0;
770
+ return true;
771
+ }
772
+
773
+ if (matchesKey(data, Key.end)) {
774
+ this.selectedIndex = Math.max(0, this.filteredItems.length - 1);
775
+ return true;
776
+ }
777
+
778
+ const selectedItem = this.getSelectedItem();
779
+ const selectedId = selectedItem?.id;
780
+
781
+ if (matchesKey(data, Key.ctrl("s")) || data === "s" || data === "S") {
782
+ this.onAction({ type: "apply" });
783
+ return true;
784
+ }
785
+
786
+ if ((matchesKey(data, Key.space) || data === " ") && selectedItem?.type === "local") {
787
+ const currentState =
788
+ getCurrentUnifiedItemState(selectedItem, this.staged) ?? selectedItem.state;
789
+ const nextState: State = currentState === "enabled" ? "disabled" : "enabled";
790
+ if (nextState === selectedItem.originalState) {
791
+ this.staged.delete(selectedItem.id);
792
+ } else {
793
+ this.staged.set(selectedItem.id, nextState);
794
+ }
795
+ this.refreshVisibleItems(selectedItem.id);
796
+ return true;
797
+ }
798
+
799
+ if (matchesKey(data, Key.enter) && selectedId) {
800
+ this.onAction({ type: "action", itemId: selectedId, action: "menu" });
801
+ return true;
802
+ }
803
+
804
+ if (data === "a" || data === "A") {
805
+ if (selectedId) {
806
+ this.onAction({ type: "action", itemId: selectedId, action: "menu" });
807
+ }
808
+ return true;
809
+ }
810
+
811
+ if (data === "i") {
812
+ this.onAction({ type: "quick", action: "install" });
813
+ return true;
814
+ }
815
+
816
+ if (data === "f") {
817
+ this.onAction({ type: "quick", action: "search" });
818
+ return true;
819
+ }
820
+
821
+ if (data === "U") {
822
+ this.onAction({ type: "quick", action: "update-all" });
823
+ return true;
824
+ }
825
+
826
+ if (data === "t" || data === "T") {
827
+ this.onAction({ type: "quick", action: "auto-update" });
828
+ return true;
829
+ }
830
+
831
+ if (selectedId && (data === "v" || data === "V")) {
832
+ this.onAction({ type: "action", itemId: selectedId, action: "details" });
833
+ return true;
834
+ }
835
+
836
+ if (selectedId && selectedItem?.type === "package") {
837
+ if (data === "u") {
838
+ this.onAction({ type: "action", itemId: selectedId, action: "update" });
839
+ return true;
840
+ }
841
+ if (data === "x" || data === "X") {
842
+ this.onAction({ type: "action", itemId: selectedId, action: "remove" });
843
+ return true;
844
+ }
845
+ if (data === "c" || data === "C") {
846
+ this.onAction({ type: "action", itemId: selectedId, action: "configure" });
847
+ return true;
848
+ }
849
+ }
850
+
851
+ if (selectedId && selectedItem?.type === "local" && (data === "x" || data === "X")) {
852
+ this.onAction({ type: "action", itemId: selectedId, action: "remove" });
853
+ return true;
854
+ }
855
+
856
+ if (data === "r" || data === "R") {
857
+ this.onAction({ type: "remote" });
858
+ return true;
859
+ }
860
+
861
+ if (data === "?" || data === "h" || data === "H") {
862
+ this.onAction({ type: "help" });
863
+ return true;
864
+ }
865
+
866
+ if (data === "m" || data === "M" || data === "p" || data === "P") {
867
+ this.onAction({ type: "menu" });
868
+ return true;
869
+ }
870
+
871
+ if (matchesKey(data, Key.escape)) {
872
+ this.onAction({ type: "cancel" });
873
+ return true;
874
+ }
875
+
876
+ return false;
877
+ }
878
+
879
+ render(width: number): string[] {
880
+ const lines: string[] = [];
881
+
882
+ const searchQuery = this.searchInput.getValue().trim();
883
+ if (this.searchActive) {
884
+ lines.push(...this.searchInput.render(width));
885
+ lines.push("");
886
+ } else if (searchQuery) {
887
+ lines.push(truncateToWidth(this.theme.fg("accent", ` Search: ${searchQuery}`), width, ""));
888
+ lines.push("");
889
+ }
890
+
891
+ lines.push(truncateToWidth(this.buildFilterLine(), width, ""));
892
+ lines.push("");
893
+
894
+ if (this.filteredItems.length === 0) {
895
+ lines.push(this.theme.fg("warning", " No matching extensions or packages"));
896
+ return lines;
897
+ }
898
+
899
+ const { startIndex, endIndex } = this.getVisibleRange();
900
+ const visibleItems = this.filteredItems.slice(startIndex, endIndex);
901
+ const localCount = this.filteredItems.filter((item) => item.type === "local").length;
902
+ const packageCount = this.filteredItems.length - localCount;
903
+ const visibleLocalItems = visibleItems.filter((item) => item.type === "local");
904
+ const visiblePackageItems = visibleItems.filter((item) => item.type === "package");
905
+
906
+ if (visibleLocalItems.length > 0) {
907
+ lines.push(this.theme.fg("accent", ` Local extensions (${localCount})`));
908
+ for (const item of visibleLocalItems) {
909
+ lines.push(this.renderItemLine(item, width));
910
+ }
911
+ if (visiblePackageItems.length > 0) {
912
+ lines.push("");
913
+ }
914
+ }
915
+
916
+ if (visiblePackageItems.length > 0) {
917
+ lines.push(this.theme.fg("accent", ` Installed packages (${packageCount})`));
918
+ for (const item of visiblePackageItems) {
919
+ lines.push(this.renderItemLine(item, width));
920
+ }
921
+ }
922
+
923
+ if (startIndex > 0 || endIndex < this.filteredItems.length) {
924
+ lines.push("");
925
+ lines.push(
926
+ this.theme.fg(
927
+ "dim",
928
+ ` Showing ${startIndex + 1}-${endIndex} of ${this.filteredItems.length}`
929
+ )
930
+ );
931
+ }
932
+
933
+ const selectedItem = this.getSelectedItem();
934
+ if (selectedItem) {
935
+ lines.push("");
936
+ const selectedState = getCurrentUnifiedItemState(selectedItem, this.staged);
937
+ const detailText = formatUnifiedItemDescription(
938
+ selectedItem,
939
+ selectedState,
940
+ selectedItem.type === "local" && selectedState !== selectedItem.originalState,
941
+ this.cwd
942
+ );
943
+ for (const line of wrapTextWithAnsi(detailText, width - 4)) {
944
+ lines.push(this.theme.fg("dim", ` ${line}`));
945
+ }
946
+ }
947
+
948
+ return lines;
949
+ }
950
+
951
+ private buildFilterLine(): string {
952
+ const filters = UNIFIED_FILTER_OPTIONS.map(({ id, key, label }) => {
953
+ const text = `${key}:${label}`;
954
+ return id === this.filter
955
+ ? this.theme.fg("accent", `[${text}]`)
956
+ : this.theme.fg("muted", text);
957
+ }).join(" ");
958
+ const searchHint = this.theme.fg(
959
+ this.searchActive || this.searchInput.getValue() ? "accent" : "dim",
960
+ "/ search"
961
+ );
962
+ return ` ${filters} · ${searchHint}`;
963
+ }
964
+
965
+ private renderItemLine(item: UnifiedItem, width: number): string {
966
+ const state = getCurrentUnifiedItemState(item, this.staged);
967
+ const changed = item.type === "local" && state !== item.originalState;
968
+ const prefix = this.getSelectedItem()?.id === item.id ? this.theme.fg("accent", "→ ") : " ";
969
+ return truncateToWidth(
970
+ prefix + formatUnifiedItemLabel(item, state, this.theme, changed),
971
+ width
972
+ );
973
+ }
974
+
975
+ private refreshVisibleItems(preferredItemId?: string): void {
976
+ const previousSelectedId = preferredItemId ?? this.getSelectedItem()?.id;
977
+ const filteredByMode = this.items.filter((item) =>
978
+ matchesUnifiedFilter(item, this.filter, this.staged)
979
+ );
980
+ const query = this.searchInput.getValue().trim();
981
+ this.filteredItems.length = 0;
982
+ this.filteredItems.push(
983
+ ...(query ? searchUnifiedItems(filteredByMode, query, this.staged, this.cwd) : filteredByMode)
984
+ );
985
+
986
+ if (this.filteredItems.length === 0) {
987
+ this.selectedIndex = 0;
988
+ return;
989
+ }
990
+
991
+ const nextSelectedIndex = previousSelectedId
992
+ ? this.filteredItems.findIndex((item) => item.id === previousSelectedId)
993
+ : -1;
994
+ if (nextSelectedIndex >= 0) {
995
+ this.selectedIndex = nextSelectedIndex;
996
+ return;
997
+ }
998
+
999
+ this.selectedIndex = Math.min(this.selectedIndex, this.filteredItems.length - 1);
1000
+ }
1001
+
1002
+ private setFilter(filter: UnifiedFilter): void {
1003
+ this.filter = filter;
1004
+ this.refreshVisibleItems();
1005
+ }
1006
+
1007
+ private cycleFilter(direction: -1 | 1): void {
1008
+ const currentIndex = UNIFIED_FILTER_OPTIONS.findIndex((option) => option.id === this.filter);
1009
+ const nextIndex =
1010
+ (currentIndex + direction + UNIFIED_FILTER_OPTIONS.length) % UNIFIED_FILTER_OPTIONS.length;
1011
+ const nextFilter = UNIFIED_FILTER_OPTIONS[nextIndex]?.id;
1012
+ if (nextFilter) {
1013
+ this.setFilter(nextFilter);
1014
+ }
1015
+ }
1016
+
1017
+ private moveSelection(delta: number): void {
1018
+ if (this.filteredItems.length === 0) {
1019
+ this.selectedIndex = 0;
1020
+ return;
1021
+ }
1022
+
1023
+ const nextIndex = this.selectedIndex + delta;
1024
+ if (nextIndex < 0) {
1025
+ this.selectedIndex = 0;
1026
+ return;
1027
+ }
1028
+
1029
+ if (nextIndex >= this.filteredItems.length) {
1030
+ this.selectedIndex = this.filteredItems.length - 1;
1031
+ return;
1032
+ }
1033
+
1034
+ this.selectedIndex = nextIndex;
1035
+ }
1036
+
1037
+ private getVisibleRange(): { startIndex: number; endIndex: number } {
1038
+ const maxVisible = Math.max(1, this.maxVisibleItems);
1039
+ const startIndex = Math.max(
1040
+ 0,
1041
+ Math.min(
1042
+ this.selectedIndex - Math.floor(maxVisible / 2),
1043
+ Math.max(0, this.filteredItems.length - maxVisible)
1044
+ )
1045
+ );
1046
+ const endIndex = Math.min(startIndex + maxVisible, this.filteredItems.length);
1047
+ return { startIndex, endIndex };
1048
+ }
1049
+ }
1050
+
1051
+ function getToggleItemsForApply(items: UnifiedItem[]): LocalUnifiedItem[] {
1052
+ return items.filter((item): item is LocalUnifiedItem => item.type === "local");
505
1053
  }
506
1054
 
507
1055
  async function applyToggleChangesFromManager(
@@ -510,7 +1058,7 @@ async function applyToggleChangesFromManager(
510
1058
  ctx: ExtensionCommandContext,
511
1059
  pi: ExtensionAPI,
512
1060
  options?: { promptReload?: boolean }
513
- ): Promise<{ changed: number; reloaded: boolean }> {
1061
+ ): Promise<{ changed: number; reloaded: boolean; hasErrors: boolean }> {
514
1062
  const toggleItems = getToggleItemsForApply(items);
515
1063
  const apply = await applyStagedChanges(toggleItems, staged, pi);
516
1064
 
@@ -529,24 +1077,14 @@ async function applyToggleChangesFromManager(
529
1077
  const shouldPromptReload = options?.promptReload ?? true;
530
1078
 
531
1079
  if (shouldPromptReload) {
532
- const shouldReload = await ctx.ui.confirm(
533
- "Reload Required",
534
- "Local extensions changed. Reload pi now?"
535
- );
536
-
537
- if (shouldReload) {
538
- await ctx.reload();
539
- return { changed: apply.changed, reloaded: true };
540
- }
541
- } else {
542
- ctx.ui.notify(
543
- "Changes saved. Reload pi later to fully apply extension state updates.",
544
- "info"
545
- );
1080
+ const reloaded = await confirmReload(ctx, "Local extensions changed.");
1081
+ return { changed: apply.changed, reloaded, hasErrors: apply.errors.length > 0 };
546
1082
  }
1083
+
1084
+ ctx.ui.notify("Changes saved. Reload pi later to fully apply extension state updates.", "info");
547
1085
  }
548
1086
 
549
- return { changed: apply.changed, reloaded: false };
1087
+ return { changed: apply.changed, reloaded: false, hasErrors: apply.errors.length > 0 };
550
1088
  }
551
1089
 
552
1090
  async function resolvePendingChangesBeforeLeave(
@@ -556,7 +1094,7 @@ async function resolvePendingChangesBeforeLeave(
556
1094
  ctx: ExtensionCommandContext,
557
1095
  pi: ExtensionAPI,
558
1096
  destinationLabel: string
559
- ): Promise<"continue" | "stay" | "exit"> {
1097
+ ): Promise<"continue" | "stay"> {
560
1098
  const pendingCount = getPendingToggleChangeCount(staged, byId);
561
1099
  if (pendingCount === 0) return "continue";
562
1100
 
@@ -571,13 +1109,14 @@ async function resolvePendingChangesBeforeLeave(
571
1109
  }
572
1110
 
573
1111
  if (choice === "Discard changes") {
1112
+ staged.clear();
574
1113
  return "continue";
575
1114
  }
576
1115
 
577
- const result = await applyToggleChangesFromManager(items, staged, ctx, pi, {
1116
+ const apply = await applyToggleChangesFromManager(items, staged, ctx, pi, {
578
1117
  promptReload: false,
579
1118
  });
580
- return result.reloaded ? "exit" : "continue";
1119
+ return apply.changed === 0 && apply.hasErrors ? "stay" : "continue";
581
1120
  }
582
1121
 
583
1122
  const PALETTE_OPTIONS = {
@@ -603,6 +1142,12 @@ const QUICK_DESTINATION_LABELS: Record<QuickDestination, string> = {
603
1142
  help: "Help",
604
1143
  };
605
1144
 
1145
+ const LOCAL_ACTION_OPTIONS = {
1146
+ details: "View details",
1147
+ remove: "Remove local extension",
1148
+ back: "Back to manager",
1149
+ } as const;
1150
+
606
1151
  const PACKAGE_ACTION_OPTIONS = {
607
1152
  configure: "Configure extensions",
608
1153
  update: "Update package",
@@ -611,10 +1156,28 @@ const PACKAGE_ACTION_OPTIONS = {
611
1156
  back: "Back to manager",
612
1157
  } as const;
613
1158
 
1159
+ type LocalActionKey = keyof typeof LOCAL_ACTION_OPTIONS;
614
1160
  type PackageActionKey = keyof typeof PACKAGE_ACTION_OPTIONS;
615
1161
 
1162
+ type LocalActionSelection = Exclude<LocalActionKey, "back"> | "cancel";
616
1163
  type PackageActionSelection = Exclude<PackageActionKey, "back"> | "cancel";
617
1164
 
1165
+ async function promptLocalActionSelection(
1166
+ item: LocalUnifiedItem,
1167
+ ctx: ExtensionCommandContext
1168
+ ): Promise<LocalActionSelection> {
1169
+ const selection = parseChoiceByLabel(
1170
+ LOCAL_ACTION_OPTIONS,
1171
+ await ctx.ui.select(item.displayName, Object.values(LOCAL_ACTION_OPTIONS))
1172
+ );
1173
+
1174
+ if (!selection || selection === "back") {
1175
+ return "cancel";
1176
+ }
1177
+
1178
+ return selection;
1179
+ }
1180
+
618
1181
  async function promptPackageActionSelection(
619
1182
  pkg: InstalledPackage,
620
1183
  ctx: ExtensionCommandContext
@@ -631,6 +1194,27 @@ async function promptPackageActionSelection(
631
1194
  return selection;
632
1195
  }
633
1196
 
1197
+ function showUnifiedItemDetails(
1198
+ item: UnifiedItem,
1199
+ ctx: ExtensionCommandContext,
1200
+ state?: State
1201
+ ): void {
1202
+ if (item.type === "local") {
1203
+ const currentState = state ?? item.state;
1204
+ ctx.ui.notify(
1205
+ `Name: ${item.displayName}\nScope: ${item.scope}\nState: ${currentState}\nPath: ${getLocalItemCurrentPath(item, currentState)}\nSummary: ${item.summary}`,
1206
+ "info"
1207
+ );
1208
+ return;
1209
+ }
1210
+
1211
+ const sizeStr = item.size !== undefined ? `\nSize: ${formatBytes(item.size)}` : "";
1212
+ ctx.ui.notify(
1213
+ `Name: ${item.displayName}\nVersion: ${item.version || "unknown"}\nSource: ${item.source}\nScope: ${item.scope}${sizeStr}${item.description ? `\nDescription: ${item.description}` : ""}`,
1214
+ "info"
1215
+ );
1216
+ }
1217
+
634
1218
  async function navigateWithPendingGuard(
635
1219
  destination: QuickDestination,
636
1220
  items: UnifiedItem[],
@@ -638,7 +1222,7 @@ async function navigateWithPendingGuard(
638
1222
  byId: Map<string, UnifiedItem>,
639
1223
  ctx: ExtensionCommandContext,
640
1224
  pi: ExtensionAPI
641
- ): Promise<"done" | "stay" | "exit"> {
1225
+ ): Promise<"reload" | "resume" | "stay" | "exit"> {
642
1226
  const pending = await resolvePendingChangesBeforeLeave(
643
1227
  items,
644
1228
  staged,
@@ -648,21 +1232,20 @@ async function navigateWithPendingGuard(
648
1232
  QUICK_DESTINATION_LABELS[destination]
649
1233
  );
650
1234
  if (pending === "stay") return "stay";
651
- if (pending === "exit") return "exit";
652
1235
 
653
1236
  switch (destination) {
654
1237
  case "install":
655
1238
  await showRemote("install", ctx, pi);
656
- return "done";
1239
+ return "reload";
657
1240
  case "search":
658
1241
  await showRemote("search", ctx, pi);
659
- return "done";
1242
+ return "reload";
660
1243
  case "browse":
661
1244
  await showRemote("", ctx, pi);
662
- return "done";
1245
+ return "reload";
663
1246
  case "update-all": {
664
1247
  const outcome = await updatePackagesWithOutcome(ctx, pi);
665
- return outcome.reloaded ? "exit" : "done";
1248
+ return outcome.reloaded ? "exit" : "reload";
666
1249
  }
667
1250
  case "auto-update":
668
1251
  await promptAutoUpdateWizard(pi, ctx, (packages) => {
@@ -672,10 +1255,10 @@ async function navigateWithPendingGuard(
672
1255
  );
673
1256
  });
674
1257
  void updateExtmgrStatus(ctx, pi);
675
- return "done";
1258
+ return "resume";
676
1259
  case "help":
677
1260
  showHelp(ctx);
678
- return "done";
1261
+ return "resume";
679
1262
  }
680
1263
  }
681
1264
 
@@ -686,7 +1269,7 @@ async function handleUnifiedAction(
686
1269
  byId: Map<string, UnifiedItem>,
687
1270
  ctx: ExtensionCommandContext,
688
1271
  pi: ExtensionAPI
689
- ): Promise<boolean> {
1272
+ ): Promise<boolean | "resume"> {
690
1273
  if (result.type === "cancel") {
691
1274
  const pendingCount = getPendingToggleChangeCount(staged, byId);
692
1275
  if (pendingCount > 0) {
@@ -697,12 +1280,13 @@ async function handleUnifiedAction(
697
1280
  ]);
698
1281
 
699
1282
  if (!choice || choice === "Stay in manager") {
700
- return false;
1283
+ return "resume";
701
1284
  }
702
1285
 
703
1286
  if (choice === "Save and exit") {
704
1287
  const apply = await applyToggleChangesFromManager(items, staged, ctx, pi);
705
1288
  if (apply.reloaded) return true;
1289
+ if (apply.changed === 0 && apply.hasErrors) return "resume";
706
1290
  }
707
1291
  }
708
1292
 
@@ -711,8 +1295,7 @@ async function handleUnifiedAction(
711
1295
 
712
1296
  if (result.type === "remote") {
713
1297
  const pending = await resolvePendingChangesBeforeLeave(items, staged, byId, ctx, pi, "Remote");
714
- if (pending === "stay") return false;
715
- if (pending === "exit") return true;
1298
+ if (pending === "stay") return "resume";
716
1299
 
717
1300
  await showRemote("", ctx, pi);
718
1301
  return false;
@@ -720,11 +1303,10 @@ async function handleUnifiedAction(
720
1303
 
721
1304
  if (result.type === "help") {
722
1305
  const pending = await resolvePendingChangesBeforeLeave(items, staged, byId, ctx, pi, "Help");
723
- if (pending === "stay") return false;
724
- if (pending === "exit") return true;
1306
+ if (pending === "stay") return "resume";
725
1307
 
726
1308
  showHelp(ctx);
727
- return false;
1309
+ return "resume";
728
1310
  }
729
1311
 
730
1312
  if (result.type === "menu") {
@@ -744,10 +1326,11 @@ async function handleUnifiedAction(
744
1326
 
745
1327
  const destination = choice ? destinationByAction[choice] : undefined;
746
1328
  if (!destination) {
747
- return false;
1329
+ return "resume";
748
1330
  }
749
1331
 
750
1332
  const outcome = await navigateWithPendingGuard(destination, items, staged, byId, ctx, pi);
1333
+ if (outcome === "stay" || outcome === "resume") return "resume";
751
1334
  return outcome === "exit";
752
1335
  }
753
1336
 
@@ -761,6 +1344,7 @@ async function handleUnifiedAction(
761
1344
 
762
1345
  const destination = quickDestinationMap[result.action];
763
1346
  const outcome = await navigateWithPendingGuard(destination, items, staged, byId, ctx, pi);
1347
+ if (outcome === "stay" || outcome === "resume") return "resume";
764
1348
  return outcome === "exit";
765
1349
  }
766
1350
 
@@ -768,35 +1352,49 @@ async function handleUnifiedAction(
768
1352
  const item = byId.get(result.itemId);
769
1353
  if (!item) return false;
770
1354
 
771
- const pendingDestination = item.type === "local" ? "remove extension" : "package actions";
772
- const pending = await resolvePendingChangesBeforeLeave(
773
- items,
774
- staged,
775
- byId,
776
- ctx,
777
- pi,
778
- pendingDestination
779
- );
780
- if (pending === "stay") return false;
781
- if (pending === "exit") return true;
782
-
783
1355
  if (item.type === "local") {
784
- if (result.action !== "remove") return false;
1356
+ const selection =
1357
+ !result.action || result.action === "menu"
1358
+ ? await promptLocalActionSelection(item, ctx)
1359
+ : result.action;
1360
+
1361
+ if (selection === "cancel") {
1362
+ return "resume";
1363
+ }
1364
+
1365
+ if (selection === "details") {
1366
+ showUnifiedItemDetails(item, ctx, staged.get(item.id) ?? item.state);
1367
+ return "resume";
1368
+ }
1369
+
1370
+ if (selection !== "remove") {
1371
+ return "resume";
1372
+ }
1373
+
1374
+ const pending = await resolvePendingChangesBeforeLeave(
1375
+ items,
1376
+ staged,
1377
+ byId,
1378
+ ctx,
1379
+ pi,
1380
+ "remove extension"
1381
+ );
1382
+ if (pending === "stay") return "resume";
785
1383
 
786
1384
  const confirmed = await ctx.ui.confirm(
787
1385
  "Delete Local Extension",
788
1386
  `Delete ${item.displayName} from disk?\n\nThis cannot be undone.`
789
1387
  );
790
- if (!confirmed) return false;
1388
+ if (!confirmed) return "resume";
791
1389
 
792
1390
  const removal = await removeLocalExtension(
793
- { activePath: item.activePath!, disabledPath: item.disabledPath! },
1391
+ { activePath: item.activePath, disabledPath: item.disabledPath },
794
1392
  ctx.cwd
795
1393
  );
796
1394
  if (!removal.ok) {
797
1395
  logExtensionDelete(pi, item.id, false, removal.error);
798
1396
  ctx.ui.notify(`Failed to remove extension: ${removal.error}`, "error");
799
- return false;
1397
+ return "resume";
800
1398
  }
801
1399
 
802
1400
  logExtensionDelete(pi, item.id, true);
@@ -805,19 +1403,15 @@ async function handleUnifiedAction(
805
1403
  "info"
806
1404
  );
807
1405
 
808
- const reloaded = await confirmReload(ctx, "Extension removed.");
809
- if (reloaded) {
810
- return true;
811
- }
812
-
813
- return false;
1406
+ return await confirmReload(ctx, "Extension removed.");
814
1407
  }
815
1408
 
816
1409
  const pkg: InstalledPackage = {
817
- source: item.source!,
1410
+ source: item.source,
818
1411
  name: item.displayName,
819
1412
  ...(item.version ? { version: item.version } : {}),
820
1413
  scope: item.scope,
1414
+ ...(item.resolvedPath ? { resolvedPath: item.resolvedPath } : {}),
821
1415
  ...(item.description ? { description: item.description } : {}),
822
1416
  ...(item.size !== undefined ? { size: item.size } : {}),
823
1417
  };
@@ -828,9 +1422,30 @@ async function handleUnifiedAction(
828
1422
  : result.action;
829
1423
 
830
1424
  if (selection === "cancel") {
831
- return false;
1425
+ return "resume";
1426
+ }
1427
+
1428
+ if (selection === "details") {
1429
+ showUnifiedItemDetails(item, ctx);
1430
+ return "resume";
832
1431
  }
833
1432
 
1433
+ const pendingDestinationBySelection = {
1434
+ configure: "configure package extensions",
1435
+ update: "update package",
1436
+ remove: "remove package",
1437
+ } satisfies Record<Exclude<PackageActionSelection, "cancel" | "details">, string>;
1438
+
1439
+ const pending = await resolvePendingChangesBeforeLeave(
1440
+ items,
1441
+ staged,
1442
+ byId,
1443
+ ctx,
1444
+ pi,
1445
+ pendingDestinationBySelection[selection]
1446
+ );
1447
+ if (pending === "stay") return "resume";
1448
+
834
1449
  switch (selection) {
835
1450
  case "configure": {
836
1451
  const outcome = await configurePackageExtensions(pkg, ctx, pi);
@@ -844,23 +1459,15 @@ async function handleUnifiedAction(
844
1459
  const outcome = await removePackageWithOutcome(pkg.source, ctx, pi);
845
1460
  return outcome.reloaded;
846
1461
  }
847
- case "details": {
848
- const sizeStr = pkg.size !== undefined ? `\nSize: ${formatBytes(pkg.size)}` : "";
849
- ctx.ui.notify(
850
- `Name: ${pkg.name}\nVersion: ${pkg.version || "unknown"}\nSource: ${pkg.source}\nScope: ${pkg.scope}${sizeStr}${pkg.description ? `\nDescription: ${pkg.description}` : ""}`,
851
- "info"
852
- );
853
- return false;
854
- }
855
1462
  }
856
1463
  }
857
1464
 
858
1465
  const apply = await applyToggleChangesFromManager(items, staged, ctx, pi);
859
- return apply.reloaded;
1466
+ return apply.reloaded ? true : "resume";
860
1467
  }
861
1468
 
862
1469
  async function applyStagedChanges(
863
- items: UnifiedItem[],
1470
+ items: LocalUnifiedItem[],
864
1471
  staged: Map<string, State>,
865
1472
  pi: ExtensionAPI
866
1473
  ) {
@@ -868,13 +1475,10 @@ async function applyStagedChanges(
868
1475
  const errors: string[] = [];
869
1476
 
870
1477
  for (const item of items) {
871
- if (item.type !== "local" || !item.originalState || !item.activePath || !item.disabledPath) {
872
- continue;
873
- }
874
-
875
1478
  const target = staged.get(item.id) ?? item.originalState;
876
1479
  if (target === item.originalState) continue;
877
1480
 
1481
+ const fromState = item.originalState;
878
1482
  const result = await setExtensionState(
879
1483
  { activePath: item.activePath, disabledPath: item.disabledPath },
880
1484
  target
@@ -882,10 +1486,13 @@ async function applyStagedChanges(
882
1486
 
883
1487
  if (result.ok) {
884
1488
  changed++;
885
- logExtensionToggle(pi, item.id, item.originalState, target, true);
1489
+ item.state = target;
1490
+ item.originalState = target;
1491
+ staged.delete(item.id);
1492
+ logExtensionToggle(pi, item.id, fromState, target, true);
886
1493
  } else {
887
1494
  errors.push(`${item.id}: ${result.error}`);
888
- logExtensionToggle(pi, item.id, item.originalState, target, false, result.error);
1495
+ logExtensionToggle(pi, item.id, fromState, target, false, result.error);
889
1496
  }
890
1497
  }
891
1498
 
@@ -913,23 +1520,9 @@ export async function showInstalledPackagesLegacy(
913
1520
  export async function showListOnly(ctx: ExtensionCommandContext): Promise<void> {
914
1521
  const entries = await discoverExtensions(ctx.cwd);
915
1522
  if (entries.length === 0) {
916
- const msg = "No extensions found in ~/.pi/agent/extensions or .pi/extensions";
917
- if (ctx.hasUI) {
918
- ctx.ui.notify(msg, "info");
919
- } else {
920
- console.log(msg);
921
- }
1523
+ notify(ctx, "No extensions found in ~/.pi/agent/extensions or .pi/extensions", "info");
922
1524
  return;
923
1525
  }
924
1526
 
925
- const lines = entries.map(formatExtEntry);
926
- const output = lines.join("\n");
927
- const titledOutput = `Local extensions:\n${output}`;
928
-
929
- if (ctx.hasUI) {
930
- ctx.ui.notify(titledOutput, "info");
931
- } else {
932
- console.log("Local extensions:");
933
- console.log(output);
934
- }
1527
+ formatListOutput(ctx, "Local extensions", entries.map(formatExtEntry));
935
1528
  }