@danypops/pi-packed 0.6.3 → 0.8.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/README.md +8 -3
- package/extension/src/discover-model.ts +15 -0
- package/extension/src/discover.ts +201 -0
- package/extension/src/index.ts +5 -13
- package/extension/src/model.ts +1 -1
- package/extension/src/packed.ts +1 -1
- package/extension/src/reload.ts +1 -1
- package/extension/src/resource-config.ts +7 -4
- package/extension/src/tui.ts +204 -24
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-packed
|
|
2
2
|
|
|
3
|
-
Pi integration for `@danypops/packed`: package tools, `/
|
|
3
|
+
Pi integration for `@danypops/packed`: package tools, `/packed`, setup apply/reload, and named profiles.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
pi install npm:@danypops/pi-packed
|
|
@@ -10,8 +10,13 @@ The extension connects to Packed's authenticated user daemon and starts the pack
|
|
|
10
10
|
|
|
11
11
|
## Commands
|
|
12
12
|
|
|
13
|
-
- `/
|
|
14
|
-
-
|
|
13
|
+
- `/packed` -- opens on the packages panel: every installed Pi package, with update availability. Mnemonics follow lazy.nvim's own convention (uppercase acts on every row, lowercase on the one under the cursor):
|
|
14
|
+
- `u` / `U` -- update the selected package / update every outdated package, one combined confirmation and one reload.
|
|
15
|
+
- `x` -- remove the selected package.
|
|
16
|
+
- `d` -- disable (or re-enable) the selected package's own extensions.
|
|
17
|
+
- `c` -- jump to resource config for the selected package (skills, prompts, themes).
|
|
18
|
+
- `f` -- find: search the npm registry for new Pi packages and install one.
|
|
19
|
+
- `s` -- settings. `Enter` opens a floating action menu with all of the above. `r` refreshes, `/` filters, `Tab` cycles view modes.
|
|
15
20
|
- `/packed config` -- enable or disable individual extensions, skills, prompt templates, and themes declared by installed packages, per package, at global or project scope.
|
|
16
21
|
- `/packed setup plan [path] [--prune]` / `/packed setup apply [path] [--prune]` -- preview or apply a reproducible-environment manifest (`pi-setup.json`) against installed packages and profiles.
|
|
17
22
|
- `/profile [name]` -- switch to a named Pi profile (provider, model, tools, instructions, theme), reading `~/.pi/agent/profiles.json` and a trusted project's own `.pi/profiles.json`. `pi --profile <name>` activates one at startup; `Ctrl+Shift+U` cycles through them.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* discover-model.ts — pure decision logic for /packed find, kept separate
|
|
3
|
+
* from ctx.ui.custom rendering so it's directly testable without faking a
|
|
4
|
+
* terminal (same split as model.ts for the packages panel).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Enter's dual purpose: run a new search when the query has changed
|
|
8
|
+
* since the last one (or nothing has been searched yet), otherwise treat
|
|
9
|
+
* it as "activate the highlighted result" -- one key, no separate search
|
|
10
|
+
* button, matching how a filter box and an action list coexist elsewhere
|
|
11
|
+
* in this panel. */
|
|
12
|
+
export function shouldSearch(query: string, lastSearchedQuery: string | undefined, hasResults: boolean): boolean {
|
|
13
|
+
if (!hasResults) return query.trim().length > 0;
|
|
14
|
+
return query !== lastSearchedQuery;
|
|
15
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* discover.ts — /packed find: query the npm registry for installable Pi
|
|
3
|
+
* packages and install one. A sub-flow like settings/config, reachable
|
|
4
|
+
* from the packages panel via f and returning to it on close. Search
|
|
5
|
+
* itself never mutates; only Install/Cancel (behind the standard
|
|
6
|
+
* approval) does.
|
|
7
|
+
*/
|
|
8
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Container, Input, SelectList, type SelectItem, type SelectListTheme, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
11
|
+
import { shouldSearch } from "./discover-model.js";
|
|
12
|
+
import type { Natives, PackageSummary } from "./packed.js";
|
|
13
|
+
import { InstallServiceError } from "./packed.js";
|
|
14
|
+
import { approvePackageOperation } from "./tools.js";
|
|
15
|
+
|
|
16
|
+
const SEARCH_LIMIT = 20;
|
|
17
|
+
|
|
18
|
+
interface FindPanelAction {
|
|
19
|
+
type: "install" | "close";
|
|
20
|
+
result?: PackageSummary;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type InstallOutcome = "installed" | "cancelled" | "failed";
|
|
24
|
+
|
|
25
|
+
/** Approve-then-install-then-reload, mirroring applyPackageChoice's own
|
|
26
|
+
* shape for the packages panel's mutations. Best-effort service
|
|
27
|
+
* registration piggybacks on the same approval, same as the agent tool
|
|
28
|
+
* path (installPackageWithPolicy) -- most packages aren't daemons at all. */
|
|
29
|
+
export async function applyInstall(result: PackageSummary, natives: Natives, ctx: ExtensionCommandContext): Promise<InstallOutcome> {
|
|
30
|
+
const source = `npm:${result.name}`;
|
|
31
|
+
const approval = await approvePackageOperation("install", `pi install ${source}`, natives, ctx);
|
|
32
|
+
if (!approval.allowed) {
|
|
33
|
+
ctx.ui.notify(approval.message ?? "install denied", "warning");
|
|
34
|
+
return "cancelled";
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const output = (await natives.install(source, approval.approved)) || `Installed ${source}`;
|
|
38
|
+
try {
|
|
39
|
+
await natives.installService(source, approval.approved);
|
|
40
|
+
} catch (e) {
|
|
41
|
+
if (!(e instanceof InstallServiceError) || !e.notADaemon) {
|
|
42
|
+
ctx.ui.notify(`note: could not register a persistent service for ${result.name}: ${e instanceof Error ? e.message : e}`, "warning");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
ctx.ui.notify(`${output}; reloading Pi resources.`, "info");
|
|
46
|
+
await ctx.reload();
|
|
47
|
+
return "installed";
|
|
48
|
+
} catch (e) {
|
|
49
|
+
ctx.ui.notify(`install failed: ${e instanceof Error ? e.message : e}`, "error");
|
|
50
|
+
return "failed";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function selectListTheme(theme: Theme): SelectListTheme {
|
|
55
|
+
return {
|
|
56
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
57
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
58
|
+
description: (text) => theme.fg("muted", text),
|
|
59
|
+
scrollInfo: (text) => theme.fg("muted", text),
|
|
60
|
+
noMatch: (text) => theme.fg("muted", text),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function showInstallMenu(ctx: ExtensionCommandContext, result: PackageSummary): Promise<boolean> {
|
|
65
|
+
const items: SelectItem[] = [{ value: "install", label: `Install ${result.name}@${result.version}` }, { value: "cancel", label: "Cancel" }];
|
|
66
|
+
const choice = await ctx.ui.custom<"install" | "cancel" | undefined>(
|
|
67
|
+
(_tui, theme, _kb, done) => {
|
|
68
|
+
const list = new SelectList(items, items.length, selectListTheme(theme));
|
|
69
|
+
list.onSelect = (item) => done(item.value as "install" | "cancel");
|
|
70
|
+
list.onCancel = () => done(undefined);
|
|
71
|
+
return list;
|
|
72
|
+
},
|
|
73
|
+
{ overlay: true, overlayOptions: { width: 40, anchor: "center" } },
|
|
74
|
+
);
|
|
75
|
+
return choice === "install";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function showDiscoverPanel(ctx: ExtensionCommandContext, natives: Natives): Promise<void> {
|
|
79
|
+
if (!ctx.hasUI) {
|
|
80
|
+
ctx.ui.notify("/packed find requires interactive mode", "warning");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (;;) {
|
|
85
|
+
const action = await renderDiscoverPanel(ctx, natives);
|
|
86
|
+
if (action.type === "close") return;
|
|
87
|
+
if (!action.result) continue;
|
|
88
|
+
const install = await showInstallMenu(ctx, action.result);
|
|
89
|
+
if (!install) continue;
|
|
90
|
+
const outcome = await applyInstall(action.result, natives, ctx);
|
|
91
|
+
if (outcome === "installed") return; // ctx.reload() already replaced the session
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renderDiscoverPanel(ctx: ExtensionCommandContext, natives: Natives): Promise<FindPanelAction> {
|
|
96
|
+
return ctx.ui.custom<FindPanelAction>((tui, theme, _kb, done) => {
|
|
97
|
+
const queryInput = new Input();
|
|
98
|
+
let results: PackageSummary[] = [];
|
|
99
|
+
let lastSearchedQuery: string | undefined;
|
|
100
|
+
let selectedIndex = 0;
|
|
101
|
+
let searching = false;
|
|
102
|
+
let error: string | undefined;
|
|
103
|
+
const maxVisible = 15;
|
|
104
|
+
|
|
105
|
+
async function runSearch(): Promise<void> {
|
|
106
|
+
const query = queryInput.getValue().trim();
|
|
107
|
+
if (!query) return;
|
|
108
|
+
searching = true;
|
|
109
|
+
error = undefined;
|
|
110
|
+
tui.requestRender();
|
|
111
|
+
try {
|
|
112
|
+
const response = await natives.search(query, SEARCH_LIMIT);
|
|
113
|
+
results = response.results;
|
|
114
|
+
lastSearchedQuery = queryInput.getValue();
|
|
115
|
+
selectedIndex = 0;
|
|
116
|
+
} catch (e) {
|
|
117
|
+
error = e instanceof Error ? e.message : String(e);
|
|
118
|
+
results = [];
|
|
119
|
+
} finally {
|
|
120
|
+
searching = false;
|
|
121
|
+
tui.requestRender();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const header = {
|
|
126
|
+
invalidate() {},
|
|
127
|
+
render(width: number): string[] {
|
|
128
|
+
const title = theme.bold("Find packages");
|
|
129
|
+
const hint = rawKeyHint("enter", "search/install") + theme.fg("muted", " · ") + rawKeyHint("esc", "back");
|
|
130
|
+
const spacing = Math.max(1, width - visibleWidth(title) - visibleWidth(hint));
|
|
131
|
+
const line1 = truncateToWidth(`${title}${" ".repeat(spacing)}`, width, "") + hint;
|
|
132
|
+
const status = searching ? "searching…" : error ? theme.fg("error", error) : `${results.length} result(s)`;
|
|
133
|
+
const line2 = truncateToWidth(theme.fg("muted", status), width, "");
|
|
134
|
+
return [line1, line2];
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const list = {
|
|
139
|
+
invalidate() {},
|
|
140
|
+
render(width: number): string[] {
|
|
141
|
+
const lines = [...queryInput.render(width), ""];
|
|
142
|
+
if (results.length === 0) {
|
|
143
|
+
lines.push(theme.fg("muted", searching ? " …" : " Type a query and press enter to search npm"));
|
|
144
|
+
return lines;
|
|
145
|
+
}
|
|
146
|
+
const start = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), results.length - maxVisible));
|
|
147
|
+
const end = Math.min(start + maxVisible, results.length);
|
|
148
|
+
for (let i = start; i < end; i++) {
|
|
149
|
+
const result = results[i]!;
|
|
150
|
+
const selected = i === selectedIndex;
|
|
151
|
+
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
152
|
+
const name = selected ? theme.bold(result.name) : result.name;
|
|
153
|
+
const ver = theme.fg("dim", `@${result.version}`);
|
|
154
|
+
const description = result.description ? theme.fg("muted", ` — ${result.description}`) : "";
|
|
155
|
+
lines.push(truncateToWidth(`${cursor} ${name}${ver}${description}`, width, ""));
|
|
156
|
+
}
|
|
157
|
+
return lines;
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const container = new Container();
|
|
162
|
+
container.addChild(new Spacer(1));
|
|
163
|
+
container.addChild(new DynamicBorder());
|
|
164
|
+
container.addChild(new Spacer(1));
|
|
165
|
+
container.addChild(header);
|
|
166
|
+
container.addChild(new Spacer(1));
|
|
167
|
+
container.addChild(list);
|
|
168
|
+
container.addChild(new Spacer(1));
|
|
169
|
+
container.addChild(new DynamicBorder());
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
render: (width: number) => container.render(width),
|
|
173
|
+
invalidate: () => container.invalidate(),
|
|
174
|
+
handleInput(data: string) {
|
|
175
|
+
switch (data) {
|
|
176
|
+
case "\x1b[A": // up
|
|
177
|
+
if (results.length > 0) selectedIndex = (selectedIndex - 1 + results.length) % results.length;
|
|
178
|
+
break;
|
|
179
|
+
case "\x1b[B": // down
|
|
180
|
+
if (results.length > 0) selectedIndex = (selectedIndex + 1) % results.length;
|
|
181
|
+
break;
|
|
182
|
+
case "\r":
|
|
183
|
+
if (shouldSearch(queryInput.getValue(), lastSearchedQuery, results.length > 0)) {
|
|
184
|
+
void runSearch();
|
|
185
|
+
} else {
|
|
186
|
+
const result = results[selectedIndex];
|
|
187
|
+
if (result) done({ type: "install", result });
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
case "\x1b":
|
|
191
|
+
done({ type: "close" });
|
|
192
|
+
return;
|
|
193
|
+
default:
|
|
194
|
+
queryInput.handleInput(data);
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
tui.requestRender();
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
}
|
package/extension/src/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* pi-packed — Pi extension seam.
|
|
3
3
|
*
|
|
4
4
|
* Thin by design: registers agent tools (pkg_search/pkg_info/pkg_install/pkg_update/pkg_remove),
|
|
5
|
-
* the /
|
|
5
|
+
* the /packed command, and a session_start update notification. ALL logic
|
|
6
6
|
* lives in the Bun service (src/): registry access, caching, watcher,
|
|
7
7
|
* catalog sync, install execution.
|
|
8
8
|
*
|
|
@@ -10,10 +10,9 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { registerTools } from "./tools.js";
|
|
13
|
-
import {
|
|
13
|
+
import { showPackedPanel } from "./tui.js";
|
|
14
14
|
import { createNatives } from "./packed.js";
|
|
15
15
|
import { formatUpdateNotice } from "./model.js";
|
|
16
|
-
import { showPackedSettings } from "./security-tui.js";
|
|
17
16
|
import { registerProfiles } from "./profile.js";
|
|
18
17
|
import { handleSetupCommand } from "./setup-command.js";
|
|
19
18
|
import { handleResourceConfigCommand } from "./resource-config.js";
|
|
@@ -26,18 +25,11 @@ export default async function (pi: ExtensionAPI) {
|
|
|
26
25
|
registerTools(pi, natives);
|
|
27
26
|
|
|
28
27
|
pi.registerCommand("packed", {
|
|
29
|
-
description: "
|
|
28
|
+
description: "Browse and manage installed Pi packages -- u/U update, x remove, d disable, c config, f find, s settings -- or run setup plan/apply or config directly",
|
|
30
29
|
handler: async (args, ctx) => {
|
|
31
30
|
if (await handleSetupCommand(args, ctx, natives)) return;
|
|
32
31
|
if (await handleResourceConfigCommand(args, ctx, natives)) return;
|
|
33
|
-
await
|
|
34
|
-
},
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
pi.registerCommand("packages", {
|
|
38
|
-
description: "Browse and manage installed Pi packages (pi-packed)",
|
|
39
|
-
handler: async (_args, ctx) => {
|
|
40
|
-
await showPackages(ctx, natives);
|
|
32
|
+
await showPackedPanel(ctx, natives);
|
|
41
33
|
},
|
|
42
34
|
});
|
|
43
35
|
|
|
@@ -47,7 +39,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
47
39
|
try {
|
|
48
40
|
const updates = await natives.updates();
|
|
49
41
|
if (updates.length) {
|
|
50
|
-
ctx.ui.notify(`${formatUpdateNotice(updates)} — /
|
|
42
|
+
ctx.ui.notify(`${formatUpdateNotice(updates)} — /packed to review`, "info");
|
|
51
43
|
}
|
|
52
44
|
} catch {
|
|
53
45
|
// mirror missing or unreadable — stay silent, never block startup.
|
package/extension/src/model.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* model.ts — pure row logic for the /
|
|
2
|
+
* model.ts — pure row logic for the /packed panel. No I/O: vitest drives
|
|
3
3
|
* this directly (the TUI component is a thin shell over these functions).
|
|
4
4
|
*/
|
|
5
5
|
import type { InstalledPkg, UpdateEntry } from "./packed.js";
|
package/extension/src/packed.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { PI_COMMAND_NAME, type InstalledPackage, type PackageInfo, type PackageR
|
|
|
15
15
|
|
|
16
16
|
export { InstallServiceError, PI_COMMAND_NAME };
|
|
17
17
|
|
|
18
|
-
export type { PackageInfo, PackageResources, PiStatus, ResourceField, UpdateEntry, UpdateOutcome };
|
|
18
|
+
export type { PackageInfo, PackageResources, PackageSummary, PiStatus, ResourceField, UpdateEntry, UpdateOutcome };
|
|
19
19
|
export type InstalledPkg = InstalledPackage;
|
|
20
20
|
export type PackageDaemonPort = PackedExtensionClient;
|
|
21
21
|
type MutationApproval = SecuritySettings["mutationApproval"];
|
package/extension/src/reload.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* reload.ts — one shared decision for whether a Pi package mutation needs a
|
|
3
3
|
* reload to take effect, and the exact wording every mutation surface
|
|
4
|
-
* (native tools, the /
|
|
4
|
+
* (native tools, the /packed panel, and the resource overlay) uses to
|
|
5
5
|
* warn about it. Keeps three independent implementations from drifting
|
|
6
6
|
* apart on when and how they say a reload is coming.
|
|
7
7
|
*/
|
|
@@ -95,7 +95,7 @@ export async function handleResourceConfigCommand(args: string, ctx: ExtensionCo
|
|
|
95
95
|
return true;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
export async function showResourceConfig(ctx: ExtensionCommandContext, natives: Natives): Promise<void> {
|
|
98
|
+
export async function showResourceConfig(ctx: ExtensionCommandContext, natives: Natives, initialFilter?: string): Promise<void> {
|
|
99
99
|
if (!ctx.hasUI) {
|
|
100
100
|
ctx.ui.notify("/packed config requires interactive mode", "warning");
|
|
101
101
|
return;
|
|
@@ -109,9 +109,11 @@ export async function showResourceConfig(ctx: ExtensionCommandContext, natives:
|
|
|
109
109
|
|
|
110
110
|
let scope: Scope = "global";
|
|
111
111
|
let pendingReload = false;
|
|
112
|
+
let filter = initialFilter ?? "";
|
|
112
113
|
|
|
113
114
|
for (;;) {
|
|
114
|
-
const action = await renderConfig(ctx, data, scope);
|
|
115
|
+
const action = await renderConfig(ctx, data, scope, filter);
|
|
116
|
+
filter = ""; // only seeds the very first open -- a later refresh/switch starts unfiltered
|
|
115
117
|
if (action.type === "close") break;
|
|
116
118
|
if (action.type === "switch") { scope = scope === "global" ? "project" : "global"; continue; }
|
|
117
119
|
if (action.type === "refresh") {
|
|
@@ -135,12 +137,13 @@ export async function showResourceConfig(ctx: ExtensionCommandContext, natives:
|
|
|
135
137
|
else ctx.ui.notify("Extension changes pending -- run /reload when ready.", "warning");
|
|
136
138
|
}
|
|
137
139
|
|
|
138
|
-
function renderConfig(ctx: ExtensionCommandContext, data: { global: PackageResources[]; project: PackageResources[] }, scope: Scope): Promise<PanelAction> {
|
|
140
|
+
function renderConfig(ctx: ExtensionCommandContext, data: { global: PackageResources[]; project: PackageResources[] }, scope: Scope, initialFilter = ""): Promise<PanelAction> {
|
|
139
141
|
return ctx.ui.custom<PanelAction>((tui, theme, _kb, done) => {
|
|
140
142
|
const searchInput = new Input();
|
|
143
|
+
if (initialFilter) searchInput.setValue(initialFilter);
|
|
141
144
|
let searchActive = false;
|
|
142
145
|
const items = flatten(scope === "global" ? data.global : data.project, scope);
|
|
143
|
-
let filtered = items;
|
|
146
|
+
let filtered = initialFilter ? filterItems(items, initialFilter) : items;
|
|
144
147
|
let selectedIndex = 0;
|
|
145
148
|
const maxVisible = 20;
|
|
146
149
|
|
package/extension/src/tui.ts
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* tui.ts — /
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* tui.ts — /packed's default panel. Mnemonics follow lazy.nvim's own
|
|
3
|
+
* convention (folke/lazy.nvim lua/lazy/view/config.lua): uppercase acts on
|
|
4
|
+
* every row, lowercase acts on the row under the cursor -- U/u update,
|
|
5
|
+
* X/x was lazy's delete key (clean); here that pairing is single-target
|
|
6
|
+
* only (x remove -- there is no safe "remove every installed package"
|
|
7
|
+
* bulk analog, so no X is bound). Adds d disable/enable this package's
|
|
8
|
+
* extensions, c jump to full resource config, f find/install new
|
|
9
|
+
* packages, s settings. Enter opens a floating (`ctx.ui.custom` with
|
|
10
|
+
* overlay:true) action menu instead of a full-screen prompt, so the list
|
|
11
|
+
* underneath never disappears. All data flows through the packed CLI
|
|
12
|
+
* (thin seam).
|
|
6
13
|
*/
|
|
7
|
-
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
8
15
|
import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
16
|
+
import { Container, Input, SelectList, type SelectItem, type SelectListTheme, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
17
|
import { filterRows, mergeRows, nextMode, visibleRows } from "./model.js";
|
|
11
18
|
import type { Row, ViewMode } from "./model.js";
|
|
12
|
-
import type { Natives } from "./packed.js";
|
|
19
|
+
import type { Natives, PackageResources } from "./packed.js";
|
|
13
20
|
import { approvePackageOperation } from "./tools.js";
|
|
21
|
+
import { showPackedSettings } from "./security-tui.js";
|
|
22
|
+
import { showResourceConfig, applyResourceToggle } from "./resource-config.js";
|
|
23
|
+
import { showDiscoverPanel } from "./discover.js";
|
|
14
24
|
|
|
15
25
|
interface PanelAction {
|
|
16
|
-
type: "
|
|
26
|
+
type: "update" | "updateAll" | "remove" | "disable" | "config" | "find" | "refresh" | "settings";
|
|
17
27
|
row?: Row;
|
|
18
28
|
}
|
|
19
29
|
|
|
@@ -74,6 +84,83 @@ export async function applyPackageChoice(
|
|
|
74
84
|
return "cancelled";
|
|
75
85
|
}
|
|
76
86
|
|
|
87
|
+
/** U -- update every outdated row with one combined approval and one
|
|
88
|
+
* reload, instead of applyPackageChoice's own per-call reload (which
|
|
89
|
+
* would end the session after the first successful update). */
|
|
90
|
+
export async function applyUpdateAll(rows: Row[], natives: Natives, ctx: ExtensionCommandContext): Promise<PackageChoiceOutcome> {
|
|
91
|
+
const outdated = rows.filter((row) => row.hasUpdate);
|
|
92
|
+
if (outdated.length === 0) {
|
|
93
|
+
ctx.ui.notify("Nothing to update.", "info");
|
|
94
|
+
return "unchanged";
|
|
95
|
+
}
|
|
96
|
+
const approval = await approvePackageOperation(
|
|
97
|
+
"update",
|
|
98
|
+
`pi update --extension ${outdated.map((row) => `npm:${row.name}`).join(" ")}`,
|
|
99
|
+
natives,
|
|
100
|
+
ctx,
|
|
101
|
+
);
|
|
102
|
+
if (!approval.allowed) {
|
|
103
|
+
ctx.ui.notify(approval.message ?? "update denied", "warning");
|
|
104
|
+
return "cancelled";
|
|
105
|
+
}
|
|
106
|
+
ctx.ui.notify(`Updating ${outdated.length} package(s)…`, "info");
|
|
107
|
+
let changed = 0;
|
|
108
|
+
let failed = 0;
|
|
109
|
+
for (const row of outdated) {
|
|
110
|
+
try {
|
|
111
|
+
const outcome = await natives.update(`npm:${row.name}`, approval.approved);
|
|
112
|
+
if (outcome.reloadRequired) changed += 1;
|
|
113
|
+
} catch (e) {
|
|
114
|
+
failed += 1;
|
|
115
|
+
ctx.ui.notify(`${row.name} update failed: ${e instanceof Error ? e.message : e}`, "error");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (changed === 0) {
|
|
119
|
+
ctx.ui.notify(failed > 0 ? `No packages updated; ${failed} failed.` : "All packages already up to date.", failed > 0 ? "warning" : "info");
|
|
120
|
+
return failed > 0 ? "cancelled" : "unchanged";
|
|
121
|
+
}
|
|
122
|
+
ctx.ui.notify(`Updated ${changed} package(s)${failed > 0 ? `, ${failed} failed` : ""}; reloading Pi resources.`, "info");
|
|
123
|
+
await ctx.reload();
|
|
124
|
+
return "changed";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** d -- toggles every declared extension of this package on or off in one
|
|
128
|
+
* step (disables if any are enabled, otherwise re-enables all). Reuses
|
|
129
|
+
* applyResourceToggle per item for its own tested approval/mutation path
|
|
130
|
+
* rather than a bespoke bulk mutation -- the common case is one extension
|
|
131
|
+
* per package, so this rarely shows more than a single confirm. */
|
|
132
|
+
export async function applyDisableExtensions(row: Row, natives: Natives, ctx: ExtensionCommandContext): Promise<PackageChoiceOutcome> {
|
|
133
|
+
let data: { global: PackageResources[]; project: PackageResources[] };
|
|
134
|
+
try {
|
|
135
|
+
data = await natives.listResources();
|
|
136
|
+
} catch (e) {
|
|
137
|
+
ctx.ui.notify(`packed unavailable: ${e instanceof Error ? e.message : e}`, "error");
|
|
138
|
+
return "cancelled";
|
|
139
|
+
}
|
|
140
|
+
const group = data.global.find((candidate) => candidate.name === row.name);
|
|
141
|
+
const extensions = group?.extensions ?? [];
|
|
142
|
+
if (!group || extensions.length === 0) {
|
|
143
|
+
ctx.ui.notify(`${row.name} declares no extensions to disable.`, "info");
|
|
144
|
+
return "unchanged";
|
|
145
|
+
}
|
|
146
|
+
const disabling = extensions.some((item) => item.enabled);
|
|
147
|
+
const targets = extensions.filter((item) => item.enabled === disabling);
|
|
148
|
+
let toggled = 0;
|
|
149
|
+
for (const item of targets) {
|
|
150
|
+
const outcome = await applyResourceToggle(
|
|
151
|
+
{ scope: "global", source: group.source, packageName: group.name, field: "extensions", path: item.path, enabled: item.enabled },
|
|
152
|
+
natives,
|
|
153
|
+
ctx,
|
|
154
|
+
);
|
|
155
|
+
if (outcome === "toggled") toggled += 1;
|
|
156
|
+
else if (outcome === "cancelled") return toggled > 0 ? "changed" : "cancelled";
|
|
157
|
+
}
|
|
158
|
+
if (toggled === 0) return "unchanged";
|
|
159
|
+
ctx.ui.notify(`${disabling ? "Disabled" : "Enabled"} ${toggled} extension(s) for ${row.name}; reloading Pi resources.`, "info");
|
|
160
|
+
await ctx.reload();
|
|
161
|
+
return "changed";
|
|
162
|
+
}
|
|
163
|
+
|
|
77
164
|
async function loadRows(natives: Natives): Promise<{ rows: Row[]; error?: string }> {
|
|
78
165
|
try {
|
|
79
166
|
const [installed, updates] = await Promise.all([
|
|
@@ -86,9 +173,40 @@ async function loadRows(natives: Natives): Promise<{ rows: Row[]; error?: string
|
|
|
86
173
|
}
|
|
87
174
|
}
|
|
88
175
|
|
|
89
|
-
|
|
176
|
+
function selectListTheme(theme: Theme): SelectListTheme {
|
|
177
|
+
return {
|
|
178
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
179
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
180
|
+
description: (text) => theme.fg("muted", text),
|
|
181
|
+
scrollInfo: (text) => theme.fg("muted", text),
|
|
182
|
+
noMatch: (text) => theme.fg("muted", text),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Enter's action menu, floated on top of the still-open packages panel
|
|
187
|
+
* via ctx.ui.custom's own overlay:true -- no full-screen teardown, unlike
|
|
188
|
+
* the ctx.ui.select this replaced. */
|
|
189
|
+
async function showActionMenu(ctx: ExtensionCommandContext, row: Row): Promise<"update" | "remove" | "disable" | "config" | undefined> {
|
|
190
|
+
const items: SelectItem[] = [
|
|
191
|
+
...(row.hasUpdate ? [{ value: "update", label: `Update to ${row.latest}` }] : []),
|
|
192
|
+
{ value: "disable", label: "Disable/enable extensions" },
|
|
193
|
+
{ value: "config", label: "Configure resources" },
|
|
194
|
+
{ value: "remove", label: "Remove" },
|
|
195
|
+
];
|
|
196
|
+
return ctx.ui.custom<"update" | "remove" | "disable" | "config" | undefined>(
|
|
197
|
+
(_tui, theme, _kb, done) => {
|
|
198
|
+
const list = new SelectList(items, items.length, selectListTheme(theme));
|
|
199
|
+
list.onSelect = (item) => done(item.value as "update" | "remove" | "disable" | "config");
|
|
200
|
+
list.onCancel = () => done(undefined);
|
|
201
|
+
return list;
|
|
202
|
+
},
|
|
203
|
+
{ overlay: true, overlayOptions: { width: 40, anchor: "center" } },
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function showPackedPanel(ctx: ExtensionCommandContext, natives: Natives): Promise<void> {
|
|
90
208
|
if (!ctx.hasUI) {
|
|
91
|
-
ctx.ui.notify("/
|
|
209
|
+
ctx.ui.notify("/packed requires interactive mode", "warning");
|
|
92
210
|
return;
|
|
93
211
|
}
|
|
94
212
|
|
|
@@ -109,19 +227,37 @@ export async function showPackages(ctx: ExtensionCommandContext, natives: Native
|
|
|
109
227
|
continue;
|
|
110
228
|
}
|
|
111
229
|
|
|
230
|
+
if (action.type === "settings") {
|
|
231
|
+
await showPackedSettings(ctx, natives);
|
|
232
|
+
continue; // settings never changes package rows -- reopen as-is
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (action.type === "find") {
|
|
236
|
+
await showDiscoverPanel(ctx, natives);
|
|
237
|
+
continue; // a successful install already reloaded; a no-op returns here
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (action.type === "updateAll") {
|
|
241
|
+
const outcome = await applyUpdateAll(rows, natives, ctx);
|
|
242
|
+
if (outcome === "changed") return; // ctx.reload() already replaced the session
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (action.type === "config") {
|
|
247
|
+
await showResourceConfig(ctx, natives, action.row?.name);
|
|
248
|
+
continue; // showResourceConfig already handles its own reload prompt
|
|
249
|
+
}
|
|
250
|
+
|
|
112
251
|
const row = action.row;
|
|
113
252
|
if (!row) continue;
|
|
114
253
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
"Cancel",
|
|
121
|
-
],
|
|
122
|
-
);
|
|
254
|
+
if (action.type === "disable") {
|
|
255
|
+
const outcome = await applyDisableExtensions(row, natives, ctx);
|
|
256
|
+
if (outcome === "changed") return;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
123
259
|
|
|
124
|
-
const outcome = await applyPackageChoice(
|
|
260
|
+
const outcome = await applyPackageChoice(action.type === "update" ? `Update to ${row.latest}` : "Remove", row, natives, ctx);
|
|
125
261
|
if (outcome === "changed") return; // ctx.reload() already replaced the session
|
|
126
262
|
}
|
|
127
263
|
}
|
|
@@ -149,18 +285,26 @@ function renderPanel(ctx: ExtensionCommandContext, rows: Row[]): Promise<PanelAc
|
|
|
149
285
|
const badge = outdated > 0 ? theme.fg("warning", ` ${outdated} update(s)`) : "";
|
|
150
286
|
const hint = searchActive
|
|
151
287
|
? rawKeyHint("esc", "clear")
|
|
152
|
-
: rawKeyHint("enter", "
|
|
288
|
+
: rawKeyHint("enter", "menu") +
|
|
153
289
|
theme.fg("muted", " · ") +
|
|
154
|
-
rawKeyHint("/", "
|
|
290
|
+
rawKeyHint("u/U", "update/all") +
|
|
155
291
|
theme.fg("muted", " · ") +
|
|
156
|
-
rawKeyHint("
|
|
292
|
+
rawKeyHint("x", "remove") +
|
|
293
|
+
theme.fg("muted", " · ") +
|
|
294
|
+
rawKeyHint("d", "disable") +
|
|
295
|
+
theme.fg("muted", " · ") +
|
|
296
|
+
rawKeyHint("c", "config") +
|
|
297
|
+
theme.fg("muted", " · ") +
|
|
298
|
+
rawKeyHint("f", "find") +
|
|
299
|
+
theme.fg("muted", " · ") +
|
|
300
|
+
rawKeyHint("s", "settings") +
|
|
157
301
|
theme.fg("muted", " · ") +
|
|
158
302
|
rawKeyHint("esc", "close");
|
|
159
303
|
const spacing = Math.max(1, width - visibleWidth(title) - visibleWidth(badge) - visibleWidth(hint));
|
|
160
304
|
const line1 = truncateToWidth(`${title}${badge}${" ".repeat(spacing)}${hint}`, width, "");
|
|
161
305
|
const dot = "·";
|
|
162
306
|
const line2 = truncateToWidth(
|
|
163
|
-
theme.fg("muted", `view: ${mode} ${dot} r refresh ${dot} ${rows.length} installed`),
|
|
307
|
+
theme.fg("muted", `view: ${mode} ${dot} / filter ${dot} tab view ${dot} r refresh ${dot} ${rows.length} installed`),
|
|
164
308
|
width,
|
|
165
309
|
"",
|
|
166
310
|
);
|
|
@@ -241,9 +385,45 @@ function renderPanel(ctx: ExtensionCommandContext, rows: Row[]): Promise<PanelAc
|
|
|
241
385
|
case "r":
|
|
242
386
|
done({ type: "refresh" });
|
|
243
387
|
return;
|
|
388
|
+
case "s":
|
|
389
|
+
done({ type: "settings" });
|
|
390
|
+
return;
|
|
391
|
+
case "f":
|
|
392
|
+
done({ type: "find" });
|
|
393
|
+
return;
|
|
394
|
+
case "U":
|
|
395
|
+
done({ type: "updateAll" });
|
|
396
|
+
return;
|
|
397
|
+
case "u": {
|
|
398
|
+
const row = filtered[selectedIndex];
|
|
399
|
+
if (row?.hasUpdate) done({ type: "update", row });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
case "x": {
|
|
403
|
+
const row = filtered[selectedIndex];
|
|
404
|
+
if (row) done({ type: "remove", row });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
case "d": {
|
|
408
|
+
const row = filtered[selectedIndex];
|
|
409
|
+
if (row) done({ type: "disable", row });
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
case "c": {
|
|
413
|
+
const row = filtered[selectedIndex];
|
|
414
|
+
if (row) done({ type: "config", row });
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
244
417
|
case "\r": {
|
|
245
418
|
const row = filtered[selectedIndex];
|
|
246
|
-
if (row)
|
|
419
|
+
if (!row) return;
|
|
420
|
+
void (async () => {
|
|
421
|
+
const choice = await showActionMenu(ctx, row);
|
|
422
|
+
if (choice === "update") done({ type: "update", row });
|
|
423
|
+
else if (choice === "remove") done({ type: "remove", row });
|
|
424
|
+
else if (choice === "disable") done({ type: "disable", row });
|
|
425
|
+
else if (choice === "config") done({ type: "config", row });
|
|
426
|
+
})();
|
|
247
427
|
return;
|
|
248
428
|
}
|
|
249
429
|
case "\x1b":
|