diff --git a/AGENTS.md b/AGENTS.md index d373b12..1e3a5ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -162,11 +162,15 @@ Notification text: `Pi — Done (Ns)` where N is the rounded elapsed seconds. ### `ext-toggle.ts` -Registers `/ext` slash command. Lists files in `~/.pi/agent/extensions/`, -shows `●` (active) / `○` (disabled) plus dir/symlink hints, and lets the -user toggle individual extensions by renaming them between `name.ts` and -`name.ts.off`. Calls `ctx.reload()` after a toggle so the change takes -effect without restarting pi. +Registers `/ext` slash command. Opens a multi-toggle overlay built on +`SettingsList` from pi-tui. Lists files in `~/.pi/agent/extensions/` with +`● enabled` / `○ disabled` values. Space stages a toggle; Enter commits +all pending renames at once and calls `ctx.reload()`; Escape cancels. + +**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:** @@ -203,10 +207,16 @@ effect without restarting pi. **API used:** - `pi.registerCommand(name, { description, handler })` — registers `/ext`. -- `ctx.ui.select(title, items)` — picker; returns selected string or `undefined`. -- `ctx.ui.confirm(title, message)` — yes/no dialog returning `boolean`. -- `ctx.ui.notify(message, level)` — transient toast. -- `ctx.reload()` — reloads extensions/skills/prompts/themes; same as `/reload`. +- `ctx.ui.custom((tui, theme, kb, done) => Component)` — full-overlay + with own keyboard handling. The wrapper component intercepts `enter` + via `matchesKey(data, Key.enter)` before forwarding to `SettingsList`, + 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. diff --git a/README.md b/README.md index fa99525..9ba7698 100644 --- a/README.md +++ b/README.md @@ -170,9 +170,16 @@ Registers `/ext` — a slash command that lists extensions in `~/.pi/agent/exten **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:** - 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. diff --git a/extensions/ext-toggle.ts b/extensions/ext-toggle.ts index 080b58a..3cc084d 100644 --- a/extensions/ext-toggle.ts +++ b/extensions/ext-toggle.ts @@ -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 | 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(); + 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((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(); + }, + }; + }); }, }); }