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
@@ -19,6 +19,7 @@ export interface NpmPackage {
19
19
  name: string;
20
20
  version?: string | undefined;
21
21
  description?: string | undefined;
22
+ author?: string | undefined;
22
23
  keywords?: string[] | undefined;
23
24
  date?: string | undefined;
24
25
  size?: number | undefined; // Package size in bytes
@@ -46,25 +47,33 @@ export interface PackageExtensionEntry {
46
47
  state: State;
47
48
  }
48
49
 
49
- export interface UnifiedItem {
50
- type: "local" | "package";
50
+ export interface LocalUnifiedItem {
51
+ type: "local";
51
52
  id: string;
52
53
  displayName: string;
53
54
  summary: string;
54
55
  scope: Scope;
55
- // Local extension fields
56
- state?: State | undefined;
57
- activePath?: string | undefined;
58
- disabledPath?: string | undefined;
59
- originalState?: State | undefined;
60
- // Package fields
61
- source?: string | undefined;
56
+ state: State;
57
+ activePath: string;
58
+ disabledPath: string;
59
+ originalState: State;
60
+ }
61
+
62
+ export interface PackageUnifiedItem {
63
+ type: "package";
64
+ id: string;
65
+ displayName: string;
66
+ scope: Scope;
67
+ source: string;
68
+ resolvedPath?: string | undefined;
62
69
  version?: string | undefined;
63
70
  description?: string | undefined;
64
71
  size?: number | undefined; // Package size in bytes
65
72
  updateAvailable?: boolean | undefined;
66
73
  }
67
74
 
75
+ export type UnifiedItem = LocalUnifiedItem | PackageUnifiedItem;
76
+
68
77
  export interface SearchCache {
69
78
  query: string;
70
79
  results: NpmPackage[];
@@ -90,7 +99,7 @@ export type BrowseAction =
90
99
  | { type: "prev" }
91
100
  | { type: "next" }
92
101
  | { type: "refresh" }
102
+ | { type: "search"; query: string }
103
+ | { type: "install" }
93
104
  | { type: "menu" }
94
- | { type: "main" }
95
- | { type: "help" }
96
105
  | { type: "cancel" };
@@ -1,9 +1,9 @@
1
- import type {
2
- ExtensionCommandContext,
3
- ExtensionContext,
4
- Theme,
1
+ import {
2
+ DynamicBorder,
3
+ type ExtensionCommandContext,
4
+ type ExtensionContext,
5
+ type Theme,
5
6
  } from "@mariozechner/pi-coding-agent";
6
- import { DynamicBorder } from "@mariozechner/pi-coding-agent";
7
7
  import { CancellableLoader, Container, Loader, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
8
8
  import { hasCustomUI } from "../utils/mode.js";
9
9
 
@@ -12,6 +12,8 @@ type AnyContext = ExtensionCommandContext | ExtensionContext;
12
12
  const TASK_ABORTED = Symbol("task-aborted");
13
13
  const TASK_FAILED = Symbol("task-failed");
14
14
 
15
+ type TaskSuccess<T> = { type: "ok"; value: T };
16
+
15
17
  export interface TaskControls {
16
18
  signal: AbortSignal;
17
19
  setMessage: (message: string) => void;
@@ -21,6 +23,7 @@ interface LoaderConfig {
21
23
  title: string;
22
24
  message: string;
23
25
  cancellable?: boolean;
26
+ fallbackWithoutLoader?: boolean;
24
27
  }
25
28
 
26
29
  function createLoaderComponent(
@@ -69,82 +72,115 @@ function createLoaderComponent(
69
72
  return { container, loader, signal };
70
73
  }
71
74
 
75
+ function runTaskWithoutLoader<T>(task: (controls: TaskControls) => Promise<T>): Promise<T> {
76
+ return Promise.resolve().then(() =>
77
+ task({
78
+ signal: new AbortController().signal,
79
+ setMessage: () => undefined,
80
+ })
81
+ );
82
+ }
83
+
72
84
  export async function runTaskWithLoader<T>(
73
85
  ctx: AnyContext,
74
86
  config: LoaderConfig,
75
87
  task: (controls: TaskControls) => Promise<T>
76
88
  ): Promise<T | undefined> {
77
89
  if (!hasCustomUI(ctx)) {
78
- return task({
79
- signal: new AbortController().signal,
80
- setMessage: () => undefined,
81
- });
90
+ return runTaskWithoutLoader(task);
82
91
  }
83
92
 
84
93
  let taskError: unknown;
85
-
86
- const result = await ctx.ui.custom<T | typeof TASK_ABORTED | typeof TASK_FAILED>(
87
- (tui, theme, _keybindings, done) => {
88
- let finished = false;
89
- const finish = (value: T | typeof TASK_ABORTED | typeof TASK_FAILED): void => {
90
- if (finished) {
91
- return;
92
- }
93
- finished = true;
94
- done(value);
95
- };
96
-
97
- const { container, loader, signal } = createLoaderComponent(
98
- tui,
99
- theme,
100
- config.title,
101
- config.message,
102
- config.cancellable ?? true,
103
- () => finish(TASK_ABORTED)
104
- );
105
-
106
- void task({
94
+ let startedTask: Promise<T> | undefined;
95
+ let cleanupStartedTaskUI: (() => void) | undefined;
96
+
97
+ const result = await ctx.ui.custom<
98
+ TaskSuccess<T> | typeof TASK_ABORTED | typeof TASK_FAILED | undefined
99
+ >((tui, theme, _keybindings, done) => {
100
+ let finished = false;
101
+ const finish = (
102
+ value: TaskSuccess<T> | typeof TASK_ABORTED | typeof TASK_FAILED | undefined
103
+ ): void => {
104
+ if (finished) {
105
+ return;
106
+ }
107
+ finished = true;
108
+ done(value);
109
+ };
110
+
111
+ const { container, loader, signal } = createLoaderComponent(
112
+ tui,
113
+ theme,
114
+ config.title,
115
+ config.message,
116
+ config.cancellable ?? true,
117
+ () => finish(TASK_ABORTED)
118
+ );
119
+
120
+ cleanupStartedTaskUI = () => {
121
+ if (loader instanceof CancellableLoader) {
122
+ loader.dispose();
123
+ return;
124
+ }
125
+
126
+ loader.stop();
127
+ };
128
+
129
+ startedTask = Promise.resolve().then(() =>
130
+ task({
107
131
  signal,
108
132
  setMessage: (message) => {
109
133
  loader.setMessage(message);
110
134
  tui.requestRender();
111
135
  },
112
136
  })
113
- .then((value) => finish(value))
114
- .catch((error) => {
115
- if (signal.aborted) {
116
- finish(TASK_ABORTED);
117
- return;
118
- }
119
-
120
- taskError = error;
121
- finish(TASK_FAILED);
122
- });
123
-
124
- return {
125
- render(width: number) {
126
- return container.render(width);
127
- },
128
- invalidate() {
129
- container.invalidate();
130
- },
131
- handleInput(data: string) {
132
- if (loader instanceof CancellableLoader) {
133
- loader.handleInput(data);
134
- tui.requestRender();
135
- }
136
- },
137
- dispose() {
138
- if (loader instanceof CancellableLoader) {
139
- loader.dispose();
140
- return;
141
- }
137
+ );
142
138
 
143
- loader.stop();
144
- },
145
- };
139
+ void startedTask
140
+ .then((value) => finish({ type: "ok", value }))
141
+ .catch((error) => {
142
+ if (signal.aborted) {
143
+ finish(TASK_ABORTED);
144
+ return;
145
+ }
146
+
147
+ taskError = error;
148
+ finish(TASK_FAILED);
149
+ });
150
+
151
+ return {
152
+ render(width: number) {
153
+ return container.render(width);
154
+ },
155
+ invalidate() {
156
+ container.invalidate();
157
+ },
158
+ handleInput(data: string) {
159
+ if (loader instanceof CancellableLoader) {
160
+ loader.handleInput(data);
161
+ tui.requestRender();
162
+ }
163
+ },
164
+ dispose() {
165
+ if (loader instanceof CancellableLoader) {
166
+ loader.dispose();
167
+ return;
168
+ }
169
+
170
+ loader.stop();
171
+ },
172
+ };
173
+ });
174
+
175
+ if (result === undefined) {
176
+ if (startedTask) {
177
+ return startedTask.finally(() => cleanupStartedTaskUI?.());
146
178
  }
147
- );
179
+ if (config.fallbackWithoutLoader) {
180
+ return runTaskWithoutLoader(task);
181
+ }
182
+ return undefined;
183
+ }
148
184
 
149
185
  if (result === TASK_ABORTED) {
150
186
  return undefined;
@@ -154,5 +190,5 @@ export async function runTaskWithLoader<T>(
154
190
  throw taskError;
155
191
  }
156
192
 
157
- return result;
193
+ return result.value;
158
194
  }
package/src/ui/footer.ts CHANGED
@@ -1,25 +1,27 @@
1
1
  /**
2
2
  * Footer helpers for the unified extension manager UI
3
3
  */
4
- import type { UnifiedItem, State } from "../types/index.js";
4
+ import { type State, type UnifiedItem } from "../types/index.js";
5
5
 
6
6
  export interface FooterState {
7
- hasToggleRows: boolean;
8
- hasLocals: boolean;
9
- hasPackages: boolean;
7
+ selectedType?: UnifiedItem["type"];
8
+ pendingChanges: number;
10
9
  }
11
10
 
12
- /**
13
- * Build footer state from visible items.
14
- */
15
- export function buildFooterState(items: UnifiedItem[]): FooterState {
16
- const hasLocals = items.some((i) => i.type === "local");
17
-
18
- return {
19
- hasToggleRows: hasLocals,
20
- hasLocals,
21
- hasPackages: items.some((i) => i.type === "package"),
11
+ export function buildFooterState(
12
+ staged: Map<string, State>,
13
+ byId: Map<string, UnifiedItem>,
14
+ selectedItem?: UnifiedItem
15
+ ): FooterState {
16
+ const state: FooterState = {
17
+ pendingChanges: getPendingToggleChangeCount(staged, byId),
22
18
  };
19
+
20
+ if (selectedItem) {
21
+ state.selectedType = selectedItem.type;
22
+ }
23
+
24
+ return state;
23
25
  }
24
26
 
25
27
  export function getPendingToggleChangeCount(
@@ -41,27 +43,41 @@ export function getPendingToggleChangeCount(
41
43
  }
42
44
 
43
45
  /**
44
- * Build keyboard shortcuts text for the footer.
46
+ * Build contextual keyboard shortcuts text for the footer.
45
47
  */
46
48
  export function buildFooterShortcuts(state: FooterState): string {
47
49
  const parts: string[] = [];
48
- parts.push("↑↓ Navigate");
49
50
 
50
- if (state.hasToggleRows) parts.push("Space/Enter Toggle");
51
- if (state.hasToggleRows) parts.push("S Save");
52
- if (state.hasPackages) parts.push("Enter/A Actions");
53
- if (state.hasPackages) parts.push("c Configure");
54
- if (state.hasPackages) parts.push("u Update");
55
- if (state.hasPackages || state.hasLocals) parts.push("X Remove");
51
+ if (state.selectedType === "local") {
52
+ parts.push("Space toggle");
53
+ parts.push("Enter/A actions");
54
+ parts.push("V details");
55
+ parts.push("X remove");
56
+ }
57
+
58
+ if (state.selectedType === "package") {
59
+ parts.push("Enter/A actions");
60
+ parts.push("V details");
61
+ parts.push("c configure");
62
+ parts.push("u update");
63
+ parts.push("X remove");
64
+ }
65
+
66
+ if (state.pendingChanges > 0) {
67
+ parts.push(`S save (${state.pendingChanges})`);
68
+ }
56
69
 
57
- parts.push("i Install");
58
- parts.push("f Search");
59
- parts.push("U Update all");
60
- parts.push("t Auto-update");
61
- parts.push("P Palette");
62
- parts.push("R Browse");
63
- parts.push("? Help");
64
- parts.push("Esc Cancel");
70
+ parts.push("/ search");
71
+ parts.push("Tab filters");
72
+ parts.push("1-5 filters");
73
+ parts.push("i install");
74
+ parts.push("f remote search");
75
+ parts.push("U update all");
76
+ parts.push("t auto-update");
77
+ parts.push("P palette");
78
+ parts.push("R browse");
79
+ parts.push("? help");
80
+ parts.push("Esc clear/cancel");
65
81
 
66
- return parts.join(" | ");
82
+ return parts.join(" · ");
67
83
  }
package/src/ui/help.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Help display
3
3
  */
4
- import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
4
+ import { type ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
5
+ import { notify } from "../utils/notify.js";
5
6
 
6
7
  export function showHelp(ctx: ExtensionCommandContext): void {
7
8
  const lines = [
@@ -9,25 +10,33 @@ export function showHelp(ctx: ExtensionCommandContext): void {
9
10
  "",
10
11
  "Unified View:",
11
12
  " Local extensions and npm/git packages are displayed together",
13
+ " The list is grouped into Local extensions and Installed packages sections",
14
+ " Rows stay compact; details for the selected item appear below the list",
12
15
  " Local extensions show ● enabled / ○ disabled with G/P scope",
13
- " Packages show 📦 with name@version and G/P scope",
16
+ " Packages show a source-type icon with name@version, scope, and size when known",
14
17
  "",
15
18
  "Navigation:",
16
19
  " ↑↓ Navigate list",
17
- " Space/Enter Toggle local extension enabled/disabled",
20
+ " PageUp/Down Jump through longer lists",
21
+ " Home/End Jump to top or bottom",
22
+ " Space Toggle selected local extension enabled/disabled",
18
23
  " S Save changes to local extensions",
19
- " Enter/A Open actions for selected package",
20
- " c Configure selected package extensions (restart required after save)",
24
+ " Enter/A Open actions for the selected item",
25
+ " / or Ctrl+F Search visible items",
26
+ " Tab/Shift+Tab Cycle filters",
27
+ " 1-5 Quick filters: All / Local / Packages / Updates / Disabled",
28
+ " c Configure selected package extensions (reload after save)",
21
29
  " u Update selected package",
30
+ " V View full details for the selected item",
22
31
  " X Remove selected item (package or local extension)",
23
32
  " i Quick install by source",
24
- " f Quick search",
33
+ " f Remote package search",
25
34
  " U Update all packages",
26
35
  " t Auto-update wizard",
27
36
  " P/M Quick actions palette",
28
37
  " R Browse remote packages",
29
38
  " ?/H Show this help",
30
- " Esc Cancel",
39
+ " Esc Clear search or cancel",
31
40
  "",
32
41
  "Extension Sources:",
33
42
  " - ~/.pi/agent/extensions/ (global - G)",
@@ -49,10 +58,5 @@ export function showHelp(ctx: ExtensionCommandContext): void {
49
58
  " /extensions auto-update Show or change update schedule",
50
59
  ];
51
60
 
52
- const output = lines.join("\n");
53
- if (ctx.hasUI) {
54
- ctx.ui.notify(output, "info");
55
- } else {
56
- console.log(output);
57
- }
61
+ notify(ctx, lines.join("\n"), "info");
58
62
  }
@@ -1,31 +1,37 @@
1
1
  /**
2
2
  * Package extension configuration panel.
3
3
  */
4
- import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
5
- import { DynamicBorder, getSettingsListTheme } from "@mariozechner/pi-coding-agent";
4
+ import {
5
+ DynamicBorder,
6
+ type ExtensionAPI,
7
+ type ExtensionCommandContext,
8
+ getSettingsListTheme,
9
+ type Theme,
10
+ } from "@mariozechner/pi-coding-agent";
6
11
  import {
7
12
  Container,
8
13
  Key,
9
14
  matchesKey,
15
+ type SettingItem,
10
16
  SettingsList,
11
17
  Spacer,
12
18
  Text,
13
- type SettingItem,
14
19
  } from "@mariozechner/pi-tui";
15
- import type { InstalledPackage, PackageExtensionEntry, State } from "../types/index.js";
20
+ import { UI } from "../constants.js";
16
21
  import {
17
22
  applyPackageExtensionStateChanges,
18
23
  discoverPackageExtensions,
19
24
  validatePackageExtensionSettings,
20
25
  } from "../packages/extensions.js";
21
- import { notify } from "../utils/notify.js";
26
+ import { type InstalledPackage, type PackageExtensionEntry, type State } from "../types/index.js";
27
+ import { fileExists } from "../utils/fs.js";
22
28
  import { logExtensionToggle } from "../utils/history.js";
23
29
  import { requireCustomUI, runCustomUI } from "../utils/mode.js";
24
- import { runTaskWithLoader } from "./async-task.js";
30
+ import { notify } from "../utils/notify.js";
25
31
  import { getPackageSourceKind } from "../utils/package-source.js";
26
32
  import { getSettingsListSelectedIndex } from "../utils/settings-list.js";
27
- import { fileExists } from "../utils/fs.js";
28
- import { UI } from "../constants.js";
33
+ import { confirmReload } from "../utils/ui-helpers.js";
34
+ import { runTaskWithLoader } from "./async-task.js";
29
35
  import { getChangeMarker, getPackageIcon, getScopeIcon, getStatusIcon } from "./theme.js";
30
36
 
31
37
  export interface PackageConfigRow {
@@ -168,10 +174,14 @@ async function showConfigurePanel(
168
174
  getSettingsListTheme(),
169
175
  (id: string, newValue: string) => {
170
176
  const row = rowById.get(id);
171
- if (!row || !row.available) return;
177
+ if (!row?.available) return;
172
178
 
173
179
  const state = newValue as State;
174
- staged.set(id, state);
180
+ if (state === row.originalState) {
181
+ staged.delete(id);
182
+ } else {
183
+ staged.set(id, state);
184
+ }
175
185
 
176
186
  const settingsItem = settingsItems.find((item) => item.id === id);
177
187
  if (settingsItem) {
@@ -286,35 +296,6 @@ export async function applyPackageExtensionChanges(
286
296
  return { changed: changedRows.length, errors };
287
297
  }
288
298
 
289
- async function promptRestartForPackageConfig(ctx: ExtensionCommandContext): Promise<boolean> {
290
- if (!ctx.hasUI) {
291
- notify(
292
- ctx,
293
- "Restart pi to apply package extension configuration changes. /reload may not be enough.",
294
- "warning"
295
- );
296
- return false;
297
- }
298
-
299
- const restartNow = await ctx.ui.confirm(
300
- "Restart Required",
301
- "Package extension configuration changed.\nA full pi restart is required to apply it.\nExit pi now?"
302
- );
303
-
304
- if (!restartNow) {
305
- notify(
306
- ctx,
307
- "Restart pi manually to apply package extension configuration changes. /reload may not be enough.",
308
- "warning"
309
- );
310
- return false;
311
- }
312
-
313
- notify(ctx, "Shutting down pi. Start it again to apply changes.", "info");
314
- ctx.shutdown();
315
- return true;
316
- }
317
-
318
299
  export async function configurePackageExtensions(
319
300
  pkg: InstalledPackage,
320
301
  ctx: ExtensionCommandContext,
@@ -393,24 +374,31 @@ export async function configurePackageExtensions(
393
374
 
394
375
  const apply = await applyPackageExtensionChanges(rows, staged, pkg, ctx.cwd, pi);
395
376
 
377
+ if (apply.changed === 0) {
378
+ if (apply.errors.length > 0) {
379
+ notify(
380
+ ctx,
381
+ `Applied ${apply.changed} change(s), ${apply.errors.length} failed.\n${apply.errors.join("\n")}`,
382
+ "warning"
383
+ );
384
+ continue;
385
+ }
386
+
387
+ notify(ctx, "No changes to apply.", "info");
388
+ return { changed: 0, reloaded: false };
389
+ }
390
+
396
391
  if (apply.errors.length > 0) {
397
392
  notify(
398
393
  ctx,
399
394
  `Applied ${apply.changed} change(s), ${apply.errors.length} failed.\n${apply.errors.join("\n")}`,
400
395
  "warning"
401
396
  );
402
- } else if (apply.changed === 0) {
403
- notify(ctx, "No changes to apply.", "info");
404
- return { changed: 0, reloaded: false };
405
397
  } else {
406
398
  notify(ctx, `Applied ${apply.changed} package extension change(s).`, "info");
407
399
  }
408
400
 
409
- if (apply.changed === 0) {
410
- return { changed: 0, reloaded: false };
411
- }
412
-
413
- const restarted = await promptRestartForPackageConfig(ctx);
414
- return { changed: apply.changed, reloaded: restarted };
401
+ const reloaded = await confirmReload(ctx, "Package extension configuration changed.");
402
+ return { changed: apply.changed, reloaded };
415
403
  }
416
404
  }