avtc-pi-settings-ui 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/assets/images/settings-dropdown.png +0 -0
- package/assets/images/settings.png +0 -0
- package/index.ts +30 -0
- package/package.json +65 -0
- package/src/ctx-store.ts +34 -0
- package/src/custom-validation.ts +45 -0
- package/src/factory.ts +233 -0
- package/src/model-presets.ts +101 -0
- package/src/normalization.ts +421 -0
- package/src/persistence.ts +115 -0
- package/src/preset-resolver.ts +66 -0
- package/src/schema.ts +144 -0
- package/src/snippets/vendored/subscribe-to-dialog-coordinator.ts +98 -0
- package/src/snippets/vendored/subscribe-to-notifications.ts +150 -0
- package/src/type-definitions.ts +301 -0
- package/src/ui/modal-frame.ts +130 -0
- package/src/ui/picker-helpers.ts +68 -0
- package/src/ui/schema-tabs.ts +140 -0
- package/src/ui/settings-command.ts +321 -0
- package/src/ui/settings-modal.ts +414 -0
- package/src/ui/value-picker.ts +353 -0
- package/src/validation.ts +107 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 avtc <tarasenkov@gmail.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# avtc-pi-settings-ui
|
|
2
|
+
|
|
3
|
+
A schema-driven settings UI for [pi](https://pi.dev) extensions — define your settings, get a tabbed modal, a `/<name>:settings` command, persistence, and presets.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Schema-driven UI** — define settings once in a typed schema; the library renders the full modal automatically
|
|
8
|
+
- **Tabbed layout** — group settings into tabs; each tab renders as a separate panel
|
|
9
|
+
- **Typed setting items** — 7 built-in types (`string`, `number`, `duration`, `compact-threshold`, `boolean`, `model`, `thinking-level`) with presets, optional `min`/`max` bounds, and custom type support
|
|
10
|
+
- **Filter-first UI** — type to filter every list by substring (over label + value); `Tab`/`Shift+Tab` switch tabs, `Enter` opens the value picker
|
|
11
|
+
- **Layered persistence** — choose any combination of three storage levels via `storageLevels` (`session`, `project`, `global`); see [Persistence & session lifecycle](#persistence--session-lifecycle)
|
|
12
|
+
- **Atomic writes** — settings are saved atomically to prevent corruption
|
|
13
|
+
- **Value normalization** — every value is validated against the schema before it's saved
|
|
14
|
+
- **Environment variable propagation** — settings can be propagated to child processes via env vars (e.g. for subagents)
|
|
15
|
+
|
|
16
|
+
The rendered modal — tabbed panels, filter-first lists, and per-type value pickers:
|
|
17
|
+
|
|
18
|
+

|
|
19
|
+
|
|
20
|
+

|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
Add it as a dependency of your extension:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install avtc-pi-settings-ui
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Dialog coordination (optional)
|
|
31
|
+
|
|
32
|
+
The settings modal queues behind other open dialogs (instead of stealing keyboard focus) via [`avtc-pi-ui-components`](https://github.com/avtc/avtc-pi-ui-components)'s dialog coordinator. If you want that coordination, also install ui-components and load it as a companion extension:
|
|
33
|
+
|
|
34
|
+
```jsonc
|
|
35
|
+
// your package.json
|
|
36
|
+
{
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"avtc-pi-settings-ui": "^1.0.0",
|
|
39
|
+
"avtc-pi-ui-components": "^1.0.0"
|
|
40
|
+
},
|
|
41
|
+
"pi": {
|
|
42
|
+
"extensions": [
|
|
43
|
+
"./index.ts",
|
|
44
|
+
"node_modules/avtc-pi-ui-components/index.ts"
|
|
45
|
+
],
|
|
46
|
+
"allowedCodeDeps": ["avtc-pi-settings-ui"] // ui-components is loaded, not imported
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Without ui-components the settings modal still works — it just opens immediately rather than queuing.
|
|
52
|
+
|
|
53
|
+
## Public API
|
|
54
|
+
|
|
55
|
+
| Export | Purpose |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `registerSettingsCommand(pi, schema, opts)` | Register the `/<name>:settings` command + modal; returns the settings handle you read from at runtime |
|
|
58
|
+
| `settingsFilePaths(name)` | Global + project file paths for `<name>`'s settings — `~/.pi/agent/<name>-settings.json` and `<cwd>/.pi/<name>-settings.json` (spread into the schema) |
|
|
59
|
+
| `SettingSchema`, `SettingsSchema` | Define your settings and tabs |
|
|
60
|
+
| `StorageLevel`, `SettingsHandle`, `RegisterSettingsOptions` | Types: storage levels (`session`/`project`/`global`), the returned handle, the options bag |
|
|
61
|
+
| `PresetsSource`, `PresetPair`, `PresetElement`, `PresetValue` | Author `presets`: full pairs or bare values (string/number/boolean/null) |
|
|
62
|
+
| `registerTypeDefinition`, `TypeDefinition`, `TypeContext` | Register custom types (7 built-ins ship pre-registered) |
|
|
63
|
+
| `formatHumanDuration` | Human form of a duration |
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { registerSettingsCommand, SettingsSchema, settingsFilePaths } from "avtc-pi-settings-ui";
|
|
69
|
+
|
|
70
|
+
const schema: SettingsSchema = {
|
|
71
|
+
settings: [
|
|
72
|
+
{
|
|
73
|
+
id: "mySetting",
|
|
74
|
+
label: "My Setting",
|
|
75
|
+
description: "Controls something important",
|
|
76
|
+
type: "string",
|
|
77
|
+
defaultValue: "default",
|
|
78
|
+
presets: [
|
|
79
|
+
["Default", "default"],
|
|
80
|
+
["Alternative", "alternative"],
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
tabs: [{ label: "General", settingIds: ["mySetting"] }],
|
|
85
|
+
...settingsFilePaths("my-extension"),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const settings = registerSettingsCommand(pi, schema, {
|
|
89
|
+
commandName: "my-extension:settings",
|
|
90
|
+
title: "My Extension Settings",
|
|
91
|
+
titleRight: "avtc-pi-my-extension",
|
|
92
|
+
storageLevels: ["session", "project", "global"],
|
|
93
|
+
envVar: "PI_SETTINGS_MY_EXT",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
settings.getSettings();
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Setting types
|
|
100
|
+
|
|
101
|
+
Every setting has a `type` resolved through a first-class `TypeDefinition` registry. Seven built-ins ship out of the box:
|
|
102
|
+
|
|
103
|
+
| Type | Stored value | Notes |
|
|
104
|
+
|------|-------------|-------|
|
|
105
|
+
| `string` | `string` | Text; closed enums via presets (`["a", "b"]`) |
|
|
106
|
+
| `number` | `number` \| `null` | Optional `min`/`max` bounds; `null` (unbounded/off) via a null preset |
|
|
107
|
+
| `duration` | `number` \| `null` | Human-readable (`5m`, `1h`, `30s`, `14d`); stored as milliseconds. `null` = unbounded/off, via a null preset |
|
|
108
|
+
| `compact-threshold` | `string` | Compact-context thresholds (`none`, `compact`, `compact>75K`, …) |
|
|
109
|
+
| `boolean` | `boolean` | `true`/`false`; presets `[true, false]` |
|
|
110
|
+
| `model` | `string` \| `null` | Model id (`provider/id`); presets generated from the available models. Nullable via a `["Default", null]` preset |
|
|
111
|
+
| `thinking-level` | `string` | Thinking level (label equals value) |
|
|
112
|
+
|
|
113
|
+
A `presets` array can hold full `[label, value]` pairs or bare values (`"a"`, `5`, `true`, `null`); a bare value pairs with itself, except a duration string which parses to milliseconds.
|
|
114
|
+
|
|
115
|
+
**Custom types:** register your own via `registerTypeDefinition(def)` and reference it as `type: "<def.id>"`. A `TypeDefinition` declares how values parse/format and optional `presets`; null support comes from including a null-valued preset (e.g. `["Off", null]`).
|
|
116
|
+
|
|
117
|
+
## Persistence & session lifecycle
|
|
118
|
+
|
|
119
|
+
Choose your storage levels via `storageLevels` on `registerSettingsCommand` (default: all three):
|
|
120
|
+
|
|
121
|
+
- **`session`** — in-memory only; survives `/reload` (same process) but not a full restart or new terminal. When `session` is included, settings are also propagated to subagents via the env var.
|
|
122
|
+
- **`project`** — `<cwd>/.pi/<name>-settings.json`. Survives across processes in that project.
|
|
123
|
+
- **`global`** — `~/.pi/agent/<name>-settings.json`. Survives everywhere.
|
|
124
|
+
|
|
125
|
+
**How edits save:** with a single file level (`global` or `project`), each edit writes straight to that file, so every pi instance reads the same on-disk truth. With multiple levels (or `session`), edits are held as a draft until you press a save key — `Ctrl+S` saves project, `Ctrl+D` saves global (each only when its level is enabled).
|
|
126
|
+
|
|
127
|
+
> Developed with [Z.ai](https://z.ai/subscribe?ic=N5IV4LLOOV) — get 10% off your subscription via this referral link.
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
|
Binary file
|
|
Binary file
|
package/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
// Minimal public API. Everything else (the settings handle factory, normalization internals,
|
|
5
|
+
// persistence helpers, UI components, display/coerce helpers, built-in type-def knobs) is
|
|
6
|
+
// internal — import it from its `src/` path only if you have a specific reason not covered here.
|
|
7
|
+
|
|
8
|
+
// Persistence — the standard global/project file-path pair every consumer spreads into its schema.
|
|
9
|
+
export { settingsFilePaths } from "./src/persistence.js";
|
|
10
|
+
// Authoring types — the schema a consumer writes.
|
|
11
|
+
export type {
|
|
12
|
+
PresetElement,
|
|
13
|
+
PresetPair,
|
|
14
|
+
PresetsSource,
|
|
15
|
+
PresetValue,
|
|
16
|
+
SettingSchema,
|
|
17
|
+
SettingsSchema,
|
|
18
|
+
SettingsTabSchema,
|
|
19
|
+
StorageLevel,
|
|
20
|
+
} from "./src/schema.js";
|
|
21
|
+
export type { TypeContext, TypeDefinition } from "./src/type-definitions.js";
|
|
22
|
+
// Type system — register a custom TypeDefinition (the built-in 7 are pre-registered).
|
|
23
|
+
export { registerTypeDefinition } from "./src/type-definitions.js";
|
|
24
|
+
export type { RegisterSettingsOptions, SettingsHandle } from "./src/ui/settings-command.js";
|
|
25
|
+
// Command registration — the single entry point: registers the /<name> command + modal and
|
|
26
|
+
// returns the settings handle (what you call getSettings/updateSetting on at runtime).
|
|
27
|
+
export { registerSettingsCommand } from "./src/ui/settings-command.js";
|
|
28
|
+
|
|
29
|
+
// Display helper — the human form of a duration (the inverse of the `duration` type's parse).
|
|
30
|
+
export { formatHumanDuration } from "./src/validation.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "avtc-pi-settings-ui",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Schema-driven settings infrastructure for pi extensions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.ts",
|
|
7
|
+
"types": "index.ts",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"lint": "biome check .",
|
|
13
|
+
"lint:optional-params": "node scripts/no-optional-params.cjs src/ tests/",
|
|
14
|
+
"lint:bare-literals": "node scripts/no-bare-literals.cjs src/ index.ts",
|
|
15
|
+
"lint:design-refs": "node scripts/no-design-refs.cjs src/ index.ts README.md",
|
|
16
|
+
"lint:bundled-imports": "node scripts/no-bundled-extension-imports.cjs src/ index.ts",
|
|
17
|
+
"knip": "knip --production",
|
|
18
|
+
"typecheck": "tsc --noEmit --incremental --tsBuildInfoFile node_modules/.cache/tsc.tsbuildinfo",
|
|
19
|
+
"test:unit": "node scripts/test-low-priority.cjs vitest run",
|
|
20
|
+
"test:watch": "node scripts/test-low-priority.cjs vitest",
|
|
21
|
+
"test": "node scripts/test-low-priority.cjs \"node scripts/run-parallel.cjs \"npm run lint:optional-params :: npm run lint:bare-literals :: npm run lint:design-refs :: npm run lint:bundled-imports :: npm run lint:as-never :: npm run lint :: npm run typecheck :: npm run knip :: npm run dupes\" && vitest run\"",
|
|
22
|
+
"dupes": "jscpd src/ --exit-code --threshold 0.01",
|
|
23
|
+
"lint:as-never": "node scripts/no-as-never.cjs src/ tests/ index.ts"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"avtc-pi-logger": "^1.0.0"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@earendil-works/pi-coding-agent": "^0.80.2",
|
|
30
|
+
"@earendil-works/pi-tui": "^0.80.2",
|
|
31
|
+
"minimatch": "^10.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@biomejs/biome": "^2.5.0",
|
|
35
|
+
"@earendil-works/pi-coding-agent": "^0.80.2",
|
|
36
|
+
"@earendil-works/pi-tui": "^0.80.2",
|
|
37
|
+
"@types/node": "^25.9.3",
|
|
38
|
+
"jscpd": "^5.0.10",
|
|
39
|
+
"knip": "^6.17.1",
|
|
40
|
+
"typescript": "^6.0.3",
|
|
41
|
+
"vitest": "^4.1.9"
|
|
42
|
+
},
|
|
43
|
+
"pi": {
|
|
44
|
+
"allowedCodeDeps": [
|
|
45
|
+
"avtc-pi-logger"
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
"author": "avtc <tarasenkov@gmail.com>",
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "https://github.com/avtc/avtc-pi-settings-ui.git"
|
|
53
|
+
},
|
|
54
|
+
"bugs": {
|
|
55
|
+
"url": "https://github.com/avtc/avtc-pi-settings-ui/issues"
|
|
56
|
+
},
|
|
57
|
+
"homepage": "https://github.com/avtc/avtc-pi-settings-ui",
|
|
58
|
+
"files": [
|
|
59
|
+
"index.ts",
|
|
60
|
+
"src/",
|
|
61
|
+
"assets/",
|
|
62
|
+
"LICENSE",
|
|
63
|
+
"README.md"
|
|
64
|
+
]
|
|
65
|
+
}
|
package/src/ctx-store.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Captured command context, stashed at modal-open so resolvers that run later (the model
|
|
6
|
+
* type-def's preset resolver) can read the host's model registry without it being threaded
|
|
7
|
+
* through every call. Captured once per open; tolerant of hosts that expose only a subset.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
interface CapturedCtx {
|
|
13
|
+
modelRegistry?: ModelRegistry;
|
|
14
|
+
cwd?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let captured: CapturedCtx = {};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Stash the command context (modelRegistry + cwd) for later resolver use.
|
|
21
|
+
* Tolerates a context lacking either field — a host that does not expose modelRegistry
|
|
22
|
+
* stores `undefined`, and the model resolver degrades to a Default-only list.
|
|
23
|
+
*/
|
|
24
|
+
export function captureCtx(ctx: { modelRegistry?: ModelRegistry; cwd?: string }): void {
|
|
25
|
+
captured = { modelRegistry: ctx.modelRegistry, cwd: ctx.cwd };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read the stash captured at modal-open. Resolvers (the model type-def) call this to reach
|
|
30
|
+
* the host's model registry without it being threaded through every call.
|
|
31
|
+
*/
|
|
32
|
+
export function getCapturedCtx(): CapturedCtx {
|
|
33
|
+
return captured;
|
|
34
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Custom-value validation for the settings value-picker.
|
|
6
|
+
*
|
|
7
|
+
* These functions sit ABOVE the `TypeDefinition` registry (they look a type up and run its
|
|
8
|
+
* parse/format), so they live in their own module to keep the dependency graph acyclic:
|
|
9
|
+
* `custom-validation → type-definitions → validation` (primitives). The parse/format PRIMITIVES
|
|
10
|
+
* (`parseHumanDuration`, `formatHumanDuration`, …) stay in `validation.ts`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { SettingType } from "./schema.js";
|
|
14
|
+
import { getTypeDefinition, type TypeContext } from "./type-definitions.js";
|
|
15
|
+
|
|
16
|
+
/** Validate a custom value for a given setting type via the TypeDefinition registry.
|
|
17
|
+
* parse(input, ctx) → undefined? invalid; non-finite? invalid; bounded && out-of-[min,max]?
|
|
18
|
+
* invalid; else format(parsed). Returns the formatted internal value string, or undefined for invalid
|
|
19
|
+
* (undefined, never null). `ctx` (presets/min/max) is required — pass `null` to skip
|
|
20
|
+
* preset-matching and bounds (the value-picker always builds a real ctx from its presets). */
|
|
21
|
+
export function validateCustomValue(
|
|
22
|
+
value: string,
|
|
23
|
+
settingType: SettingType,
|
|
24
|
+
ctx: TypeContext | null,
|
|
25
|
+
): string | undefined {
|
|
26
|
+
const typeDef = getTypeDefinition(settingType);
|
|
27
|
+
const parsed = typeDef.parse(value.trim(), ctx ?? { presets: [] });
|
|
28
|
+
if (parsed === undefined) return undefined;
|
|
29
|
+
// Finite check: reject Infinity/NaN so the resolver's accept-criteria stays a subset
|
|
30
|
+
// of the gate's (the gate rejects non-finite via !Number.isFinite). The duration parser's bare-ms
|
|
31
|
+
// fallback would otherwise let "Infinity" through (Number("Infinity")=Infinity).
|
|
32
|
+
if (typeDef.valueType === "number" && typeof parsed === "number" && !Number.isFinite(parsed)) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
if (typeDef.bounded === true && typeof parsed === "number" && ctx !== null) {
|
|
36
|
+
if (ctx.min !== undefined && parsed < ctx.min) return undefined;
|
|
37
|
+
if (ctx.max !== undefined && parsed > ctx.max) return undefined;
|
|
38
|
+
}
|
|
39
|
+
return typeDef.format(parsed, ctx ?? { presets: [] });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Setting types that accept genuinely meaningful free-form custom input (a TypeDefinition field lookup). */
|
|
43
|
+
export function supportsCustomValues(settingType: SettingType): boolean {
|
|
44
|
+
return getTypeDefinition(settingType).supportsCustomValues;
|
|
45
|
+
}
|
package/src/factory.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Factory for creating settings extensions.
|
|
6
|
+
*
|
|
7
|
+
* Given a SettingsSchema + options, provides getSettings / updateSetting / loadSettingsIntoMemory.
|
|
8
|
+
* Two persistence modes, selected by `storageLevels`:
|
|
9
|
+
* - STATELESS (a single file level — `["global"]` or `["project"]`): `getSettings` reads the file
|
|
10
|
+
* fresh on every call (cross-instance immediate), `updateSetting` does an immediate
|
|
11
|
+
* read-modify-write of that file, and the env var is never read or written.
|
|
12
|
+
* - BUFFER (everything else — the default session+project+global, or any combination involving
|
|
13
|
+
* session or multiple files): an in-memory buffer is the read target; the env var is serialized
|
|
14
|
+
* iff `session` is included (for subagent propagation + reload survival).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
clampSettings,
|
|
19
|
+
isInvalidResult,
|
|
20
|
+
normalizeFromSchema,
|
|
21
|
+
resolveSettingValue,
|
|
22
|
+
validateSchema,
|
|
23
|
+
} from "./normalization.js";
|
|
24
|
+
import {
|
|
25
|
+
saveGlobalSettings as _saveGlobalSettings,
|
|
26
|
+
saveProjectSettings as _saveProjectSettings,
|
|
27
|
+
atomicWriteJson,
|
|
28
|
+
loadSettingsFromFiles,
|
|
29
|
+
readJsonFile,
|
|
30
|
+
} from "./persistence.js";
|
|
31
|
+
import { normalizeStorageLevels, type SettingSchema, type SettingsSchema, type StorageLevel } from "./schema.js";
|
|
32
|
+
|
|
33
|
+
/** Resolve a raw value through the gate, returning the default for the `undefined` reset sentinel. */
|
|
34
|
+
function resolveUpdateValue(
|
|
35
|
+
raw: unknown,
|
|
36
|
+
setting: SettingSchema,
|
|
37
|
+
): { value: unknown; invalid: false } | { invalid: true } {
|
|
38
|
+
if (raw === undefined) return { value: setting.defaultValue, invalid: false };
|
|
39
|
+
const resolved = resolveSettingValue({ [setting.id]: raw }, setting);
|
|
40
|
+
if (isInvalidResult(resolved)) return { invalid: true };
|
|
41
|
+
return { value: resolved, invalid: false };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create a settings extension from a schema.
|
|
46
|
+
*
|
|
47
|
+
* The host extension registers the command via `registerSettingsCommand` (which auto-calls
|
|
48
|
+
* `loadSettingsIntoMemory` at registration + on `session_start`), so consumers no longer wire
|
|
49
|
+
* startup loading by hand.
|
|
50
|
+
*/
|
|
51
|
+
export function createSettingsExtension<S extends object = Record<string, unknown>>(
|
|
52
|
+
schema: SettingsSchema,
|
|
53
|
+
options: {
|
|
54
|
+
storageLevels?: StorageLevel[];
|
|
55
|
+
clampFn?: (result: Record<string, unknown>) => void;
|
|
56
|
+
envVar?: string;
|
|
57
|
+
onLoad?: () => void;
|
|
58
|
+
} = {},
|
|
59
|
+
) {
|
|
60
|
+
// Validate the schema up front (fail-fast): preset strings parse, every setting is in a tab,
|
|
61
|
+
// every tab id resolves, no duplicate/orphan ids. Also eagerly populates the static-presets
|
|
62
|
+
// cache so runtime resolution never re-throws.
|
|
63
|
+
validateSchema(schema);
|
|
64
|
+
|
|
65
|
+
const clampFn = options.clampFn;
|
|
66
|
+
const envVar = options.envVar;
|
|
67
|
+
const onLoad = options.onLoad;
|
|
68
|
+
const storageLevels = normalizeStorageLevels(options.storageLevels);
|
|
69
|
+
const sessionIncluded = storageLevels.includes("session");
|
|
70
|
+
const singleFileLevel =
|
|
71
|
+
storageLevels.length === 1 && (storageLevels[0] === "global" || storageLevels[0] === "project");
|
|
72
|
+
const singleFileKind: "global" | "project" | null = singleFileLevel
|
|
73
|
+
? (storageLevels[0] as "global" | "project")
|
|
74
|
+
: null;
|
|
75
|
+
|
|
76
|
+
// Buffer-mode state (unused in stateless mode — every read/write hits the file directly).
|
|
77
|
+
let settings: Record<string, unknown> = {};
|
|
78
|
+
let settingsLoaded = false;
|
|
79
|
+
// cwd/globalDir learned via loadSettingsIntoMemory (used by stateless reads + project writes).
|
|
80
|
+
// Default to process.cwd() so a stateless getSettings() works before any load call.
|
|
81
|
+
let cwd = process.cwd();
|
|
82
|
+
let globalDir: string | undefined;
|
|
83
|
+
|
|
84
|
+
function computeDefaults(): S {
|
|
85
|
+
const defaults: Record<string, unknown> = {};
|
|
86
|
+
for (const setting of schema.settings) {
|
|
87
|
+
defaults[setting.id] = setting.defaultValue;
|
|
88
|
+
}
|
|
89
|
+
return defaults as S;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The single file path for a stateless handle (global or project, per `singleFileKind`). */
|
|
93
|
+
function singleFilePath(): string {
|
|
94
|
+
return singleFileKind === "global" ? schema.globalPath(globalDir) : schema.projectPath(cwd);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function getSettings(): S {
|
|
98
|
+
if (singleFileLevel) {
|
|
99
|
+
// Stateless: read the file fresh every call (cross-instance immediate), normalize + clamp.
|
|
100
|
+
const raw = readJsonFile(singleFilePath()) ?? {};
|
|
101
|
+
const normalized = normalizeFromSchema(raw, schema);
|
|
102
|
+
clampSettings(normalized, clampFn);
|
|
103
|
+
return normalized as S;
|
|
104
|
+
}
|
|
105
|
+
return settings as S;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Apply a resolved value to the buffer + clamp + serialize the env var (buffer mode only). */
|
|
109
|
+
function applyToBuffer(canonicalKey: string, resolvedValue: unknown): void {
|
|
110
|
+
settings[canonicalKey] = resolvedValue;
|
|
111
|
+
settingsLoaded = true;
|
|
112
|
+
clampSettings(settings, clampFn);
|
|
113
|
+
if (envVar && sessionIncluded) {
|
|
114
|
+
process.env[envVar] = JSON.stringify(settings);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Read-modify-write a single key into a file: read the fresh file, set the key, normalize +
|
|
120
|
+
* clamp the whole record, full-rewrite atomically. Used by both stateless no-level writes and
|
|
121
|
+
* explicit project/global surgical writes.
|
|
122
|
+
*/
|
|
123
|
+
function writeKeyToFile(path: string, canonicalKey: string, resolvedValue: unknown): void {
|
|
124
|
+
const existing = readJsonFile(path) ?? {};
|
|
125
|
+
existing[canonicalKey] = resolvedValue;
|
|
126
|
+
const normalized = normalizeFromSchema(existing, schema);
|
|
127
|
+
clampSettings(normalized, clampFn);
|
|
128
|
+
atomicWriteJson(path, normalized);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function updateSetting(
|
|
132
|
+
key: string,
|
|
133
|
+
value: unknown,
|
|
134
|
+
opts: {
|
|
135
|
+
level?: StorageLevel;
|
|
136
|
+
} = {},
|
|
137
|
+
): void {
|
|
138
|
+
const settingSchema = schema.settings.find((s) => s.id === key || s.aliases?.includes(key));
|
|
139
|
+
if (!settingSchema) return;
|
|
140
|
+
const canonicalKey = settingSchema.id;
|
|
141
|
+
const level = opts?.level;
|
|
142
|
+
|
|
143
|
+
// Route the value through the same gate as load. INVALID → no write (keep the old value).
|
|
144
|
+
const resolved = resolveUpdateValue(value, settingSchema);
|
|
145
|
+
if (resolved.invalid) return;
|
|
146
|
+
|
|
147
|
+
if (level === "session") {
|
|
148
|
+
// Buffer only — no file write. (Only meaningful in buffer mode.)
|
|
149
|
+
applyToBuffer(canonicalKey, resolved.value);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (level === "project" || level === "global") {
|
|
154
|
+
// Explicit surgical one-key write to that file (preserves other keys), + sync the buffer in
|
|
155
|
+
// buffer mode so a subsequent getSettings() reflects it.
|
|
156
|
+
const path = level === "global" ? schema.globalPath(globalDir) : schema.projectPath(cwd);
|
|
157
|
+
writeKeyToFile(path, canonicalKey, resolved.value);
|
|
158
|
+
if (!singleFileLevel) applyToBuffer(canonicalKey, resolved.value);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// No level → mode-aware.
|
|
163
|
+
if (singleFileLevel) {
|
|
164
|
+
// Immediate read-modify-write of the single file. Stateless: no buffer, no env var.
|
|
165
|
+
writeKeyToFile(singleFilePath(), canonicalKey, resolved.value);
|
|
166
|
+
} else {
|
|
167
|
+
// Buffer: update the buffer + clamp + env var (iff session is included).
|
|
168
|
+
applyToBuffer(canonicalKey, resolved.value);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function loadSettingsIntoMemory(cwdArg: string | undefined, globalDirArg: string | undefined): void {
|
|
173
|
+
cwd = cwdArg ?? process.cwd();
|
|
174
|
+
globalDir = globalDirArg;
|
|
175
|
+
|
|
176
|
+
// Stateless: nothing to buffer (every read hits the file). Just fire the load hook.
|
|
177
|
+
if (singleFileLevel) {
|
|
178
|
+
onLoad?.();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
// Env var first (subagent propagation + reload survival) — only when session is included.
|
|
184
|
+
if (envVar && sessionIncluded) {
|
|
185
|
+
const envSettings = process.env[envVar];
|
|
186
|
+
if (envSettings) {
|
|
187
|
+
const raw = JSON.parse(envSettings) as Record<string, unknown>;
|
|
188
|
+
const parsed: Record<string, unknown> = Object.create(null);
|
|
189
|
+
for (const key of Object.keys(raw)) parsed[key] = raw[key]; // Prevent prototype pollution
|
|
190
|
+
settings = normalizeFromSchema(parsed, schema);
|
|
191
|
+
clampSettings(settings, clampFn);
|
|
192
|
+
settingsLoaded = true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!settingsLoaded) {
|
|
197
|
+
const defaults = computeDefaults();
|
|
198
|
+
settings = loadSettingsFromFiles(cwd, schema, defaults as unknown as Record<string, unknown>, globalDir);
|
|
199
|
+
clampSettings(settings, clampFn);
|
|
200
|
+
settingsLoaded = true;
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
settings = computeDefaults() as unknown as Record<string, unknown>;
|
|
204
|
+
settingsLoaded = true;
|
|
205
|
+
} finally {
|
|
206
|
+
if (envVar && sessionIncluded) {
|
|
207
|
+
process.env[envVar] = JSON.stringify(settings);
|
|
208
|
+
}
|
|
209
|
+
onLoad?.();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function saveGlobalSettings(saveSettings: Record<string, unknown>, globalDirArg: string | undefined): void {
|
|
214
|
+
if (globalDirArg !== undefined) globalDir = globalDirArg;
|
|
215
|
+
_saveGlobalSettings(saveSettings, schema, globalDir);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function saveProjectSettings(saveSettings: Record<string, unknown>, cwdArg: string): void {
|
|
219
|
+
cwd = cwdArg;
|
|
220
|
+
_saveProjectSettings(saveSettings, cwd, schema);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
getSettings,
|
|
225
|
+
updateSetting,
|
|
226
|
+
loadSettingsIntoMemory,
|
|
227
|
+
saveGlobalSettings,
|
|
228
|
+
saveProjectSettings,
|
|
229
|
+
computeDefaults,
|
|
230
|
+
schema,
|
|
231
|
+
storageLevels,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The model presets resolver — the default `presets` source for the `model` type-def. Reads the
|
|
6
|
+
* host's model registry (captured at modal-open), keeps only the enabled models, and returns the
|
|
7
|
+
* `[label, value]` pairs the picker renders. Resolved once at open (by `resolveFunctionPresets`),
|
|
8
|
+
* so the list reflects the live models.json / auth state on each open.
|
|
9
|
+
*
|
|
10
|
+
* Value shape: `"<provider>/<id>"` (the form pi's own model selector pushes), with a leading
|
|
11
|
+
* `["Default", null]` pair so a model setting can be unset. Labels mirror pi's selector:
|
|
12
|
+
* `"<id> [<provider>]"`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { minimatch } from "minimatch";
|
|
18
|
+
import { getCapturedCtx } from "./ctx-store.js";
|
|
19
|
+
import { readJsonFile } from "./persistence.js";
|
|
20
|
+
import type { PresetPair } from "./schema.js";
|
|
21
|
+
|
|
22
|
+
/** The model fields `modelLabel` reads. Structural so any model-like object works. */
|
|
23
|
+
interface ModelLike {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly provider: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The null entry — a model setting is always selectable as "Default" (unset). */
|
|
29
|
+
const DEFAULT_PAIR: PresetPair = ["Default", null];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Format a model as `"<id> [<provider>]"` — the same shape pi's own model selector renders (the
|
|
33
|
+
* provider sits in a muted badge there; the raw provider id is used, not a display name).
|
|
34
|
+
*/
|
|
35
|
+
export function modelLabel(model: ModelLike): string {
|
|
36
|
+
return `${model.id} [${model.provider}]`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The serialized value for a model: `"<provider>/<id>"` (pi's full-id form). */
|
|
40
|
+
function modelValue(model: ModelLike): string {
|
|
41
|
+
return `${model.provider}/${model.id}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Split a serialized model value `"<provider>/<id>"` on the FIRST slash, so an id that itself
|
|
45
|
+
* contains a slash still resolves by its leading provider/id segments. Returns `null` when the
|
|
46
|
+
* value has no slash (not a `provider/id` form — e.g. the null/"Default" entry). */
|
|
47
|
+
export function splitModelId(value: string): { provider: string; id: string } | null {
|
|
48
|
+
const slash = value.indexOf("/");
|
|
49
|
+
if (slash < 0) return null;
|
|
50
|
+
return { provider: value.slice(0, slash), id: value.slice(slash + 1) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read the host's enabled-models patterns from its settings file. Returns `null` when the file is
|
|
55
|
+
* absent or carries no `enabledModels` (meaning "no scoping — all available models"). Exposed as a
|
|
56
|
+
* seam so the resolver can be unit-tested without touching the real settings path.
|
|
57
|
+
*/
|
|
58
|
+
export function readEnabledModelPatterns(): string[] | null {
|
|
59
|
+
const raw = readJsonFile(join(getAgentDir(), "settings.json"));
|
|
60
|
+
const patterns = raw?.enabledModels;
|
|
61
|
+
return Array.isArray(patterns) ? patterns : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Keep a model iff any enabled-models pattern matches it — either its full `provider/id` or its
|
|
66
|
+
* bare `id`. Matching is case-insensitive (pi resolves scopes nocase). A pattern without glob
|
|
67
|
+
* metacharacters matches as a literal under minimatch, so a full id like `anthropic/claude-3-5`
|
|
68
|
+
* matches exactly the same way a glob like `anthropic/*` does.
|
|
69
|
+
*
|
|
70
|
+
* Two deviations from pi's own resolver: a non-glob partial name (e.g. `claude`) does not get
|
|
71
|
+
* fuzzy-matched against model display names, and a trailing `:thinkingLevel` suffix on a pattern
|
|
72
|
+
* is not stripped (thinking levels are ignored here).
|
|
73
|
+
*/
|
|
74
|
+
function isEnabled(model: ModelLike, patterns: readonly string[]): boolean {
|
|
75
|
+
const fullId = modelValue(model);
|
|
76
|
+
return patterns.some(
|
|
77
|
+
(pattern) => minimatch(fullId, pattern, { nocase: true }) || minimatch(model.id, pattern, { nocase: true }),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the model presets: the captured registry's available models, scoped by the host's
|
|
83
|
+
* enabled-models patterns, each `[modelLabel(model), "provider/id"]`, with `["Default", null]`
|
|
84
|
+
* prepended. When the registry was not captured, or enabled-models patterns are configured but
|
|
85
|
+
* match nothing, the list degrades to `["Default", null]` (the modal still opens).
|
|
86
|
+
*/
|
|
87
|
+
export function resolveModelPresets(): PresetPair[] {
|
|
88
|
+
const { modelRegistry } = getCapturedCtx();
|
|
89
|
+
if (!modelRegistry) return [DEFAULT_PAIR];
|
|
90
|
+
|
|
91
|
+
modelRegistry.refresh();
|
|
92
|
+
const all = modelRegistry.getAvailable();
|
|
93
|
+
const patterns = readEnabledModelPatterns();
|
|
94
|
+
|
|
95
|
+
// null/absent enabledModels → no scoping (all available). Non-null but matching nothing → also
|
|
96
|
+
// fall back to all (a stale/typo pattern should not empty the picker).
|
|
97
|
+
const scoped = patterns === null ? all : all.filter((m) => isEnabled(m, patterns));
|
|
98
|
+
const effective = scoped.length > 0 ? scoped : all;
|
|
99
|
+
|
|
100
|
+
return [DEFAULT_PAIR, ...effective.map((m): PresetPair => [modelLabel(m), modelValue(m)])];
|
|
101
|
+
}
|