pi-extmgr 0.1.28 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/unified.ts CHANGED
@@ -2,21 +2,26 @@
2
2
  * Unified extension manager UI
3
3
  * Displays local extensions and installed packages in one view
4
4
  */
5
+ import { homedir } from "node:os";
6
+ import { relative } from "node:path";
5
7
  import {
6
8
  DynamicBorder,
7
9
  type ExtensionAPI,
8
10
  type ExtensionCommandContext,
9
- getSettingsListTheme,
10
11
  type Theme,
11
12
  } from "@mariozechner/pi-coding-agent";
12
13
  import {
13
14
  Container,
15
+ type Focusable,
16
+ fuzzyMatch,
17
+ getKeybindings,
18
+ Input,
14
19
  Key,
15
20
  matchesKey,
16
- type SettingItem,
17
- SettingsList,
18
21
  Spacer,
19
22
  Text,
23
+ truncateToWidth,
24
+ wrapTextWithAnsi,
20
25
  } from "@mariozechner/pi-tui";
21
26
  import { UI } from "../constants.js";
22
27
  import {
@@ -40,26 +45,20 @@ import {
40
45
  } from "../types/index.js";
41
46
  import { getKnownUpdates, promptAutoUpdateWizard } from "../utils/auto-update.js";
42
47
  import { parseChoiceByLabel } from "../utils/command.js";
43
- import { dynamicTruncate, formatBytes, formatEntry as formatExtEntry } from "../utils/format.js";
48
+ import { formatBytes, formatEntry as formatExtEntry } from "../utils/format.js";
44
49
  import { logExtensionDelete, logExtensionToggle } from "../utils/history.js";
45
50
  import { hasCustomUI, runCustomUI } from "../utils/mode.js";
46
51
  import { notify } from "../utils/notify.js";
52
+ import { normalizePathIdentity } from "../utils/path-identity.js";
47
53
  import { getPackageSourceKind, normalizePackageIdentity } from "../utils/package-source.js";
48
- import { getSettingsListSelectedIndex } from "../utils/settings-list.js";
49
54
  import { updateExtmgrStatus } from "../utils/status.js";
50
- import { confirmReload } from "../utils/ui-helpers.js";
55
+ import { confirmReload, formatListOutput } from "../utils/ui-helpers.js";
51
56
  import { runTaskWithLoader } from "./async-task.js";
52
57
  import { buildFooterShortcuts, buildFooterState, getPendingToggleChangeCount } from "./footer.js";
53
58
  import { showHelp } from "./help.js";
54
59
  import { configurePackageExtensions } from "./package-config.js";
55
60
  import { showRemote } from "./remote.js";
56
- import {
57
- formatSize,
58
- getChangeMarker,
59
- getPackageIcon,
60
- getScopeIcon,
61
- getStatusIcon,
62
- } from "./theme.js";
61
+ import { getChangeMarker, getPackageIcon, getScopeIcon, getStatusIcon } from "./theme.js";
63
62
 
64
63
  async function showInteractiveFallback(
65
64
  ctx: ExtensionCommandContext,
@@ -156,206 +155,108 @@ async function showInteractiveOnce(
156
155
  // Staged changes tracking for local extensions.
157
156
  const staged = new Map<string, State>();
158
157
  const byId = new Map(items.map((item) => [item.id, item]));
158
+ let managerState: UnifiedManagerViewState | undefined;
159
159
 
160
- const result = await runCustomUI(
161
- ctx,
162
- "The unified extensions manager",
163
- () =>
164
- ctx.ui.custom<UnifiedAction>((tui, theme, _keybindings, done) => {
165
- const container = new Container();
166
-
167
- const titleText = new Text("", 2, 0);
168
- const subtitleText = new Text("", 2, 0);
169
- const quickText = new Text("", 2, 0);
170
- const footerState = buildFooterState(items);
171
- const footerText = new Text("", 2, 0);
172
-
173
- // Header
174
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
175
- container.addChild(titleText);
176
- container.addChild(subtitleText);
177
- container.addChild(quickText);
178
- container.addChild(new Spacer(1));
179
-
180
- // Build settings items
181
- const settingsItems = buildSettingsItems(items, staged, theme);
182
- const syncThemedContent = (): void => {
183
- titleText.setText(theme.fg("accent", theme.bold("Extensions Manager")));
184
- subtitleText.setText(
185
- theme.fg(
186
- "muted",
187
- `${items.length} item${items.length === 1 ? "" : "s"} • Space/Enter toggle local • Enter/A actions • c configure pkg extensions • u update pkg • x remove selected`
188
- )
189
- );
190
- quickText.setText(
191
- theme.fg(
192
- "dim",
193
- "Quick: i Install | f Search | U Update all | t Auto-update | p Palette"
194
- )
195
- );
196
- footerText.setText(theme.fg("dim", buildFooterShortcuts(footerState)));
197
-
198
- for (const settingsItem of settingsItems) {
199
- const item = byId.get(settingsItem.id);
200
- if (!item) continue;
201
-
202
- if (item.type === "local") {
203
- const currentState = staged.get(item.id) ?? item.state;
204
- const changed = currentState !== item.originalState;
205
- settingsItem.label = formatUnifiedItemLabel(item, currentState, theme, changed);
206
- } else {
207
- settingsItem.label = formatUnifiedItemLabel(item, "enabled", theme, false);
208
- }
209
- }
210
- };
211
- syncThemedContent();
212
-
213
- const settingsList = new SettingsList(
214
- settingsItems,
215
- Math.min(items.length + 2, UI.maxListHeight),
216
- getSettingsListTheme(),
217
- (id: string, newValue: string) => {
218
- const item = byId.get(id);
219
- if (!item || item.type !== "local") return;
220
-
221
- const state = newValue as State;
222
- if (state === item.originalState) {
223
- staged.delete(id);
224
- } else {
225
- staged.set(id, state);
226
- }
227
-
228
- const settingsItem = settingsItems.find((x) => x.id === id);
229
- if (settingsItem) {
230
- const changed = state !== item.originalState;
231
- settingsItem.label = formatUnifiedItemLabel(item, state, theme, changed);
232
- }
233
- tui.requestRender();
234
- },
235
- () => done({ type: "cancel" })
236
- );
237
-
238
- container.addChild(settingsList);
239
- container.addChild(new Spacer(1));
240
-
241
- // Footer with keyboard shortcuts
242
- container.addChild(footerText);
243
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
244
-
245
- return {
246
- render(width: number) {
247
- return container.render(width);
248
- },
249
- invalidate() {
250
- container.invalidate();
251
- syncThemedContent();
252
- },
253
- handleInput(data: string) {
254
- const selIdx = getSettingsListSelectedIndex(settingsList) ?? 0;
255
- const selectedId = settingsItems[selIdx]?.id ?? settingsItems[0]?.id;
256
- const selectedItem = selectedId ? byId.get(selectedId) : undefined;
257
-
258
- if (matchesKey(data, Key.ctrl("s")) || data === "s" || data === "S") {
259
- done({ type: "apply" });
260
- return;
261
- }
262
-
263
- // Enter on a package opens its action menu (fewer clicks)
264
- if (
265
- (data === "\r" || data === "\n") &&
266
- selectedId &&
267
- selectedItem?.type === "package"
268
- ) {
269
- done({ type: "action", itemId: selectedId, action: "menu" });
270
- return;
271
- }
272
-
273
- if (data === "a" || data === "A") {
274
- if (selectedId) {
275
- done({ type: "action", itemId: selectedId, action: "menu" });
276
- }
277
- return;
278
- }
279
-
280
- // Quick actions (global)
281
- if (data === "i") {
282
- done({ type: "quick", action: "install" });
283
- return;
284
- }
285
- if (data === "f") {
286
- done({ type: "quick", action: "search" });
287
- return;
288
- }
289
- if (data === "U") {
290
- done({ type: "quick", action: "update-all" });
291
- return;
292
- }
293
- if (data === "t" || data === "T") {
294
- done({ type: "quick", action: "auto-update" });
295
- return;
296
- }
297
-
298
- // Fast actions on selected row
299
- if (selectedId && selectedItem?.type === "package") {
300
- if (data === "u") {
301
- done({ type: "action", itemId: selectedId, action: "update" });
302
- return;
303
- }
304
- if (data === "x" || data === "X") {
305
- done({ type: "action", itemId: selectedId, action: "remove" });
306
- return;
307
- }
308
- if (data === "v" || data === "V") {
309
- done({ type: "action", itemId: selectedId, action: "details" });
310
- return;
311
- }
312
- if (data === "c" || data === "C") {
313
- done({ type: "action", itemId: selectedId, action: "configure" });
314
- return;
315
- }
316
- }
160
+ while (true) {
161
+ let nextManagerState = managerState;
317
162
 
318
- if (selectedId && selectedItem?.type === "local") {
319
- if (data === "x" || data === "X") {
320
- done({ type: "action", itemId: selectedId, action: "remove" });
321
- 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();
322
240
  }
323
- }
324
-
325
- if (data === "r" || data === "R") {
326
- done({ type: "remote" });
327
- return;
328
- }
329
- if (data === "?" || data === "h" || data === "H") {
330
- done({ type: "help" });
331
- return;
332
- }
333
- if (data === "m" || data === "M" || data === "p" || data === "P") {
334
- done({ type: "menu" });
335
- return;
336
- }
337
- settingsList.handleInput?.(data);
338
- tui.requestRender();
339
- },
340
- };
341
- }),
342
- "Showing read-only local and installed package lists instead."
343
- );
344
-
345
- if (!result) {
346
- await showInteractiveFallback(ctx, pi);
347
- return true;
348
- }
241
+ },
242
+ };
243
+ }),
244
+ "Showing read-only local and installed package lists instead."
245
+ );
349
246
 
350
- return await handleUnifiedAction(result, items, staged, byId, ctx, pi);
351
- }
247
+ if (!result) {
248
+ await showInteractiveFallback(ctx, pi);
249
+ return true;
250
+ }
352
251
 
353
- function normalizePathForDuplicateCheck(value: string): string {
354
- const normalized = value.replace(/\\/g, "/");
355
- const looksWindowsPath =
356
- /^[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
+ }
357
257
 
358
- return looksWindowsPath ? normalized.toLowerCase() : normalized;
258
+ return outcome;
259
+ }
359
260
  }
360
261
 
361
262
  export function buildUnifiedItems(
@@ -368,7 +269,8 @@ export function buildUnifiedItems(
368
269
 
369
270
  // Add local extensions
370
271
  for (const entry of localEntries) {
371
- localPaths.add(normalizePathForDuplicateCheck(entry.activePath));
272
+ const currentPath = entry.state === "disabled" ? entry.disabledPath : entry.activePath;
273
+ localPaths.add(normalizePathIdentity(currentPath));
372
274
  items.push({
373
275
  type: "local",
374
276
  id: entry.id,
@@ -383,10 +285,8 @@ export function buildUnifiedItems(
383
285
  }
384
286
 
385
287
  for (const pkg of installedPackages) {
386
- const pkgSourceNormalized = normalizePathForDuplicateCheck(pkg.source);
387
- const pkgResolvedNormalized = pkg.resolvedPath
388
- ? normalizePathForDuplicateCheck(pkg.resolvedPath)
389
- : "";
288
+ const pkgSourceNormalized = normalizePathIdentity(pkg.source);
289
+ const pkgResolvedNormalized = pkg.resolvedPath ? normalizePathIdentity(pkg.resolvedPath) : "";
390
290
 
391
291
  let isDuplicate = false;
392
292
  for (const localPath of localPaths) {
@@ -407,6 +307,7 @@ export function buildUnifiedItems(
407
307
  displayName: pkg.name,
408
308
  scope: pkg.scope,
409
309
  source: pkg.source,
310
+ resolvedPath: pkg.resolvedPath,
410
311
  version: pkg.version,
411
312
  description: pkg.description,
412
313
  size: pkg.size,
@@ -429,45 +330,87 @@ export function buildUnifiedItems(
429
330
  return items;
430
331
  }
431
332
 
432
- function buildSettingsItems(
333
+ function buildManagerSummary(
433
334
  items: UnifiedItem[],
434
335
  staged: Map<string, State>,
435
- theme: Theme
436
- ): SettingItem[] {
437
- return items.map((item) => {
438
- if (item.type === "local") {
439
- const currentState = staged.get(item.id) ?? item.state;
440
- const changed = currentState !== item.originalState;
441
- return {
442
- id: item.id,
443
- label: formatUnifiedItemLabel(item, currentState, theme, changed),
444
- currentValue: currentState,
445
- values: ["enabled", "disabled"],
446
- };
447
- }
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
+ }
448
367
 
449
- return {
450
- id: item.id,
451
- label: formatUnifiedItemLabel(item, "enabled", theme, false),
452
- currentValue: "enabled",
453
- values: ["enabled"],
454
- };
455
- });
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;
456
400
  }
457
401
 
458
402
  function formatUnifiedItemLabel(
459
403
  item: UnifiedItem,
460
- state: State,
404
+ state: State | undefined,
461
405
  theme: Theme,
462
406
  changed = false
463
407
  ): string {
464
408
  if (item.type === "local") {
465
- const statusIcon = getStatusIcon(theme, state);
409
+ const statusIcon = getStatusIcon(theme, state ?? item.state);
466
410
  const scopeIcon = getScopeIcon(theme, item.scope);
467
411
  const changeMarker = getChangeMarker(theme, changed);
468
412
  const name = theme.bold(item.displayName);
469
- const summary = theme.fg("dim", item.summary);
470
- return `${statusIcon} [${scopeIcon}] ${name} - ${summary}${changeMarker}`;
413
+ return `${statusIcon} [${scopeIcon}] ${name}${changeMarker}`;
471
414
  }
472
415
 
473
416
  const sourceKind = getPackageSourceKind(item.source);
@@ -478,30 +421,631 @@ function formatUnifiedItemLabel(
478
421
  const scopeIcon = getScopeIcon(theme, item.scope);
479
422
  const name = theme.bold(item.displayName);
480
423
  const version = item.version ? theme.fg("dim", `@${item.version}`) : "";
424
+ const size = item.size !== undefined ? theme.fg("dim", ` • ${formatBytes(item.size)}`) : "";
481
425
  const updateBadge = item.updateAvailable ? ` ${theme.fg("warning", "[update]")}` : "";
482
426
 
483
- // Build info parts
484
- const infoParts: string[] = [];
485
-
486
- // Show description if available
487
- // Reserved space: icon (2) + scope (3) + name (~25) + version (~10) + separator (3) = ~43 chars
488
- if (item.description) {
489
- infoParts.push(dynamicTruncate(item.description, 43));
490
- } else if (sourceKind === "npm") {
491
- infoParts.push("npm");
492
- } else if (sourceKind === "git") {
493
- infoParts.push("git");
494
- } else {
495
- 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 "~";
472
+ }
473
+
474
+ if (normalizedPath.startsWith(`${normalizedHome}/`)) {
475
+ return `~/${normalizedPath.slice(normalizedHome.length + 1)}`;
476
+ }
477
+
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;
489
+ }
490
+
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);
496
1000
  }
497
1001
 
498
- // Show size if available
499
- if (item.size !== undefined) {
500
- infoParts.push(formatSize(theme, item.size));
1002
+ private setFilter(filter: UnifiedFilter): void {
1003
+ this.filter = filter;
1004
+ this.refreshVisibleItems();
501
1005
  }
502
1006
 
503
- const summary = theme.fg("dim", infoParts.join(" "));
504
- return `${pkgIcon} [${scopeIcon}] ${name}${version}${updateBadge} - ${summary}`;
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
+ }
505
1049
  }
506
1050
 
507
1051
  function getToggleItemsForApply(items: UnifiedItem[]): LocalUnifiedItem[] {
@@ -565,6 +1109,7 @@ async function resolvePendingChangesBeforeLeave(
565
1109
  }
566
1110
 
567
1111
  if (choice === "Discard changes") {
1112
+ staged.clear();
568
1113
  return "continue";
569
1114
  }
570
1115
 
@@ -597,6 +1142,12 @@ const QUICK_DESTINATION_LABELS: Record<QuickDestination, string> = {
597
1142
  help: "Help",
598
1143
  };
599
1144
 
1145
+ const LOCAL_ACTION_OPTIONS = {
1146
+ details: "View details",
1147
+ remove: "Remove local extension",
1148
+ back: "Back to manager",
1149
+ } as const;
1150
+
600
1151
  const PACKAGE_ACTION_OPTIONS = {
601
1152
  configure: "Configure extensions",
602
1153
  update: "Update package",
@@ -605,10 +1156,28 @@ const PACKAGE_ACTION_OPTIONS = {
605
1156
  back: "Back to manager",
606
1157
  } as const;
607
1158
 
1159
+ type LocalActionKey = keyof typeof LOCAL_ACTION_OPTIONS;
608
1160
  type PackageActionKey = keyof typeof PACKAGE_ACTION_OPTIONS;
609
1161
 
1162
+ type LocalActionSelection = Exclude<LocalActionKey, "back"> | "cancel";
610
1163
  type PackageActionSelection = Exclude<PackageActionKey, "back"> | "cancel";
611
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
+
612
1181
  async function promptPackageActionSelection(
613
1182
  pkg: InstalledPackage,
614
1183
  ctx: ExtensionCommandContext
@@ -625,6 +1194,27 @@ async function promptPackageActionSelection(
625
1194
  return selection;
626
1195
  }
627
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
+
628
1218
  async function navigateWithPendingGuard(
629
1219
  destination: QuickDestination,
630
1220
  items: UnifiedItem[],
@@ -632,7 +1222,7 @@ async function navigateWithPendingGuard(
632
1222
  byId: Map<string, UnifiedItem>,
633
1223
  ctx: ExtensionCommandContext,
634
1224
  pi: ExtensionAPI
635
- ): Promise<"done" | "stay" | "exit"> {
1225
+ ): Promise<"reload" | "resume" | "stay" | "exit"> {
636
1226
  const pending = await resolvePendingChangesBeforeLeave(
637
1227
  items,
638
1228
  staged,
@@ -646,16 +1236,16 @@ async function navigateWithPendingGuard(
646
1236
  switch (destination) {
647
1237
  case "install":
648
1238
  await showRemote("install", ctx, pi);
649
- return "done";
1239
+ return "reload";
650
1240
  case "search":
651
1241
  await showRemote("search", ctx, pi);
652
- return "done";
1242
+ return "reload";
653
1243
  case "browse":
654
1244
  await showRemote("", ctx, pi);
655
- return "done";
1245
+ return "reload";
656
1246
  case "update-all": {
657
1247
  const outcome = await updatePackagesWithOutcome(ctx, pi);
658
- return outcome.reloaded ? "exit" : "done";
1248
+ return outcome.reloaded ? "exit" : "reload";
659
1249
  }
660
1250
  case "auto-update":
661
1251
  await promptAutoUpdateWizard(pi, ctx, (packages) => {
@@ -665,10 +1255,10 @@ async function navigateWithPendingGuard(
665
1255
  );
666
1256
  });
667
1257
  void updateExtmgrStatus(ctx, pi);
668
- return "done";
1258
+ return "resume";
669
1259
  case "help":
670
1260
  showHelp(ctx);
671
- return "done";
1261
+ return "resume";
672
1262
  }
673
1263
  }
674
1264
 
@@ -679,7 +1269,7 @@ async function handleUnifiedAction(
679
1269
  byId: Map<string, UnifiedItem>,
680
1270
  ctx: ExtensionCommandContext,
681
1271
  pi: ExtensionAPI
682
- ): Promise<boolean> {
1272
+ ): Promise<boolean | "resume"> {
683
1273
  if (result.type === "cancel") {
684
1274
  const pendingCount = getPendingToggleChangeCount(staged, byId);
685
1275
  if (pendingCount > 0) {
@@ -690,13 +1280,13 @@ async function handleUnifiedAction(
690
1280
  ]);
691
1281
 
692
1282
  if (!choice || choice === "Stay in manager") {
693
- return false;
1283
+ return "resume";
694
1284
  }
695
1285
 
696
1286
  if (choice === "Save and exit") {
697
1287
  const apply = await applyToggleChangesFromManager(items, staged, ctx, pi);
698
1288
  if (apply.reloaded) return true;
699
- if (apply.changed === 0 && apply.hasErrors) return false;
1289
+ if (apply.changed === 0 && apply.hasErrors) return "resume";
700
1290
  }
701
1291
  }
702
1292
 
@@ -705,7 +1295,7 @@ async function handleUnifiedAction(
705
1295
 
706
1296
  if (result.type === "remote") {
707
1297
  const pending = await resolvePendingChangesBeforeLeave(items, staged, byId, ctx, pi, "Remote");
708
- if (pending === "stay") return false;
1298
+ if (pending === "stay") return "resume";
709
1299
 
710
1300
  await showRemote("", ctx, pi);
711
1301
  return false;
@@ -713,10 +1303,10 @@ async function handleUnifiedAction(
713
1303
 
714
1304
  if (result.type === "help") {
715
1305
  const pending = await resolvePendingChangesBeforeLeave(items, staged, byId, ctx, pi, "Help");
716
- if (pending === "stay") return false;
1306
+ if (pending === "stay") return "resume";
717
1307
 
718
1308
  showHelp(ctx);
719
- return false;
1309
+ return "resume";
720
1310
  }
721
1311
 
722
1312
  if (result.type === "menu") {
@@ -736,10 +1326,11 @@ async function handleUnifiedAction(
736
1326
 
737
1327
  const destination = choice ? destinationByAction[choice] : undefined;
738
1328
  if (!destination) {
739
- return false;
1329
+ return "resume";
740
1330
  }
741
1331
 
742
1332
  const outcome = await navigateWithPendingGuard(destination, items, staged, byId, ctx, pi);
1333
+ if (outcome === "stay" || outcome === "resume") return "resume";
743
1334
  return outcome === "exit";
744
1335
  }
745
1336
 
@@ -753,6 +1344,7 @@ async function handleUnifiedAction(
753
1344
 
754
1345
  const destination = quickDestinationMap[result.action];
755
1346
  const outcome = await navigateWithPendingGuard(destination, items, staged, byId, ctx, pi);
1347
+ if (outcome === "stay" || outcome === "resume") return "resume";
756
1348
  return outcome === "exit";
757
1349
  }
758
1350
 
@@ -760,25 +1352,40 @@ async function handleUnifiedAction(
760
1352
  const item = byId.get(result.itemId);
761
1353
  if (!item) return false;
762
1354
 
763
- const pendingDestination = item.type === "local" ? "remove extension" : "package actions";
764
- const pending = await resolvePendingChangesBeforeLeave(
765
- items,
766
- staged,
767
- byId,
768
- ctx,
769
- pi,
770
- pendingDestination
771
- );
772
- if (pending === "stay") return false;
773
-
774
1355
  if (item.type === "local") {
775
- 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";
776
1383
 
777
1384
  const confirmed = await ctx.ui.confirm(
778
1385
  "Delete Local Extension",
779
1386
  `Delete ${item.displayName} from disk?\n\nThis cannot be undone.`
780
1387
  );
781
- if (!confirmed) return false;
1388
+ if (!confirmed) return "resume";
782
1389
 
783
1390
  const removal = await removeLocalExtension(
784
1391
  { activePath: item.activePath, disabledPath: item.disabledPath },
@@ -787,7 +1394,7 @@ async function handleUnifiedAction(
787
1394
  if (!removal.ok) {
788
1395
  logExtensionDelete(pi, item.id, false, removal.error);
789
1396
  ctx.ui.notify(`Failed to remove extension: ${removal.error}`, "error");
790
- return false;
1397
+ return "resume";
791
1398
  }
792
1399
 
793
1400
  logExtensionDelete(pi, item.id, true);
@@ -804,6 +1411,7 @@ async function handleUnifiedAction(
804
1411
  name: item.displayName,
805
1412
  ...(item.version ? { version: item.version } : {}),
806
1413
  scope: item.scope,
1414
+ ...(item.resolvedPath ? { resolvedPath: item.resolvedPath } : {}),
807
1415
  ...(item.description ? { description: item.description } : {}),
808
1416
  ...(item.size !== undefined ? { size: item.size } : {}),
809
1417
  };
@@ -814,9 +1422,30 @@ async function handleUnifiedAction(
814
1422
  : result.action;
815
1423
 
816
1424
  if (selection === "cancel") {
817
- return false;
1425
+ return "resume";
818
1426
  }
819
1427
 
1428
+ if (selection === "details") {
1429
+ showUnifiedItemDetails(item, ctx);
1430
+ return "resume";
1431
+ }
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
+
820
1449
  switch (selection) {
821
1450
  case "configure": {
822
1451
  const outcome = await configurePackageExtensions(pkg, ctx, pi);
@@ -830,19 +1459,11 @@ async function handleUnifiedAction(
830
1459
  const outcome = await removePackageWithOutcome(pkg.source, ctx, pi);
831
1460
  return outcome.reloaded;
832
1461
  }
833
- case "details": {
834
- const sizeStr = pkg.size !== undefined ? `\nSize: ${formatBytes(pkg.size)}` : "";
835
- ctx.ui.notify(
836
- `Name: ${pkg.name}\nVersion: ${pkg.version || "unknown"}\nSource: ${pkg.source}\nScope: ${pkg.scope}${sizeStr}${pkg.description ? `\nDescription: ${pkg.description}` : ""}`,
837
- "info"
838
- );
839
- return false;
840
- }
841
1462
  }
842
1463
  }
843
1464
 
844
1465
  const apply = await applyToggleChangesFromManager(items, staged, ctx, pi);
845
- return apply.reloaded;
1466
+ return apply.reloaded ? true : "resume";
846
1467
  }
847
1468
 
848
1469
  async function applyStagedChanges(
@@ -857,6 +1478,7 @@ async function applyStagedChanges(
857
1478
  const target = staged.get(item.id) ?? item.originalState;
858
1479
  if (target === item.originalState) continue;
859
1480
 
1481
+ const fromState = item.originalState;
860
1482
  const result = await setExtensionState(
861
1483
  { activePath: item.activePath, disabledPath: item.disabledPath },
862
1484
  target
@@ -864,10 +1486,13 @@ async function applyStagedChanges(
864
1486
 
865
1487
  if (result.ok) {
866
1488
  changed++;
867
- 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);
868
1493
  } else {
869
1494
  errors.push(`${item.id}: ${result.error}`);
870
- logExtensionToggle(pi, item.id, item.originalState, target, false, result.error);
1495
+ logExtensionToggle(pi, item.id, fromState, target, false, result.error);
871
1496
  }
872
1497
  }
873
1498
 
@@ -895,23 +1520,9 @@ export async function showInstalledPackagesLegacy(
895
1520
  export async function showListOnly(ctx: ExtensionCommandContext): Promise<void> {
896
1521
  const entries = await discoverExtensions(ctx.cwd);
897
1522
  if (entries.length === 0) {
898
- const msg = "No extensions found in ~/.pi/agent/extensions or .pi/extensions";
899
- if (ctx.hasUI) {
900
- ctx.ui.notify(msg, "info");
901
- } else {
902
- console.log(msg);
903
- }
1523
+ notify(ctx, "No extensions found in ~/.pi/agent/extensions or .pi/extensions", "info");
904
1524
  return;
905
1525
  }
906
1526
 
907
- const lines = entries.map(formatExtEntry);
908
- const output = lines.join("\n");
909
- const titledOutput = `Local extensions:\n${output}`;
910
-
911
- if (ctx.hasUI) {
912
- ctx.ui.notify(titledOutput, "info");
913
- } else {
914
- console.log("Local extensions:");
915
- console.log(output);
916
- }
1527
+ formatListOutput(ctx, "Local extensions", entries.map(formatExtEntry));
917
1528
  }