@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-packed
2
2
 
3
- Pi integration for `@danypops/packed`: package tools, `/packages`, `/packed`, setup apply/reload, and named profiles.
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
- - `/packages` -- an interactive panel listing every installed Pi package, with update availability. Select one to update or remove it; `r` refreshes, `/` filters, `Tab` cycles view modes.
14
- - `/packed` -- package mutation-approval settings: require confirmation before install/update/remove/security changes (the default), or turn it off.
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
+ }
@@ -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 /packages command, and a session_start update notification. ALL logic
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 { showPackages } from "./tui.js";
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: "Configure pi-packed security settings, run setup plan/apply, or manage resources with config",
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 showPackedSettings(ctx, natives);
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)} — /packages to review`, "info");
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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * model.ts — pure row logic for the /packages panel. No I/O: vitest drives
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";
@@ -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"];
@@ -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 /packages panel, and the resource overlay) uses to
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
 
@@ -1,19 +1,29 @@
1
1
  /**
2
- * tui.ts — /packages interactive panel. Follows the pi-extension-manager
3
- * idiom: ctx.ui.custom with Container/DynamicBorder layout, header hints,
4
- * type-to-filter (/), Tab view modes, Enter actions, r refresh, esc close.
5
- * All data flows through the packed CLI (thin seam).
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: "menu" | "refresh";
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
- export async function showPackages(ctx: ExtensionCommandContext, natives: Natives): Promise<void> {
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("/packages requires interactive mode", "warning");
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
- const choice = await ctx.ui.select(
116
- `${row.name}@${row.version}${row.hasUpdate ? ` → ${row.latest}` : ""}`,
117
- [
118
- ...(row.hasUpdate ? [`Update to ${row.latest}`] : []),
119
- "Remove",
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(choice, row, natives, ctx);
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", "actions") +
288
+ : rawKeyHint("enter", "menu") +
153
289
  theme.fg("muted", " · ") +
154
- rawKeyHint("/", "filter") +
290
+ rawKeyHint("u/U", "update/all") +
155
291
  theme.fg("muted", " · ") +
156
- rawKeyHint("tab", "view") +
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) done({ type: "menu", 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":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-packed",
3
- "version": "0.6.3",
3
+ "version": "0.8.0",
4
4
  "description": "Pi package tools, commands, profiles, and TUI for the Packed daemon",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],