@zigai/pi-mode 0.1.4 → 0.2.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/README.md +9 -1
- package/package.json +4 -1
- package/src/index.ts +2 -0
- package/src/mode-state.ts +54 -18
- package/src/settings.ts +108 -18
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Pi Mode
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@zigai/pi-mode)
|
|
4
|
+
[](https://www.npmjs.com/package/@zigai/pi-mode)
|
|
5
|
+
[](../../LICENSE)
|
|
6
|
+
|
|
3
7
|
This Pi extension adds prompt modes for model and thinking-level switching.
|
|
4
8
|
|
|
5
9
|
## Install
|
|
@@ -18,4 +22,8 @@ pi install npm:@zigai/pi-mode
|
|
|
18
22
|
|
|
19
23
|
Modes can store a provider, model, thinking level, and optional color. Project-local modes live in `.pi/modes.json` when present; otherwise global modes live in `~/.pi/agent/modes.json`.
|
|
20
24
|
|
|
21
|
-
By default, Pi Mode does not print the mode name in the editor border. To opt in, toggle `Show mode name` from `/mode` → `Configure modes…`, or set `"modeShowName": true` in `~/.pi/agent/settings.json`.
|
|
25
|
+
By default, Pi Mode does not print the mode name in the editor border. To opt in, toggle `Show mode name` from `/mode` → `Configure modes…`, or set `"modeShowName": true` in global `~/.pi/agent/settings.json` or trusted project `.pi/settings.json`. The `/mode` toggle writes to global settings.
|
|
26
|
+
|
|
27
|
+
## License
|
|
28
|
+
|
|
29
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zigai/pi-mode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Pi package that adds prompt modes for model and thinking-level switching.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mode",
|
|
@@ -29,6 +29,9 @@
|
|
|
29
29
|
"publishConfig": {
|
|
30
30
|
"access": "public"
|
|
31
31
|
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"typebox": "^1.1.38"
|
|
34
|
+
},
|
|
32
35
|
"peerDependencies": {
|
|
33
36
|
"@earendil-works/pi-agent-core": "*",
|
|
34
37
|
"@earendil-works/pi-ai": "*",
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
handleSessionActivated,
|
|
8
8
|
selectModeUI,
|
|
9
9
|
} from "./mode-state.ts";
|
|
10
|
+
import { setSettingsContext } from "./settings.ts";
|
|
10
11
|
|
|
11
12
|
export default function (pi: ExtensionAPI) {
|
|
12
13
|
pi.registerCommand("mode", {
|
|
@@ -31,6 +32,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
31
32
|
});
|
|
32
33
|
|
|
33
34
|
pi.on("session_start", async (_event, ctx) => {
|
|
35
|
+
setSettingsContext(ctx);
|
|
34
36
|
await handleSessionActivated(pi, ctx);
|
|
35
37
|
applyModeEditor(pi, ctx);
|
|
36
38
|
});
|
package/src/mode-state.ts
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
8
8
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
9
9
|
import fs from "node:fs/promises";
|
|
10
|
+
import { Type, type Static, type TSchema } from "typebox";
|
|
11
|
+
import { Value } from "typebox/value";
|
|
10
12
|
import {
|
|
11
13
|
ALL_THINKING_LEVELS,
|
|
12
14
|
CUSTOM_MODE_NAME,
|
|
@@ -34,17 +36,51 @@ type ScopedModelItem = {
|
|
|
34
36
|
thinkingLevel?: string;
|
|
35
37
|
};
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
provider
|
|
39
|
-
modelId
|
|
40
|
-
thinkingLevel
|
|
41
|
-
color
|
|
42
|
-
};
|
|
39
|
+
const ModeSpecJsonSchema = Type.Object({
|
|
40
|
+
provider: Type.Optional(Type.String()),
|
|
41
|
+
modelId: Type.Optional(Type.String()),
|
|
42
|
+
thinkingLevel: Type.Optional(Type.Unknown()),
|
|
43
|
+
color: Type.Optional(Type.String()),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const ModesFileJsonSchema = Type.Object({
|
|
47
|
+
$schema: Type.Optional(Type.String()),
|
|
48
|
+
version: Type.Optional(Type.Number()),
|
|
49
|
+
currentMode: Type.Optional(Type.String()),
|
|
50
|
+
modes: Type.Optional(Type.Record(Type.String(), ModeSpecJsonSchema)),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
type ModeSpecJson = Static<typeof ModeSpecJsonSchema>;
|
|
54
|
+
type ModesFileJson = Static<typeof ModesFileJsonSchema>;
|
|
55
|
+
|
|
56
|
+
function formatSchemaPath(instancePath: string): string {
|
|
57
|
+
if (instancePath.length === 0) return "root";
|
|
58
|
+
return instancePath
|
|
59
|
+
.slice(1)
|
|
60
|
+
.split("/")
|
|
61
|
+
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
|
|
62
|
+
.join(".");
|
|
63
|
+
}
|
|
43
64
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
65
|
+
function parseSchema(schema: TSchema, value: unknown, label: string): unknown {
|
|
66
|
+
const errors = [...Value.Errors(schema, value)];
|
|
67
|
+
if (errors.length > 0) {
|
|
68
|
+
const messages = errors
|
|
69
|
+
.slice(0, 5)
|
|
70
|
+
.map((error) => `${formatSchemaPath(error.instancePath)} ${error.message}`);
|
|
71
|
+
let suffix = "";
|
|
72
|
+
if (errors.length > messages.length) {
|
|
73
|
+
suffix = `; and ${errors.length - messages.length} more`;
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`${label} is invalid: ${messages.join("; ")}${suffix}`);
|
|
76
|
+
}
|
|
77
|
+
const parsed: unknown = Value.Parse(schema, value);
|
|
78
|
+
return parsed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseModesFileJson(value: unknown): ModesFileJson {
|
|
82
|
+
return parseSchema(ModesFileJsonSchema, value, "modes.json") as ModesFileJson;
|
|
83
|
+
}
|
|
48
84
|
|
|
49
85
|
function cloneModesFile(file: ModesFile): ModesFile {
|
|
50
86
|
return JSON.parse(JSON.stringify(file)) as ModesFile;
|
|
@@ -55,7 +91,7 @@ function modeSpec(modes: Record<string, ModeSpec>, name: string): ModeSpec | und
|
|
|
55
91
|
return undefined;
|
|
56
92
|
}
|
|
57
93
|
|
|
58
|
-
function computeModesPatch(
|
|
94
|
+
export function computeModesPatch(
|
|
59
95
|
base: ModesFile,
|
|
60
96
|
next: ModesFile,
|
|
61
97
|
includeCurrentMode: boolean,
|
|
@@ -108,7 +144,7 @@ function computeModesPatch(
|
|
|
108
144
|
return patch;
|
|
109
145
|
}
|
|
110
146
|
|
|
111
|
-
function applyModesPatch(target: ModesFile, patch: ModesPatch): void {
|
|
147
|
+
export function applyModesPatch(target: ModesFile, patch: ModesPatch): void {
|
|
112
148
|
if (patch.currentMode !== undefined) {
|
|
113
149
|
target.currentMode = patch.currentMode;
|
|
114
150
|
}
|
|
@@ -153,10 +189,10 @@ function applyModesPatch(target: ModesFile, patch: ModesPatch): void {
|
|
|
153
189
|
}
|
|
154
190
|
}
|
|
155
191
|
|
|
156
|
-
function normalizeThinkingLevel(level:
|
|
157
|
-
if (level
|
|
158
|
-
if (ALL_THINKING_LEVELS.includes(level)) {
|
|
159
|
-
return level;
|
|
192
|
+
function normalizeThinkingLevel(level: unknown): ThinkingLevel | undefined {
|
|
193
|
+
if (typeof level !== "string") return undefined;
|
|
194
|
+
if (ALL_THINKING_LEVELS.includes(level as ThinkingLevel)) {
|
|
195
|
+
return level as ThinkingLevel;
|
|
160
196
|
}
|
|
161
197
|
return undefined;
|
|
162
198
|
}
|
|
@@ -185,7 +221,7 @@ function sanitizeModeSpec(spec: ModeSpecJson | undefined): ModeSpec {
|
|
|
185
221
|
};
|
|
186
222
|
if (spec.provider !== undefined) sanitized.provider = spec.provider;
|
|
187
223
|
if (spec.modelId !== undefined) sanitized.modelId = spec.modelId;
|
|
188
|
-
if (spec.color !== undefined) sanitized.color = spec.color;
|
|
224
|
+
if (spec.color !== undefined) sanitized.color = spec.color as ModeSpec["color"];
|
|
189
225
|
return sanitized;
|
|
190
226
|
}
|
|
191
227
|
|
|
@@ -245,7 +281,7 @@ async function loadModesFile(
|
|
|
245
281
|
): Promise<ModesFile> {
|
|
246
282
|
try {
|
|
247
283
|
const raw = await fs.readFile(filePath, "utf8");
|
|
248
|
-
const parsed = JSON.parse(raw)
|
|
284
|
+
const parsed = parseModesFileJson(JSON.parse(raw));
|
|
249
285
|
const currentMode = parsed.currentMode ?? "default";
|
|
250
286
|
const modesRaw = parsed.modes ?? {};
|
|
251
287
|
|
package/src/settings.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
getAgentDir,
|
|
3
|
+
SettingsManager,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
2
6
|
import {
|
|
3
7
|
closeSync,
|
|
4
8
|
existsSync,
|
|
@@ -11,19 +15,59 @@ import {
|
|
|
11
15
|
writeFileSync,
|
|
12
16
|
} from "node:fs";
|
|
13
17
|
import { dirname, join } from "node:path";
|
|
18
|
+
import { Type, type TSchema } from "typebox";
|
|
19
|
+
import { Value } from "typebox/value";
|
|
14
20
|
|
|
15
21
|
export const SHOW_MODE_NAME_SETTINGS_KEY = "modeShowName";
|
|
16
22
|
|
|
17
23
|
const SETTINGS_LOCK_TIMEOUT_MS = 5_000;
|
|
18
24
|
const STALE_SETTINGS_LOCK_MS = 30_000;
|
|
25
|
+
const SettingsObjectSchema = Type.Object({});
|
|
26
|
+
const ShowModeNameSchema = Type.Boolean();
|
|
27
|
+
|
|
28
|
+
type SettingsReadContext = {
|
|
29
|
+
cwd: string;
|
|
30
|
+
projectTrusted: boolean;
|
|
31
|
+
};
|
|
19
32
|
|
|
33
|
+
let settingsReadContext: SettingsReadContext | undefined;
|
|
20
34
|
let cachedShowModeName: boolean | undefined;
|
|
21
|
-
let
|
|
35
|
+
let cachedSettingsMtimeKey: string | undefined;
|
|
36
|
+
|
|
37
|
+
type ProjectTrustContext = ExtensionContext & {
|
|
38
|
+
isProjectTrusted?: () => boolean;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function isProjectTrusted(ctx: ExtensionContext): boolean {
|
|
42
|
+
return (ctx as ProjectTrustContext).isProjectTrusted?.() ?? true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function setSettingsContext(ctx: ExtensionContext): void {
|
|
46
|
+
const next: SettingsReadContext = {
|
|
47
|
+
cwd: ctx.cwd,
|
|
48
|
+
projectTrusted: isProjectTrusted(ctx),
|
|
49
|
+
};
|
|
50
|
+
if (
|
|
51
|
+
settingsReadContext?.cwd !== next.cwd ||
|
|
52
|
+
settingsReadContext.projectTrusted !== next.projectTrusted
|
|
53
|
+
) {
|
|
54
|
+
settingsReadContext = next;
|
|
55
|
+
cachedShowModeName = undefined;
|
|
56
|
+
cachedSettingsMtimeKey = undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
22
59
|
|
|
23
60
|
function getSettingsPath(): string {
|
|
24
61
|
return join(getAgentDir(), "settings.json");
|
|
25
62
|
}
|
|
26
63
|
|
|
64
|
+
function getProjectSettingsPath(): string | undefined {
|
|
65
|
+
if (settingsReadContext === undefined || !settingsReadContext.projectTrusted) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
return join(settingsReadContext.cwd, ".pi", "settings.json");
|
|
69
|
+
}
|
|
70
|
+
|
|
27
71
|
function getErrorCode(error: unknown): string | undefined {
|
|
28
72
|
if (!(error instanceof Error)) return undefined;
|
|
29
73
|
const code = (error as NodeJS.ErrnoException).code;
|
|
@@ -41,6 +85,14 @@ function sleepSync(ms: number): void {
|
|
|
41
85
|
Atomics.wait(new Int32Array(buffer), 0, 0, ms);
|
|
42
86
|
}
|
|
43
87
|
|
|
88
|
+
function parseOptionalBoolean(schema: TSchema, value: unknown): boolean | undefined {
|
|
89
|
+
if (value === undefined) return undefined;
|
|
90
|
+
if (!Value.Check(schema, value)) return undefined;
|
|
91
|
+
const parsed: unknown = Value.Parse(schema, value);
|
|
92
|
+
if (typeof parsed === "boolean") return parsed;
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
44
96
|
function withSettingsLock<T>(settingsPath: string, fn: () => T): T {
|
|
45
97
|
const lockPath = `${settingsPath}.lock`;
|
|
46
98
|
mkdirSync(dirname(lockPath), { recursive: true });
|
|
@@ -128,26 +180,51 @@ function atomicWriteUtf8Sync(filePath: string, content: string): void {
|
|
|
128
180
|
}
|
|
129
181
|
}
|
|
130
182
|
|
|
131
|
-
function
|
|
183
|
+
function getFileMtimeMs(filePath: string | undefined): number | null {
|
|
184
|
+
if (filePath === undefined) return null;
|
|
132
185
|
try {
|
|
133
|
-
if (!existsSync(
|
|
134
|
-
return statSync(
|
|
186
|
+
if (!existsSync(filePath)) return null;
|
|
187
|
+
return statSync(filePath).mtimeMs;
|
|
135
188
|
} catch {
|
|
136
189
|
return null;
|
|
137
190
|
}
|
|
138
191
|
}
|
|
139
192
|
|
|
193
|
+
function getSettingsMtimeKey(): string {
|
|
194
|
+
return `${getFileMtimeMs(getSettingsPath())}:${getFileMtimeMs(getProjectSettingsPath())}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function formatSchemaPath(instancePath: string): string {
|
|
198
|
+
if (instancePath.length === 0) return "root";
|
|
199
|
+
return instancePath
|
|
200
|
+
.slice(1)
|
|
201
|
+
.split("/")
|
|
202
|
+
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
|
|
203
|
+
.join(".");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function parseSettingsObject(value: unknown, settingsPath: string): Record<string, unknown> {
|
|
207
|
+
const errors = [...Value.Errors(SettingsObjectSchema, value)];
|
|
208
|
+
if (errors.length > 0) {
|
|
209
|
+
const messages = errors
|
|
210
|
+
.slice(0, 5)
|
|
211
|
+
.map((error) => `${formatSchemaPath(error.instancePath)} ${error.message}`);
|
|
212
|
+
let suffix = "";
|
|
213
|
+
if (errors.length > messages.length) {
|
|
214
|
+
suffix = `; and ${errors.length - messages.length} more`;
|
|
215
|
+
}
|
|
216
|
+
throw new Error(
|
|
217
|
+
`${settingsPath} must contain a JSON object: ${messages.join("; ")}${suffix}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return { ...(Value.Parse(SettingsObjectSchema, value) as Record<string, unknown>) };
|
|
221
|
+
}
|
|
222
|
+
|
|
140
223
|
function readSettingsObject(options?: { throwOnInvalid?: boolean }): Record<string, unknown> {
|
|
141
224
|
const settingsPath = getSettingsPath();
|
|
142
225
|
try {
|
|
143
226
|
const raw = readFileSync(settingsPath, "utf8");
|
|
144
|
-
|
|
145
|
-
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
146
|
-
return { ...parsed };
|
|
147
|
-
}
|
|
148
|
-
if (options?.throwOnInvalid === true) {
|
|
149
|
-
throw new Error(`${settingsPath} must contain a JSON object.`);
|
|
150
|
-
}
|
|
227
|
+
return parseSettingsObject(JSON.parse(raw), settingsPath);
|
|
151
228
|
} catch (error: unknown) {
|
|
152
229
|
if (getErrorCode(error) === "ENOENT") return {};
|
|
153
230
|
if (options?.throwOnInvalid === true) throwError(error);
|
|
@@ -166,15 +243,28 @@ function updateSettingsObject(update: (settings: Record<string, unknown>) => voi
|
|
|
166
243
|
});
|
|
167
244
|
}
|
|
168
245
|
|
|
246
|
+
function applyShowModeNameSetting(settings: Record<string, unknown>, fallback: boolean): boolean {
|
|
247
|
+
return (
|
|
248
|
+
parseOptionalBoolean(ShowModeNameSchema, settings[SHOW_MODE_NAME_SETTINGS_KEY]) ?? fallback
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
169
252
|
export function shouldShowModeName(): boolean {
|
|
170
|
-
const
|
|
171
|
-
if (cachedShowModeName !== undefined &&
|
|
253
|
+
const mtimeKey = getSettingsMtimeKey();
|
|
254
|
+
if (cachedShowModeName !== undefined && cachedSettingsMtimeKey === mtimeKey) {
|
|
172
255
|
return cachedShowModeName;
|
|
173
256
|
}
|
|
174
257
|
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
258
|
+
const context = settingsReadContext ?? { cwd: process.cwd(), projectTrusted: false };
|
|
259
|
+
const manager = SettingsManager.create(context.cwd, getAgentDir(), {
|
|
260
|
+
projectTrusted: context.projectTrusted,
|
|
261
|
+
});
|
|
262
|
+
let show = false;
|
|
263
|
+
show = applyShowModeNameSetting(manager.getGlobalSettings() as Record<string, unknown>, show);
|
|
264
|
+
show = applyShowModeNameSetting(manager.getProjectSettings() as Record<string, unknown>, show);
|
|
265
|
+
|
|
266
|
+
cachedSettingsMtimeKey = mtimeKey;
|
|
267
|
+
cachedShowModeName = show;
|
|
178
268
|
return cachedShowModeName;
|
|
179
269
|
}
|
|
180
270
|
|
|
@@ -183,6 +273,6 @@ export function setShowModeName(show: boolean): void {
|
|
|
183
273
|
settings[SHOW_MODE_NAME_SETTINGS_KEY] = show;
|
|
184
274
|
});
|
|
185
275
|
|
|
186
|
-
|
|
276
|
+
cachedSettingsMtimeKey = getSettingsMtimeKey();
|
|
187
277
|
cachedShowModeName = show;
|
|
188
278
|
}
|