ext-toggle: stage-then-commit UX (space stages, enter applies)
Replaces the single-pick + immediate-apply flow with a SettingsList overlay where: - ↑/↓ navigate - space stages a toggle (●/○ flip in-place; not yet applied) - enter commits all staged renames at once and triggers ctx.reload() - esc cancels, no changes applied Implementation: ctx.ui.custom() builds a Container with header, a SettingsList (which cycles values on space), and a footer status line showing pending changes (e.g. 'pending: notify→off, foo→on'). The wrapper's handleInput intercepts Enter via matchesKey(data, Key.enter) before SettingsList sees it — SettingsList would otherwise consume Enter for cycling. Disable guards still fire on the space-stage attempt: a refused toggle is reverted via settingsList.updateValue and the reason shown in the footer. ssh-controlmaster guard during --ssh therefore now refuses at stage time, not commit time — clearer feedback. Subdir extensions render as read-only rows (no , so SettingsList will not cycle them). Batches multiple toggles into a single ctx.reload() instead of one reload per change, which was awkward when flipping several at once.
This commit is contained in:
+203
-95
@@ -4,23 +4,45 @@
|
||||
* Provides `/ext` to list and toggle pi extensions installed under
|
||||
* `~/.pi/agent/extensions/` (the global auto-discovery dir).
|
||||
*
|
||||
* UX:
|
||||
* ↑/↓ navigate
|
||||
* space stage a toggle (visual ●/○ flip, not yet applied)
|
||||
* enter commit all staged changes and reload pi
|
||||
* esc cancel, no changes applied
|
||||
*
|
||||
* Toggling works by renaming the file (or symlink) between `name.ts` and
|
||||
* `name.ts.off`. Pi discovers `*.ts` only, so a `.ts.off` file is invisible
|
||||
* at load time. `ctx.reload()` is invoked after a toggle so the change takes
|
||||
* at load time. After commit, `ctx.reload()` is invoked so changes take
|
||||
* effect without a restart.
|
||||
*
|
||||
* Subdirectory-style extensions (`name/index.ts`) are listed as read-only
|
||||
* in v1 — toggling a directory cleanly is more work than the renaming trick
|
||||
* is worth. If you need to disable one, move the directory aside manually.
|
||||
* Subdirectory-style extensions (`name/index.ts`) are listed read-only —
|
||||
* toggling a directory cleanly is more work than the renaming trick is
|
||||
* worth. If you need to disable one, move the directory aside manually.
|
||||
*
|
||||
* DISABLE_GUARDS: per-extension predicates that refuse a stage attempt
|
||||
* when toggling would silently break in-flight session state. The space
|
||||
* key flip is reverted and a status line explains why.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
type ExtensionAPI,
|
||||
getSettingsListTheme,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
Container,
|
||||
Key,
|
||||
matchesKey,
|
||||
type SettingItem,
|
||||
SettingsList,
|
||||
} from "@mariozechner/pi-tui";
|
||||
|
||||
const EXT_DIR = path.join(os.homedir(), ".pi", "agent", "extensions");
|
||||
|
||||
// ── Disable guards ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Per-extension guards that refuse a disable when toggling would silently
|
||||
* break in-flight session state. Returns a string explaining why the
|
||||
@@ -30,34 +52,22 @@ const EXT_DIR = path.join(os.homedir(), ".pi", "agent", "extensions");
|
||||
*/
|
||||
const DISABLE_GUARDS: Record<string, () => string | null> = {
|
||||
"ssh-controlmaster": () => {
|
||||
// If pi was launched with --ssh, disabling will (a) tear down the
|
||||
// ControlMaster (if we own it) and (b) silently revert read/write/
|
||||
// edit/bash to the LOCAL filesystem while the system prompt still
|
||||
// claims we're on the remote. Refuse, point at the safe path.
|
||||
const hasSshFlag = process.argv.some(
|
||||
(a) => a === "--ssh" || a.startsWith("--ssh="),
|
||||
);
|
||||
if (!hasSshFlag) return null;
|
||||
return [
|
||||
"Refusing to disable ssh-controlmaster during an active --ssh session.",
|
||||
"",
|
||||
"Disabling now would:",
|
||||
" \u2022 tear down the SSH ControlMaster (if this extension started it)",
|
||||
" \u2022 silently redirect read/write/edit/bash back to the LOCAL",
|
||||
" machine while the system prompt still says you're on the remote",
|
||||
"",
|
||||
"Exit pi and relaunch without --ssh instead.",
|
||||
].join("\n");
|
||||
return "Active --ssh session — disabling would silently revert read/write/edit/bash to local. Exit pi and relaunch without --ssh.";
|
||||
},
|
||||
};
|
||||
|
||||
// ── Filesystem helpers ────────────────────────────────────────────────────────
|
||||
|
||||
type Entry = {
|
||||
name: string; // bare name without extension (e.g. "notify")
|
||||
fullPath: string; // absolute path on disk
|
||||
enabled: boolean; // .ts (true) or .ts.off (false)
|
||||
kind: "file" | "dir"; // flat .ts file or subdir/index.ts
|
||||
name: string;
|
||||
fullPath: string;
|
||||
enabled: boolean;
|
||||
kind: "file" | "dir";
|
||||
isSymlink: boolean;
|
||||
linkTarget?: string; // resolved target if symlink
|
||||
};
|
||||
|
||||
function listEntries(): Entry[] {
|
||||
@@ -72,17 +82,8 @@ function listEntries(): Entry[] {
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
let linkTarget: string | undefined;
|
||||
if (isSymlink) {
|
||||
try {
|
||||
linkTarget = fs.readlinkSync(full);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Subdir extension: pi expects index.ts inside.
|
||||
const indexPath = path.join(full, "index.ts");
|
||||
if (fs.existsSync(indexPath)) {
|
||||
out.push({
|
||||
@@ -91,7 +92,6 @@ function listEntries(): Entry[] {
|
||||
enabled: true,
|
||||
kind: "dir",
|
||||
isSymlink,
|
||||
linkTarget,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -104,7 +104,6 @@ function listEntries(): Entry[] {
|
||||
enabled: true,
|
||||
kind: "file",
|
||||
isSymlink,
|
||||
linkTarget,
|
||||
});
|
||||
} else if (ent.name.endsWith(".ts.off")) {
|
||||
out.push({
|
||||
@@ -113,36 +112,33 @@ function listEntries(): Entry[] {
|
||||
enabled: false,
|
||||
kind: "file",
|
||||
isSymlink,
|
||||
linkTarget,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function formatRow(e: Entry): string {
|
||||
const dot = e.enabled ? "●" : "○";
|
||||
const kind = e.kind === "dir" ? " [dir]" : "";
|
||||
const link = e.isSymlink ? " ↳ symlink" : "";
|
||||
return `${dot} ${e.name}${kind}${link}`;
|
||||
/** Rename to match a target enabled state. Returns the new path. */
|
||||
function applyState(entry: Entry, enabled: boolean): string {
|
||||
const dir = path.dirname(entry.fullPath);
|
||||
const target = path.join(dir, enabled ? `${entry.name}.ts` : `${entry.name}.ts.off`);
|
||||
if (entry.fullPath === target) return target;
|
||||
fs.renameSync(entry.fullPath, target);
|
||||
return target;
|
||||
}
|
||||
|
||||
function toggleFile(e: Entry): { newPath: string; action: "enabled" | "disabled" } {
|
||||
if (e.kind !== "file") {
|
||||
throw new Error(`Cannot toggle directory-style extension "${e.name}" — move it aside manually.`);
|
||||
}
|
||||
const dir = path.dirname(e.fullPath);
|
||||
if (e.enabled) {
|
||||
const target = path.join(dir, `${e.name}.ts.off`);
|
||||
fs.renameSync(e.fullPath, target);
|
||||
return { newPath: target, action: "disabled" };
|
||||
} else {
|
||||
const target = path.join(dir, `${e.name}.ts`);
|
||||
fs.renameSync(e.fullPath, target);
|
||||
return { newPath: target, action: "enabled" };
|
||||
}
|
||||
// ── Settings list values ──────────────────────────────────────────────────────
|
||||
|
||||
const VAL_ON = "● enabled";
|
||||
const VAL_OFF = "○ disabled";
|
||||
const VAL_DIR = "[dir] (manage manually)";
|
||||
|
||||
function valueFor(enabled: boolean): string {
|
||||
return enabled ? VAL_ON : VAL_OFF;
|
||||
}
|
||||
|
||||
// ── Extension ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function extToggleExtension(pi: ExtensionAPI) {
|
||||
pi.registerCommand("ext", {
|
||||
description: "List and toggle pi extensions in ~/.pi/agent/extensions/",
|
||||
@@ -153,52 +149,164 @@ export default function extToggleExtension(pi: ExtensionAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = entries.map(formatRow);
|
||||
const selected = await ctx.ui.select(
|
||||
`Extensions (${entries.filter((e) => e.enabled).length}/${entries.length} active)`,
|
||||
labels,
|
||||
);
|
||||
if (!selected) return;
|
||||
// Staged state: name → enabled. Initialized from disk; mutated by space.
|
||||
const staged = new Map<string, boolean>();
|
||||
for (const e of entries) staged.set(e.name, e.enabled);
|
||||
|
||||
const idx = labels.indexOf(selected);
|
||||
if (idx < 0) return;
|
||||
const entry = entries[idx];
|
||||
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
||||
let statusText = "";
|
||||
|
||||
if (entry.kind === "dir") {
|
||||
ctx.ui.notify(
|
||||
`"${entry.name}" is a directory-style extension. Move it aside manually if you need to disable it.`,
|
||||
"info",
|
||||
const items: SettingItem[] = entries.map((e) => {
|
||||
if (e.kind === "dir") {
|
||||
return {
|
||||
id: e.name,
|
||||
label: e.name,
|
||||
currentValue: VAL_DIR,
|
||||
description: "Subdirectory extension — toggle by moving the dir manually.",
|
||||
};
|
||||
}
|
||||
const guard = DISABLE_GUARDS[e.name];
|
||||
const guardNote = guard?.();
|
||||
const desc = [
|
||||
e.isSymlink ? "symlink" : "file",
|
||||
guardNote ? `guarded: ${guardNote}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return {
|
||||
id: e.name,
|
||||
label: e.name,
|
||||
currentValue: valueFor(e.enabled),
|
||||
values: [VAL_ON, VAL_OFF],
|
||||
description: desc || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const container = new Container();
|
||||
|
||||
// Header
|
||||
container.addChild({
|
||||
render(_w: number) {
|
||||
return [
|
||||
theme.fg("accent", theme.bold("Extensions — ~/.pi/agent/extensions/")),
|
||||
theme.fg(
|
||||
"muted",
|
||||
" space: toggle (stage) · enter: apply · esc: cancel",
|
||||
),
|
||||
"",
|
||||
];
|
||||
},
|
||||
invalidate() {},
|
||||
});
|
||||
|
||||
const settingsList = new SettingsList(
|
||||
items,
|
||||
Math.min(items.length + 2, 20),
|
||||
getSettingsListTheme(),
|
||||
(id, newValue) => {
|
||||
// SettingsList just cycled this row. Update staged state, but
|
||||
// first check the guard if this is a disable transition.
|
||||
const entry = entries.find((e) => e.name === id);
|
||||
if (!entry || entry.kind === "dir") {
|
||||
// Should not happen — dir items have no `values` and won't cycle.
|
||||
settingsList.updateValue(id, VAL_DIR);
|
||||
return;
|
||||
}
|
||||
|
||||
const stagingEnabled = newValue === VAL_ON;
|
||||
|
||||
// Guard check: disabling a guarded extension is refused.
|
||||
if (!stagingEnabled) {
|
||||
const reason = DISABLE_GUARDS[entry.name]?.();
|
||||
if (reason) {
|
||||
settingsList.updateValue(id, VAL_ON);
|
||||
statusText = `⊘ ${entry.name}: ${reason}`;
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
staged.set(id, stagingEnabled);
|
||||
|
||||
// Reflect drift from disk in status line so the user knows what
|
||||
// will be applied on Enter.
|
||||
const drift: string[] = [];
|
||||
for (const e of entries) {
|
||||
if (e.kind !== "file") continue;
|
||||
const s = staged.get(e.name);
|
||||
if (s !== e.enabled) {
|
||||
drift.push(`${e.name}→${s ? "on" : "off"}`);
|
||||
}
|
||||
}
|
||||
statusText = drift.length
|
||||
? `pending: ${drift.join(", ")}`
|
||||
: "";
|
||||
tui.requestRender();
|
||||
},
|
||||
() => {
|
||||
// SettingsList's onCancel fires on escape.
|
||||
done(undefined);
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Run guard before prompting — only blocks disables, not re-enables.
|
||||
if (entry.enabled) {
|
||||
const guard = DISABLE_GUARDS[entry.name];
|
||||
const reason = guard?.();
|
||||
if (reason) {
|
||||
await ctx.ui.confirm(`Cannot disable "${entry.name}"`, reason);
|
||||
return;
|
||||
}
|
||||
}
|
||||
container.addChild(settingsList);
|
||||
|
||||
const verb = entry.enabled ? "Disable" : "Enable";
|
||||
const ok = await ctx.ui.confirm(
|
||||
`${verb} "${entry.name}"?`,
|
||||
`${entry.fullPath}\n\nWill be renamed to ${
|
||||
entry.enabled ? entry.name + ".ts.off" : entry.name + ".ts"
|
||||
} and pi will reload.`,
|
||||
);
|
||||
if (!ok) return;
|
||||
// Footer status line — shows pending changes or guard rejections.
|
||||
container.addChild({
|
||||
render(_w: number) {
|
||||
if (!statusText) return [""];
|
||||
const colored = statusText.startsWith("⊘")
|
||||
? theme.fg("warning", statusText)
|
||||
: theme.fg("muted", statusText);
|
||||
return ["", colored];
|
||||
},
|
||||
invalidate() {},
|
||||
});
|
||||
|
||||
try {
|
||||
const { action } = toggleFile(entry);
|
||||
ctx.ui.notify(`${entry.name}: ${action}. Reloading…`, "info");
|
||||
await ctx.reload();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
ctx.ui.notify(`Toggle failed: ${msg}`, "error");
|
||||
}
|
||||
return {
|
||||
render(width: number) {
|
||||
return container.render(width);
|
||||
},
|
||||
invalidate() {
|
||||
container.invalidate();
|
||||
},
|
||||
handleInput(data: string) {
|
||||
// Intercept Enter at the wrapper level so SettingsList doesn't
|
||||
// consume it for cycling. Everything else (space, arrows, esc)
|
||||
// passes through unchanged.
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
// Commit staged → disk.
|
||||
const errors: string[] = [];
|
||||
let applied = 0;
|
||||
for (const e of entries) {
|
||||
if (e.kind !== "file") continue;
|
||||
const s = staged.get(e.name);
|
||||
if (s === undefined || s === e.enabled) continue;
|
||||
try {
|
||||
applyState(e, s);
|
||||
applied++;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
errors.push(`${e.name}: ${msg}`);
|
||||
}
|
||||
}
|
||||
done(undefined);
|
||||
if (errors.length) {
|
||||
ctx.ui.notify(`ext-toggle: ${errors.join(" | ")}`, "error");
|
||||
} else if (applied > 0) {
|
||||
ctx.ui.notify(`ext-toggle: applied ${applied} change(s); reloading…`, "info");
|
||||
ctx.reload().catch((err) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
ctx.ui.notify(`reload failed: ${msg}`, "error");
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
settingsList.handleInput?.(data);
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user