pi-extmgr 0.1.21 → 0.1.22

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/README.md CHANGED
@@ -17,11 +17,16 @@ pi install npm:pi-extmgr
17
17
 
18
18
  Then reload Pi.
19
19
 
20
+ Requires Node.js `>=22.5.0`.
21
+
20
22
  ## Features
21
23
 
22
24
  - **Unified manager UI**
23
25
  - Local extensions (`~/.pi/agent/extensions`, `.pi/extensions`) and installed packages in one list
24
26
  - Scope indicators (global/project), status indicators, update badges
27
+ - **Package extension configuration panel**
28
+ - Configure individual extension entrypoints inside an installed package (`c` on package row)
29
+ - Persists to package filters in `settings.json` (no manual JSON editing)
25
30
  - **Safe staged local extension toggles**
26
31
  - Toggle with `Space/Enter`, apply with `S`
27
32
  - Unsaved-change guard when leaving (save/discard/stay)
@@ -54,22 +59,23 @@ Open the manager:
54
59
 
55
60
  ### In the manager
56
61
 
57
- | Key | Action |
58
- | ------------- | ------------------------------------------------ |
59
- | `↑↓` | Navigate |
60
- | `Space/Enter` | Toggle local/package extension on/off |
61
- | `S` | Save changes |
62
- | `Enter` / `A` | Actions on selected package (update/remove/view) |
63
- | `u` | Update selected package directly |
64
- | `X` | Remove selected item (package/local extension) |
65
- | `i` | Quick install by source |
66
- | `f` | Quick search |
67
- | `U` | Update all packages |
68
- | `t` | Auto-update wizard |
69
- | `P` / `M` | Quick actions palette |
70
- | `R` | Browse remote packages |
71
- | `?` / `H` | Help |
72
- | `Esc` | Exit |
62
+ | Key | Action |
63
+ | ------------- | ----------------------------------------------------- |
64
+ | `↑↓` | Navigate |
65
+ | `Space/Enter` | Toggle local extension on/off |
66
+ | `S` | Save local extension changes |
67
+ | `Enter` / `A` | Actions on selected package (configure/update/remove) |
68
+ | `c` | Configure selected package extensions |
69
+ | `u` | Update selected package directly |
70
+ | `X` | Remove selected item (package/local extension) |
71
+ | `i` | Quick install by source |
72
+ | `f` | Quick search |
73
+ | `U` | Update all packages |
74
+ | `t` | Auto-update wizard |
75
+ | `P` / `M` | Quick actions palette |
76
+ | `R` | Browse remote packages |
77
+ | `?` / `H` | Help |
78
+ | `Esc` | Exit |
73
79
 
74
80
  ### Commands
75
81
 
@@ -134,7 +140,9 @@ Examples:
134
140
 
135
141
  ## Tips
136
142
 
137
- - **Staged changes**: Toggle extensions on/off, then press `S` to apply all at once. A `*` shows pending changes.
143
+ - **Staged local changes**: Toggle local extensions on/off, then press `S` to apply all at once.
144
+ - **Package extension config**: Select a package and press `c` (or Enter/A → Configure) to enable/disable individual package entrypoints.
145
+ - After saving package extension config, restart pi to fully apply changes.
138
146
  - **Two install modes**:
139
147
  - **Managed** (npm): Auto-updates with `pi update`, stored in pi's package cache
140
148
  - **Local** (standalone): Copies to `~/.pi/agent/extensions/{package}/`, supports multi-file extensions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extmgr",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Enhanced UX for managing local Pi extensions and community packages",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -57,7 +57,7 @@
57
57
  "author": "ayagmar",
58
58
  "license": "MIT",
59
59
  "engines": {
60
- "node": ">=22"
60
+ "node": ">=22.5.0"
61
61
  },
62
62
  "repository": {
63
63
  "type": "git",
@@ -1,9 +1,13 @@
1
- import { mkdir, readFile, writeFile, rename, rm } from "node:fs/promises";
2
- import { dirname, join, relative, resolve } from "node:path";
1
+ import { mkdir, readFile, writeFile, rename, rm, readdir } from "node:fs/promises";
2
+ import type { Dirent } from "node:fs";
3
+ import { dirname, join, matchesGlob, relative, resolve } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { homedir } from "node:os";
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
5
8
  import { getAgentDir } from "@mariozechner/pi-coding-agent";
6
9
  import type { InstalledPackage, PackageExtensionEntry, Scope, State } from "../types/index.js";
10
+ import { parseNpmSource } from "../utils/format.js";
7
11
  import { fileExists, readSummary } from "../utils/fs.js";
8
12
 
9
13
  interface PackageSettingsObject {
@@ -15,6 +19,9 @@ interface SettingsFile {
15
19
  packages?: (string | PackageSettingsObject)[];
16
20
  }
17
21
 
22
+ const execFileAsync = promisify(execFile);
23
+ let globalNpmRootCache: string | null | undefined;
24
+
18
25
  function normalizeRelativePath(value: string): string {
19
26
  const normalized = value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "");
20
27
  return normalized;
@@ -37,11 +44,69 @@ function normalizePackageRootCandidate(candidate: string): string {
37
44
  return resolved;
38
45
  }
39
46
 
40
- function toPackageRoot(pkg: InstalledPackage, cwd: string): string | undefined {
47
+ async function getGlobalNpmRoot(): Promise<string | undefined> {
48
+ if (globalNpmRootCache !== undefined) {
49
+ return globalNpmRootCache ?? undefined;
50
+ }
51
+
52
+ try {
53
+ const { stdout } = await execFileAsync("npm", ["root", "-g"], {
54
+ timeout: 2_000,
55
+ windowsHide: true,
56
+ });
57
+ const root = stdout.trim();
58
+ globalNpmRootCache = root || null;
59
+ } catch {
60
+ globalNpmRootCache = null;
61
+ }
62
+
63
+ return globalNpmRootCache ?? undefined;
64
+ }
65
+
66
+ async function resolveNpmPackageRoot(
67
+ pkg: InstalledPackage,
68
+ cwd: string
69
+ ): Promise<string | undefined> {
70
+ const parsed = parseNpmSource(pkg.source);
71
+ if (!parsed?.name) {
72
+ return undefined;
73
+ }
74
+
75
+ const packageName = parsed.name;
76
+ const projectCandidates = [
77
+ join(cwd, ".pi", "npm", "node_modules", packageName),
78
+ join(cwd, "node_modules", packageName),
79
+ ];
80
+
81
+ const packageDir = process.env.PI_PACKAGE_DIR || join(homedir(), ".pi", "agent");
82
+ const globalCandidates = [join(packageDir, "npm", "node_modules", packageName)];
83
+
84
+ const npmGlobalRoot = await getGlobalNpmRoot();
85
+ if (npmGlobalRoot) {
86
+ globalCandidates.unshift(join(npmGlobalRoot, packageName));
87
+ }
88
+
89
+ const candidates =
90
+ pkg.scope === "project" ? projectCandidates : [...globalCandidates, ...projectCandidates];
91
+
92
+ for (const candidate of candidates) {
93
+ if (await fileExists(join(candidate, "package.json"))) {
94
+ return candidate;
95
+ }
96
+ }
97
+
98
+ return undefined;
99
+ }
100
+
101
+ async function toPackageRoot(pkg: InstalledPackage, cwd: string): Promise<string | undefined> {
41
102
  if (pkg.resolvedPath) {
42
103
  return normalizePackageRootCandidate(pkg.resolvedPath);
43
104
  }
44
105
 
106
+ if (pkg.source.startsWith("npm:")) {
107
+ return resolveNpmPackageRoot(pkg, cwd);
108
+ }
109
+
45
110
  if (pkg.source.startsWith("file://")) {
46
111
  try {
47
112
  return normalizePackageRootCandidate(fileURLToPath(pkg.source));
@@ -141,23 +206,79 @@ async function writeSettingsFile(path: string, settings: SettingsFile): Promise<
141
206
  }
142
207
  }
143
208
 
209
+ function safeMatchesGlob(targetPath: string, pattern: string): boolean {
210
+ try {
211
+ return matchesGlob(targetPath, pattern);
212
+ } catch {
213
+ return false;
214
+ }
215
+ }
216
+
217
+ function matchesFilterPattern(targetPath: string, pattern: string): boolean {
218
+ const normalizedPattern = normalizeRelativePath(pattern.trim());
219
+ if (!normalizedPattern) return false;
220
+ if (targetPath === normalizedPattern) return true;
221
+
222
+ return safeMatchesGlob(targetPath, normalizedPattern);
223
+ }
224
+
144
225
  function getPackageFilterState(filters: string[] | undefined, extensionPath: string): State {
145
- if (!filters || filters.length === 0) {
226
+ // Omitted key => all enabled (pi default).
227
+ if (filters === undefined) {
146
228
  return "enabled";
147
229
  }
148
230
 
231
+ // Explicit empty array => load none.
232
+ if (filters.length === 0) {
233
+ return "disabled";
234
+ }
235
+
149
236
  const normalizedTarget = normalizeRelativePath(extensionPath);
150
- let state: State = "enabled";
237
+ const includePatterns: string[] = [];
238
+ const excludePatterns: string[] = [];
239
+ let markerOverride: State | undefined;
240
+
241
+ for (const rawToken of filters) {
242
+ const token = rawToken.trim();
243
+ if (!token) continue;
244
+
245
+ const prefix = token[0];
246
+
247
+ if (prefix === "+" || prefix === "-") {
248
+ const markerPath = normalizeRelativePath(token.slice(1));
249
+ if (markerPath === normalizedTarget) {
250
+ markerOverride = prefix === "+" ? "enabled" : "disabled";
251
+ }
252
+ continue;
253
+ }
254
+
255
+ if (prefix === "!") {
256
+ const pattern = normalizeRelativePath(token.slice(1));
257
+ if (pattern) {
258
+ excludePatterns.push(pattern);
259
+ }
260
+ continue;
261
+ }
262
+
263
+ const include = normalizeRelativePath(token);
264
+ if (include) {
265
+ includePatterns.push(include);
266
+ }
267
+ }
268
+
269
+ let enabled =
270
+ includePatterns.length === 0 ||
271
+ includePatterns.some((p) => matchesFilterPattern(normalizedTarget, p));
272
+
273
+ if (enabled && excludePatterns.some((p) => matchesFilterPattern(normalizedTarget, p))) {
274
+ enabled = false;
275
+ }
151
276
 
152
- for (const token of filters) {
153
- if (!token || (token[0] !== "+" && token[0] !== "-")) continue;
154
- const sign = token[0];
155
- const path = normalizeRelativePath(token.slice(1));
156
- if (path !== normalizedTarget) continue;
157
- state = sign === "+" ? "enabled" : "disabled";
277
+ if (markerOverride !== undefined) {
278
+ enabled = markerOverride === "enabled";
158
279
  }
159
280
 
160
- return state;
281
+ return enabled ? "enabled" : "disabled";
161
282
  }
162
283
 
163
284
  async function getPackageExtensionState(
@@ -185,26 +306,131 @@ async function getPackageExtensionState(
185
306
  return getPackageFilterState(entry.extensions, extensionPath);
186
307
  }
187
308
 
188
- async function discoverEntrypoints(packageRoot: string): Promise<string[]> {
309
+ function isExtensionEntrypointPath(path: string): boolean {
310
+ return /\.(ts|js)$/i.test(path);
311
+ }
312
+
313
+ function hasGlobMagic(path: string): boolean {
314
+ return /[*?{}[\]]/.test(path);
315
+ }
316
+
317
+ function isSafeRelativePath(path: string): boolean {
318
+ return path !== "" && path !== ".." && !path.startsWith("../") && !path.includes("/../");
319
+ }
320
+
321
+ function selectDirectoryFiles(allFiles: string[], directoryPath: string): string[] {
322
+ const prefix = `${directoryPath}/`;
323
+ return allFiles.filter((file) => file.startsWith(prefix));
324
+ }
325
+
326
+ function applySelection(selected: Set<string>, files: Iterable<string>, exclude: boolean): void {
327
+ for (const file of files) {
328
+ if (exclude) {
329
+ selected.delete(file);
330
+ } else {
331
+ selected.add(file);
332
+ }
333
+ }
334
+ }
335
+
336
+ async function collectExtensionFilesFromDir(
337
+ packageRoot: string,
338
+ startDir: string
339
+ ): Promise<string[]> {
340
+ const collected: string[] = [];
341
+
342
+ let entries: Dirent[];
343
+ try {
344
+ entries = await readdir(startDir, { withFileTypes: true });
345
+ } catch {
346
+ return collected;
347
+ }
348
+
349
+ for (const entry of entries) {
350
+ const absolutePath = join(startDir, entry.name);
351
+
352
+ if (entry.isDirectory()) {
353
+ collected.push(...(await collectExtensionFilesFromDir(packageRoot, absolutePath)));
354
+ continue;
355
+ }
356
+
357
+ if (!entry.isFile()) {
358
+ continue;
359
+ }
360
+
361
+ const relativePath = normalizeRelativePath(relative(packageRoot, absolutePath));
362
+ if (isExtensionEntrypointPath(relativePath)) {
363
+ collected.push(relativePath);
364
+ }
365
+ }
366
+
367
+ return collected;
368
+ }
369
+
370
+ async function resolveManifestExtensionEntries(
371
+ packageRoot: string,
372
+ entries: string[]
373
+ ): Promise<string[]> {
374
+ const selected = new Set<string>();
375
+ const allFiles = await collectExtensionFilesFromDir(packageRoot, packageRoot);
376
+
377
+ for (const rawToken of entries) {
378
+ const token = rawToken.trim();
379
+ if (!token) continue;
380
+
381
+ const exclude = token.startsWith("!");
382
+ const normalizedToken = normalizeRelativePath(exclude ? token.slice(1) : token);
383
+ const pattern = normalizedToken.replace(/[\\/]+$/g, "");
384
+ if (!isSafeRelativePath(pattern)) {
385
+ continue;
386
+ }
387
+
388
+ if (hasGlobMagic(pattern)) {
389
+ const matchedFiles = allFiles.filter((file) => matchesFilterPattern(file, pattern));
390
+ applySelection(selected, matchedFiles, exclude);
391
+ continue;
392
+ }
393
+
394
+ const directoryFiles = selectDirectoryFiles(allFiles, pattern);
395
+ if (directoryFiles.length > 0) {
396
+ applySelection(selected, directoryFiles, exclude);
397
+ continue;
398
+ }
399
+
400
+ if (isExtensionEntrypointPath(pattern)) {
401
+ applySelection(selected, [pattern], exclude);
402
+ }
403
+ }
404
+
405
+ return Array.from(selected).sort((a, b) => a.localeCompare(b));
406
+ }
407
+
408
+ export async function resolveManifestExtensionEntrypoints(
409
+ packageRoot: string
410
+ ): Promise<string[] | undefined> {
189
411
  const packageJsonPath = join(packageRoot, "package.json");
190
- let manifestExtensions: string[] | undefined;
191
412
 
413
+ let parsed: { pi?: { extensions?: unknown } };
192
414
  try {
193
415
  const raw = await readFile(packageJsonPath, "utf8");
194
- const parsed = JSON.parse(raw) as { pi?: { extensions?: unknown } };
195
- const ext = parsed.pi?.extensions;
196
- if (Array.isArray(ext)) {
197
- const entries = ext.filter((value): value is string => typeof value === "string");
198
- if (entries.length > 0) {
199
- manifestExtensions = entries;
200
- }
201
- }
416
+ parsed = JSON.parse(raw) as { pi?: { extensions?: unknown } };
202
417
  } catch {
203
- // Ignore invalid/missing package.json and fall back.
418
+ return undefined;
419
+ }
420
+
421
+ const extensions = parsed.pi?.extensions;
422
+ if (!Array.isArray(extensions)) {
423
+ return undefined;
204
424
  }
205
425
 
206
- if (manifestExtensions && manifestExtensions.length > 0) {
207
- return manifestExtensions.map((entry) => normalizeRelativePath(entry));
426
+ const entries = extensions.filter((value): value is string => typeof value === "string");
427
+ return resolveManifestExtensionEntries(packageRoot, entries);
428
+ }
429
+
430
+ async function discoverEntrypoints(packageRoot: string): Promise<string[]> {
431
+ const manifestEntrypoints = await resolveManifestExtensionEntrypoints(packageRoot);
432
+ if (manifestEntrypoints !== undefined) {
433
+ return manifestEntrypoints;
208
434
  }
209
435
 
210
436
  const indexTs = join(packageRoot, "index.ts");
@@ -227,7 +453,7 @@ export async function discoverPackageExtensions(
227
453
  const entries: PackageExtensionEntry[] = [];
228
454
 
229
455
  for (const pkg of packages) {
230
- const packageRoot = toPackageRoot(pkg, cwd);
456
+ const packageRoot = await toPackageRoot(pkg, cwd);
231
457
  if (!packageRoot) continue;
232
458
 
233
459
  const extensionPaths = await discoverEntrypoints(packageRoot);
@@ -319,8 +545,3 @@ export async function setPackageExtensionState(
319
545
  };
320
546
  }
321
547
  }
322
-
323
- export function toProjectRelativePath(path: string, cwd: string): string {
324
- const rel = relative(cwd, path);
325
- return rel.startsWith("..") ? path : normalizeRelativePath(rel);
326
- }
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Package installation logic
3
3
  */
4
- import { mkdir, rm, writeFile, cp, readFile } from "node:fs/promises";
4
+ import { mkdir, rm, writeFile, cp } from "node:fs/promises";
5
5
  import { join } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
8
8
  import { normalizePackageSource } from "../utils/format.js";
9
9
  import { fileExists } from "../utils/fs.js";
10
10
  import { clearSearchCache, isSourceInstalled } from "./discovery.js";
11
+ import { resolveManifestExtensionEntrypoints } from "./extensions.js";
11
12
  import { waitForCondition } from "../utils/retry.js";
12
13
  import { logPackageInstall } from "../utils/history.js";
13
14
  import { clearUpdatesAvailable } from "../utils/settings.js";
@@ -70,29 +71,15 @@ function safeExtractGithubMatch(match: RegExpMatchArray | null): GithubUrlInfo |
70
71
  return { owner, repo, branch, filePath };
71
72
  }
72
73
 
73
- function normalizeRelativePath(value: string): string {
74
- return value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "");
75
- }
76
-
77
74
  async function hasStandaloneEntrypoint(packageRoot: string): Promise<boolean> {
78
- try {
79
- const manifestPath = join(packageRoot, "package.json");
80
- const raw = await readFile(manifestPath, "utf8");
81
- const parsed = JSON.parse(raw) as { pi?: { extensions?: unknown } };
82
- const declared = parsed.pi?.extensions;
83
-
84
- if (Array.isArray(declared) && declared.length > 0) {
85
- for (const entry of declared) {
86
- if (typeof entry !== "string" || !entry.trim()) continue;
87
- const candidate = join(packageRoot, normalizeRelativePath(entry));
88
- if (await fileExists(candidate)) {
89
- return true;
90
- }
75
+ const declared = await resolveManifestExtensionEntrypoints(packageRoot);
76
+ if (declared !== undefined) {
77
+ for (const path of declared) {
78
+ if (await fileExists(join(packageRoot, path))) {
79
+ return true;
91
80
  }
92
- return false;
93
81
  }
94
- } catch {
95
- // Ignore invalid/missing manifest and fall back to conventional entrypoints.
82
+ return false;
96
83
  }
97
84
 
98
85
  return (
@@ -10,7 +10,7 @@ import {
10
10
  isSourceInstalled,
11
11
  } from "./discovery.js";
12
12
  import { waitForCondition } from "../utils/retry.js";
13
- import { formatInstalledPackageLabel, formatBytes, parseNpmSource } from "../utils/format.js";
13
+ import { formatInstalledPackageLabel, parseNpmSource } from "../utils/format.js";
14
14
  import { getPackageSourceKind, splitGitRepoAndRef } from "../utils/package-source.js";
15
15
  import { logPackageUpdate, logPackageRemove } from "../utils/history.js";
16
16
  import { clearUpdatesAvailable } from "../utils/settings.js";
@@ -408,62 +408,6 @@ export async function promptRemove(ctx: ExtensionCommandContext, pi: ExtensionAP
408
408
  }
409
409
  }
410
410
 
411
- export async function showPackageActions(
412
- pkg: InstalledPackage,
413
- ctx: ExtensionCommandContext,
414
- pi: ExtensionAPI
415
- ): Promise<boolean> {
416
- if (!requireUI(ctx, "Package actions")) {
417
- console.log(`Package: ${pkg.name}`);
418
- console.log(`Version: ${pkg.version || "unknown"}`);
419
- console.log(`Source: ${pkg.source}`);
420
- console.log(`Scope: ${pkg.scope}`);
421
- return true;
422
- }
423
-
424
- const choice = await ctx.ui.select(pkg.name, [
425
- `Remove ${pkg.name}`,
426
- `Update ${pkg.name}`,
427
- "View details",
428
- "Back to manager",
429
- ]);
430
-
431
- if (!choice || choice.includes("Back")) {
432
- return false;
433
- }
434
-
435
- const action = choice.startsWith("Remove")
436
- ? "remove"
437
- : choice.startsWith("Update")
438
- ? "update"
439
- : choice.includes("details")
440
- ? "details"
441
- : "back";
442
-
443
- switch (action) {
444
- case "remove": {
445
- const outcome = await removePackageWithOutcome(pkg.source, ctx, pi);
446
- return outcome.reloaded;
447
- }
448
- case "update": {
449
- const outcome = await updatePackageWithOutcome(pkg.source, ctx, pi);
450
- return outcome.reloaded;
451
- }
452
- case "details": {
453
- const sizeStr = pkg.size !== undefined ? `\nSize: ${formatBytes(pkg.size)}` : "";
454
- notify(
455
- ctx,
456
- `Name: ${pkg.name}\nVersion: ${pkg.version || "unknown"}\nSource: ${pkg.source}\nScope: ${pkg.scope}${sizeStr}`,
457
- "info"
458
- );
459
- return showPackageActions(pkg, ctx, pi);
460
- }
461
- case "back":
462
- default:
463
- return false;
464
- }
465
- }
466
-
467
411
  export async function showInstalledPackagesList(
468
412
  ctx: ExtensionCommandContext,
469
413
  pi: ExtensionAPI
@@ -47,11 +47,11 @@ export interface PackageExtensionEntry {
47
47
  }
48
48
 
49
49
  export interface UnifiedItem {
50
- type: "local" | "package" | "package-extension";
50
+ type: "local" | "package";
51
51
  id: string;
52
52
  displayName: string;
53
53
  summary: string;
54
- scope: Scope | "global" | "project";
54
+ scope: Scope;
55
55
  // Local extension fields
56
56
  state?: State | undefined;
57
57
  activePath?: string | undefined;
@@ -63,9 +63,6 @@ export interface UnifiedItem {
63
63
  description?: string | undefined;
64
64
  size?: number | undefined; // Package size in bytes
65
65
  updateAvailable?: boolean | undefined;
66
- // Package extension fields
67
- packageSource?: string | undefined;
68
- extensionPath?: string | undefined;
69
66
  }
70
67
 
71
68
  export interface SearchCache {
@@ -82,7 +79,11 @@ export type UnifiedAction =
82
79
  | { type: "help" }
83
80
  | { type: "menu" }
84
81
  | { type: "quick"; action: "install" | "search" | "update-all" | "auto-update" }
85
- | { type: "action"; itemId: string; action?: "menu" | "update" | "remove" | "details" };
82
+ | {
83
+ type: "action";
84
+ itemId: string;
85
+ action?: "menu" | "update" | "remove" | "details" | "configure";
86
+ };
86
87
 
87
88
  export type BrowseAction =
88
89
  | { type: "package"; name: string }
package/src/ui/footer.ts CHANGED
@@ -14,10 +14,9 @@ export interface FooterState {
14
14
  */
15
15
  export function buildFooterState(items: UnifiedItem[]): FooterState {
16
16
  const hasLocals = items.some((i) => i.type === "local");
17
- const hasPackageExtensions = items.some((i) => i.type === "package-extension");
18
17
 
19
18
  return {
20
- hasToggleRows: hasLocals || hasPackageExtensions,
19
+ hasToggleRows: hasLocals,
21
20
  hasLocals,
22
21
  hasPackages: items.some((i) => i.type === "package"),
23
22
  };
@@ -33,10 +32,7 @@ export function getPendingToggleChangeCount(
33
32
  const item = byId.get(id);
34
33
  if (!item) continue;
35
34
 
36
- if (
37
- (item.type === "local" || item.type === "package-extension") &&
38
- item.originalState !== state
39
- ) {
35
+ if (item.type === "local" && item.originalState !== state) {
40
36
  count += 1;
41
37
  }
42
38
  }
@@ -54,6 +50,7 @@ export function buildFooterShortcuts(state: FooterState): string {
54
50
  if (state.hasToggleRows) parts.push("Space/Enter Toggle");
55
51
  if (state.hasToggleRows) parts.push("S Save");
56
52
  if (state.hasPackages) parts.push("Enter/A Actions");
53
+ if (state.hasPackages) parts.push("c Configure");
57
54
  if (state.hasPackages) parts.push("u Update");
58
55
  if (state.hasPackages || state.hasLocals) parts.push("X Remove");
59
56
 
package/src/ui/help.ts CHANGED
@@ -17,6 +17,7 @@ export function showHelp(ctx: ExtensionCommandContext): void {
17
17
  " Space/Enter Toggle local extension enabled/disabled",
18
18
  " S Save changes to local extensions",
19
19
  " Enter/A Open actions for selected package",
20
+ " c Configure selected package extensions (restart required after save)",
20
21
  " u Update selected package",
21
22
  " X Remove selected item (package or local extension)",
22
23
  " i Quick install by source",