@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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # Pi Mode
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@zigai/pi-mode.svg?color=blue)](https://www.npmjs.com/package/@zigai/pi-mode)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@zigai/pi-mode.svg)](https://www.npmjs.com/package/@zigai/pi-mode)
5
+ [![license](https://img.shields.io/npm/l/@zigai/pi-mode.svg)](../../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.1.4",
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
- type ModeSpecJson = {
38
- provider?: string;
39
- modelId?: string;
40
- thinkingLevel?: ThinkingLevel;
41
- color?: ModeSpec["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
- type ModesFileJson = {
45
- currentMode?: string;
46
- modes?: Record<string, ModeSpecJson>;
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: ThinkingLevel | undefined): ThinkingLevel | undefined {
157
- if (level === undefined) return undefined;
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) as ModesFileJson;
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 { getAgentDir } from "@earendil-works/pi-coding-agent";
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 cachedSettingsMtimeMs: number | null | undefined;
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 getSettingsMtimeMs(): number | null {
183
+ function getFileMtimeMs(filePath: string | undefined): number | null {
184
+ if (filePath === undefined) return null;
132
185
  try {
133
- if (!existsSync(getSettingsPath())) return null;
134
- return statSync(getSettingsPath()).mtimeMs;
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
- const parsed = JSON.parse(raw) as unknown;
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 mtimeMs = getSettingsMtimeMs();
171
- if (cachedShowModeName !== undefined && cachedSettingsMtimeMs === mtimeMs) {
253
+ const mtimeKey = getSettingsMtimeKey();
254
+ if (cachedShowModeName !== undefined && cachedSettingsMtimeKey === mtimeKey) {
172
255
  return cachedShowModeName;
173
256
  }
174
257
 
175
- const settings = readSettingsObject();
176
- cachedSettingsMtimeMs = mtimeMs;
177
- cachedShowModeName = settings[SHOW_MODE_NAME_SETTINGS_KEY] === true;
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
- cachedSettingsMtimeMs = getSettingsMtimeMs();
276
+ cachedSettingsMtimeKey = getSettingsMtimeKey();
187
277
  cachedShowModeName = show;
188
278
  }