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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user