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:
2026-05-07 20:51:13 +02:00
parent c624eafe64
commit e47cbe5795
3 changed files with 230 additions and 105 deletions
+19 -9
View File
@@ -162,11 +162,15 @@ Notification text: `Pi — Done (Ns)` where N is the rounded elapsed seconds.
### `ext-toggle.ts` ### `ext-toggle.ts`
Registers `/ext` slash command. Lists files in `~/.pi/agent/extensions/`, Registers `/ext` slash command. Opens a multi-toggle overlay built on
shows `` (active) / `` (disabled) plus dir/symlink hints, and lets the `SettingsList` from pi-tui. Lists files in `~/.pi/agent/extensions/` with
user toggle individual extensions by renaming them between `name.ts` and `● enabled` / `○ disabled` values. Space stages a toggle; Enter commits
`name.ts.off`. Calls `ctx.reload()` after a toggle so the change takes all pending renames at once and calls `ctx.reload()`; Escape cancels.
effect without restarting pi.
**UX rationale:** the previous single-pick + immediate-apply flow made
it awkward to flip several extensions in a row (every toggle reloaded
the whole runtime). Stage-then-commit batches reloads to one per
session.
**Key design decisions:** **Key design decisions:**
@@ -203,10 +207,16 @@ effect without restarting pi.
**API used:** **API used:**
- `pi.registerCommand(name, { description, handler })` — registers `/ext`. - `pi.registerCommand(name, { description, handler })` — registers `/ext`.
- `ctx.ui.select(title, items)` — picker; returns selected string or `undefined`. - `ctx.ui.custom<T>((tui, theme, kb, done) => Component)` — full-overlay
- `ctx.ui.confirm(title, message)` — yes/no dialog returning `boolean`. with own keyboard handling. The wrapper component intercepts `enter`
- `ctx.ui.notify(message, level)` — transient toast. via `matchesKey(data, Key.enter)` before forwarding to `SettingsList`,
- `ctx.reload()` — reloads extensions/skills/prompts/themes; same as `/reload`. which would otherwise consume Enter for value-cycling.
- `SettingsList` from pi-tui — the list itself. Cycles values on space
(and on enter, but enter is intercepted upstream). `onChange` fires
per cycle and is where staging happens.
- `getSettingsListTheme()` from pi-coding-agent — themed colors.
- `ctx.ui.notify(message, level)` — toast for post-commit status / errors.
- `ctx.reload()` — same reload as `/reload` command.
No flags, no `agent_*` event handlers — fully passive until `/ext` is invoked. No flags, no `agent_*` event handlers — fully passive until `/ext` is invoked.
+8 -1
View File
@@ -170,9 +170,16 @@ Registers `/ext` — a slash command that lists extensions in `~/.pi/agent/exten
**Usage:** **Usage:**
``` ```
/ext # opens a picker; ● = active, ○ = disabled /ext # opens the multi-toggle overlay
``` ```
- `↑` / `↓` — navigate
- `space` — stage a toggle (visual `●` / `○` flip; not yet applied)
- `enter` — commit all staged changes and reload pi
- `esc` — cancel, no changes
A footer line shows pending changes (e.g. `pending: notify→off, foo→on`) so you can see exactly what `enter` will apply. Guard rejections appear there too (`⊘ ssh-controlmaster: …`).
**Notes:** **Notes:**
- Subdirectory-style extensions (`name/index.ts`) are listed read-only — v1 doesn't toggle them. Move the directory aside manually if needed. - Subdirectory-style extensions (`name/index.ts`) are listed read-only — v1 doesn't toggle them. Move the directory aside manually if needed.
- `install.sh --uninstall` cleans up both `.ts` and `.ts.off` symlinks pointing into this repo, so a disabled extension won't be left behind. - `install.sh --uninstall` cleans up both `.ts` and `.ts.off` symlinks pointing into this repo, so a disabled extension won't be left behind.
+203 -95
View File
@@ -4,23 +4,45 @@
* Provides `/ext` to list and toggle pi extensions installed under * Provides `/ext` to list and toggle pi extensions installed under
* `~/.pi/agent/extensions/` (the global auto-discovery dir). * `~/.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 * 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 * `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. * effect without a restart.
* *
* Subdirectory-style extensions (`name/index.ts`) are listed as read-only * Subdirectory-style extensions (`name/index.ts`) are listed read-only
* in v1 — toggling a directory cleanly is more work than the renaming trick * toggling a directory cleanly is more work than the renaming trick is
* is worth. If you need to disable one, move the directory aside manually. * 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 fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import * as os from "node:os"; 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"); const EXT_DIR = path.join(os.homedir(), ".pi", "agent", "extensions");
// ── Disable guards ────────────────────────────────────────────────────────────
/** /**
* Per-extension guards that refuse a disable when toggling would silently * Per-extension guards that refuse a disable when toggling would silently
* break in-flight session state. Returns a string explaining why the * 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> = { const DISABLE_GUARDS: Record<string, () => string | null> = {
"ssh-controlmaster": () => { "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( const hasSshFlag = process.argv.some(
(a) => a === "--ssh" || a.startsWith("--ssh="), (a) => a === "--ssh" || a.startsWith("--ssh="),
); );
if (!hasSshFlag) return null; if (!hasSshFlag) return null;
return [ return "Active --ssh session — disabling would silently revert read/write/edit/bash to local. Exit pi and relaunch without --ssh.";
"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");
}, },
}; };
// ── Filesystem helpers ────────────────────────────────────────────────────────
type Entry = { type Entry = {
name: string; // bare name without extension (e.g. "notify") name: string;
fullPath: string; // absolute path on disk fullPath: string;
enabled: boolean; // .ts (true) or .ts.off (false) enabled: boolean;
kind: "file" | "dir"; // flat .ts file or subdir/index.ts kind: "file" | "dir";
isSymlink: boolean; isSymlink: boolean;
linkTarget?: string; // resolved target if symlink
}; };
function listEntries(): Entry[] { function listEntries(): Entry[] {
@@ -72,17 +82,8 @@ function listEntries(): Entry[] {
} catch { } catch {
continue; continue;
} }
let linkTarget: string | undefined;
if (isSymlink) {
try {
linkTarget = fs.readlinkSync(full);
} catch {
// ignore
}
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Subdir extension: pi expects index.ts inside.
const indexPath = path.join(full, "index.ts"); const indexPath = path.join(full, "index.ts");
if (fs.existsSync(indexPath)) { if (fs.existsSync(indexPath)) {
out.push({ out.push({
@@ -91,7 +92,6 @@ function listEntries(): Entry[] {
enabled: true, enabled: true,
kind: "dir", kind: "dir",
isSymlink, isSymlink,
linkTarget,
}); });
} }
continue; continue;
@@ -104,7 +104,6 @@ function listEntries(): Entry[] {
enabled: true, enabled: true,
kind: "file", kind: "file",
isSymlink, isSymlink,
linkTarget,
}); });
} else if (ent.name.endsWith(".ts.off")) { } else if (ent.name.endsWith(".ts.off")) {
out.push({ out.push({
@@ -113,36 +112,33 @@ function listEntries(): Entry[] {
enabled: false, enabled: false,
kind: "file", kind: "file",
isSymlink, isSymlink,
linkTarget,
}); });
} }
} }
return out.sort((a, b) => a.name.localeCompare(b.name)); return out.sort((a, b) => a.name.localeCompare(b.name));
} }
function formatRow(e: Entry): string { /** Rename to match a target enabled state. Returns the new path. */
const dot = e.enabled ? "●" : "○"; function applyState(entry: Entry, enabled: boolean): string {
const kind = e.kind === "dir" ? " [dir]" : ""; const dir = path.dirname(entry.fullPath);
const link = e.isSymlink ? " ↳ symlink" : ""; const target = path.join(dir, enabled ? `${entry.name}.ts` : `${entry.name}.ts.off`);
return `${dot} ${e.name}${kind}${link}`; if (entry.fullPath === target) return target;
fs.renameSync(entry.fullPath, target);
return target;
} }
function toggleFile(e: Entry): { newPath: string; action: "enabled" | "disabled" } { // ── Settings list values ──────────────────────────────────────────────────────
if (e.kind !== "file") {
throw new Error(`Cannot toggle directory-style extension "${e.name}" — move it aside manually.`); const VAL_ON = "● enabled";
} const VAL_OFF = "○ disabled";
const dir = path.dirname(e.fullPath); const VAL_DIR = "[dir] (manage manually)";
if (e.enabled) {
const target = path.join(dir, `${e.name}.ts.off`); function valueFor(enabled: boolean): string {
fs.renameSync(e.fullPath, target); return enabled ? VAL_ON : VAL_OFF;
return { newPath: target, action: "disabled" };
} else {
const target = path.join(dir, `${e.name}.ts`);
fs.renameSync(e.fullPath, target);
return { newPath: target, action: "enabled" };
}
} }
// ── Extension ────────────────────────────────────────────────────────────────
export default function extToggleExtension(pi: ExtensionAPI) { export default function extToggleExtension(pi: ExtensionAPI) {
pi.registerCommand("ext", { pi.registerCommand("ext", {
description: "List and toggle pi extensions in ~/.pi/agent/extensions/", description: "List and toggle pi extensions in ~/.pi/agent/extensions/",
@@ -153,52 +149,164 @@ export default function extToggleExtension(pi: ExtensionAPI) {
return; return;
} }
const labels = entries.map(formatRow); // Staged state: name → enabled. Initialized from disk; mutated by space.
const selected = await ctx.ui.select( const staged = new Map<string, boolean>();
`Extensions (${entries.filter((e) => e.enabled).length}/${entries.length} active)`, for (const e of entries) staged.set(e.name, e.enabled);
labels,
);
if (!selected) return;
const idx = labels.indexOf(selected); await ctx.ui.custom<void>((tui, theme, _kb, done) => {
if (idx < 0) return; let statusText = "";
const entry = entries[idx];
if (entry.kind === "dir") { const items: SettingItem[] = entries.map((e) => {
ctx.ui.notify( if (e.kind === "dir") {
`"${entry.name}" is a directory-style extension. Move it aside manually if you need to disable it.`, return {
"info", 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. container.addChild(settingsList);
if (entry.enabled) {
const guard = DISABLE_GUARDS[entry.name];
const reason = guard?.();
if (reason) {
await ctx.ui.confirm(`Cannot disable "${entry.name}"`, reason);
return;
}
}
const verb = entry.enabled ? "Disable" : "Enable"; // Footer status line — shows pending changes or guard rejections.
const ok = await ctx.ui.confirm( container.addChild({
`${verb} "${entry.name}"?`, render(_w: number) {
`${entry.fullPath}\n\nWill be renamed to ${ if (!statusText) return [""];
entry.enabled ? entry.name + ".ts.off" : entry.name + ".ts" const colored = statusText.startsWith("⊘")
} and pi will reload.`, ? theme.fg("warning", statusText)
); : theme.fg("muted", statusText);
if (!ok) return; return ["", colored];
},
invalidate() {},
});
try { return {
const { action } = toggleFile(entry); render(width: number) {
ctx.ui.notify(`${entry.name}: ${action}. Reloading…`, "info"); return container.render(width);
await ctx.reload(); },
} catch (err) { invalidate() {
const msg = err instanceof Error ? err.message : String(err); container.invalidate();
ctx.ui.notify(`Toggle failed: ${msg}`, "error"); },
} 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();
},
};
});
}, },
}); });
} }