@zigai/pi-mode 0.3.0 → 0.4.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
@@ -20,11 +20,32 @@ pi install npm:@zigai/pi-mode
20
20
  - Can show the current mode in the prompt editor border when enabled.
21
21
  - Colors the prompt editor border from the active mode, with an opt-in setting for thinking-derived border colors.
22
22
 
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`.
24
-
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
- By default, Pi Mode uses the normal editor border color when the active mode does not define an explicit `color`. To opt in to thinking-derived border colors, toggle `Thinking border colors` from `/mode` → `Configure modes…`, or set `"modeUseThinkingBorderColors": true` in global `~/.pi/agent/settings.json` or trusted project `.pi/settings.json`. Explicit per-mode `color` values still apply.
23
+ Modes can store a provider, model, thinking level, and optional color. By default, Pi Mode hides Pi's transient `Thinking level: …` status message because the active thinking level is already visible in the footer.
24
+
25
+ ## Configuration
26
+
27
+ Configure global settings at `~/.pi/agent/pi-mode/config.json`.
28
+
29
+ | Option | Type | Default | Description |
30
+ | ----------------------------- | --------- | ----------- | -------------------------------------------------------------------------- |
31
+ | `version` | `number` | `1` | Config format version. |
32
+ | `currentMode` | `string` | `"default"` | Mode selected at startup. |
33
+ | `modeShowName` | `boolean` | `false` | Shows the current mode name in the prompt editor border. |
34
+ | `modeUseThinkingBorderColors` | `boolean` | `false` | Uses thinking-derived border colors when a mode has no explicit color. |
35
+ | `modeShowThinkingLevelStatus` | `boolean` | `false` | Shows Pi's transient thinking-level status message. |
36
+ | `modes` | `object` | `{}` | Named modes with optional `provider`, `modelId`, `thinkingLevel`, `color`. |
37
+
38
+ ```json
39
+ {
40
+ "$schema": "./config.schema.json",
41
+ "version": 1,
42
+ "currentMode": "default",
43
+ "modeShowName": false,
44
+ "modeUseThinkingBorderColors": false,
45
+ "modeShowThinkingLevelStatus": false,
46
+ "modes": {}
47
+ }
48
+ ```
28
49
 
29
50
  ## License
30
51
 
@@ -0,0 +1,29 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/zigai/pi-tweaks/main/packages/pi-mode/config.schema.json",
4
+ "title": "Pi mode config",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {
8
+ "$schema": { "type": "string" },
9
+ "version": { "type": "number", "default": 1 },
10
+ "currentMode": { "type": "string", "default": "default" },
11
+ "modeShowName": { "type": "boolean", "default": false },
12
+ "modeUseThinkingBorderColors": { "type": "boolean", "default": false },
13
+ "modeShowThinkingLevelStatus": { "type": "boolean", "default": false },
14
+ "modes": {
15
+ "type": "object",
16
+ "additionalProperties": {
17
+ "type": "object",
18
+ "additionalProperties": false,
19
+ "properties": {
20
+ "provider": { "type": "string" },
21
+ "modelId": { "type": "string" },
22
+ "thinkingLevel": {},
23
+ "color": { "type": "string" }
24
+ }
25
+ },
26
+ "default": {}
27
+ }
28
+ }
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zigai/pi-mode",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Pi package that adds prompt modes for model and thinking-level switching.",
5
5
  "keywords": [
6
6
  "mode",
@@ -23,7 +23,8 @@
23
23
  },
24
24
  "files": [
25
25
  "src",
26
- "README.md"
26
+ "README.md",
27
+ "*.json"
27
28
  ],
28
29
  "type": "module",
29
30
  "publishConfig": {
package/src/constants.ts CHANGED
@@ -9,6 +9,8 @@ export const MODE_UI_SHOW_NAME_ON = "Show mode name: on";
9
9
  export const MODE_UI_SHOW_NAME_OFF = "Show mode name: off";
10
10
  export const MODE_UI_THINKING_COLORS_ON = "Thinking border colors: on";
11
11
  export const MODE_UI_THINKING_COLORS_OFF = "Thinking border colors: off";
12
+ export const MODE_UI_THINKING_STATUS_ON = "Thinking level status: on";
13
+ export const MODE_UI_THINKING_STATUS_OFF = "Thinking level status: off";
12
14
  export const MODE_UI_BACK = "Back";
13
15
 
14
16
  export const ALL_THINKING_LEVELS: ThinkingLevel[] = [
package/src/index.ts CHANGED
@@ -8,8 +8,11 @@ import {
8
8
  selectModeUI,
9
9
  } from "./mode-state.ts";
10
10
  import { setSettingsContext } from "./settings.ts";
11
+ import { applyThinkingLevelStatusPatch } from "./status.ts";
11
12
 
12
13
  export default function (pi: ExtensionAPI) {
14
+ void applyThinkingLevelStatusPatch();
15
+
13
16
  pi.registerCommand("mode", {
14
17
  description: "Select prompt mode",
15
18
  handler: async (args, ctx) => {
package/src/mode-state.ts CHANGED
@@ -20,6 +20,8 @@ import {
20
20
  MODE_UI_SHOW_NAME_ON,
21
21
  MODE_UI_THINKING_COLORS_OFF,
22
22
  MODE_UI_THINKING_COLORS_ON,
23
+ MODE_UI_THINKING_STATUS_OFF,
24
+ MODE_UI_THINKING_STATUS_ON,
23
25
  THINKING_UNSET_LABEL,
24
26
  } from "./constants.ts";
25
27
  import {
@@ -28,12 +30,15 @@ import {
28
30
  getGlobalModesPath,
29
31
  getMtimeMs,
30
32
  getProjectModesPath,
33
+ scaffoldGlobalModesConfig,
31
34
  withFileLock,
32
35
  } from "./storage.ts";
33
36
  import {
34
37
  setShowModeName,
38
+ setShowThinkingLevelStatus,
35
39
  setUseThinkingBorderColors,
36
40
  shouldShowModeName,
41
+ shouldShowThinkingLevelStatus,
37
42
  shouldUseThinkingBorderColors,
38
43
  } from "./settings.ts";
39
44
  import type { ModeRuntime, ModesFile, ModesPatch, ModeSpec, ModeSpecPatch } from "./types.ts";
@@ -43,19 +48,29 @@ type ScopedModelItem = {
43
48
  thinkingLevel?: string;
44
49
  };
45
50
 
46
- const ModeSpecJsonSchema = Type.Object({
47
- provider: Type.Optional(Type.String()),
48
- modelId: Type.Optional(Type.String()),
49
- thinkingLevel: Type.Optional(Type.Unknown()),
50
- color: Type.Optional(Type.String()),
51
- });
52
-
53
- const ModesFileJsonSchema = Type.Object({
54
- $schema: Type.Optional(Type.String()),
55
- version: Type.Optional(Type.Number()),
56
- currentMode: Type.Optional(Type.String()),
57
- modes: Type.Optional(Type.Record(Type.String(), ModeSpecJsonSchema)),
58
- });
51
+ const ModeSpecJsonSchema = Type.Object(
52
+ {
53
+ provider: Type.Optional(Type.String()),
54
+ modelId: Type.Optional(Type.String()),
55
+ thinkingLevel: Type.Optional(Type.Unknown()),
56
+ color: Type.Optional(Type.String()),
57
+ },
58
+ { additionalProperties: false },
59
+ );
60
+
61
+ const ModesFileJsonSchema = Type.Object(
62
+ {
63
+ $schema: Type.Optional(Type.String()),
64
+ version: Type.Optional(Type.Number()),
65
+ currentMode: Type.Optional(Type.String()),
66
+ modeShowName: Type.Optional(Type.Boolean()),
67
+ modeUseThinkingBorderColors: Type.Optional(Type.Boolean()),
68
+ modeShowThinkingLevelStatus: Type.Optional(Type.Boolean()),
69
+ modes: Type.Optional(Type.Record(Type.String(), ModeSpecJsonSchema)),
70
+ },
71
+ { additionalProperties: false },
72
+ );
73
+ const ConfigObjectSchema = ModesFileJsonSchema;
59
74
 
60
75
  type ModeSpecJson = Static<typeof ModeSpecJsonSchema>;
61
76
  type ModesFileJson = Static<typeof ModesFileJsonSchema>;
@@ -69,7 +84,11 @@ function formatSchemaPath(instancePath: string): string {
69
84
  .join(".");
70
85
  }
71
86
 
72
- function parseSchema(schema: TSchema, value: unknown, label: string): unknown {
87
+ function parseSchema<Schema extends TSchema>(
88
+ schema: Schema,
89
+ value: unknown,
90
+ label: string,
91
+ ): Static<Schema> {
73
92
  const errors = [...Value.Errors(schema, value)];
74
93
  if (errors.length > 0) {
75
94
  const messages = errors
@@ -82,15 +101,39 @@ function parseSchema(schema: TSchema, value: unknown, label: string): unknown {
82
101
  throw new Error(`${label} is invalid: ${messages.join("; ")}${suffix}`);
83
102
  }
84
103
  const parsed: unknown = Value.Parse(schema, value);
85
- return parsed;
104
+ // SAFETY: Value.Errors returned no schema violations, so Value.Parse returns
105
+ // the TypeBox static type represented by the same schema.
106
+ // oxlint-disable-next-line typescript/no-unsafe-return -- SAFETY: TypeBox exposes parsed schema output through a conditional static type that oxlint treats as any here.
107
+ return parsed as Static<Schema>;
86
108
  }
87
109
 
88
110
  function parseModesFileJson(value: unknown): ModesFileJson {
89
- return parseSchema(ModesFileJsonSchema, value, "modes.json") as ModesFileJson;
111
+ return parseSchema(ModesFileJsonSchema, value, "pi-mode config.json");
112
+ }
113
+
114
+ function parseConfigObject(value: unknown, filePath: string): Record<string, unknown> {
115
+ return Object.fromEntries(Object.entries(parseSchema(ConfigObjectSchema, value, filePath)));
116
+ }
117
+
118
+ function cloneModeSpec(spec: ModeSpec): ModeSpec {
119
+ const cloned: ModeSpec = {};
120
+ if (spec.provider !== undefined) cloned.provider = spec.provider;
121
+ if (spec.modelId !== undefined) cloned.modelId = spec.modelId;
122
+ if (spec.thinkingLevel !== undefined) cloned.thinkingLevel = spec.thinkingLevel;
123
+ if (spec.color !== undefined) cloned.color = spec.color;
124
+ return cloned;
90
125
  }
91
126
 
92
127
  function cloneModesFile(file: ModesFile): ModesFile {
93
- return JSON.parse(JSON.stringify(file)) as ModesFile;
128
+ const modes: Record<string, ModeSpec> = {};
129
+ for (const [name, spec] of Object.entries(file.modes)) {
130
+ modes[name] = cloneModeSpec(spec);
131
+ }
132
+ return {
133
+ version: file.version,
134
+ currentMode: file.currentMode,
135
+ modes,
136
+ };
94
137
  }
95
138
 
96
139
  function modeSpec(modes: Record<string, ModeSpec>, name: string): ModeSpec | undefined {
@@ -256,7 +299,10 @@ function ensureDefaultModeEntries(file: ModesFile, ctx: ExtensionContext, pi: Ex
256
299
  for (const name of DEFAULT_MODE_ORDER) {
257
300
  if (modeSpec(file.modes, name) === undefined) {
258
301
  const defaults = createDefaultModes(ctx, pi);
259
- file.modes[name] = defaults.modes[name]!;
302
+ const defaultSpec = modeSpec(defaults.modes, name);
303
+ if (defaultSpec !== undefined) {
304
+ file.modes[name] = cloneModeSpec(defaultSpec);
305
+ }
260
306
  }
261
307
  }
262
308
 
@@ -286,9 +332,14 @@ async function loadModesFile(
286
332
  pi: ExtensionAPI,
287
333
  options?: { throwOnInvalid?: boolean },
288
334
  ): Promise<ModesFile> {
335
+ if (filePath === getGlobalModesPath()) {
336
+ await scaffoldGlobalModesConfig();
337
+ }
338
+
289
339
  try {
290
340
  const raw = await fs.readFile(filePath, "utf8");
291
- const parsed = parseModesFileJson(JSON.parse(raw));
341
+ const parsedJson: unknown = JSON.parse(raw);
342
+ const parsed = parseModesFileJson(parsedJson);
292
343
  const currentMode = parsed.currentMode ?? "default";
293
344
  const modesRaw = parsed.modes ?? {};
294
345
 
@@ -311,8 +362,23 @@ async function loadModesFile(
311
362
  }
312
363
  }
313
364
 
365
+ async function readConfigObject(filePath: string): Promise<Record<string, unknown>> {
366
+ try {
367
+ const raw = await fs.readFile(filePath, "utf8");
368
+ const parsedJson: unknown = JSON.parse(raw);
369
+ return parseConfigObject(parsedJson, filePath);
370
+ } catch (error: unknown) {
371
+ if (getLoadErrorCode(error) === "ENOENT") return {};
372
+ throwLoadError(filePath, error);
373
+ }
374
+ }
375
+
314
376
  async function saveModesFile(filePath: string, data: ModesFile): Promise<void> {
315
- await atomicWriteUtf8(filePath, JSON.stringify(data, null, 2) + "\n");
377
+ const config = await readConfigObject(filePath);
378
+ config.version = data.version;
379
+ config.currentMode = data.currentMode;
380
+ config.modes = data.modes;
381
+ await atomicWriteUtf8(filePath, JSON.stringify(config, null, 2) + "\n");
316
382
  }
317
383
 
318
384
  function orderedModeNames(modes: Record<string, ModeSpec>): string[] {
@@ -323,9 +389,19 @@ function formatModeLabel(mode: string): string {
323
389
  return mode;
324
390
  }
325
391
 
326
- async function resolveModesPath(cwd: string): Promise<string> {
327
- const projectPath = getProjectModesPath(cwd);
328
- if (await fileExists(projectPath)) return projectPath;
392
+ type ProjectTrustContext = ExtensionContext & {
393
+ isProjectTrusted?: () => boolean;
394
+ };
395
+
396
+ function isProjectTrusted(ctx: ExtensionContext): boolean {
397
+ return (ctx as ProjectTrustContext).isProjectTrusted?.() ?? true;
398
+ }
399
+
400
+ async function resolveModesPath(ctx: ExtensionContext): Promise<string> {
401
+ if (isProjectTrusted(ctx)) {
402
+ const projectPath = getProjectModesPath(ctx.cwd);
403
+ if (await fileExists(projectPath)) return projectPath;
404
+ }
329
405
  return getGlobalModesPath();
330
406
  }
331
407
 
@@ -434,7 +510,7 @@ export function getModeBorderColor(
434
510
  }
435
511
 
436
512
  async function ensureRuntime(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
437
- const filePath = await resolveModesPath(ctx.cwd);
513
+ const filePath = await resolveModesPath(ctx);
438
514
 
439
515
  const mtimeMs = await getMtimeMs(filePath);
440
516
  const filePathChanged = runtime.filePath !== filePath;
@@ -871,11 +947,16 @@ async function configureModesUI(pi: ExtensionAPI, ctx: ExtensionContext): Promis
871
947
  if (shouldUseThinkingBorderColors()) {
872
948
  thinkingColorsChoice = MODE_UI_THINKING_COLORS_ON;
873
949
  }
950
+ let thinkingStatusChoice = MODE_UI_THINKING_STATUS_OFF;
951
+ if (shouldShowThinkingLevelStatus()) {
952
+ thinkingStatusChoice = MODE_UI_THINKING_STATUS_ON;
953
+ }
874
954
  const choice = await ctx.ui.select("Configure modes", [
875
955
  ...names,
876
956
  MODE_UI_ADD,
877
957
  showModeNameChoice,
878
958
  thinkingColorsChoice,
959
+ thinkingStatusChoice,
879
960
  MODE_UI_BACK,
880
961
  ]);
881
962
  if (choice === undefined || choice.length === 0 || choice === MODE_UI_BACK) return;
@@ -925,6 +1006,25 @@ async function configureModesUI(pi: ExtensionAPI, ctx: ExtensionContext): Promis
925
1006
  continue;
926
1007
  }
927
1008
 
1009
+ if (choice === MODE_UI_THINKING_STATUS_ON || choice === MODE_UI_THINKING_STATUS_OFF) {
1010
+ const next = !shouldShowThinkingLevelStatus();
1011
+ try {
1012
+ setShowThinkingLevelStatus(next);
1013
+ } catch (error: unknown) {
1014
+ ctx.ui.notify(
1015
+ `Thinking level status was not saved: ${errorMessage(error)}`,
1016
+ "error",
1017
+ );
1018
+ continue;
1019
+ }
1020
+ let displayState = "disabled";
1021
+ if (next) {
1022
+ displayState = "enabled";
1023
+ }
1024
+ ctx.ui.notify(`Thinking level status ${displayState}`, "info");
1025
+ continue;
1026
+ }
1027
+
928
1028
  await editModeUI(pi, ctx, choice);
929
1029
  }
930
1030
  }
@@ -992,8 +1092,11 @@ export async function cycleMode(
992
1092
  if (runtime.currentMode === CUSTOM_MODE_NAME) {
993
1093
  baseMode = runtime.lastRealMode;
994
1094
  }
1095
+ const fallbackMode = names[0];
1096
+ if (fallbackMode === undefined) return;
1097
+
995
1098
  const index = Math.max(0, names.indexOf(baseMode));
996
- const next = names[(index + direction + names.length) % names.length] ?? names[0]!;
1099
+ const next = names[(index + direction + names.length) % names.length] ?? fallbackMode;
997
1100
  await applyMode(pi, ctx, next);
998
1101
  }
999
1102
 
package/src/settings.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  import {
2
+ CONFIG_DIR_NAME,
2
3
  getAgentDir,
3
- SettingsManager,
4
4
  type ExtensionContext,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import {
7
7
  closeSync,
8
- existsSync,
9
8
  mkdirSync,
10
9
  openSync,
11
10
  readFileSync,
@@ -20,10 +19,35 @@ import { Value } from "typebox/value";
20
19
 
21
20
  export const SHOW_MODE_NAME_SETTINGS_KEY = "modeShowName";
22
21
  export const USE_THINKING_BORDER_COLORS_SETTINGS_KEY = "modeUseThinkingBorderColors";
22
+ export const SHOW_THINKING_LEVEL_STATUS_SETTINGS_KEY = "modeShowThinkingLevelStatus";
23
23
 
24
24
  const SETTINGS_LOCK_TIMEOUT_MS = 5_000;
25
25
  const STALE_SETTINGS_LOCK_MS = 30_000;
26
- const SettingsObjectSchema = Type.Object({});
26
+ const EXTENSION_ID = "pi-mode";
27
+ const CONFIG_FILE = "config.json";
28
+ const SCHEMA_FILE = "config.schema.json";
29
+ const ModeSpecJsonSchema = Type.Object(
30
+ {
31
+ provider: Type.Optional(Type.String()),
32
+ modelId: Type.Optional(Type.String()),
33
+ thinkingLevel: Type.Optional(Type.Unknown()),
34
+ color: Type.Optional(Type.String()),
35
+ },
36
+ { additionalProperties: false },
37
+ );
38
+
39
+ const SettingsObjectSchema = Type.Object(
40
+ {
41
+ $schema: Type.Optional(Type.String()),
42
+ version: Type.Optional(Type.Number()),
43
+ currentMode: Type.Optional(Type.String()),
44
+ [SHOW_MODE_NAME_SETTINGS_KEY]: Type.Optional(Type.Boolean()),
45
+ [USE_THINKING_BORDER_COLORS_SETTINGS_KEY]: Type.Optional(Type.Boolean()),
46
+ [SHOW_THINKING_LEVEL_STATUS_SETTINGS_KEY]: Type.Optional(Type.Boolean()),
47
+ modes: Type.Optional(Type.Record(Type.String(), ModeSpecJsonSchema)),
48
+ },
49
+ { additionalProperties: false },
50
+ );
27
51
  const BooleanSettingSchema = Type.Boolean();
28
52
 
29
53
  type SettingsReadContext = {
@@ -31,12 +55,22 @@ type SettingsReadContext = {
31
55
  projectTrusted: boolean;
32
56
  };
33
57
 
58
+ const DEFAULT_MODE_CONFIG_FILE = {
59
+ $schema: `./${SCHEMA_FILE}`,
60
+ version: 1,
61
+ currentMode: "default",
62
+ [SHOW_MODE_NAME_SETTINGS_KEY]: false,
63
+ [USE_THINKING_BORDER_COLORS_SETTINGS_KEY]: false,
64
+ [SHOW_THINKING_LEVEL_STATUS_SETTINGS_KEY]: false,
65
+ modes: {},
66
+ };
67
+
34
68
  let settingsReadContext: SettingsReadContext | undefined;
35
69
  let cachedSettings:
36
70
  | {
37
- mtimeKey: string;
38
71
  showModeName: boolean;
39
72
  useThinkingBorderColors: boolean;
73
+ showThinkingLevelStatus: boolean;
40
74
  }
41
75
  | undefined;
42
76
 
@@ -63,14 +97,76 @@ export function setSettingsContext(ctx: ExtensionContext): void {
63
97
  }
64
98
 
65
99
  function getSettingsPath(): string {
66
- return join(getAgentDir(), "settings.json");
100
+ return join(getAgentDir(), EXTENSION_ID, CONFIG_FILE);
101
+ }
102
+
103
+ function getSchemaPath(configPath: string): string {
104
+ return join(dirname(configPath), SCHEMA_FILE);
105
+ }
106
+
107
+ function writeIfMissing(filePath: string, content: string): void {
108
+ try {
109
+ mkdirSync(dirname(filePath), { recursive: true });
110
+ writeFileSync(filePath, content, { encoding: "utf8", flag: "wx" });
111
+ } catch (error: unknown) {
112
+ if (error instanceof Error && (error as NodeJS.ErrnoException).code === "EEXIST") return;
113
+ if (error instanceof Error) throw error;
114
+ throw new Error(String(error));
115
+ }
116
+ }
117
+
118
+ function refreshSchemaFile(filePath: string, content: string): void {
119
+ let temporaryPath: string | undefined;
120
+ try {
121
+ mkdirSync(dirname(filePath), { recursive: true });
122
+ try {
123
+ if (readFileSync(filePath, "utf8") === content) return;
124
+ } catch (error: unknown) {
125
+ if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== "ENOENT") {
126
+ throw error;
127
+ }
128
+ }
129
+
130
+ const nextTemporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
131
+ writeFileSync(nextTemporaryPath, content, { encoding: "utf8", flag: "wx" });
132
+ temporaryPath = nextTemporaryPath;
133
+ renameSync(temporaryPath, filePath);
134
+ temporaryPath = undefined;
135
+ } catch (error: unknown) {
136
+ if (temporaryPath !== undefined) {
137
+ try {
138
+ unlinkSync(temporaryPath);
139
+ } catch {
140
+ // Ignore cleanup failure while reporting the original scaffold failure.
141
+ }
142
+ }
143
+ if (error instanceof Error) throw error;
144
+ throw new Error(String(error));
145
+ }
146
+ }
147
+
148
+ function readBundledSchema(): string | undefined {
149
+ try {
150
+ return readFileSync(new URL("../config.schema.json", import.meta.url), "utf8");
151
+ } catch {
152
+ return undefined;
153
+ }
154
+ }
155
+
156
+ function scaffoldGlobalConfig(): void {
157
+ const globalConfigPath = getSettingsPath();
158
+ const schema = readBundledSchema();
159
+ if (schema !== undefined) {
160
+ refreshSchemaFile(getSchemaPath(globalConfigPath), schema);
161
+ }
162
+ writeIfMissing(globalConfigPath, `${JSON.stringify(DEFAULT_MODE_CONFIG_FILE, null, 2)}\n`);
67
163
  }
68
164
 
69
165
  function getProjectSettingsPath(): string | undefined {
70
166
  if (settingsReadContext === undefined || !settingsReadContext.projectTrusted) {
71
167
  return undefined;
72
168
  }
73
- return join(settingsReadContext.cwd, ".pi", "settings.json");
169
+ return join(settingsReadContext.cwd, CONFIG_DIR_NAME, EXTENSION_ID, CONFIG_FILE);
74
170
  }
75
171
 
76
172
  function getErrorCode(error: unknown): string | undefined {
@@ -185,20 +281,6 @@ function atomicWriteUtf8Sync(filePath: string, content: string): void {
185
281
  }
186
282
  }
187
283
 
188
- function getFileMtimeMs(filePath: string | undefined): number | null {
189
- if (filePath === undefined) return null;
190
- try {
191
- if (!existsSync(filePath)) return null;
192
- return statSync(filePath).mtimeMs;
193
- } catch {
194
- return null;
195
- }
196
- }
197
-
198
- function getSettingsMtimeKey(): string {
199
- return `${getFileMtimeMs(getSettingsPath())}:${getFileMtimeMs(getProjectSettingsPath())}`;
200
- }
201
-
202
284
  function formatSchemaPath(instancePath: string): string {
203
285
  if (instancePath.length === 0) return "root";
204
286
  return instancePath
@@ -222,27 +304,35 @@ function parseSettingsObject(value: unknown, settingsPath: string): Record<strin
222
304
  `${settingsPath} must contain a JSON object: ${messages.join("; ")}${suffix}`,
223
305
  );
224
306
  }
225
- return { ...(Value.Parse(SettingsObjectSchema, value) as Record<string, unknown>) };
307
+ return Object.fromEntries(Object.entries(Value.Parse(SettingsObjectSchema, value)));
226
308
  }
227
309
 
228
- function readSettingsObject(options?: { throwOnInvalid?: boolean }): Record<string, unknown> {
229
- const settingsPath = getSettingsPath();
310
+ function readSettingsObject(
311
+ settingsPath: string,
312
+ options?: { throwOnInvalid?: boolean },
313
+ ): Record<string, unknown> {
314
+ if (settingsPath === getSettingsPath()) {
315
+ scaffoldGlobalConfig();
316
+ }
317
+
230
318
  try {
231
319
  const raw = readFileSync(settingsPath, "utf8");
232
- return parseSettingsObject(JSON.parse(raw), settingsPath);
320
+ const parsedJson: unknown = JSON.parse(raw);
321
+ return parseSettingsObject(parsedJson, settingsPath);
233
322
  } catch (error: unknown) {
234
323
  if (getErrorCode(error) === "ENOENT") return {};
235
324
  if (options?.throwOnInvalid === true) throwError(error);
236
- // Ignore malformed settings files while reading and fall back to defaults.
325
+ // Ignore malformed config files while reading and fall back to defaults.
237
326
  }
238
327
 
239
328
  return {};
240
329
  }
241
330
 
242
331
  function updateSettingsObject(update: (settings: Record<string, unknown>) => void): void {
332
+ scaffoldGlobalConfig();
243
333
  const settingsPath = getSettingsPath();
244
334
  withSettingsLock(settingsPath, () => {
245
- const settings = readSettingsObject({ throwOnInvalid: true });
335
+ const settings = readSettingsObject(settingsPath, { throwOnInvalid: true });
246
336
  update(settings);
247
337
  atomicWriteUtf8Sync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
248
338
  });
@@ -261,39 +351,53 @@ function applyBooleanSetting(
261
351
  function readModeSettings(): {
262
352
  showModeName: boolean;
263
353
  useThinkingBorderColors: boolean;
354
+ showThinkingLevelStatus: boolean;
264
355
  } {
265
- const mtimeKey = getSettingsMtimeKey();
266
- if (cachedSettings?.mtimeKey === mtimeKey) {
356
+ if (cachedSettings !== undefined) {
267
357
  return cachedSettings;
268
358
  }
269
359
 
270
- const context = settingsReadContext ?? { cwd: process.cwd(), projectTrusted: false };
271
- const manager = SettingsManager.create(context.cwd, getAgentDir(), {
272
- projectTrusted: context.projectTrusted,
273
- });
274
360
  let showModeName = false;
275
361
  let useThinkingBorderColors = false;
362
+ let showThinkingLevelStatus = false;
276
363
 
277
- const globalSettings = manager.getGlobalSettings() as Record<string, unknown>;
364
+ const globalSettings = readSettingsObject(getSettingsPath());
278
365
  showModeName = applyBooleanSetting(globalSettings, SHOW_MODE_NAME_SETTINGS_KEY, showModeName);
279
366
  useThinkingBorderColors = applyBooleanSetting(
280
367
  globalSettings,
281
368
  USE_THINKING_BORDER_COLORS_SETTINGS_KEY,
282
369
  useThinkingBorderColors,
283
370
  );
284
-
285
- const projectSettings = manager.getProjectSettings() as Record<string, unknown>;
286
- showModeName = applyBooleanSetting(projectSettings, SHOW_MODE_NAME_SETTINGS_KEY, showModeName);
287
- useThinkingBorderColors = applyBooleanSetting(
288
- projectSettings,
289
- USE_THINKING_BORDER_COLORS_SETTINGS_KEY,
290
- useThinkingBorderColors,
371
+ showThinkingLevelStatus = applyBooleanSetting(
372
+ globalSettings,
373
+ SHOW_THINKING_LEVEL_STATUS_SETTINGS_KEY,
374
+ showThinkingLevelStatus,
291
375
  );
292
376
 
377
+ const projectSettingsPath = getProjectSettingsPath();
378
+ if (projectSettingsPath !== undefined) {
379
+ const projectSettings = readSettingsObject(projectSettingsPath);
380
+ showModeName = applyBooleanSetting(
381
+ projectSettings,
382
+ SHOW_MODE_NAME_SETTINGS_KEY,
383
+ showModeName,
384
+ );
385
+ useThinkingBorderColors = applyBooleanSetting(
386
+ projectSettings,
387
+ USE_THINKING_BORDER_COLORS_SETTINGS_KEY,
388
+ useThinkingBorderColors,
389
+ );
390
+ showThinkingLevelStatus = applyBooleanSetting(
391
+ projectSettings,
392
+ SHOW_THINKING_LEVEL_STATUS_SETTINGS_KEY,
393
+ showThinkingLevelStatus,
394
+ );
395
+ }
396
+
293
397
  cachedSettings = {
294
- mtimeKey,
295
398
  showModeName,
296
399
  useThinkingBorderColors,
400
+ showThinkingLevelStatus,
297
401
  };
298
402
  return cachedSettings;
299
403
  }
@@ -306,6 +410,10 @@ export function shouldUseThinkingBorderColors(): boolean {
306
410
  return readModeSettings().useThinkingBorderColors;
307
411
  }
308
412
 
413
+ export function shouldShowThinkingLevelStatus(): boolean {
414
+ return readModeSettings().showThinkingLevelStatus;
415
+ }
416
+
309
417
  export function setShowModeName(show: boolean): void {
310
418
  updateSettingsObject((settings) => {
311
419
  settings[SHOW_MODE_NAME_SETTINGS_KEY] = show;
@@ -321,3 +429,11 @@ export function setUseThinkingBorderColors(useThinkingBorderColors: boolean): vo
321
429
 
322
430
  cachedSettings = undefined;
323
431
  }
432
+
433
+ export function setShowThinkingLevelStatus(showThinkingLevelStatus: boolean): void {
434
+ updateSettingsObject((settings) => {
435
+ settings[SHOW_THINKING_LEVEL_STATUS_SETTINGS_KEY] = showThinkingLevelStatus;
436
+ });
437
+
438
+ cachedSettings = undefined;
439
+ }
package/src/status.ts ADDED
@@ -0,0 +1,116 @@
1
+ import { dirname, join } from "node:path";
2
+ import { fileURLToPath, pathToFileURL } from "node:url";
3
+ import { shouldShowThinkingLevelStatus } from "./settings.ts";
4
+
5
+ const STATUS_PATCH_MARKER = Symbol.for("zigai.pi-mode.thinking-status-patch");
6
+ const STATUS_PATCH_STATE = Symbol.for("zigai.pi-mode.thinking-status-state");
7
+ const THINKING_LEVEL_STATUS_PREFIX = "Thinking level: ";
8
+
9
+ type InteractiveModePrototype = {
10
+ showStatus(message: string): void;
11
+ [STATUS_PATCH_MARKER]?: true;
12
+ };
13
+
14
+ type StatusPatchState = {
15
+ shouldShowThinkingLevelStatus: () => boolean;
16
+ };
17
+
18
+ type InteractiveModeClass = {
19
+ prototype: InteractiveModePrototype;
20
+ };
21
+
22
+ type InteractiveModeModule = {
23
+ InteractiveMode?: InteractiveModeClass;
24
+ };
25
+
26
+ type ShowStatus = (this: InteractiveModePrototype, message: string) => void;
27
+
28
+ type ThinkingLevelStatusPatchOptions = {
29
+ readonly loadInteractiveModeModule?: () => Promise<InteractiveModeModule | undefined>;
30
+ readonly shouldShowThinkingLevelStatus?: () => boolean;
31
+ };
32
+
33
+ function isShowStatus(value: unknown): value is ShowStatus {
34
+ return typeof value === "function";
35
+ }
36
+
37
+ function getStatusPatchState(): StatusPatchState {
38
+ const existing = Reflect.get(globalThis, STATUS_PATCH_STATE);
39
+ if (typeof existing === "object" && existing !== null) {
40
+ const reader = Reflect.get(existing, "shouldShowThinkingLevelStatus");
41
+ if (typeof reader === "function") {
42
+ return existing as StatusPatchState;
43
+ }
44
+ }
45
+
46
+ const patchState: StatusPatchState = {
47
+ shouldShowThinkingLevelStatus: () => true,
48
+ };
49
+ Reflect.set(globalThis, STATUS_PATCH_STATE, patchState);
50
+ return patchState;
51
+ }
52
+
53
+ function warnStatusPatchUnavailable(error?: unknown): void {
54
+ let suffix = "";
55
+ if (error instanceof Error && error.message.length > 0) {
56
+ suffix = `: ${error.message}`;
57
+ }
58
+ console.warn(
59
+ `[pi-mode] thinking-level status patch unavailable; Pi internals may have changed${suffix}`,
60
+ );
61
+ }
62
+
63
+ async function loadInteractiveModeModule(): Promise<InteractiveModeModule | undefined> {
64
+ try {
65
+ const codingAgentEntry = fileURLToPath(
66
+ import.meta.resolve("@earendil-works/pi-coding-agent"),
67
+ );
68
+ const distDir = dirname(codingAgentEntry);
69
+ const interactiveModePath = pathToFileURL(
70
+ join(distDir, "modes/interactive/interactive-mode.js"),
71
+ ).href;
72
+ return (await import(interactiveModePath)) as InteractiveModeModule;
73
+ } catch (error: unknown) {
74
+ warnStatusPatchUnavailable(error);
75
+ return undefined;
76
+ }
77
+ }
78
+
79
+ export async function applyThinkingLevelStatusPatch(
80
+ options: ThinkingLevelStatusPatchOptions = {},
81
+ ): Promise<void> {
82
+ const patchState = getStatusPatchState();
83
+ patchState.shouldShowThinkingLevelStatus =
84
+ options.shouldShowThinkingLevelStatus ?? shouldShowThinkingLevelStatus;
85
+
86
+ const loadModule = options.loadInteractiveModeModule ?? loadInteractiveModeModule;
87
+ const module = await loadModule();
88
+ const prototype = module?.InteractiveMode?.prototype;
89
+ if (prototype === undefined) return;
90
+ if (prototype[STATUS_PATCH_MARKER] === true) return;
91
+ if (typeof prototype.showStatus !== "function") {
92
+ warnStatusPatchUnavailable(new Error("InteractiveMode.showStatus is not a function"));
93
+ return;
94
+ }
95
+
96
+ const showStatusDescriptor = Object.getOwnPropertyDescriptor(prototype, "showStatus");
97
+ const showStatus: unknown = showStatusDescriptor?.value;
98
+ if (!isShowStatus(showStatus)) {
99
+ warnStatusPatchUnavailable(new Error("InteractiveMode.showStatus descriptor is invalid"));
100
+ return;
101
+ }
102
+
103
+ prototype.showStatus = function patchedShowStatus(
104
+ this: InteractiveModePrototype,
105
+ message: string,
106
+ ): void {
107
+ if (
108
+ message.startsWith(THINKING_LEVEL_STATUS_PREFIX) &&
109
+ patchState.shouldShowThinkingLevelStatus() === false
110
+ ) {
111
+ return;
112
+ }
113
+ showStatus.call(this, message);
114
+ };
115
+ prototype[STATUS_PATCH_MARKER] = true;
116
+ }
package/src/storage.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
1
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
@@ -6,12 +6,26 @@ export function getGlobalAgentDir(): string {
6
6
  return getAgentDir();
7
7
  }
8
8
 
9
+ const EXTENSION_ID = "pi-mode";
10
+ const CONFIG_FILE = "config.json";
11
+ const SCHEMA_FILE = "config.schema.json";
12
+
13
+ const DEFAULT_MODES_CONFIG_FILE = {
14
+ $schema: `./${SCHEMA_FILE}`,
15
+ version: 1,
16
+ currentMode: "default",
17
+ modeShowName: false,
18
+ modeUseThinkingBorderColors: false,
19
+ modeShowThinkingLevelStatus: false,
20
+ modes: {},
21
+ };
22
+
9
23
  export function getGlobalModesPath(): string {
10
- return path.join(getGlobalAgentDir(), "modes.json");
24
+ return path.join(getGlobalAgentDir(), EXTENSION_ID, CONFIG_FILE);
11
25
  }
12
26
 
13
27
  export function getProjectModesPath(cwd: string): string {
14
- return path.join(cwd, ".pi", "modes.json");
28
+ return path.join(cwd, CONFIG_DIR_NAME, EXTENSION_ID, CONFIG_FILE);
15
29
  }
16
30
 
17
31
  export async function fileExists(filePath: string): Promise<boolean> {
@@ -27,6 +41,71 @@ export async function ensureDirForFile(filePath: string): Promise<void> {
27
41
  await fs.mkdir(path.dirname(filePath), { recursive: true });
28
42
  }
29
43
 
44
+ function getSchemaPath(configPath: string): string {
45
+ return path.join(path.dirname(configPath), SCHEMA_FILE);
46
+ }
47
+
48
+ async function writeIfMissing(filePath: string, content: string): Promise<void> {
49
+ try {
50
+ await ensureDirForFile(filePath);
51
+ await fs.writeFile(filePath, content, { encoding: "utf8", flag: "wx" });
52
+ } catch (error: unknown) {
53
+ if (error instanceof Error && (error as NodeJS.ErrnoException).code === "EEXIST") return;
54
+ if (error instanceof Error) throw error;
55
+ throw new Error(String(error));
56
+ }
57
+ }
58
+
59
+ async function refreshSchemaFile(filePath: string, content: string): Promise<void> {
60
+ let temporaryPath: string | undefined;
61
+ try {
62
+ await ensureDirForFile(filePath);
63
+ try {
64
+ if ((await fs.readFile(filePath, "utf8")) === content) return;
65
+ } catch (error: unknown) {
66
+ if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== "ENOENT") {
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ const nextTemporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
72
+ await fs.writeFile(nextTemporaryPath, content, { encoding: "utf8", flag: "wx" });
73
+ temporaryPath = nextTemporaryPath;
74
+ await fs.rename(temporaryPath, filePath);
75
+ temporaryPath = undefined;
76
+ } catch (error: unknown) {
77
+ if (temporaryPath !== undefined) {
78
+ try {
79
+ await fs.unlink(temporaryPath);
80
+ } catch {
81
+ // Ignore cleanup failure while reporting the original scaffold failure.
82
+ }
83
+ }
84
+ if (error instanceof Error) throw error;
85
+ throw new Error(String(error));
86
+ }
87
+ }
88
+
89
+ async function readBundledSchema(): Promise<string | undefined> {
90
+ try {
91
+ return await fs.readFile(new URL("../config.schema.json", import.meta.url), "utf8");
92
+ } catch {
93
+ return undefined;
94
+ }
95
+ }
96
+
97
+ export async function scaffoldGlobalModesConfig(): Promise<void> {
98
+ const globalConfigPath = getGlobalModesPath();
99
+ const schema = await readBundledSchema();
100
+ if (schema !== undefined) {
101
+ await refreshSchemaFile(getSchemaPath(globalConfigPath), schema);
102
+ }
103
+ await writeIfMissing(
104
+ globalConfigPath,
105
+ `${JSON.stringify(DEFAULT_MODES_CONFIG_FILE, null, 2)}\n`,
106
+ );
107
+ }
108
+
30
109
  export async function getMtimeMs(filePath: string): Promise<number | null> {
31
110
  try {
32
111
  const stat = await fs.stat(filePath);