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 +25 -17
- package/package.json +2 -2
- package/src/packages/extensions.ts +252 -31
- package/src/packages/install.ts +8 -21
- package/src/packages/management.ts +1 -57
- package/src/types/index.ts +7 -6
- package/src/ui/footer.ts +3 -6
- package/src/ui/help.ts +1 -0
- package/src/ui/package-config.ts +361 -0
- package/src/ui/unified.ts +84 -111
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package extension configuration panel.
|
|
3
|
+
*/
|
|
4
|
+
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import { DynamicBorder, getSettingsListTheme } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import {
|
|
7
|
+
Container,
|
|
8
|
+
Key,
|
|
9
|
+
matchesKey,
|
|
10
|
+
SettingsList,
|
|
11
|
+
Spacer,
|
|
12
|
+
Text,
|
|
13
|
+
type SettingItem,
|
|
14
|
+
} from "@mariozechner/pi-tui";
|
|
15
|
+
import type { InstalledPackage, PackageExtensionEntry, State } from "../types/index.js";
|
|
16
|
+
import { discoverPackageExtensions, setPackageExtensionState } from "../packages/extensions.js";
|
|
17
|
+
import { notify } from "../utils/notify.js";
|
|
18
|
+
import { logExtensionToggle } from "../utils/history.js";
|
|
19
|
+
import { getPackageSourceKind } from "../utils/package-source.js";
|
|
20
|
+
import { fileExists } from "../utils/fs.js";
|
|
21
|
+
import { UI } from "../constants.js";
|
|
22
|
+
import { getChangeMarker, getPackageIcon, getScopeIcon, getStatusIcon } from "./theme.js";
|
|
23
|
+
|
|
24
|
+
interface SelectableList {
|
|
25
|
+
selectedIndex?: number;
|
|
26
|
+
handleInput?(data: string): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PackageConfigRow {
|
|
30
|
+
id: string;
|
|
31
|
+
extensionPath: string;
|
|
32
|
+
summary: string;
|
|
33
|
+
originalState: State;
|
|
34
|
+
available: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type ConfigurePanelAction = { type: "cancel" } | { type: "save" };
|
|
38
|
+
|
|
39
|
+
function getSelectedIndex(settingsList: unknown): number | undefined {
|
|
40
|
+
if (settingsList && typeof settingsList === "object") {
|
|
41
|
+
const selectable = settingsList as SelectableList;
|
|
42
|
+
if (typeof selectable.selectedIndex === "number") {
|
|
43
|
+
return selectable.selectedIndex;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function buildPackageConfigRows(
|
|
50
|
+
entries: PackageExtensionEntry[]
|
|
51
|
+
): Promise<PackageConfigRow[]> {
|
|
52
|
+
const dedupedEntries = new Map<string, PackageExtensionEntry>();
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (!dedupedEntries.has(entry.extensionPath)) {
|
|
55
|
+
dedupedEntries.set(entry.extensionPath, entry);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const rows = await Promise.all(
|
|
60
|
+
Array.from(dedupedEntries.values()).map(async (entry) => ({
|
|
61
|
+
id: entry.id,
|
|
62
|
+
extensionPath: entry.extensionPath,
|
|
63
|
+
summary: entry.summary,
|
|
64
|
+
originalState: entry.state,
|
|
65
|
+
available: await fileExists(entry.absolutePath),
|
|
66
|
+
}))
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
rows.sort((a, b) => a.extensionPath.localeCompare(b.extensionPath));
|
|
70
|
+
return rows;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function formatConfigRowLabel(
|
|
74
|
+
row: PackageConfigRow,
|
|
75
|
+
state: State,
|
|
76
|
+
pkg: InstalledPackage,
|
|
77
|
+
theme: Theme,
|
|
78
|
+
changed: boolean
|
|
79
|
+
): string {
|
|
80
|
+
const statusIcon = getStatusIcon(theme, state);
|
|
81
|
+
const scopeIcon = getScopeIcon(theme, pkg.scope);
|
|
82
|
+
const sourceKind = getPackageSourceKind(pkg.source);
|
|
83
|
+
const pkgIcon = getPackageIcon(
|
|
84
|
+
theme,
|
|
85
|
+
sourceKind === "npm" || sourceKind === "git" || sourceKind === "local" ? sourceKind : "local"
|
|
86
|
+
);
|
|
87
|
+
const changeMarker = getChangeMarker(theme, changed);
|
|
88
|
+
const name = theme.bold(row.extensionPath);
|
|
89
|
+
const availability = row.available
|
|
90
|
+
? ""
|
|
91
|
+
: ` ${theme.fg("warning", "[missing]")}${theme.fg("dim", " (cannot toggle)")}`;
|
|
92
|
+
const summary = theme.fg("dim", row.summary);
|
|
93
|
+
|
|
94
|
+
return `${statusIcon} ${pkgIcon} [${scopeIcon}] ${name}${availability} - ${summary}${changeMarker}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildSettingItems(
|
|
98
|
+
rows: PackageConfigRow[],
|
|
99
|
+
staged: Map<string, State>,
|
|
100
|
+
pkg: InstalledPackage,
|
|
101
|
+
theme: Theme
|
|
102
|
+
): SettingItem[] {
|
|
103
|
+
return rows.map((row) => {
|
|
104
|
+
const current = staged.get(row.id) ?? row.originalState;
|
|
105
|
+
const changed = current !== row.originalState;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
id: row.id,
|
|
109
|
+
label: formatConfigRowLabel(row, current, pkg, theme, changed),
|
|
110
|
+
currentValue: current,
|
|
111
|
+
values: row.available ? ["enabled", "disabled"] : [current],
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getPendingChangeCount(rows: PackageConfigRow[], staged: Map<string, State>): number {
|
|
117
|
+
let count = 0;
|
|
118
|
+
|
|
119
|
+
for (const row of rows) {
|
|
120
|
+
const target = staged.get(row.id);
|
|
121
|
+
if (!target) continue;
|
|
122
|
+
if (target !== row.originalState) count += 1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return count;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function showConfigurePanel(
|
|
129
|
+
pkg: InstalledPackage,
|
|
130
|
+
rows: PackageConfigRow[],
|
|
131
|
+
staged: Map<string, State>,
|
|
132
|
+
ctx: ExtensionCommandContext
|
|
133
|
+
): Promise<ConfigurePanelAction> {
|
|
134
|
+
return ctx.ui.custom<ConfigurePanelAction>((tui, theme, _keybindings, done) => {
|
|
135
|
+
const container = new Container();
|
|
136
|
+
|
|
137
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
138
|
+
container.addChild(
|
|
139
|
+
new Text(theme.fg("accent", theme.bold(`Configure extensions: ${pkg.name}`)), 2, 0)
|
|
140
|
+
);
|
|
141
|
+
container.addChild(
|
|
142
|
+
new Text(
|
|
143
|
+
theme.fg(
|
|
144
|
+
"muted",
|
|
145
|
+
`${rows.length} extension path${rows.length === 1 ? "" : "s"} • Space/Enter toggle • S save • Esc cancel`
|
|
146
|
+
),
|
|
147
|
+
2,
|
|
148
|
+
0
|
|
149
|
+
)
|
|
150
|
+
);
|
|
151
|
+
container.addChild(new Spacer(1));
|
|
152
|
+
|
|
153
|
+
const settingsItems = buildSettingItems(rows, staged, pkg, theme);
|
|
154
|
+
const rowById = new Map(rows.map((row) => [row.id, row]));
|
|
155
|
+
|
|
156
|
+
const settingsList = new SettingsList(
|
|
157
|
+
settingsItems,
|
|
158
|
+
Math.min(rows.length + 2, UI.maxListHeight),
|
|
159
|
+
getSettingsListTheme(),
|
|
160
|
+
(id: string, newValue: string) => {
|
|
161
|
+
const row = rowById.get(id);
|
|
162
|
+
if (!row || !row.available) return;
|
|
163
|
+
|
|
164
|
+
const state = newValue as State;
|
|
165
|
+
staged.set(id, state);
|
|
166
|
+
|
|
167
|
+
const settingsItem = settingsItems.find((item) => item.id === id);
|
|
168
|
+
if (settingsItem) {
|
|
169
|
+
settingsItem.label = formatConfigRowLabel(
|
|
170
|
+
row,
|
|
171
|
+
state,
|
|
172
|
+
pkg,
|
|
173
|
+
theme,
|
|
174
|
+
state !== row.originalState
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
tui.requestRender();
|
|
179
|
+
},
|
|
180
|
+
() => done({ type: "cancel" }),
|
|
181
|
+
{ enableSearch: rows.length > UI.searchThreshold }
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
container.addChild(settingsList);
|
|
185
|
+
container.addChild(new Spacer(1));
|
|
186
|
+
container.addChild(
|
|
187
|
+
new Text(theme.fg("dim", "↑↓ Navigate | Space/Enter Toggle | S Save | Esc Back"), 2, 0)
|
|
188
|
+
);
|
|
189
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
render(width: number) {
|
|
193
|
+
return container.render(width);
|
|
194
|
+
},
|
|
195
|
+
invalidate() {
|
|
196
|
+
container.invalidate();
|
|
197
|
+
},
|
|
198
|
+
handleInput(data: string) {
|
|
199
|
+
if (matchesKey(data, Key.ctrl("s")) || data === "s" || data === "S") {
|
|
200
|
+
done({ type: "save" });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const selectedIndex = getSelectedIndex(settingsList) ?? 0;
|
|
205
|
+
const selectedId = settingsItems[selectedIndex]?.id ?? settingsItems[0]?.id;
|
|
206
|
+
const selectedRow = selectedId ? rowById.get(selectedId) : undefined;
|
|
207
|
+
|
|
208
|
+
if (
|
|
209
|
+
selectedRow &&
|
|
210
|
+
!selectedRow.available &&
|
|
211
|
+
(data === " " || data === "\r" || data === "\n")
|
|
212
|
+
) {
|
|
213
|
+
notify(
|
|
214
|
+
ctx,
|
|
215
|
+
`${selectedRow.extensionPath} is missing on disk and cannot be toggled.`,
|
|
216
|
+
"warning"
|
|
217
|
+
);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
settingsList.handleInput?.(data);
|
|
222
|
+
tui.requestRender();
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function applyPackageExtensionChanges(
|
|
229
|
+
rows: PackageConfigRow[],
|
|
230
|
+
staged: Map<string, State>,
|
|
231
|
+
pkg: InstalledPackage,
|
|
232
|
+
cwd: string,
|
|
233
|
+
pi: ExtensionAPI
|
|
234
|
+
): Promise<{ changed: number; errors: string[] }> {
|
|
235
|
+
let changed = 0;
|
|
236
|
+
const errors: string[] = [];
|
|
237
|
+
|
|
238
|
+
const sortedRows = [...rows].sort((a, b) => a.extensionPath.localeCompare(b.extensionPath));
|
|
239
|
+
|
|
240
|
+
for (const row of sortedRows) {
|
|
241
|
+
const target = staged.get(row.id) ?? row.originalState;
|
|
242
|
+
if (target === row.originalState) continue;
|
|
243
|
+
|
|
244
|
+
if (!row.available) {
|
|
245
|
+
const error = `${row.extensionPath}: extension entrypoint is missing on disk`;
|
|
246
|
+
errors.push(error);
|
|
247
|
+
logExtensionToggle(pi, row.id, row.originalState, target, false, error);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const result = await setPackageExtensionState(
|
|
252
|
+
pkg.source,
|
|
253
|
+
row.extensionPath,
|
|
254
|
+
pkg.scope,
|
|
255
|
+
target,
|
|
256
|
+
cwd
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
if (result.ok) {
|
|
260
|
+
changed += 1;
|
|
261
|
+
logExtensionToggle(pi, row.id, row.originalState, target, true);
|
|
262
|
+
} else {
|
|
263
|
+
errors.push(`${row.extensionPath}: ${result.error}`);
|
|
264
|
+
logExtensionToggle(pi, row.id, row.originalState, target, false, result.error);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { changed, errors };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function promptRestartForPackageConfig(ctx: ExtensionCommandContext): Promise<boolean> {
|
|
272
|
+
if (!ctx.hasUI) {
|
|
273
|
+
notify(
|
|
274
|
+
ctx,
|
|
275
|
+
"Restart pi to apply package extension configuration changes. /reload may not be enough.",
|
|
276
|
+
"warning"
|
|
277
|
+
);
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const restartNow = await ctx.ui.confirm(
|
|
282
|
+
"Restart Required",
|
|
283
|
+
"Package extension configuration changed.\nA full pi restart is required to apply it.\nExit pi now?"
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
if (!restartNow) {
|
|
287
|
+
notify(
|
|
288
|
+
ctx,
|
|
289
|
+
"Restart pi manually to apply package extension configuration changes. /reload may not be enough.",
|
|
290
|
+
"warning"
|
|
291
|
+
);
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
notify(ctx, "Shutting down pi. Start it again to apply changes.", "info");
|
|
296
|
+
ctx.shutdown();
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function configurePackageExtensions(
|
|
301
|
+
pkg: InstalledPackage,
|
|
302
|
+
ctx: ExtensionCommandContext,
|
|
303
|
+
pi: ExtensionAPI
|
|
304
|
+
): Promise<{ changed: number; reloaded: boolean }> {
|
|
305
|
+
const discovered = await discoverPackageExtensions([pkg], ctx.cwd);
|
|
306
|
+
const rows = await buildPackageConfigRows(discovered);
|
|
307
|
+
|
|
308
|
+
if (rows.length === 0) {
|
|
309
|
+
notify(ctx, "No configurable extensions discovered for this package.", "info");
|
|
310
|
+
return { changed: 0, reloaded: false };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const staged = new Map<string, State>();
|
|
314
|
+
|
|
315
|
+
while (true) {
|
|
316
|
+
const action = await showConfigurePanel(pkg, rows, staged, ctx);
|
|
317
|
+
|
|
318
|
+
if (action.type === "cancel") {
|
|
319
|
+
const pending = getPendingChangeCount(rows, staged);
|
|
320
|
+
if (pending === 0) {
|
|
321
|
+
return { changed: 0, reloaded: false };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const choice = await ctx.ui.select(`Unsaved changes (${pending})`, [
|
|
325
|
+
"Save and back",
|
|
326
|
+
"Discard changes",
|
|
327
|
+
"Stay in configure",
|
|
328
|
+
]);
|
|
329
|
+
|
|
330
|
+
if (!choice || choice === "Stay in configure") {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (choice === "Discard changes") {
|
|
335
|
+
return { changed: 0, reloaded: false };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const apply = await applyPackageExtensionChanges(rows, staged, pkg, ctx.cwd, pi);
|
|
340
|
+
|
|
341
|
+
if (apply.errors.length > 0) {
|
|
342
|
+
notify(
|
|
343
|
+
ctx,
|
|
344
|
+
`Applied ${apply.changed} change(s), ${apply.errors.length} failed.\n${apply.errors.join("\n")}`,
|
|
345
|
+
"warning"
|
|
346
|
+
);
|
|
347
|
+
} else if (apply.changed === 0) {
|
|
348
|
+
notify(ctx, "No changes to apply.", "info");
|
|
349
|
+
return { changed: 0, reloaded: false };
|
|
350
|
+
} else {
|
|
351
|
+
notify(ctx, `Applied ${apply.changed} package extension change(s).`, "info");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (apply.changed === 0) {
|
|
355
|
+
return { changed: 0, reloaded: false };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const restarted = await promptRestartForPackageConfig(ctx);
|
|
359
|
+
return { changed: apply.changed, reloaded: restarted };
|
|
360
|
+
}
|
|
361
|
+
}
|