@danypops/pi-packed 0.16.0 → 0.17.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 +11 -8
- package/extension/src/discover.ts +124 -140
- package/extension/src/index.ts +1 -1
- package/extension/src/menu-theme.ts +11 -1
- package/extension/src/resource-config.ts +170 -138
- package/extension/src/security-tui.ts +98 -18
- package/extension/src/tab-host.ts +30 -0
- package/extension/src/tui.ts +431 -342
- package/package.json +2 -2
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* resource-config.ts — /packed
|
|
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 {
|
|
13
|
-
import {
|
|
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,176 @@ 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
|
-
|
|
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
|
|
105
|
+
await openPanel(ctx, natives, { initialTab: "config" });
|
|
96
106
|
return true;
|
|
97
107
|
}
|
|
98
108
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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);
|
|
103
136
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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());
|
|
109
146
|
}
|
|
110
147
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
invalidate(): void {}
|
|
157
|
+
|
|
158
|
+
render(width: number): string[] {
|
|
159
|
+
const { theme } = this;
|
|
160
|
+
const scopeLabel = theme.bold(this.scope === "project" ? "Project Resources" : "Global Resources");
|
|
161
|
+
const hint = this.searchActive
|
|
162
|
+
? rawKeyHint("esc", "clear")
|
|
163
|
+
: rawKeyHint("space", "toggle") + theme.fg("muted", " \u00b7 ") + rawKeyHint("/", "filter") + theme.fg("muted", " \u00b7 ") + rawKeyHint("tab", "scope") + theme.fg("muted", " \u00b7 ") + rawKeyHint("r", "refresh");
|
|
164
|
+
const spacing = Math.max(1, width - visibleWidth(scopeLabel) - visibleWidth(hint));
|
|
165
|
+
const line1 = truncateToWidth(`${scopeLabel}${" ".repeat(spacing)}${hint}`, width, "");
|
|
166
|
+
const line2 = truncateToWidth(theme.fg("muted", `${this.items.length} resource(s) \u00b7 extensions need a reload after a change`), width, "");
|
|
167
|
+
const lines = [line1, line2];
|
|
168
|
+
if (this.searchActive) lines.push(...this.searchInput.render(width));
|
|
169
|
+
lines.push("");
|
|
170
|
+
if (this.filtered.length === 0) {
|
|
171
|
+
lines.push(theme.fg("muted", " No resources found"));
|
|
172
|
+
return lines;
|
|
124
173
|
}
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
174
|
+
const start = Math.max(0, Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filtered.length - this.maxVisible));
|
|
175
|
+
const end = Math.min(start + this.maxVisible, this.filtered.length);
|
|
176
|
+
for (let index = start; index < end; index++) {
|
|
177
|
+
const item = this.filtered[index]!;
|
|
178
|
+
const isSelected = index === this.selectedIndex;
|
|
179
|
+
const cursor = isSelected ? theme.fg("accent", "\u276f") : " ";
|
|
180
|
+
const box = item.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
|
|
181
|
+
const reloadMark = item.field === "extensions" ? theme.fg("warning", " \u27f3") : "";
|
|
182
|
+
const label = `${item.packageName} \u00b7 ${item.field} \u00b7 ${item.path}${reloadMark}`;
|
|
183
|
+
lines.push(truncateToWidth(`${cursor} ${box} ${isSelected ? theme.bold(label) : label}`, width, "..."));
|
|
184
|
+
}
|
|
185
|
+
const hasScroll = start > 0 || end < this.filtered.length;
|
|
186
|
+
if (hasScroll) lines.push(theme.fg("dim", ` ${this.selectedIndex + 1}/${this.filtered.length}`));
|
|
187
|
+
return lines;
|
|
133
188
|
}
|
|
134
189
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
190
|
+
handleInput(raw: string): void {
|
|
191
|
+
if (this.busy) return;
|
|
192
|
+
if (this.searchActive) {
|
|
193
|
+
if (raw === "\x1b") { this.searchActive = false; this.searchInput.setValue?.(""); this.applyFilter(); }
|
|
194
|
+
else if (raw === "\r") this.searchActive = false;
|
|
195
|
+
else { this.searchInput.handleInput(raw); this.applyFilter(); }
|
|
196
|
+
this.host.requestRender();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
switch (raw) {
|
|
200
|
+
case "\x1b[A": this.selectedIndex = (this.selectedIndex - 1 + this.filtered.length) % Math.max(this.filtered.length, 1); break;
|
|
201
|
+
case "\x1b[B": this.selectedIndex = (this.selectedIndex + 1) % Math.max(this.filtered.length, 1); break;
|
|
202
|
+
case "\t":
|
|
203
|
+
this.scope = this.scope === "global" ? "project" : "global";
|
|
204
|
+
this.rebuildItems();
|
|
205
|
+
this.applyFilter();
|
|
206
|
+
break;
|
|
207
|
+
case "/": this.searchActive = true; break;
|
|
208
|
+
case "r": void this.refresh(); return;
|
|
209
|
+
case " ":
|
|
210
|
+
case "\r": void this.toggleSelected(); return;
|
|
211
|
+
default: return;
|
|
212
|
+
}
|
|
213
|
+
this.host.requestRender();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
setFilter(filter: string): void {
|
|
217
|
+
this.searchInput.setValue(filter);
|
|
218
|
+
this.applyFilter(filter);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private rebuildItems(): void {
|
|
222
|
+
this.items = flatten(this.scope === "global" ? this.data.global : this.data.project, this.scope);
|
|
223
|
+
}
|
|
139
224
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
225
|
+
private applyFilter(seed?: string): void {
|
|
226
|
+
this.filtered = filterItems(this.items, seed ?? this.searchInput.getValue());
|
|
227
|
+
this.selectedIndex = 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private async refresh(): Promise<void> {
|
|
231
|
+
await this.load();
|
|
232
|
+
this.host.requestRender();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private async toggleSelected(): Promise<void> {
|
|
236
|
+
const item = this.filtered[this.selectedIndex];
|
|
237
|
+
if (!item) return;
|
|
238
|
+
this.busy = true;
|
|
239
|
+
this.host.requestRender();
|
|
240
|
+
try {
|
|
241
|
+
const outcome = await applyResourceToggle(item, this.natives, this.host.inlineCtx);
|
|
242
|
+
if (outcome !== "toggled") return;
|
|
243
|
+
const group = (item.scope === "global" ? this.data.global : this.data.project).find((candidate) => candidate.source === item.source);
|
|
244
|
+
const entry = group?.[item.field].find((candidate) => candidate.path === item.path);
|
|
245
|
+
if (entry) entry.enabled = !item.enabled;
|
|
246
|
+
if (item.field !== "extensions") return;
|
|
247
|
+
if (await confirmReload(this.host.inlineCtx)) {
|
|
248
|
+
this.host.ctx.ui.notify("Toggled; reloading Pi resources.", "info");
|
|
249
|
+
await this.host.ctx.reload();
|
|
250
|
+
this.host.onSessionReplaced();
|
|
251
|
+
} else {
|
|
252
|
+
this.host.ctx.ui.notify("Extension changes pending -- run /reload when ready.", "warning");
|
|
253
|
+
}
|
|
254
|
+
} finally {
|
|
255
|
+
this.busy = false;
|
|
256
|
+
this.applyFilter();
|
|
257
|
+
this.host.requestRender();
|
|
153
258
|
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
154
261
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
});
|
|
262
|
+
export async function createConfigTab(natives: Natives, host: TabHost, theme: Theme, initialFilter?: string): Promise<ConfigTab> {
|
|
263
|
+
const tab = new ConfigTab(natives, host, theme, initialFilter);
|
|
264
|
+
await tab.load();
|
|
265
|
+
return tab;
|
|
234
266
|
}
|
|
@@ -1,41 +1,121 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|