pi-system-theme 0.1.1

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.
Files changed (4) hide show
  1. package/README.md +75 -0
  2. package/TODO.md +3 -0
  3. package/index.ts +364 -0
  4. package/package.json +32 -0
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # pi-system-theme
2
+
3
+ A Pi extension that syncs Pi's theme with macOS appearance (dark/light mode).
4
+
5
+ ## Behavior
6
+
7
+ - macOS dark mode -> `darkTheme`
8
+ - macOS light mode -> `lightTheme`
9
+ - Detection uses:
10
+
11
+ ```bash
12
+ /usr/bin/defaults read -g AppleInterfaceStyle
13
+ ```
14
+
15
+ If detection fails or returns an unknown value, the extension keeps the current Pi theme unchanged.
16
+
17
+ ## Defaults (works out of the box)
18
+
19
+ No config is required.
20
+
21
+ - `darkTheme`: `dark`
22
+ - `lightTheme`: `light`
23
+ - `pollMs`: `2000`
24
+
25
+ ## Configuration file (global only)
26
+
27
+ Path:
28
+
29
+ - `~/.pi/agent/system-theme.json`
30
+
31
+ The extension stores only **overrides** in this file. If all values match defaults, the file is removed.
32
+
33
+ Example:
34
+
35
+ ```json
36
+ {
37
+ "darkTheme": "rose-pine",
38
+ "lightTheme": "rose-pine-dawn"
39
+ }
40
+ ```
41
+
42
+ ## Interactive command
43
+
44
+ Use `/system-theme` to edit all knobs directly:
45
+
46
+ 1. dark theme name
47
+ 2. light theme name
48
+ 3. poll interval (ms)
49
+
50
+ After saving, the extension writes global overrides and applies changes immediately.
51
+
52
+ ## Notes
53
+
54
+ - This extension currently only acts on macOS (`process.platform === "darwin"`).
55
+ - If a configured theme name is missing, it falls back to Pi built-ins (`dark`/`light`).
56
+
57
+ ## Install
58
+
59
+ From git:
60
+
61
+ ```bash
62
+ pi install git:github.com/ferologics/pi-system-theme
63
+ ```
64
+
65
+ As part of `pi-shit` package:
66
+
67
+ ```bash
68
+ pi install npm:pi-shit
69
+ ```
70
+
71
+ Or from local source while developing:
72
+
73
+ ```bash
74
+ pi -e /path/to/pi-system-theme/index.ts
75
+ ```
package/TODO.md ADDED
@@ -0,0 +1,3 @@
1
+ # TODO
2
+
3
+ - Evaluate optional debounce/hysteresis (`stablePolls`) if appearance detection glitches persist in real-world usage.
package/index.ts ADDED
@@ -0,0 +1,364 @@
1
+ import { execFile } from "node:child_process";
2
+ import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ type Appearance = "dark" | "light" | "unknown";
11
+
12
+ type RawConfig = {
13
+ darkTheme?: unknown;
14
+ lightTheme?: unknown;
15
+ pollMs?: unknown;
16
+ };
17
+
18
+ type Config = {
19
+ darkTheme: string;
20
+ lightTheme: string;
21
+ pollMs: number;
22
+ };
23
+
24
+ const DEFAULT_CONFIG: Config = {
25
+ darkTheme: "dark",
26
+ lightTheme: "light",
27
+ pollMs: 2000,
28
+ };
29
+
30
+ const GLOBAL_CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "system-theme.json");
31
+ const MIN_POLL_MS = 500;
32
+ const DETECTION_TIMEOUT_MS = 1200;
33
+
34
+ function toNonEmptyString(value: unknown): string | undefined {
35
+ if (typeof value !== "string") return undefined;
36
+
37
+ const trimmed = value.trim();
38
+ return trimmed.length > 0 ? trimmed : undefined;
39
+ }
40
+
41
+ function toPositiveInteger(value: unknown): number | undefined {
42
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
43
+
44
+ const rounded = Math.round(value);
45
+ return rounded > 0 ? rounded : undefined;
46
+ }
47
+
48
+ function mergeConfig(base: Config, rawConfig: RawConfig | undefined): Config {
49
+ if (!rawConfig) return base;
50
+
51
+ const darkTheme = toNonEmptyString(rawConfig.darkTheme) ?? base.darkTheme;
52
+ const lightTheme = toNonEmptyString(rawConfig.lightTheme) ?? base.lightTheme;
53
+
54
+ const pollMsValue = toPositiveInteger(rawConfig.pollMs);
55
+ const pollMs = pollMsValue ? Math.max(pollMsValue, MIN_POLL_MS) : base.pollMs;
56
+
57
+ return {
58
+ darkTheme,
59
+ lightTheme,
60
+ pollMs,
61
+ };
62
+ }
63
+
64
+ function isObject(value: unknown): value is Record<string, unknown> {
65
+ return typeof value === "object" && value !== null && !Array.isArray(value);
66
+ }
67
+
68
+ async function readConfig(pathToConfig: string): Promise<RawConfig | undefined> {
69
+ try {
70
+ await access(pathToConfig);
71
+ } catch {
72
+ return undefined;
73
+ }
74
+
75
+ try {
76
+ const rawContent = await readFile(pathToConfig, "utf8");
77
+ const parsed = JSON.parse(rawContent) as unknown;
78
+
79
+ if (!isObject(parsed)) {
80
+ console.warn(`[pi-system-theme] Ignoring ${pathToConfig}: expected a JSON object.`);
81
+ return undefined;
82
+ }
83
+
84
+ return parsed;
85
+ } catch (error) {
86
+ const message = error instanceof Error ? error.message : String(error);
87
+ console.warn(`[pi-system-theme] Failed to load ${pathToConfig}: ${message}`);
88
+ return undefined;
89
+ }
90
+ }
91
+
92
+ async function loadConfig(): Promise<Config> {
93
+ const globalConfig = await readConfig(GLOBAL_CONFIG_PATH);
94
+ return mergeConfig(DEFAULT_CONFIG, globalConfig);
95
+ }
96
+
97
+ function getOverrides(config: Config): Record<string, string | number> {
98
+ const overrides: Record<string, string | number> = {};
99
+
100
+ if (config.darkTheme !== DEFAULT_CONFIG.darkTheme) {
101
+ overrides.darkTheme = config.darkTheme;
102
+ }
103
+
104
+ if (config.lightTheme !== DEFAULT_CONFIG.lightTheme) {
105
+ overrides.lightTheme = config.lightTheme;
106
+ }
107
+
108
+ if (config.pollMs !== DEFAULT_CONFIG.pollMs) {
109
+ overrides.pollMs = config.pollMs;
110
+ }
111
+
112
+ return overrides;
113
+ }
114
+
115
+ async function writeGlobalOverrides(config: Config): Promise<{ wroteFile: boolean; overrideCount: number }> {
116
+ const overrides = getOverrides(config);
117
+ const overrideCount = Object.keys(overrides).length;
118
+
119
+ if (overrideCount === 0) {
120
+ await rm(GLOBAL_CONFIG_PATH, { force: true });
121
+ return { wroteFile: false, overrideCount };
122
+ }
123
+
124
+ await mkdir(path.dirname(GLOBAL_CONFIG_PATH), { recursive: true });
125
+ await writeFile(GLOBAL_CONFIG_PATH, `${JSON.stringify(overrides, null, 4)}\n`, "utf8");
126
+
127
+ return { wroteFile: true, overrideCount };
128
+ }
129
+
130
+ function extractStderr(error: unknown): string {
131
+ if (typeof error !== "object" || error === null) return "";
132
+
133
+ const maybeStderr = (error as { stderr?: unknown }).stderr;
134
+ return typeof maybeStderr === "string" ? maybeStderr : "";
135
+ }
136
+
137
+ async function detectAppearance(): Promise<Appearance> {
138
+ try {
139
+ const { stdout } = await execFileAsync("/usr/bin/defaults", ["read", "-g", "AppleInterfaceStyle"], {
140
+ timeout: DETECTION_TIMEOUT_MS,
141
+ windowsHide: true,
142
+ });
143
+
144
+ const normalized = stdout.trim().toLowerCase();
145
+ if (normalized === "dark") return "dark";
146
+ if (normalized === "light") return "light";
147
+
148
+ return "unknown";
149
+ } catch (error) {
150
+ const stderr = extractStderr(error).toLowerCase();
151
+ if (stderr.includes("does not exist")) {
152
+ return "light";
153
+ }
154
+
155
+ return "unknown";
156
+ }
157
+ }
158
+
159
+ function getRequestedTheme(config: Config, appearance: Exclude<Appearance, "unknown">): string {
160
+ return appearance === "dark" ? config.darkTheme : config.lightTheme;
161
+ }
162
+
163
+ function getBuiltinFallbackTheme(appearance: Exclude<Appearance, "unknown">): string {
164
+ return appearance === "dark" ? "dark" : "light";
165
+ }
166
+
167
+ function resolveThemeName(
168
+ ctx: ExtensionContext,
169
+ requestedTheme: string,
170
+ fallbackTheme: string,
171
+ warnedMissingThemes: Set<string>,
172
+ ): string {
173
+ if (ctx.ui.getTheme(requestedTheme)) {
174
+ return requestedTheme;
175
+ }
176
+
177
+ const warningKey = `${requestedTheme}=>${fallbackTheme}`;
178
+ if (!warnedMissingThemes.has(warningKey)) {
179
+ warnedMissingThemes.add(warningKey);
180
+ console.warn(
181
+ `[pi-system-theme] Theme "${requestedTheme}" is not available. Falling back to "${fallbackTheme}".`,
182
+ );
183
+ }
184
+
185
+ if (ctx.ui.getTheme(fallbackTheme)) {
186
+ return fallbackTheme;
187
+ }
188
+
189
+ return requestedTheme;
190
+ }
191
+
192
+ function warnSetThemeFailureOnce(
193
+ warningKey: string,
194
+ warnings: Set<string>,
195
+ themeName: string,
196
+ errorMessage: string,
197
+ ): void {
198
+ if (warnings.has(warningKey)) return;
199
+
200
+ warnings.add(warningKey);
201
+ console.warn(`[pi-system-theme] Failed to set theme "${themeName}": ${errorMessage}`);
202
+ }
203
+
204
+ async function promptStringSetting(
205
+ ctx: ExtensionCommandContext,
206
+ label: string,
207
+ currentValue: string,
208
+ ): Promise<string | undefined> {
209
+ const value = await ctx.ui.input(`${label} (current: ${currentValue})`, currentValue);
210
+ if (value === undefined) return undefined;
211
+
212
+ const trimmed = value.trim();
213
+ return trimmed.length > 0 ? trimmed : currentValue;
214
+ }
215
+
216
+ async function promptIntegerSetting(
217
+ ctx: ExtensionCommandContext,
218
+ label: string,
219
+ currentValue: number,
220
+ minimum: number,
221
+ ): Promise<number | undefined> {
222
+ while (true) {
223
+ const value = await ctx.ui.input(`${label} (current: ${currentValue})`, String(currentValue));
224
+ if (value === undefined) return undefined;
225
+
226
+ const trimmed = value.trim();
227
+ if (trimmed.length === 0) return currentValue;
228
+
229
+ const parsed = Number.parseInt(trimmed, 10);
230
+ if (Number.isFinite(parsed) && parsed >= minimum) {
231
+ return parsed;
232
+ }
233
+
234
+ ctx.ui.notify(`Enter a whole number >= ${minimum}, or leave blank to keep current value.`, "warning");
235
+ }
236
+ }
237
+
238
+ export default function systemThemeExtension(pi: ExtensionAPI): void {
239
+ let intervalId: ReturnType<typeof setInterval> | null = null;
240
+ let syncInProgress = false;
241
+ let lastAppliedThemeName: string | undefined;
242
+ let activeConfig: Config = DEFAULT_CONFIG;
243
+
244
+ const missingThemeWarnings = new Set<string>();
245
+ const setThemeWarnings = new Set<string>();
246
+
247
+ async function syncTheme(ctx: ExtensionContext): Promise<void> {
248
+ if (syncInProgress) return;
249
+ syncInProgress = true;
250
+
251
+ try {
252
+ const appearance = await detectAppearance();
253
+ if (appearance === "unknown") return;
254
+
255
+ const requestedTheme = getRequestedTheme(activeConfig, appearance);
256
+ const fallbackTheme = getBuiltinFallbackTheme(appearance);
257
+ const targetTheme = resolveThemeName(ctx, requestedTheme, fallbackTheme, missingThemeWarnings);
258
+
259
+ const activeThemeName = ctx.ui.theme.name ?? lastAppliedThemeName;
260
+ if (activeThemeName === targetTheme) {
261
+ lastAppliedThemeName = activeThemeName;
262
+ return;
263
+ }
264
+
265
+ const setResult = ctx.ui.setTheme(targetTheme);
266
+ if (setResult.success) {
267
+ lastAppliedThemeName = targetTheme;
268
+ return;
269
+ }
270
+
271
+ warnSetThemeFailureOnce(
272
+ `set:${targetTheme}`,
273
+ setThemeWarnings,
274
+ targetTheme,
275
+ setResult.error ?? "unknown error",
276
+ );
277
+
278
+ if (targetTheme === fallbackTheme) return;
279
+
280
+ const fallbackResult = ctx.ui.setTheme(fallbackTheme);
281
+ if (fallbackResult.success) {
282
+ lastAppliedThemeName = fallbackTheme;
283
+ return;
284
+ }
285
+
286
+ warnSetThemeFailureOnce(
287
+ `fallback:${fallbackTheme}`,
288
+ setThemeWarnings,
289
+ fallbackTheme,
290
+ fallbackResult.error ?? "unknown error",
291
+ );
292
+ } finally {
293
+ syncInProgress = false;
294
+ }
295
+ }
296
+
297
+ function restartPolling(ctx: ExtensionContext): void {
298
+ if (intervalId) {
299
+ clearInterval(intervalId);
300
+ }
301
+
302
+ intervalId = setInterval(() => {
303
+ void syncTheme(ctx);
304
+ }, activeConfig.pollMs);
305
+ }
306
+
307
+ pi.registerCommand("system-theme", {
308
+ description: "Configure global pi-system-theme settings",
309
+ handler: async (_args, ctx) => {
310
+ if (process.platform !== "darwin") {
311
+ ctx.ui.notify("pi-system-theme currently supports macOS only.", "info");
312
+ return;
313
+ }
314
+
315
+ const darkTheme = await promptStringSetting(ctx, "Dark theme", activeConfig.darkTheme);
316
+ if (darkTheme === undefined) return;
317
+
318
+ const lightTheme = await promptStringSetting(ctx, "Light theme", activeConfig.lightTheme);
319
+ if (lightTheme === undefined) return;
320
+
321
+ const pollMs = await promptIntegerSetting(ctx, "Poll interval (ms)", activeConfig.pollMs, MIN_POLL_MS);
322
+ if (pollMs === undefined) return;
323
+
324
+ activeConfig = mergeConfig(DEFAULT_CONFIG, {
325
+ darkTheme,
326
+ lightTheme,
327
+ pollMs,
328
+ });
329
+
330
+ try {
331
+ const result = await writeGlobalOverrides(activeConfig);
332
+ if (result.wroteFile) {
333
+ ctx.ui.notify(`Saved ${result.overrideCount} override(s) to ${GLOBAL_CONFIG_PATH}.`, "info");
334
+ } else {
335
+ ctx.ui.notify("No overrides left. Removed global config file; defaults are active.", "info");
336
+ }
337
+ } catch (error) {
338
+ const message = error instanceof Error ? error.message : String(error);
339
+ ctx.ui.notify(`Failed to save config: ${message}`, "error");
340
+ return;
341
+ }
342
+
343
+ await syncTheme(ctx);
344
+ restartPolling(ctx);
345
+ },
346
+ });
347
+
348
+ pi.on("session_start", async (_event, ctx) => {
349
+ if (process.platform !== "darwin") return;
350
+
351
+ activeConfig = await loadConfig();
352
+ lastAppliedThemeName = ctx.ui.theme.name;
353
+
354
+ await syncTheme(ctx);
355
+ restartPolling(ctx);
356
+ });
357
+
358
+ pi.on("session_shutdown", () => {
359
+ if (!intervalId) return;
360
+
361
+ clearInterval(intervalId);
362
+ intervalId = null;
363
+ });
364
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "pi-system-theme",
3
+ "version": "0.1.1",
4
+ "description": "Sync Pi theme with macOS light/dark appearance",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "pi-coding-agent",
9
+ "theme",
10
+ "macos"
11
+ ],
12
+ "author": "ferologics",
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/ferologics/pi-system-theme.git"
17
+ },
18
+ "homepage": "https://github.com/ferologics/pi-system-theme#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/ferologics/pi-system-theme/issues"
21
+ },
22
+ "pi": {
23
+ "extensions": [
24
+ "index.ts"
25
+ ]
26
+ },
27
+ "piRelease": {
28
+ "repo": "ferologics/pi-system-theme",
29
+ "branch": "main",
30
+ "subtreePublishRecipe": "publish-pi-system-theme"
31
+ }
32
+ }