| name | pi-utils-settings |
| description | Guide for using @aliou/pi-utils-settings to add persistent settings to pi extensions. Use when implementing config loading, settings UI, migrations, scopes, or TUI components (ArrayEditor, PathArrayEditor, SectionedSettings) for a pi extension. |
pi-utils-settings
Shared settings infrastructure for pi extensions. Provides JSON config with scoped persistence, draft-based settings UI, and reusable TUI components.
Quick Start
1. Define config types
Two types: a partial user-facing schema and a fully-resolved internal schema.
import { ConfigLoader } from "@aliou/pi-utils-settings";
interface MyConfig {
features?: { darkMode?: boolean };
tags?: string[];
}
interface ResolvedConfig {
features: { darkMode: boolean };
tags: string[];
}
const defaults: ResolvedConfig = {
features: { darkMode: false },
tags: [],
};
export const configLoader = new ConfigLoader<MyConfig, ResolvedConfig>(
"my-extension",
defaults,
);
await configLoader.load();
const config = configLoader.getConfig();
2. Register settings command
import { registerSettingsCommand } from "@aliou/pi-utils-settings";
registerSettingsCommand<MyConfig, ResolvedConfig>(pi, {
commandName: "my-ext:settings",
title: "My Extension Settings",
configStore: configLoader,
buildSections: (tabConfig, resolved) => [
{
label: "General",
items: [
{
id: "features.darkMode",
label: "Dark mode",
description: "Enable dark theme",
currentValue: (tabConfig?.features?.darkMode ?? resolved.features.darkMode) ? "on" : "off",
values: ["on", "off"],
},
],
},
],
onSettingChange: (id, newValue, config) => {
const updated = structuredClone(config);
if (id === "features.darkMode") {
updated.features ??= {};
updated.features.darkMode = newValue === "on";
updated;
}
;
},
});
The buildSections callback receives a third ctx argument:
buildSections: (tabConfig, resolved, ctx) => { ... }
Extra top-level tabs (non-scope)
Use extraTabs when you need tabs like Examples, Help, or Presets that are not tied to a specific scope.
import { registerSettingsCommand, type ExtraSettingsTab } from "@aliou/pi-utils-settings";
const extraTabs: ExtraSettingsTab<MyConfig, ResolvedConfig>[] = [
{
id: "examples",
label: "Examples",
buildSections: ({ resolved, enabledScopes, getRawForScope }) => [
{
label: "Info",
items: [
{
id: "examples.scopes",
label: "Enabled scopes",
currentValue: enabledScopes.join(", "),
},
{
id: "examples.theme",
label: "Dark mode",
currentValue: resolved.features.darkMode ? "on" : "off",
},
{
id: "examples.global",
label: "Global config",
currentValue: getRawForScope("global") ? "present" : "missing",
},
],
},
],
},
];
registerSettingsCommand<, >(pi, {
: ,
: ,
: configLoader,
extraTabs,
: {
[];
},
});
Ctrl+S semantics stay the same: only dirty scope drafts are saved. Extra tabs can mutate scope drafts via setDraftForScope(...) (typically from submenu callbacks).
For value-cycling items (values) in an extra tab, add onSettingChange to the extra tab and choose the target scope explicitly. applySettingChangeToScope(...) reuses the command-level onSettingChange handler, falling back to default dotted-path string storage when that handler returns null.
const extraTabs: ExtraSettingsTab<MyConfig, ResolvedConfig>[] = [
{
id: "presets",
label: "Presets",
buildSections: ({ getDraftForScope, getRawForScope }) => {
const config = getDraftForScope("global") ?? getRawForScope("global");
return [
{
label: "Presets",
items: [
{
id: "features.darkMode",
label: "Dark mode",
currentValue: config?.features?.darkMode ? "on" : "off",
values: ["on", "off"],
},
],
},
];
},
onSettingChange: (id, newValue, ctx) => {
ctx.applySettingChangeToScope("global", id, newValue);
},
},
];
Scopes
ConfigLoader supports three scopes, merged lowest-to-highest priority:
| Scope | Path | Persisted |
|---|
global | ~/.pi/agent/extensions/{name}.json | Yes |
local | {project}/.pi/extensions/{name}.json | Yes |
memory | In-memory only | No |
Default: ["global", "local"]. Configure via scopes option:
new ConfigLoader("my-ext", defaults, {
scopes: ["global", "memory"],
});
The settings UI shows one tab per enabled scope. You can also add non-scope top-level tabs with extraTabs. Tab/Shift+Tab switches across all tabs.
Adding Settings Items
Each item needs id, label, and currentValue. For interaction, provide either:
values: Array of allowed string values (cycles on Enter/Space) — for toggles and enums
submenu: Factory (currentValue, done) => Component — for complex editors (arrays, objects, fuzzy selectors)
description is optional and shown below the list when the item is selected.
The default change handler stores all values as raw strings (e.g., "on"/"off", "pnpm"). Use onSettingChange to convert display values to the correct storage types (booleans, numbers, etc.). Return the updated config, or null for unhandled IDs:
onSettingChange: (id, newValue, config) => {
const updated = structuredClone(config);
if (id === "refreshInterval") {
updated.refreshInterval = parseInt(newValue, 10);
return updated;
}
return null;
},
Submenu Items
For arrays or complex values, use submenu instead of values. Inside buildSections, use ctx.theme (a SettingsTheme that works as both SettingsListTheme and full Theme). The submenu factory also receives a { requestRender, hideHint } context so async submenus can request a redraw once data is ready. hideHint is true when the host panel renders its own controls line; submenus with a built-in hint footer (like SettingsDetailEditor) should forward it (hideHint: submenuCtx.hideHint) and implement getShortcuts() so the panel shows exactly one shortcut line, accurate for the active submenu:
import { ArrayEditor, PathArrayEditor } from "@aliou/pi-utils-settings";
const current = tabConfig ?? ({} as MyConfig);
const tags = current.tags ?? resolved.tags;
{
id: "tags",
label: "Tags",
currentValue: `${tags.length} items`,
submenu: (_val, done, submenuCtx) =>
new ArrayEditor({
label: "Tags",
items: [...tags],
theme: ctx.theme,
hideHint: submenuCtx.hideHint,
onSave: (items) => {
ctx.setDraft({ ...current, tags: items });
done(`${items.length} items`);
},
onDone: () => done(undefined),
}),
}
PathArrayEditor is identical but adds Tab completion for filesystem paths. Accepts optional validatePath hook.
These submenu components (ArrayEditor, PathArrayEditor, FuzzySelector, FuzzyMultiSelector, SettingsDetailEditor) render unframed — a plain title line followed by the body, no border — so they sit cleanly inside the host panel's own border. Each implements getShortcuts() per internal mode (list vs add/edit input, search vs no-matches) and accepts a hideHint option that suppresses its own shortcut footer. FuzzyMultiSelector keeps its existing showHints option (default true); hideHint wins over it when both are set.
For submenus that need to fetch remote data before showing the real editor, return a small wrapper that loads in the background and calls requestRender() when ready:
import type { Component } from "@earendil-works/pi-tui";
import { Key, matchesKey } from "@earendil-works/pi-tui";
import { FuzzySelector } from "@aliou/pi-utils-settings";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function loadRemoteThemes(): Promise<string[]> {
await sleep(2000);
return ["dark", "light", "solarized-dark", "nord"];
}
{
id: "appearance.remoteTheme",
label: "Remote theme",
currentValue: theme,
submenu: (_val, done, { requestRender, hideHint }) => {
class AsyncThemePicker {
: | = ;
() {
().( {
. = ({
: ,
: themes,
: theme,
: ctx.,
hideHint,
: {
: = {
...current,
: { ...current., : selected },
};
ctx.(updated);
(selected);
},
: (),
});
();
});
}
(: ): [] {
.?.(width) ?? [ctx..()];
}
(): | {
.?.();
}
(: ): {
(. === && (data, .)) {
();
;
}
.?.?.(data);
}
(): {
.?.?.();
}
}
();
},
}
When building components outside registerSettingsCommand (e.g., inside ctx.ui.custom), use getSettingsTheme(theme) to create a combined theme:
import { getSettingsTheme } from "@aliou/pi-utils-settings";
const settingsTheme = getSettingsTheme(uiTheme);
Migrations
Transform config on load. Applied in order; if any run, the result is saved back to disk.
import { type Migration } from "@aliou/pi-utils-settings";
const migrations: Migration<MyConfig>[] = [
{
name: "rename-field",
shouldRun: (config) => "oldField" in config,
run: (config, _filePath) => {
const { oldField, ...rest } = config as any;
return { ...rest, newField: oldField };
},
},
];
new ConfigLoader("my-ext", defaults, { migrations });
Versioned migrations
Declare a monotonic version on each migration: a non-negative integer or a semver string (e.g. the extension's package version, "1.2.0"; minor/patch may be omitted, no prerelease tags). All versioned migrations in one loader must use the same scheme — mixing integers and semver strings throws at construction. shouldRun then defaults to "config version < migration version", and the loader stamps the config file with the highest applied version. shouldRun, run, and the message factory receive a MigrationContext (filePath, fromVersion, toVersion, appliedMigrations).
Semver is for new extensions or a fresh baseline: a config already stamped with integer 3 reads as 3.0.0 under a semver loader, which is above "1.2.0", so existing integer-stamped extensions should keep integers (or gate the first semver migration with an explicit shouldRun).
const migrations: Migration<MyConfig>[] = [
{
name: "v1-features",
version: 1,
run: (c) => ({ ...c, features: {} }),
},
{
name: "v2-rename-field",
version: 2,
run: (c, _filePath, ctx) => {
const { oldField, ...rest } = c as any;
return { ...rest, newField: oldField };
},
},
];
configLoader.getVersion();
A migration with neither version nor shouldRun throws at construction. Provide both to gate on content while still stamping.
Migration messages
Migrations can declare an optional message that is queued when the migration runs successfully. Extensions drain these messages and display them to the user (e.g. via ctx.ui.notify in session_start).
{
name: "remove-legacy-field",
shouldRun: (c) => "legacyField" in c,
message: "[my-ext] legacyField has been removed from config.",
run: (c) => { const { legacyField, ...rest } = c as any; return rest; },
}
{
name: "rename-toolchain",
shouldRun: (c) => c.packageManager !== undefined,
message: (before, after) =>
`packageManager renamed to toolchain (was ${before.packageManager}, now ${after.toolchain})`,
run: (c) => { const { packageManager, ...rest } = c as any; return { ...rest, toolchain: packageManager }; },
}
{
name: "strip-deprecated",
shouldRun: (c) => "deprecatedField" in c,
message: (before) =>
(before ). !==
?
: ,
: { { deprecatedField, ...rest } = c ; rest; },
}
Display pending messages in your extension's session_start hook:
pi.on("session_start", (_event, ctx) => {
for (const message of configLoader.drainMessages()) {
ctx.ui.notify(message, "warning");
}
});
Messages are only queued when the migration succeeds. Failed migrations do not produce messages, and message factory errors are caught gracefully (logged to console, not queued).
afterMerge Hook
For post-merge logic that cannot be expressed as a simple deep merge:
new ConfigLoader("my-ext", defaults, {
afterMerge: (resolved, global, local, memory) => {
if (local?.overrideAll) {
resolved.features = local.overrideAll;
}
return resolved;
},
});
registerSettingsCommand Options
registerSettingsCommand(pi, {
commandName,
commandDescription,
title,
configStore,
buildSections,
extraTabs,
onSettingChange,
onBeforeClose,
onSave,
contentHeight,
});
onSave(ctx) is called after a successful save. Use it to reload runtime state.
onBeforeClose(isDirty) lets you prevent closing with unsaved drafts (return false to keep open).
ConfigStore Interface
Extensions with custom storage can implement ConfigStore directly instead of using ConfigLoader. Scope is exported as "global" | "local" | "memory".
import type { Scope, ConfigStore } from "@aliou/pi-utils-settings";
interface ConfigStore<TConfig, TResolved> {
getConfig(): TResolved;
getRawConfig(scope: Scope): TConfig | null;
hasScope(scope: Scope): boolean;
hasConfig(scope: Scope): boolean;
getEnabledScopes(): Scope[];
save(scope: Scope, config: TConfig): Promise<void>;
}
Helpers
import { setNestedValue, getNestedValue } from "@aliou/pi-utils-settings";
setNestedValue(obj, "a.b.c", true);
getNestedValue(obj, "a.b.c");
Setup Commands (Wizard Component)
For first-time configuration or multi-step onboarding, use the Wizard component. It renders all steps as tabs inside a bordered frame with navigation, progress indicators, and a shared state model.
import { Wizard, type WizardStepContext } from "@aliou/pi-utils-settings";
pi.registerCommand("my-ext:setup", {
description: "First-time setup wizard",
handler: async (_args, ctx) => {
const state = { url: "", name: "" };
const saved = await ctx.ui.custom<boolean>((_tui, uiTheme, _kb, done) => {
return new Wizard({
title: "My Extension Setup",
theme: uiTheme,
onComplete: () => done(true),
onCancel: () => done(false),
steps: [
{
label: "URL",
build: (wizardCtx) => new UrlStep(state, wizardCtx),
},
{
label: "Name",
build: () => (state, wizardCtx),
},
],
});
});
(!saved) ;
configLoader.(, state);
ctx..(, );
},
});
Each step is a Component that receives a WizardStepContext:
markComplete() / markIncomplete() — fill or clear the step's progress dot (●/○)
goNext() — advance to the next step
goPrev() — go back to the previous step
The Wizard handles borders, tab rendering, and global navigation (Tab/Shift+Tab between steps, Ctrl+S to submit, Esc to cancel). Step components should NOT handle Esc or Tab. Steps should call goNext() after the user completes them (e.g. after Enter selects a value).
Steps write into shared mutable state. After onComplete fires, read the state and save.
Wizard options: title, steps, theme, onComplete, onCancel, minContentHeight (minimum lines for the step area), hintSuffix (extra hint text).
Components
This package includes TUI components for use in settings UIs and setup wizards. All are exported from @aliou/pi-utils-settings.
| Component | Use case |
|---|
Wizard | Multi-step setup with tabbed navigation + borders |
SectionedSettings | Grouped settings list with search and submenus |
SettingsDetailEditor | Focused second-level editor for one selected item |
ArrayEditor | Edit a string[] (add/edit/delete) |
PathArrayEditor | Same as ArrayEditor + Tab path completion |
FuzzySelector | Fuzzy-searchable single-select list |
FuzzyMultiSelector | Fuzzy-searchable multi-select checklist with locked/recommended items and sub-options |
These components implement the pi-tui Component interface (render, handleInput, invalidate). They are designed for use inside registerSettingsCommand submenus or ctx.ui.custom calls.
Note: packages/ui/ is a separate package with different primitives (panels, tool renderers). There is no overlap.
Save Model
All changes are held as in-memory drafts until Ctrl+S. Ctrl+S saves from any depth, including inside open submenus — submenus commit edits to the draft on every mutation. Esc exits without saving. Dirty tabs show a * marker. After save, onSave callback fires (use to reload runtime state).
Wizard is the exception: it uses Ctrl+S for its own submit and is designed as a standalone command UI (onboarding/auth), not as a registerSettingsCommand submenu.
JSON Schema for Settings Files
Extensions can ship a JSON Schema so editors provide autocomplete and validation for settings files. The schema is generated from the TConfig interface with the bundled pi-settings-schema CLI (wraps ts-json-schema-generator, injects the reserved $schema/version properties), and ConfigLoader injects a $schema field into saved files.
See references/json-schema.md (relative to this skill directory) for the full setup guide: JSDoc conventions, gen:schema/check:schema scripts, buildSchemaUrl wiring, CI integration, and testing commands.
Pi Extension Wiring
pi is the ExtensionAPI passed to your extension's activate function:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default async function activate(pi: ExtensionAPI) {
await configLoader.load();
registerSettingsCommand(pi, { });
}
For Wizard commands, the handler receives a ctx with ctx.ui.custom for custom UI rendering. Check ctx.hasUI before using it:
pi.registerCommand("my-ext:setup", {
description: "First-time setup wizard",
handler: async (_args, ctx) => {
if (!ctx.hasUI) return;
const result = await ctx.ui.custom((_tui, uiTheme, _kb, done) => {
return new Wizard({ });
});
},
});
Full Pattern
Typical extension file structure:
my-extension/
index.ts # activate() calls configLoader.load(), registers commands
config.ts # ConfigLoader + types + migrations + buildSchemaUrl
schema.json # auto-generated JSON Schema (committed, hosted publicly)
commands/
settings.ts # registerSettingsCommand (edit existing config)
setup.ts # optional: multi-step wizard for first-time config
A complete reference extension is bundled at references/example-extension/ (relative to this skill directory). It demonstrates every feature: config types, migrations, afterMerge, settings command with scope tabs plus an extra non-scope tab, all item types (toggles, enums, submenus with ArrayEditor/PathArrayEditor/FuzzySelector), setup wizard using the Wizard component with tabbed steps, and the activation pattern.