@danypops/pi-packed 0.7.0 → 0.9.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
@@ -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
- - `/packed` -- opens on the packages panel: every installed Pi package, with update availability. Select one to update or remove it; `r` refreshes, `/` filters, `Tab` cycles view modes, `s` opens settings.
14
- - `/packed` settings (`s` from the panel) -- 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,192 @@
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 } from "@earendil-works/pi-coding-agent";
9
+ import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
10
+ import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
+ import { Menu, type MenuItem } from "malevich-tui-components";
12
+ import { shouldSearch } from "./discover-model.js";
13
+ import type { Natives, PackageSummary } from "./packed.js";
14
+ import { InstallServiceError } from "./packed.js";
15
+ import { approvePackageOperation } from "./tools.js";
16
+ import { menuTheme } from "./menu-theme.js";
17
+
18
+ const SEARCH_LIMIT = 20;
19
+
20
+ interface FindPanelAction {
21
+ type: "install" | "close";
22
+ result?: PackageSummary;
23
+ }
24
+
25
+ export type InstallOutcome = "installed" | "cancelled" | "failed";
26
+
27
+ /** Approve-then-install-then-reload, mirroring applyPackageChoice's own
28
+ * shape for the packages panel's mutations. Best-effort service
29
+ * registration piggybacks on the same approval, same as the agent tool
30
+ * path (installPackageWithPolicy) -- most packages aren't daemons at all. */
31
+ export async function applyInstall(result: PackageSummary, natives: Natives, ctx: ExtensionCommandContext): Promise<InstallOutcome> {
32
+ const source = `npm:${result.name}`;
33
+ const approval = await approvePackageOperation("install", `pi install ${source}`, natives, ctx);
34
+ if (!approval.allowed) {
35
+ ctx.ui.notify(approval.message ?? "install denied", "warning");
36
+ return "cancelled";
37
+ }
38
+ try {
39
+ const output = (await natives.install(source, approval.approved)) || `Installed ${source}`;
40
+ try {
41
+ await natives.installService(source, approval.approved);
42
+ } catch (e) {
43
+ if (!(e instanceof InstallServiceError) || !e.notADaemon) {
44
+ ctx.ui.notify(`note: could not register a persistent service for ${result.name}: ${e instanceof Error ? e.message : e}`, "warning");
45
+ }
46
+ }
47
+ ctx.ui.notify(`${output}; reloading Pi resources.`, "info");
48
+ await ctx.reload();
49
+ return "installed";
50
+ } catch (e) {
51
+ ctx.ui.notify(`install failed: ${e instanceof Error ? e.message : e}`, "error");
52
+ return "failed";
53
+ }
54
+ }
55
+
56
+ async function showInstallMenu(ctx: ExtensionCommandContext, result: PackageSummary): Promise<boolean> {
57
+ return ctx.ui.custom<boolean>(
58
+ (_tui, theme, _kb, done) => {
59
+ const items: MenuItem[] = [
60
+ { label: `Install ${result.name}@${result.version}`, action: () => done(true) },
61
+ { label: "Cancel", action: () => done(false) },
62
+ ];
63
+ return new Menu({ items, theme: menuTheme(theme), onClose: () => done(false) });
64
+ },
65
+ { overlay: true, overlayOptions: { width: 40, anchor: "center" } },
66
+ );
67
+ }
68
+
69
+ export async function showDiscoverPanel(ctx: ExtensionCommandContext, natives: Natives): Promise<void> {
70
+ if (!ctx.hasUI) {
71
+ ctx.ui.notify("/packed find requires interactive mode", "warning");
72
+ return;
73
+ }
74
+
75
+ for (;;) {
76
+ const action = await renderDiscoverPanel(ctx, natives);
77
+ if (action.type === "close") return;
78
+ if (!action.result) continue;
79
+ const install = await showInstallMenu(ctx, action.result);
80
+ if (!install) continue;
81
+ const outcome = await applyInstall(action.result, natives, ctx);
82
+ if (outcome === "installed") return; // ctx.reload() already replaced the session
83
+ }
84
+ }
85
+
86
+ function renderDiscoverPanel(ctx: ExtensionCommandContext, natives: Natives): Promise<FindPanelAction> {
87
+ return ctx.ui.custom<FindPanelAction>((tui, theme, _kb, done) => {
88
+ const queryInput = new Input();
89
+ let results: PackageSummary[] = [];
90
+ let lastSearchedQuery: string | undefined;
91
+ let selectedIndex = 0;
92
+ let searching = false;
93
+ let error: string | undefined;
94
+ const maxVisible = 15;
95
+
96
+ async function runSearch(): Promise<void> {
97
+ const query = queryInput.getValue().trim();
98
+ if (!query) return;
99
+ searching = true;
100
+ error = undefined;
101
+ tui.requestRender();
102
+ try {
103
+ const response = await natives.search(query, SEARCH_LIMIT);
104
+ results = response.results;
105
+ lastSearchedQuery = queryInput.getValue();
106
+ selectedIndex = 0;
107
+ } catch (e) {
108
+ error = e instanceof Error ? e.message : String(e);
109
+ results = [];
110
+ } finally {
111
+ searching = false;
112
+ tui.requestRender();
113
+ }
114
+ }
115
+
116
+ const header = {
117
+ invalidate() {},
118
+ render(width: number): string[] {
119
+ const title = theme.bold("Find packages");
120
+ const hint = rawKeyHint("enter", "search/install") + theme.fg("muted", " · ") + rawKeyHint("esc", "back");
121
+ const spacing = Math.max(1, width - visibleWidth(title) - visibleWidth(hint));
122
+ const line1 = truncateToWidth(`${title}${" ".repeat(spacing)}`, width, "") + hint;
123
+ const status = searching ? "searching…" : error ? theme.fg("error", error) : `${results.length} result(s)`;
124
+ const line2 = truncateToWidth(theme.fg("muted", status), width, "");
125
+ return [line1, line2];
126
+ },
127
+ };
128
+
129
+ const list = {
130
+ invalidate() {},
131
+ render(width: number): string[] {
132
+ const lines = [...queryInput.render(width), ""];
133
+ if (results.length === 0) {
134
+ lines.push(theme.fg("muted", searching ? " …" : " Type a query and press enter to search npm"));
135
+ return lines;
136
+ }
137
+ const start = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), results.length - maxVisible));
138
+ const end = Math.min(start + maxVisible, results.length);
139
+ for (let i = start; i < end; i++) {
140
+ const result = results[i]!;
141
+ const selected = i === selectedIndex;
142
+ const cursor = selected ? theme.fg("accent", "❯") : " ";
143
+ const name = selected ? theme.bold(result.name) : result.name;
144
+ const ver = theme.fg("dim", `@${result.version}`);
145
+ const description = result.description ? theme.fg("muted", ` — ${result.description}`) : "";
146
+ lines.push(truncateToWidth(`${cursor} ${name}${ver}${description}`, width, ""));
147
+ }
148
+ return lines;
149
+ },
150
+ };
151
+
152
+ const container = new Container();
153
+ container.addChild(new Spacer(1));
154
+ container.addChild(new DynamicBorder());
155
+ container.addChild(new Spacer(1));
156
+ container.addChild(header);
157
+ container.addChild(new Spacer(1));
158
+ container.addChild(list);
159
+ container.addChild(new Spacer(1));
160
+ container.addChild(new DynamicBorder());
161
+
162
+ return {
163
+ render: (width: number) => container.render(width),
164
+ invalidate: () => container.invalidate(),
165
+ handleInput(data: string) {
166
+ switch (data) {
167
+ case "\x1b[A": // up
168
+ if (results.length > 0) selectedIndex = (selectedIndex - 1 + results.length) % results.length;
169
+ break;
170
+ case "\x1b[B": // down
171
+ if (results.length > 0) selectedIndex = (selectedIndex + 1) % results.length;
172
+ break;
173
+ case "\r":
174
+ if (shouldSearch(queryInput.getValue(), lastSearchedQuery, results.length > 0)) {
175
+ void runSearch();
176
+ } else {
177
+ const result = results[selectedIndex];
178
+ if (result) done({ type: "install", result });
179
+ }
180
+ return;
181
+ case "\x1b":
182
+ done({ type: "close" });
183
+ return;
184
+ default:
185
+ queryInput.handleInput(data);
186
+ break;
187
+ }
188
+ tui.requestRender();
189
+ },
190
+ };
191
+ });
192
+ }
@@ -25,7 +25,7 @@ export default async function (pi: ExtensionAPI) {
25
25
  registerTools(pi, natives);
26
26
 
27
27
  pi.registerCommand("packed", {
28
- description: "Browse and manage installed Pi packages; press s for settings, or run setup plan/apply or config directly",
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",
29
29
  handler: async (args, ctx) => {
30
30
  if (await handleSetupCommand(args, ctx, natives)) return;
31
31
  if (await handleResourceConfigCommand(args, ctx, natives)) return;
@@ -0,0 +1,14 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { MenuTheme } from "malevich-tui-components";
3
+
4
+ /** Maps Pi's own Theme onto Malevich's Menu -- the one place this mapping
5
+ * exists, shared by every ctx.ui.custom overlay menu in this extension. */
6
+ export function menuTheme(theme: Theme): MenuTheme {
7
+ return {
8
+ border: (s) => theme.fg("border", s),
9
+ selected: (s) => theme.fg("accent", s),
10
+ normal: (s) => s,
11
+ dim: (s) => theme.fg("muted", s),
12
+ title: (s) => theme.fg("accent", s),
13
+ };
14
+ }
@@ -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"];
@@ -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,23 +1,31 @@
1
1
  /**
2
- * tui.ts — /packed's default panel. Follows the pi-extension-manager idiom:
3
- * ctx.ui.custom with Container/DynamicBorder layout, header hints,
4
- * type-to-filter (/), Tab view modes, Enter actions, r refresh, s
5
- * settings, esc close. Packages is the landing page; s opens settings
6
- * (mutation approval) as a sub-flow and returns to the same panel
7
- * afterward, rather than a second rendered page. All data flows through
8
- * 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).
9
13
  */
10
14
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
11
15
  import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
12
16
  import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
17
+ import { Menu, type MenuItem } from "malevich-tui-components";
13
18
  import { filterRows, mergeRows, nextMode, visibleRows } from "./model.js";
14
19
  import type { Row, ViewMode } from "./model.js";
15
- import type { Natives } from "./packed.js";
20
+ import type { Natives, PackageResources } from "./packed.js";
16
21
  import { approvePackageOperation } from "./tools.js";
17
22
  import { showPackedSettings } from "./security-tui.js";
23
+ import { showResourceConfig, applyResourceToggle } from "./resource-config.js";
24
+ import { showDiscoverPanel } from "./discover.js";
25
+ import { menuTheme } from "./menu-theme.js";
18
26
 
19
27
  interface PanelAction {
20
- type: "menu" | "refresh" | "settings";
28
+ type: "update" | "updateAll" | "remove" | "disable" | "config" | "find" | "refresh" | "settings";
21
29
  row?: Row;
22
30
  }
23
31
 
@@ -78,6 +86,83 @@ export async function applyPackageChoice(
78
86
  return "cancelled";
79
87
  }
80
88
 
89
+ /** U -- update every outdated row with one combined approval and one
90
+ * reload, instead of applyPackageChoice's own per-call reload (which
91
+ * would end the session after the first successful update). */
92
+ export async function applyUpdateAll(rows: Row[], natives: Natives, ctx: ExtensionCommandContext): Promise<PackageChoiceOutcome> {
93
+ const outdated = rows.filter((row) => row.hasUpdate);
94
+ if (outdated.length === 0) {
95
+ ctx.ui.notify("Nothing to update.", "info");
96
+ return "unchanged";
97
+ }
98
+ const approval = await approvePackageOperation(
99
+ "update",
100
+ `pi update --extension ${outdated.map((row) => `npm:${row.name}`).join(" ")}`,
101
+ natives,
102
+ ctx,
103
+ );
104
+ if (!approval.allowed) {
105
+ ctx.ui.notify(approval.message ?? "update denied", "warning");
106
+ return "cancelled";
107
+ }
108
+ ctx.ui.notify(`Updating ${outdated.length} package(s)…`, "info");
109
+ let changed = 0;
110
+ let failed = 0;
111
+ for (const row of outdated) {
112
+ try {
113
+ const outcome = await natives.update(`npm:${row.name}`, approval.approved);
114
+ if (outcome.reloadRequired) changed += 1;
115
+ } catch (e) {
116
+ failed += 1;
117
+ ctx.ui.notify(`${row.name} update failed: ${e instanceof Error ? e.message : e}`, "error");
118
+ }
119
+ }
120
+ if (changed === 0) {
121
+ ctx.ui.notify(failed > 0 ? `No packages updated; ${failed} failed.` : "All packages already up to date.", failed > 0 ? "warning" : "info");
122
+ return failed > 0 ? "cancelled" : "unchanged";
123
+ }
124
+ ctx.ui.notify(`Updated ${changed} package(s)${failed > 0 ? `, ${failed} failed` : ""}; reloading Pi resources.`, "info");
125
+ await ctx.reload();
126
+ return "changed";
127
+ }
128
+
129
+ /** d -- toggles every declared extension of this package on or off in one
130
+ * step (disables if any are enabled, otherwise re-enables all). Reuses
131
+ * applyResourceToggle per item for its own tested approval/mutation path
132
+ * rather than a bespoke bulk mutation -- the common case is one extension
133
+ * per package, so this rarely shows more than a single confirm. */
134
+ export async function applyDisableExtensions(row: Row, natives: Natives, ctx: ExtensionCommandContext): Promise<PackageChoiceOutcome> {
135
+ let data: { global: PackageResources[]; project: PackageResources[] };
136
+ try {
137
+ data = await natives.listResources();
138
+ } catch (e) {
139
+ ctx.ui.notify(`packed unavailable: ${e instanceof Error ? e.message : e}`, "error");
140
+ return "cancelled";
141
+ }
142
+ const group = data.global.find((candidate) => candidate.name === row.name);
143
+ const extensions = group?.extensions ?? [];
144
+ if (!group || extensions.length === 0) {
145
+ ctx.ui.notify(`${row.name} declares no extensions to disable.`, "info");
146
+ return "unchanged";
147
+ }
148
+ const disabling = extensions.some((item) => item.enabled);
149
+ const targets = extensions.filter((item) => item.enabled === disabling);
150
+ let toggled = 0;
151
+ for (const item of targets) {
152
+ const outcome = await applyResourceToggle(
153
+ { scope: "global", source: group.source, packageName: group.name, field: "extensions", path: item.path, enabled: item.enabled },
154
+ natives,
155
+ ctx,
156
+ );
157
+ if (outcome === "toggled") toggled += 1;
158
+ else if (outcome === "cancelled") return toggled > 0 ? "changed" : "cancelled";
159
+ }
160
+ if (toggled === 0) return "unchanged";
161
+ ctx.ui.notify(`${disabling ? "Disabled" : "Enabled"} ${toggled} extension(s) for ${row.name}; reloading Pi resources.`, "info");
162
+ await ctx.reload();
163
+ return "changed";
164
+ }
165
+
81
166
  async function loadRows(natives: Natives): Promise<{ rows: Row[]; error?: string }> {
82
167
  try {
83
168
  const [installed, updates] = await Promise.all([
@@ -90,6 +175,25 @@ async function loadRows(natives: Natives): Promise<{ rows: Row[]; error?: string
90
175
  }
91
176
  }
92
177
 
178
+ /** Enter's action menu, floated on top of the still-open packages panel
179
+ * via ctx.ui.custom's own overlay:true -- no full-screen teardown, unlike
180
+ * the ctx.ui.select this replaced. */
181
+ async function showActionMenu(ctx: ExtensionCommandContext, row: Row): Promise<"update" | "remove" | "disable" | "config" | undefined> {
182
+ type Choice = "update" | "remove" | "disable" | "config";
183
+ return ctx.ui.custom<Choice | undefined>(
184
+ (_tui, theme, _kb, done) => {
185
+ const items: MenuItem[] = [
186
+ ...(row.hasUpdate ? [{ label: `Update to ${row.latest}`, action: () => done("update" as Choice) }] : []),
187
+ { label: "Disable/enable extensions", action: () => done("disable") },
188
+ { label: "Configure resources", action: () => done("config") },
189
+ { label: "Remove", action: () => done("remove") },
190
+ ];
191
+ return new Menu({ items, theme: menuTheme(theme), onClose: () => done(undefined) });
192
+ },
193
+ { overlay: true, overlayOptions: { width: 40, anchor: "center" } },
194
+ );
195
+ }
196
+
93
197
  export async function showPackedPanel(ctx: ExtensionCommandContext, natives: Natives): Promise<void> {
94
198
  if (!ctx.hasUI) {
95
199
  ctx.ui.notify("/packed requires interactive mode", "warning");
@@ -118,19 +222,32 @@ export async function showPackedPanel(ctx: ExtensionCommandContext, natives: Nat
118
222
  continue; // settings never changes package rows -- reopen as-is
119
223
  }
120
224
 
225
+ if (action.type === "find") {
226
+ await showDiscoverPanel(ctx, natives);
227
+ continue; // a successful install already reloaded; a no-op returns here
228
+ }
229
+
230
+ if (action.type === "updateAll") {
231
+ const outcome = await applyUpdateAll(rows, natives, ctx);
232
+ if (outcome === "changed") return; // ctx.reload() already replaced the session
233
+ continue;
234
+ }
235
+
236
+ if (action.type === "config") {
237
+ await showResourceConfig(ctx, natives, action.row?.name);
238
+ continue; // showResourceConfig already handles its own reload prompt
239
+ }
240
+
121
241
  const row = action.row;
122
242
  if (!row) continue;
123
243
 
124
- const choice = await ctx.ui.select(
125
- `${row.name}@${row.version}${row.hasUpdate ? ` → ${row.latest}` : ""}`,
126
- [
127
- ...(row.hasUpdate ? [`Update to ${row.latest}`] : []),
128
- "Remove",
129
- "Cancel",
130
- ],
131
- );
244
+ if (action.type === "disable") {
245
+ const outcome = await applyDisableExtensions(row, natives, ctx);
246
+ if (outcome === "changed") return;
247
+ continue;
248
+ }
132
249
 
133
- const outcome = await applyPackageChoice(choice, row, natives, ctx);
250
+ const outcome = await applyPackageChoice(action.type === "update" ? `Update to ${row.latest}` : "Remove", row, natives, ctx);
134
251
  if (outcome === "changed") return; // ctx.reload() already replaced the session
135
252
  }
136
253
  }
@@ -158,11 +275,17 @@ function renderPanel(ctx: ExtensionCommandContext, rows: Row[]): Promise<PanelAc
158
275
  const badge = outdated > 0 ? theme.fg("warning", ` ${outdated} update(s)`) : "";
159
276
  const hint = searchActive
160
277
  ? rawKeyHint("esc", "clear")
161
- : rawKeyHint("enter", "actions") +
278
+ : rawKeyHint("enter", "menu") +
162
279
  theme.fg("muted", " · ") +
163
- rawKeyHint("/", "filter") +
280
+ rawKeyHint("u/U", "update/all") +
164
281
  theme.fg("muted", " · ") +
165
- rawKeyHint("tab", "view") +
282
+ rawKeyHint("x", "remove") +
283
+ theme.fg("muted", " · ") +
284
+ rawKeyHint("d", "disable") +
285
+ theme.fg("muted", " · ") +
286
+ rawKeyHint("c", "config") +
287
+ theme.fg("muted", " · ") +
288
+ rawKeyHint("f", "find") +
166
289
  theme.fg("muted", " · ") +
167
290
  rawKeyHint("s", "settings") +
168
291
  theme.fg("muted", " · ") +
@@ -171,7 +294,7 @@ function renderPanel(ctx: ExtensionCommandContext, rows: Row[]): Promise<PanelAc
171
294
  const line1 = truncateToWidth(`${title}${badge}${" ".repeat(spacing)}${hint}`, width, "");
172
295
  const dot = "·";
173
296
  const line2 = truncateToWidth(
174
- theme.fg("muted", `view: ${mode} ${dot} r refresh ${dot} ${rows.length} installed`),
297
+ theme.fg("muted", `view: ${mode} ${dot} / filter ${dot} tab view ${dot} r refresh ${dot} ${rows.length} installed`),
175
298
  width,
176
299
  "",
177
300
  );
@@ -255,9 +378,42 @@ function renderPanel(ctx: ExtensionCommandContext, rows: Row[]): Promise<PanelAc
255
378
  case "s":
256
379
  done({ type: "settings" });
257
380
  return;
381
+ case "f":
382
+ done({ type: "find" });
383
+ return;
384
+ case "U":
385
+ done({ type: "updateAll" });
386
+ return;
387
+ case "u": {
388
+ const row = filtered[selectedIndex];
389
+ if (row?.hasUpdate) done({ type: "update", row });
390
+ return;
391
+ }
392
+ case "x": {
393
+ const row = filtered[selectedIndex];
394
+ if (row) done({ type: "remove", row });
395
+ return;
396
+ }
397
+ case "d": {
398
+ const row = filtered[selectedIndex];
399
+ if (row) done({ type: "disable", row });
400
+ return;
401
+ }
402
+ case "c": {
403
+ const row = filtered[selectedIndex];
404
+ if (row) done({ type: "config", row });
405
+ return;
406
+ }
258
407
  case "\r": {
259
408
  const row = filtered[selectedIndex];
260
- if (row) done({ type: "menu", row });
409
+ if (!row) return;
410
+ void (async () => {
411
+ const choice = await showActionMenu(ctx, row);
412
+ if (choice === "update") done({ type: "update", row });
413
+ else if (choice === "remove") done({ type: "remove", row });
414
+ else if (choice === "disable") done({ type: "disable", row });
415
+ else if (choice === "config") done({ type: "config", row });
416
+ })();
261
417
  return;
262
418
  }
263
419
  case "\x1b":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-packed",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Pi package tools, commands, profiles, and TUI for the Packed daemon",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -14,7 +14,8 @@
14
14
  "dependencies": {
15
15
  "@danypops/packed": "^0.2.0",
16
16
  "@danypops/vehicle-client": "^0.1.1",
17
- "@danypops/vehicle-client-pi": "^0.1.5"
17
+ "@danypops/vehicle-client-pi": "^0.1.5",
18
+ "malevich-tui-components": "^0.7.0"
18
19
  },
19
20
  "peerDependencies": {
20
21
  "@earendil-works/pi-coding-agent": "*",