@danypops/pi-packed 0.16.1 → 0.18.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.
@@ -1,20 +1,27 @@
1
1
  /**
2
- * resource-config.ts — /packed config overlay: enable/disable individual
2
+ * resource-config.ts — /packed's Config tab: enable/disable individual
3
3
  * extensions, skills, prompt templates, and themes declared by installed
4
4
  * Pi packages, matching pi's own `pi config` interaction model (group by
5
5
  * package, toggle with space/enter, Tab to switch global/project scope).
6
6
  * Pi's own settings mutation lives in its internal SettingsManager, which
7
7
  * extensions cannot reach -- this reads and writes the same documented
8
8
  * settings.json filter arrays (docs/packages.md, "Enable and Disable
9
- * Resources") entirely through Packed's authenticated daemon.
9
+ * Resources") entirely through Packed's authenticated daemon. A real
10
+ * Component (not its own ctx.ui.custom overlay) so it plugs straight into
11
+ * TabbedContainer alongside Packages/Find/Settings on the same overlay --
12
+ * it used to open a second, visually distinct screen (no Malevich
13
+ * Envelope, its own hand-rolled Container+DynamicBorder chrome), confirmed
14
+ * live as a real "opens a different TUI" complaint.
10
15
  */
11
16
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
- import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
13
- import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
17
+ import { rawKeyHint } from "@earendil-works/pi-coding-agent";
18
+ import { Input, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
19
+ import type { Component } from "malevich-tui-components";
14
20
  import type { PackageResources, ResourceField } from "./packed.js";
15
21
  import type { Natives } from "./packed.js";
16
22
  import { packagePermissionDecision } from "./permission.js";
17
23
  import { confirmReload } from "./reload.js";
24
+ import type { TabHost } from "./tab-host.js";
18
25
 
19
26
  type Scope = "global" | "project";
20
27
  const RESOURCE_FIELDS = ["extensions", "skills", "prompts", "themes"] as const satisfies readonly ResourceField[];
@@ -46,11 +53,6 @@ export function filterItems(items: FlatItem[], query: string): FlatItem[] {
46
53
 
47
54
  export type { FlatItem };
48
55
 
49
- interface PanelAction {
50
- type: "toggle" | "switch" | "refresh" | "close";
51
- item?: FlatItem;
52
- }
53
-
54
56
  async function approveToggle(
55
57
  natives: Pick<Natives, "security">,
56
58
  ctx: Pick<ExtensionCommandContext, "hasUI" | "ui">,
@@ -89,146 +91,183 @@ export async function applyResourceToggle(item: FlatItem, natives: Pick<Natives,
89
91
  }
90
92
 
91
93
  /** `/packed config` dispatch, matching handleSetupCommand's own
92
- * returns-true-if-handled shape so index.ts can chain both checks. */
93
- export async function handleResourceConfigCommand(args: string, ctx: ExtensionCommandContext, natives: Natives): Promise<boolean> {
94
+ * returns-true-if-handled shape so index.ts can chain both checks.
95
+ * openPanel is injected (rather than importing showPackedPanel from
96
+ * tui.ts directly) to avoid a cycle: tui.ts already imports ConfigTab
97
+ * from this file. */
98
+ export async function handleResourceConfigCommand(
99
+ args: string,
100
+ ctx: ExtensionCommandContext,
101
+ natives: Natives,
102
+ openPanel: (ctx: ExtensionCommandContext, natives: Natives, opts?: { initialTab?: "config" }) => Promise<void>,
103
+ ): Promise<boolean> {
94
104
  if (args.trim() !== "config") return false;
95
- await showResourceConfig(ctx, natives);
105
+ await openPanel(ctx, natives, { initialTab: "config" });
96
106
  return true;
97
107
  }
98
108
 
99
- export async function showResourceConfig(ctx: ExtensionCommandContext, natives: Natives, initialFilter?: string): Promise<void> {
100
- if (!ctx.hasUI) {
101
- ctx.ui.notify("/packed config requires interactive mode", "warning");
102
- return;
109
+ interface Theme { fg(color: string, s: string): string; bold(s: string): string; }
110
+
111
+ /** /packed's Config tab -- a real Component, not its own ctx.ui.custom
112
+ * overlay. Each toggle's reload decision (confirmReload) fires
113
+ * immediately after that toggle, the same as Packages' own u/x/d --
114
+ * replacing the old standalone panel's "accumulate pendingReload, ask
115
+ * once when this whole screen finally closes" (there is no "closes"
116
+ * anymore; this tab can stay mounted indefinitely while other tabs are
117
+ * active). */
118
+ export class ConfigTab implements Component {
119
+ private data: { global: PackageResources[]; project: PackageResources[] } = { global: [], project: [] };
120
+ private scope: Scope = "global";
121
+ private readonly searchInput = new Input();
122
+ private searchActive = false;
123
+ private items: FlatItem[] = [];
124
+ private filtered: FlatItem[] = [];
125
+ private selectedIndex = 0;
126
+ private busy = false;
127
+ private readonly maxVisible = 20;
128
+
129
+ constructor(
130
+ private readonly natives: Pick<Natives, "listResources" | "security" | "toggleResource">,
131
+ private readonly host: TabHost,
132
+ private readonly theme: Theme,
133
+ initialFilter?: string,
134
+ ) {
135
+ if (initialFilter) this.searchInput.setValue(initialFilter);
136
+ }
137
+
138
+ async load(): Promise<void> {
139
+ try {
140
+ this.data = await this.natives.listResources(this.host.ctx.cwd);
141
+ } catch (error) {
142
+ this.host.ctx.ui.notify(`packed unavailable: ${error instanceof Error ? error.message : error}`, "error");
143
+ }
144
+ this.rebuildItems();
145
+ this.applyFilter(this.searchInput.getValue());
146
+ }
147
+
148
+ /** True while an active filter or an in-flight toggle+reload flow means
149
+ * Escape should clear/wait rather than the host's own "back to Packages"
150
+ * handling. Config never needs Left/Right for itself (no free-text
151
+ * cursor movement outside the filter box, which doesn't use them either). */
152
+ capturesEscape(): boolean {
153
+ return this.searchActive || this.busy;
103
154
  }
104
- let data: { global: PackageResources[]; project: PackageResources[] };
105
- try { data = await natives.listResources(ctx.cwd); }
106
- catch (error) {
107
- ctx.ui.notify(`packed unavailable: ${error instanceof Error ? error.message : error}`, "error");
108
- return;
155
+
156
+ /** Same condition as capturesEscape -- an active filter or an in-flight
157
+ * toggle means every printable key (including a letter that would
158
+ * otherwise be a global tab-jump mnemonic) belongs to this tab right now. */
159
+ capturesMnemonics(): boolean {
160
+ return this.searchActive || this.busy;
109
161
  }
110
162
 
111
- let scope: Scope = "global";
112
- let pendingReload = false;
113
- let filter = initialFilter ?? "";
114
-
115
- for (;;) {
116
- const action = await renderConfig(ctx, data, scope, filter);
117
- filter = ""; // only seeds the very first open -- a later refresh/switch starts unfiltered
118
- if (action.type === "close") break;
119
- if (action.type === "switch") { scope = scope === "global" ? "project" : "global"; continue; }
120
- if (action.type === "refresh") {
121
- try { data = await natives.listResources(ctx.cwd); }
122
- catch (error) { ctx.ui.notify(`refresh failed: ${error instanceof Error ? error.message : error}`, "error"); }
123
- continue;
163
+ invalidate(): void {}
164
+
165
+ render(width: number): string[] {
166
+ const { theme } = this;
167
+ const scopeLabel = theme.bold(this.scope === "project" ? "Project Resources" : "Global Resources");
168
+ const hint = this.searchActive
169
+ ? rawKeyHint("esc", "clear")
170
+ : rawKeyHint("space", "toggle") + theme.fg("muted", " · ") + rawKeyHint("/", "filter") + theme.fg("muted", " · ") + rawKeyHint("v", "scope") + theme.fg("muted", " · ") + rawKeyHint("r", "refresh");
171
+ const spacing = Math.max(1, width - visibleWidth(scopeLabel) - visibleWidth(hint));
172
+ const line1 = truncateToWidth(`${scopeLabel}${" ".repeat(spacing)}${hint}`, width, "");
173
+ const line2 = truncateToWidth(theme.fg("muted", `${this.items.length} resource(s) \u00b7 extensions need a reload after a change`), width, "");
174
+ const lines = [line1, line2];
175
+ if (this.searchActive) lines.push(...this.searchInput.render(width));
176
+ lines.push("");
177
+ if (this.filtered.length === 0) {
178
+ lines.push(theme.fg("muted", " No resources found"));
179
+ return lines;
180
+ }
181
+ const start = Math.max(0, Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filtered.length - this.maxVisible));
182
+ const end = Math.min(start + this.maxVisible, this.filtered.length);
183
+ for (let index = start; index < end; index++) {
184
+ const item = this.filtered[index]!;
185
+ const isSelected = index === this.selectedIndex;
186
+ const cursor = isSelected ? theme.fg("accent", "\u276f") : " ";
187
+ const box = item.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
188
+ const reloadMark = item.field === "extensions" ? theme.fg("warning", " \u27f3") : "";
189
+ const label = `${item.packageName} \u00b7 ${item.field} \u00b7 ${item.path}${reloadMark}`;
190
+ lines.push(truncateToWidth(`${cursor} ${box} ${isSelected ? theme.bold(label) : label}`, width, "..."));
124
191
  }
125
- const item = action.item;
126
- if (!item) continue;
127
- const outcome = await applyResourceToggle(item, natives, ctx);
128
- if (outcome !== "toggled") continue;
129
- const group = (item.scope === "global" ? data.global : data.project).find((candidate) => candidate.source === item.source);
130
- const entry = group?.[item.field].find((candidate) => candidate.path === item.path);
131
- if (entry) entry.enabled = !item.enabled;
132
- if (item.field === "extensions") pendingReload = true;
192
+ const hasScroll = start > 0 || end < this.filtered.length;
193
+ if (hasScroll) lines.push(theme.fg("dim", ` ${this.selectedIndex + 1}/${this.filtered.length}`));
194
+ return lines;
133
195
  }
134
196
 
135
- if (!pendingReload) return;
136
- if (await confirmReload(ctx)) await ctx.reload();
137
- else ctx.ui.notify("Extension changes pending -- run /reload when ready.", "warning");
138
- }
197
+ handleInput(raw: string): void {
198
+ if (this.busy) return;
199
+ if (this.searchActive) {
200
+ if (raw === "\x1b") { this.searchActive = false; this.searchInput.setValue?.(""); this.applyFilter(); }
201
+ else if (raw === "\r") this.searchActive = false;
202
+ else { this.searchInput.handleInput(raw); this.applyFilter(); }
203
+ this.host.requestRender();
204
+ return;
205
+ }
206
+ switch (raw) {
207
+ case "\x1b[A": this.selectedIndex = (this.selectedIndex - 1 + this.filtered.length) % Math.max(this.filtered.length, 1); break;
208
+ case "\x1b[B": this.selectedIndex = (this.selectedIndex + 1) % Math.max(this.filtered.length, 1); break;
209
+ case "v": // Tab is now reserved globally for sweeping between menus (TabbedContainer)
210
+ this.scope = this.scope === "global" ? "project" : "global";
211
+ this.rebuildItems();
212
+ this.applyFilter();
213
+ break;
214
+ case "/": this.searchActive = true; break;
215
+ case "r": void this.refresh(); return;
216
+ case " ":
217
+ case "\r": void this.toggleSelected(); return;
218
+ default: return;
219
+ }
220
+ this.host.requestRender();
221
+ }
222
+
223
+ setFilter(filter: string): void {
224
+ this.searchInput.setValue(filter);
225
+ this.applyFilter(filter);
226
+ }
227
+
228
+ private rebuildItems(): void {
229
+ this.items = flatten(this.scope === "global" ? this.data.global : this.data.project, this.scope);
230
+ }
139
231
 
140
- function renderConfig(ctx: ExtensionCommandContext, data: { global: PackageResources[]; project: PackageResources[] }, scope: Scope, initialFilter = ""): Promise<PanelAction> {
141
- return ctx.ui.custom<PanelAction>((tui, theme, _kb, done) => {
142
- const searchInput = new Input();
143
- if (initialFilter) searchInput.setValue(initialFilter);
144
- let searchActive = false;
145
- const items = flatten(scope === "global" ? data.global : data.project, scope);
146
- let filtered = initialFilter ? filterItems(items, initialFilter) : items;
147
- let selectedIndex = 0;
148
- const maxVisible = 20;
149
-
150
- function applyFilter(): void {
151
- filtered = filterItems(items, searchInput.getValue());
152
- selectedIndex = 0;
232
+ private applyFilter(seed?: string): void {
233
+ this.filtered = filterItems(this.items, seed ?? this.searchInput.getValue());
234
+ this.selectedIndex = 0;
235
+ }
236
+
237
+ private async refresh(): Promise<void> {
238
+ await this.load();
239
+ this.host.requestRender();
240
+ }
241
+
242
+ private async toggleSelected(): Promise<void> {
243
+ const item = this.filtered[this.selectedIndex];
244
+ if (!item) return;
245
+ this.busy = true;
246
+ this.host.requestRender();
247
+ try {
248
+ const outcome = await applyResourceToggle(item, this.natives, this.host.inlineCtx);
249
+ if (outcome !== "toggled") return;
250
+ const group = (item.scope === "global" ? this.data.global : this.data.project).find((candidate) => candidate.source === item.source);
251
+ const entry = group?.[item.field].find((candidate) => candidate.path === item.path);
252
+ if (entry) entry.enabled = !item.enabled;
253
+ if (item.field !== "extensions") return;
254
+ if (await confirmReload(this.host.inlineCtx)) {
255
+ this.host.ctx.ui.notify("Toggled; reloading Pi resources.", "info");
256
+ await this.host.ctx.reload();
257
+ this.host.onSessionReplaced();
258
+ } else {
259
+ this.host.ctx.ui.notify("Extension changes pending -- run /reload when ready.", "warning");
260
+ }
261
+ } finally {
262
+ this.busy = false;
263
+ this.applyFilter();
264
+ this.host.requestRender();
153
265
  }
266
+ }
267
+ }
154
268
 
155
- const header = {
156
- invalidate() {},
157
- render(width: number): string[] {
158
- const title = theme.bold(scope === "project" ? "Project Resources" : "Global Resources");
159
- const hint = searchActive
160
- ? rawKeyHint("esc", "clear")
161
- : rawKeyHint("space", "toggle") + theme.fg("muted", " \u00b7 ") + rawKeyHint("/", "filter") + theme.fg("muted", " \u00b7 ") + rawKeyHint("tab", "scope") + theme.fg("muted", " \u00b7 ") + rawKeyHint("esc", "close");
162
- const spacing = Math.max(1, width - visibleWidth(title) - visibleWidth(hint));
163
- const line1 = truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, "");
164
- const line2 = truncateToWidth(theme.fg("muted", `${items.length} resource(s) \u00b7 extensions need /reload after a change`), width, "");
165
- return [line1, line2];
166
- },
167
- };
168
-
169
- const list = {
170
- invalidate() {},
171
- render(width: number): string[] {
172
- const lines: string[] = [];
173
- if (searchActive) lines.push(...searchInput.render(width));
174
- lines.push("");
175
- if (filtered.length === 0) { lines.push(theme.fg("muted", " No resources found")); return lines; }
176
- const start = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), filtered.length - maxVisible));
177
- const end = Math.min(start + maxVisible, filtered.length);
178
- for (let index = start; index < end; index++) {
179
- const item = filtered[index]!;
180
- const isSelected = index === selectedIndex;
181
- const cursor = isSelected ? theme.fg("accent", "\u276f") : " ";
182
- const box = item.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
183
- const reloadMark = item.field === "extensions" ? theme.fg("warning", " \u27f3") : "";
184
- const label = `${item.packageName} \u00b7 ${item.field} \u00b7 ${item.path}${reloadMark}`;
185
- lines.push(truncateToWidth(`${cursor} ${box} ${isSelected ? theme.bold(label) : label}`, width, "..."));
186
- }
187
- const hasScroll = start > 0 || end < filtered.length;
188
- if (hasScroll) lines.push(theme.fg("dim", ` ${selectedIndex + 1}/${filtered.length}`));
189
- return lines;
190
- },
191
- };
192
-
193
- const border = () => new DynamicBorder((s) => theme.fg("border", s));
194
- const container = new Container();
195
- container.addChild(new Spacer(1));
196
- container.addChild(border());
197
- container.addChild(new Spacer(1));
198
- container.addChild(header);
199
- container.addChild(new Spacer(1));
200
- container.addChild(list);
201
- container.addChild(new Spacer(1));
202
- container.addChild(border());
203
-
204
- return {
205
- render: (width: number) => container.render(width),
206
- invalidate: () => container.invalidate(),
207
- handleInput(raw: string) {
208
- if (searchActive) {
209
- if (raw === "\x1b") { searchActive = false; searchInput.setValue?.(""); applyFilter(); }
210
- else if (raw === "\r") searchActive = false;
211
- else { searchInput.handleInput(raw); applyFilter(); }
212
- tui.requestRender();
213
- return;
214
- }
215
- switch (raw) {
216
- case "\x1b[A": selectedIndex = (selectedIndex - 1 + filtered.length) % Math.max(filtered.length, 1); break;
217
- case "\x1b[B": selectedIndex = (selectedIndex + 1) % Math.max(filtered.length, 1); break;
218
- case "\t": done({ type: "switch" }); return;
219
- case "/": searchActive = true; break;
220
- case "r": done({ type: "refresh" }); return;
221
- case " ":
222
- case "\r": {
223
- const item = filtered[selectedIndex];
224
- if (item) done({ type: "toggle", item });
225
- return;
226
- }
227
- case "\x1b": done({ type: "close" }); return;
228
- default: return;
229
- }
230
- tui.requestRender();
231
- },
232
- };
233
- });
269
+ export async function createConfigTab(natives: Natives, host: TabHost, theme: Theme, initialFilter?: string): Promise<ConfigTab> {
270
+ const tab = new ConfigTab(natives, host, theme, initialFilter);
271
+ await tab.load();
272
+ return tab;
234
273
  }
@@ -1,41 +1,121 @@
1
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1
+ /**
2
+ * security-tui.ts — /packed's Settings tab: a small radio-style list
3
+ * (Malevich's Component contract, not a bespoke Menu) so it can plug
4
+ * straight into TabbedContainer alongside Packages/Find/Config on the
5
+ * same overlay. Previously ran through Pi's own native
6
+ * ctx.ui.select/ctx.ui.confirm -- a genuinely separate, arrow-select-only
7
+ * dialog from any custom overlay, confirmed live as the same "opens a
8
+ * different TUI" complaint the other three tabs had.
9
+ */
10
+ import { rawKeyHint } from "@earendil-works/pi-coding-agent";
11
+ import { truncateToWidth } from "@earendil-works/pi-tui";
12
+ import type { Component } from "malevich-tui-components";
2
13
  import type { SecuritySettings } from "@danypops/packed/protocol";
3
14
  type MutationApproval = SecuritySettings["mutationApproval"];
4
15
  import type { Natives } from "./packed.ts";
16
+ import type { TabHost } from "./tab-host.ts";
5
17
 
6
18
  const OPTIONS: Array<{ value: MutationApproval; label: string }> = [
7
19
  { value: "always", label: "Always require mutation approval (recommended)" },
8
20
  { value: "never", label: "Never require mutation approval (unsafe opt-out)" },
9
21
  ];
10
22
 
11
- export async function showPackedSettings(ctx: ExtensionCommandContext, natives: Natives): Promise<void> {
12
- if (!ctx.hasUI) {
13
- ctx.ui.notify("/packed requires interactive mode", "warning");
14
- return;
23
+ interface Theme { fg(color: string, s: string): string; bold(s: string): string; }
24
+
25
+ export class SettingsTab implements Component {
26
+ private current: MutationApproval = "always";
27
+ private selectedIndex = 0;
28
+ private loaded = false;
29
+
30
+ constructor(
31
+ private readonly natives: Pick<Natives, "security" | "setMutationApproval">,
32
+ private readonly host: TabHost,
33
+ private readonly theme: Theme,
34
+ ) {}
35
+
36
+ async load(): Promise<void> {
37
+ try {
38
+ const settings = await this.natives.security();
39
+ this.current = settings.mutationApproval;
40
+ } catch (error) {
41
+ this.host.ctx.ui.notify(`packed unavailable: ${error instanceof Error ? error.message : error}`, "error");
42
+ }
43
+ this.selectedIndex = OPTIONS.findIndex((o) => o.value === this.current);
44
+ if (this.selectedIndex < 0) this.selectedIndex = 0;
45
+ this.loaded = true;
15
46
  }
16
- try {
17
- const current = await natives.security();
18
- const choice = await ctx.ui.select(
19
- `Package mutation approval · current: ${current.mutationApproval}`,
20
- [...OPTIONS.map(({ label }) => label), "Cancel"],
21
- );
22
- const selected = OPTIONS.find(({ label }) => label === choice);
23
- if (!selected || selected.value === current.mutationApproval) return;
24
- const approved = await ctx.ui.confirm(
47
+
48
+ // No capturesEscape/capturesHorizontalArrows override -- no free-text entry
49
+ // or in-flight blocking state here, so the host's own global Escape/Left/
50
+ // Right handling is always safe (both default to false when absent).
51
+
52
+ invalidate(): void {}
53
+
54
+ render(width: number): string[] {
55
+ const { theme } = this;
56
+ const hint = rawKeyHint("up/down", "move") + theme.fg("muted", " · ") + rawKeyHint("enter", "change");
57
+ const line1 = truncateToWidth(hint, width, "");
58
+ const line2 = truncateToWidth(theme.fg("muted", `current: ${this.current}`), width, "");
59
+ const lines = [line1, line2, ""];
60
+ if (!this.loaded) {
61
+ lines.push(theme.fg("muted", " Loading…"));
62
+ return lines;
63
+ }
64
+ for (let i = 0; i < OPTIONS.length; i++) {
65
+ const option = OPTIONS[i]!;
66
+ const selected = i === this.selectedIndex;
67
+ const cursor = selected ? theme.fg("accent", "❯ ") : " ";
68
+ const mark = option.value === this.current ? theme.fg("success", "●") : theme.fg("dim", "○");
69
+ const label = selected ? theme.bold(option.label) : option.label;
70
+ lines.push(truncateToWidth(`${cursor}${mark} ${label}`, width, "…"));
71
+ }
72
+ return lines;
73
+ }
74
+
75
+ handleInput(data: string): void {
76
+ if (!this.loaded) return;
77
+ switch (data) {
78
+ case "\x1b[A":
79
+ this.selectedIndex = (this.selectedIndex - 1 + OPTIONS.length) % OPTIONS.length;
80
+ this.host.requestRender();
81
+ return;
82
+ case "\x1b[B":
83
+ this.selectedIndex = (this.selectedIndex + 1) % OPTIONS.length;
84
+ this.host.requestRender();
85
+ return;
86
+ case "\r":
87
+ void this.apply(OPTIONS[this.selectedIndex]!);
88
+ return;
89
+ default:
90
+ return;
91
+ }
92
+ }
93
+
94
+ private async apply(selected: { value: MutationApproval; label: string }): Promise<void> {
95
+ if (selected.value === this.current) return;
96
+ const approved = await this.host.inlineCtx.ui.confirm(
25
97
  "Change package mutation approval",
26
98
  selected.value === "never"
27
99
  ? "Disable confirmation for install, update, remove, and package security changes? Packages can execute arbitrary code."
28
100
  : "Restore confirmation for install, update, remove, and package security changes?",
29
101
  );
102
+ this.host.requestRender();
30
103
  if (!approved) return;
31
- const updated = await natives.setMutationApproval(selected.value, true);
32
- ctx.ui.notify(
104
+ const updated = await this.natives.setMutationApproval(selected.value, true);
105
+ this.current = updated.mutationApproval;
106
+ this.selectedIndex = OPTIONS.findIndex((o) => o.value === this.current);
107
+ this.host.ctx.ui.notify(
33
108
  updated.mutationApproval === "always"
34
109
  ? "Package mutations now require confirmation."
35
110
  : "Package mutation confirmation disabled. Packages can execute arbitrary code.",
36
111
  updated.mutationApproval === "always" ? "info" : "warning",
37
112
  );
38
- } catch (error) {
39
- ctx.ui.notify(`packed security settings failed: ${error instanceof Error ? error.message : error}`, "error");
113
+ this.host.requestRender();
40
114
  }
41
115
  }
116
+
117
+ export async function createSettingsTab(natives: Natives, host: TabHost, theme: Theme): Promise<SettingsTab> {
118
+ const tab = new SettingsTab(natives, host, theme);
119
+ await tab.load();
120
+ return tab;
121
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared dependencies every /packed panel tab needs for its own approval,
3
+ * confirm, and reload flow -- injected once by the single overlay that
4
+ * owns all four tabs (Packages/Find/Config/Settings), so a tab's own
5
+ * mutation code never needs to know about Envelope, Dialog, or
6
+ * TabbedContainer at all. Root cause this replaces: each tab used to open
7
+ * its own separate ctx.ui.custom overlay (or, for Settings, Pi's own
8
+ * native ctx.ui.select/confirm) to run its approve+mutate+reload flow --
9
+ * confirmed live as a real user complaint ("Find, Settings, Config all
10
+ * open a different TUI"). Every tab now runs entirely inside the one
11
+ * overlay tui.ts already owns; this is the seam that makes that possible
12
+ * without each tab file importing tui.ts back (which would cycle).
13
+ */
14
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
15
+
16
+ export interface TabHost {
17
+ /** The real ctx -- notify/reload/cwd/etc, exactly as Pi provides it. */
18
+ ctx: ExtensionCommandContext;
19
+ /** The same ctx, except ui.confirm renders as this overlay's own inline
20
+ * Malevich Dialog instead of Pi's separate native confirm dialog. Every
21
+ * approve/reload call inside a tab's own mutation flow must go through
22
+ * this, not ctx directly. */
23
+ inlineCtx: ExtensionCommandContext;
24
+ /** Re-renders the whole overlay. Call after any state change a tab
25
+ * makes outside of a handleInput call the host already re-renders for. */
26
+ requestRender(): void;
27
+ /** Call once a mutation's own ctx.reload() has already replaced the
28
+ * session -- the host must stop rendering the now-stale overlay. */
29
+ onSessionReplaced(): void;
30
+ }