pi-system-theme-ssh-bridge 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mise42
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,92 @@
1
+ # pi-system-theme-ssh-bridge
2
+
3
+ Sync pi theme with system appearance — works both **locally** and **over SSH**.
4
+
5
+ Drop-in replacement for `pi-system-theme`, with added SSH support.
6
+
7
+ ## How it works
8
+
9
+ The extension uses a three-layer detection strategy (in priority order):
10
+
11
+ | Priority | Strategy | When it helps |
12
+ |----------|----------|---------------|
13
+ | 1 | **Override file** (`~/.pi/agent/system-theme-override.json`) | Manual push from another machine |
14
+ | 2 | **Terminal query** (OSC 11 background-color) | SSH sessions — escape sequences travel through the SSH tunnel back to your local Ghostty, which responds with its current background color |
15
+ | 3 | **OS-level detection** (macOS `defaults` / GNOME `gsettings` / Windows `reg`) | Local sessions without a capable terminal |
16
+
17
+ ### Why it works over SSH
18
+
19
+ When you SSH into a remote machine, your local terminal (Ghostty) is still rendering everything. The extension sends an [OSC 11](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands) query (`\033]11;?\033\\`) to stdout. This travels through the SSH tunnel to Ghostty, which replies with the current background RGB. The extension parses the luminance to determine dark or light.
20
+
21
+ This means: **when Ghostty switches `theme = auto` on your Laptop, the remote pi detects it within seconds — no push scripts needed.**
22
+
23
+ The OSC 11 query runs in a short-lived subprocess that opens `/dev/tty` directly, so it doesn't interfere with pi's own terminal I/O.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pi install npm:pi-system-theme-ssh-bridge
29
+ ```
30
+
31
+ > **Important:** Remove `pi-system-theme` first to avoid two extensions fighting over `setTheme`:
32
+ > ```bash
33
+ > pi remove npm:pi-system-theme
34
+ > ```
35
+
36
+ ## Configuration
37
+
38
+ Use the `/system-theme` command inside pi to configure:
39
+
40
+ 1. **Dark theme** name (default: `dark`)
41
+ 2. **Light theme** name (default: `light`)
42
+ 3. **Poll interval** in ms (default: `2000`)
43
+
44
+ Settings are saved to `~/.pi/agent/system-theme.json` (same location as `pi-system-theme`, so existing config carries over).
45
+
46
+ ## Override file (optional)
47
+
48
+ For environments where neither OSC 11 nor OS detection works, you can push an override file manually:
49
+
50
+ ```bash
51
+ # On your Laptop, push current appearance to Desktop:
52
+ ./push-theme-override.sh user@desktop
53
+
54
+ # Or inside pi on any machine:
55
+ /system-theme-push dark
56
+ /system-theme-push light
57
+ /system-theme-push auto # clears override, falls back to detection
58
+ ```
59
+
60
+ ### Override file format
61
+
62
+ ```json
63
+ {
64
+ "appearance": "dark",
65
+ "updatedAt": "2026-02-22T07:00:00Z",
66
+ "source": "my-laptop"
67
+ }
68
+ ```
69
+
70
+ ## Environment variables
71
+
72
+ | Variable | Default | Description |
73
+ |----------|---------|-------------|
74
+ | `PI_SYSTEM_THEME_OVERRIDE_FILE` | `~/.pi/agent/system-theme-override.json` | Override file path |
75
+ | `PI_SYSTEM_THEME_OVERRIDE_MAX_AGE_MS` | `60000` | Max age before override is considered stale |
76
+
77
+ ## Compatibility
78
+
79
+ - **Terminals:** Any terminal supporting OSC 11 color queries (Ghostty, iTerm2, kitty, foot, WezTerm, xterm, etc.)
80
+ - **OS detection:** macOS, Linux (GNOME gsettings), Windows
81
+ - **SSH:** Works transparently — no special setup required
82
+ - **Ghostty `theme = auto`:** Fully supported. When Ghostty switches colors, the next poll detects it.
83
+
84
+ ## Migrating from pi-system-theme
85
+
86
+ 1. `pi remove npm:pi-system-theme`
87
+ 2. `pi install npm:pi-system-theme-ssh-bridge`
88
+ 3. Done. Your `~/.pi/agent/system-theme.json` config (if any) is reused automatically.
89
+
90
+ ## License
91
+
92
+ MIT
package/index.ts ADDED
@@ -0,0 +1,588 @@
1
+ /**
2
+ * pi-system-theme-ssh-bridge
3
+ *
4
+ * Sync pi theme with system appearance — works both locally and over SSH.
5
+ *
6
+ * Detection strategy (in priority order):
7
+ * 1. Override file (~/.pi/agent/system-theme-override.json)
8
+ * – If present & fresh, use its "dark" / "light" value directly.
9
+ * – If value is "auto", fall through.
10
+ * 2. Terminal query (OSC 11 background-color)
11
+ * – Works transparently over SSH because escape sequences travel
12
+ * through the SSH tunnel back to the local terminal (Ghostty, etc.).
13
+ * – A helper subprocess opens /dev/tty to avoid interfering with
14
+ * pi's own stdin/stdout.
15
+ * 3. OS-level detection (macOS defaults / GNOME gsettings / Windows reg)
16
+ * – Classic local detection, same as pi-system-theme.
17
+ */
18
+
19
+ import { execFile, spawn } from "node:child_process";
20
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ import { promisify } from "node:util";
24
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
25
+
26
+ const execFileAsync = promisify(execFile);
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Types
30
+ // ---------------------------------------------------------------------------
31
+
32
+ type Appearance = "dark" | "light";
33
+ type OverrideAppearance = Appearance | "auto";
34
+
35
+ type Config = {
36
+ darkTheme: string;
37
+ lightTheme: string;
38
+ pollMs: number;
39
+ overrideFile: string;
40
+ overrideMaxAgeMs: number;
41
+ };
42
+
43
+ type OverridePayload = {
44
+ appearance: OverrideAppearance;
45
+ updatedAt?: string;
46
+ source?: string;
47
+ };
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Constants
51
+ // ---------------------------------------------------------------------------
52
+
53
+ const DEFAULT_CONFIG: Config = {
54
+ darkTheme: "dark",
55
+ lightTheme: "light",
56
+ pollMs: 2000,
57
+ overrideFile: path.join(os.homedir(), ".pi", "agent", "system-theme-override.json"),
58
+ overrideMaxAgeMs: 60_000,
59
+ };
60
+
61
+ const GLOBAL_CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "system-theme.json");
62
+ const DETECTION_TIMEOUT_MS = 1200;
63
+ const MIN_POLL_MS = 500;
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Helpers
67
+ // ---------------------------------------------------------------------------
68
+
69
+ function isObject(value: unknown): value is Record<string, unknown> {
70
+ return typeof value === "object" && value !== null && !Array.isArray(value);
71
+ }
72
+
73
+ function normalizeSettingValue(value: string): string {
74
+ const trimmed = value.trim().toLowerCase();
75
+ if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) {
76
+ return trimmed.slice(1, -1);
77
+ }
78
+ return trimmed;
79
+ }
80
+
81
+ function toThemeName(value: unknown, fallback: string): string {
82
+ if (typeof value !== "string") return fallback;
83
+ const trimmed = value.trim();
84
+ return trimmed.length > 0 ? trimmed : fallback;
85
+ }
86
+
87
+ function toPollMs(value: unknown, fallback: number): number {
88
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
89
+ return Math.max(MIN_POLL_MS, Math.round(value));
90
+ }
91
+
92
+ function extractStderr(error: unknown): string {
93
+ if (!error || typeof error !== "object") return "";
94
+ const stderr = (error as { stderr?: unknown }).stderr;
95
+ return typeof stderr === "string" ? stderr : "";
96
+ }
97
+
98
+ function parseOverrideAppearance(value: unknown): OverrideAppearance | null {
99
+ if (value === "dark" || value === "light" || value === "auto") return value;
100
+ return null;
101
+ }
102
+
103
+ function isDefaultThemeName(name: string | undefined): boolean {
104
+ return name === DEFAULT_CONFIG.darkTheme || name === DEFAULT_CONFIG.lightTheme;
105
+ }
106
+
107
+ function canManageThemes(ctx: ExtensionContext): boolean {
108
+ return ctx.hasUI && ctx.ui.getAllThemes().length > 0;
109
+ }
110
+
111
+ function isSSHSession(): boolean {
112
+ return !!(process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT);
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Config I/O (reads ~/.pi/agent/system-theme.json written by /system-theme)
117
+ // ---------------------------------------------------------------------------
118
+
119
+ async function loadConfig(): Promise<Config> {
120
+ const config = { ...DEFAULT_CONFIG };
121
+
122
+ // Read theme mapping from shared config (same file as pi-system-theme)
123
+ try {
124
+ const raw = await readFile(GLOBAL_CONFIG_PATH, "utf8");
125
+ const parsed = JSON.parse(raw) as unknown;
126
+ if (isObject(parsed)) {
127
+ config.darkTheme = toThemeName(parsed.darkTheme, config.darkTheme);
128
+ config.lightTheme = toThemeName(parsed.lightTheme, config.lightTheme);
129
+ config.pollMs = toPollMs(parsed.pollMs, config.pollMs);
130
+ }
131
+ } catch {
132
+ // missing or corrupt → use defaults
133
+ }
134
+
135
+ // Env overrides for bridge-specific settings
136
+ const envFile = process.env.PI_SYSTEM_THEME_OVERRIDE_FILE;
137
+ if (typeof envFile === "string" && envFile.trim().length > 0) {
138
+ config.overrideFile = envFile.trim();
139
+ }
140
+
141
+ const envMaxAge = process.env.PI_SYSTEM_THEME_OVERRIDE_MAX_AGE_MS;
142
+ if (envMaxAge) {
143
+ const v = Number.parseInt(envMaxAge, 10);
144
+ if (Number.isFinite(v)) config.overrideMaxAgeMs = Math.max(0, v);
145
+ }
146
+
147
+ return config;
148
+ }
149
+
150
+ async function saveConfig(config: Config): Promise<void> {
151
+ const overrides: Partial<Config> = {};
152
+ if (config.darkTheme !== DEFAULT_CONFIG.darkTheme) overrides.darkTheme = config.darkTheme;
153
+ if (config.lightTheme !== DEFAULT_CONFIG.lightTheme) overrides.lightTheme = config.lightTheme;
154
+ if (config.pollMs !== DEFAULT_CONFIG.pollMs) overrides.pollMs = config.pollMs;
155
+
156
+ if (Object.keys(overrides).length === 0) {
157
+ const { rm } = await import("node:fs/promises");
158
+ await rm(GLOBAL_CONFIG_PATH, { force: true });
159
+ return;
160
+ }
161
+
162
+ await mkdir(path.dirname(GLOBAL_CONFIG_PATH), { recursive: true });
163
+ await writeFile(GLOBAL_CONFIG_PATH, `${JSON.stringify(overrides, null, 4)}\n`, "utf8");
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Strategy 1: Override file
168
+ // ---------------------------------------------------------------------------
169
+
170
+ async function readOverrideFile(filePath: string, maxAgeMs: number): Promise<Appearance | "auto" | null> {
171
+ try {
172
+ const raw = await readFile(filePath, "utf8");
173
+ const parsed = JSON.parse(raw) as unknown;
174
+ if (!isObject(parsed)) return null;
175
+
176
+ const appearance = parseOverrideAppearance(parsed.appearance);
177
+ if (!appearance) return null;
178
+
179
+ // Check freshness
180
+ if (maxAgeMs > 0) {
181
+ const updatedAt = typeof parsed.updatedAt === "string" ? parsed.updatedAt : undefined;
182
+ if (!updatedAt) return null;
183
+ const time = Date.parse(updatedAt);
184
+ if (!Number.isFinite(time) || Date.now() - time > maxAgeMs) return null;
185
+ }
186
+
187
+ return appearance;
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Strategy 2: Terminal background color query (OSC 11)
195
+ //
196
+ // We spawn a short-lived subprocess that opens /dev/tty directly.
197
+ // This avoids competing with pi's own stdin/stdout handling.
198
+ // The query works transparently over SSH because escape sequences
199
+ // travel through the SSH pseudo-terminal back to the local terminal.
200
+ // ---------------------------------------------------------------------------
201
+
202
+ const OSC11_QUERY_SCRIPT = `
203
+ 'use strict';
204
+ const fs = require('fs');
205
+ const net = require('net');
206
+
207
+ let fd;
208
+ try { fd = fs.openSync('/dev/tty', fs.constants.O_RDWR | fs.constants.O_NOCTTY); }
209
+ catch { process.exit(1); }
210
+
211
+ // Send OSC 11 query
212
+ fs.writeSync(fd, '\\x1b]11;?\\x1b\\\\');
213
+
214
+ // Read response with polling + timeout
215
+ const buf = Buffer.alloc(512);
216
+ let response = '';
217
+ const deadline = Date.now() + 1500;
218
+
219
+ function tryRead() {
220
+ try {
221
+ const n = fs.readSync(fd, buf, 0, buf.length);
222
+ if (n > 0) response += buf.toString('utf8', 0, n);
223
+ } catch {}
224
+ }
225
+
226
+ // Use a tight loop with small sleeps
227
+ function poll() {
228
+ tryRead();
229
+ if (response.includes('\\x1b\\\\') || response.includes('\\x07') || Date.now() > deadline) {
230
+ fs.closeSync(fd);
231
+ // Parse rgb:RRRR/GGGG/BBBB (16-bit) or rgb:RR/GG/BB (8-bit)
232
+ const m = response.match(/rgb:([0-9a-fA-F]+)\\/([0-9a-fA-F]+)\\/([0-9a-fA-F]+)/);
233
+ if (m) {
234
+ // Normalize to 8-bit (take first 2 hex chars of each component)
235
+ const r = parseInt(m[1].substring(0, 2), 16);
236
+ const g = parseInt(m[2].substring(0, 2), 16);
237
+ const b = parseInt(m[3].substring(0, 2), 16);
238
+ const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
239
+ process.stdout.write(luminance < 128 ? 'dark' : 'light');
240
+ }
241
+ process.exit(0);
242
+ }
243
+ setTimeout(poll, 20);
244
+ }
245
+
246
+ poll();
247
+ `;
248
+
249
+ function queryTerminalBackground(): Promise<Appearance | null> {
250
+ return new Promise((resolve) => {
251
+ const timer = setTimeout(() => {
252
+ child.kill();
253
+ resolve(null);
254
+ }, 3000);
255
+
256
+ const child = spawn(process.execPath, ["-e", OSC11_QUERY_SCRIPT], {
257
+ stdio: ["ignore", "pipe", "ignore"],
258
+ timeout: 3000,
259
+ });
260
+
261
+ let stdout = "";
262
+ child.stdout!.on("data", (chunk: Buffer) => {
263
+ stdout += chunk.toString();
264
+ });
265
+
266
+ child.on("close", () => {
267
+ clearTimeout(timer);
268
+ const trimmed = stdout.trim();
269
+ if (trimmed === "dark" || trimmed === "light") {
270
+ resolve(trimmed);
271
+ } else {
272
+ resolve(null);
273
+ }
274
+ });
275
+
276
+ child.on("error", () => {
277
+ clearTimeout(timer);
278
+ resolve(null);
279
+ });
280
+ });
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Strategy 3: OS-level detection (fallback)
285
+ // ---------------------------------------------------------------------------
286
+
287
+ async function detectMacAppearance(): Promise<Appearance | null> {
288
+ try {
289
+ const { stdout } = await execFileAsync("/usr/bin/defaults", ["read", "-g", "AppleInterfaceStyle"], {
290
+ timeout: DETECTION_TIMEOUT_MS,
291
+ windowsHide: true,
292
+ });
293
+ return normalizeSettingValue(stdout) === "dark" ? "dark" : null;
294
+ } catch (error) {
295
+ const stderr = extractStderr(error).toLowerCase();
296
+ if (stderr.includes("does not exist")) return "light";
297
+ return null;
298
+ }
299
+ }
300
+
301
+ async function detectLinuxAppearance(): Promise<Appearance | null> {
302
+ try {
303
+ const { stdout } = await execFileAsync("gsettings", ["get", "org.gnome.desktop.interface", "color-scheme"], {
304
+ timeout: DETECTION_TIMEOUT_MS,
305
+ windowsHide: true,
306
+ });
307
+ const v = normalizeSettingValue(stdout);
308
+ if (v === "prefer-dark") return "dark";
309
+ if (v === "prefer-light") return "light";
310
+ } catch {
311
+ // ignore
312
+ }
313
+
314
+ try {
315
+ const { stdout } = await execFileAsync("gsettings", ["get", "org.gnome.desktop.interface", "gtk-theme"], {
316
+ timeout: DETECTION_TIMEOUT_MS,
317
+ windowsHide: true,
318
+ });
319
+ const v = normalizeSettingValue(stdout);
320
+ if (v.includes("dark")) return "dark";
321
+ if (v.includes("light")) return "light";
322
+ } catch {
323
+ // ignore
324
+ }
325
+
326
+ return null;
327
+ }
328
+
329
+ async function detectWindowsAppearance(): Promise<Appearance | null> {
330
+ try {
331
+ const { stdout } = await execFileAsync(
332
+ "reg",
333
+ [
334
+ "query",
335
+ "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
336
+ "/v",
337
+ "AppsUseLightTheme",
338
+ ],
339
+ { timeout: DETECTION_TIMEOUT_MS, windowsHide: true },
340
+ );
341
+ const match = stdout.match(/AppsUseLightTheme\s+REG_DWORD\s+(\S+)/i);
342
+ if (!match) return null;
343
+ const raw = match[1] ?? "";
344
+ const num = raw.toLowerCase().startsWith("0x")
345
+ ? Number.parseInt(raw.slice(2), 16)
346
+ : Number.parseInt(raw, 10);
347
+ if (num === 0) return "dark";
348
+ if (num === 1) return "light";
349
+ return null;
350
+ } catch {
351
+ return null;
352
+ }
353
+ }
354
+
355
+ async function detectOSAppearance(): Promise<Appearance | null> {
356
+ switch (process.platform) {
357
+ case "darwin":
358
+ return detectMacAppearance();
359
+ case "linux":
360
+ return detectLinuxAppearance();
361
+ case "win32":
362
+ return detectWindowsAppearance();
363
+ default:
364
+ return null;
365
+ }
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Unified detection: override file → terminal query → OS detection
370
+ // ---------------------------------------------------------------------------
371
+
372
+ async function resolveAppearance(config: Config): Promise<Appearance | null> {
373
+ // 1. Override file (highest priority)
374
+ const override = await readOverrideFile(config.overrideFile, config.overrideMaxAgeMs);
375
+ if (override === "dark" || override === "light") return override;
376
+ // "auto" or null → continue
377
+
378
+ // 2. Terminal query via OSC 11 (works over SSH)
379
+ const fromTerminal = await queryTerminalBackground();
380
+ if (fromTerminal) return fromTerminal;
381
+
382
+ // 3. OS-level detection (local fallback)
383
+ return detectOSAppearance();
384
+ }
385
+
386
+ // ---------------------------------------------------------------------------
387
+ // Interactive settings command (/system-theme)
388
+ // ---------------------------------------------------------------------------
389
+
390
+ async function promptTheme(
391
+ ctx: ExtensionCommandContext,
392
+ label: string,
393
+ currentValue: string,
394
+ ): Promise<string | undefined> {
395
+ const next = await ctx.ui.input(label, currentValue);
396
+ if (next === undefined) return undefined;
397
+ const trimmed = next.trim();
398
+ return trimmed.length > 0 ? trimmed : currentValue;
399
+ }
400
+
401
+ async function promptPollMs(ctx: ExtensionCommandContext, currentValue: number): Promise<number | undefined> {
402
+ while (true) {
403
+ const next = await ctx.ui.input("Poll interval (ms)", String(currentValue));
404
+ if (next === undefined) return undefined;
405
+ const trimmed = next.trim();
406
+ if (trimmed.length === 0) return currentValue;
407
+ const parsed = Number.parseInt(trimmed, 10);
408
+ if (Number.isFinite(parsed) && parsed >= MIN_POLL_MS) return parsed;
409
+ ctx.ui.notify(`Enter a whole number ≥ ${MIN_POLL_MS}.`, "warning");
410
+ }
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // Extension entry point
415
+ // ---------------------------------------------------------------------------
416
+
417
+ export default function systemThemeBridge(pi: ExtensionAPI): void {
418
+ let intervalId: ReturnType<typeof setInterval> | null = null;
419
+ let inFlight = false;
420
+ let config: Config = { ...DEFAULT_CONFIG };
421
+ let lastAppliedTheme: string | null = null;
422
+ let didWarnCustomTheme = false;
423
+
424
+ function hasThemeOverrides(): boolean {
425
+ return config.darkTheme !== DEFAULT_CONFIG.darkTheme || config.lightTheme !== DEFAULT_CONFIG.lightTheme;
426
+ }
427
+
428
+ function shouldAutoSync(ctx: ExtensionContext): boolean {
429
+ if (!canManageThemes(ctx)) return false;
430
+ if (hasThemeOverrides()) return true;
431
+ return isDefaultThemeName(ctx.ui.theme.name);
432
+ }
433
+
434
+ function maybeWarnCustomTheme(ctx: ExtensionContext): void {
435
+ if (didWarnCustomTheme || !canManageThemes(ctx) || hasThemeOverrides()) return;
436
+ const currentTheme = ctx.ui.theme.name;
437
+ if (isDefaultThemeName(currentTheme)) return;
438
+ didWarnCustomTheme = true;
439
+ ctx.ui.notify(
440
+ `Current theme "${currentTheme ?? "unknown"}" is custom. ` +
441
+ `Auto-sync skipped. Configure /system-theme to enable.`,
442
+ "info",
443
+ );
444
+ }
445
+
446
+ async function tick(ctx: ExtensionContext): Promise<void> {
447
+ if (!shouldAutoSync(ctx) || inFlight) return;
448
+
449
+ inFlight = true;
450
+ try {
451
+ const appearance = await resolveAppearance(config);
452
+ if (!appearance) return;
453
+
454
+ const targetTheme = appearance === "dark" ? config.darkTheme : config.lightTheme;
455
+ if (ctx.ui.theme.name === targetTheme && lastAppliedTheme === targetTheme) return;
456
+
457
+ const result = ctx.ui.setTheme(targetTheme);
458
+ if (result.success) {
459
+ lastAppliedTheme = targetTheme;
460
+ } else {
461
+ const msg = result.error ?? "unknown";
462
+ if (lastAppliedTheme !== `err:${targetTheme}:${msg}`) {
463
+ lastAppliedTheme = `err:${targetTheme}:${msg}`;
464
+ console.warn(`[pi-system-theme-ssh-bridge] setTheme("${targetTheme}"): ${msg}`);
465
+ }
466
+ }
467
+ } finally {
468
+ inFlight = false;
469
+ }
470
+ }
471
+
472
+ function restartPolling(ctx: ExtensionContext): void {
473
+ if (intervalId) {
474
+ clearInterval(intervalId);
475
+ intervalId = null;
476
+ }
477
+ if (!shouldAutoSync(ctx)) return;
478
+ intervalId = setInterval(() => void tick(ctx), config.pollMs);
479
+ }
480
+
481
+ // -- /system-theme command (interactive settings) -------------------------
482
+
483
+ pi.registerCommand("system-theme", {
484
+ description: "Configure system theme sync (dark/light mapping, poll interval)",
485
+ handler: async (_args, ctx) => {
486
+ if (!canManageThemes(ctx)) {
487
+ if (ctx.hasUI) ctx.ui.notify("Requires interactive mode with themes.", "info");
488
+ return;
489
+ }
490
+
491
+ const draft: Config = { ...config };
492
+
493
+ while (true) {
494
+ const darkOpt = `Dark theme: ${draft.darkTheme}`;
495
+ const lightOpt = `Light theme: ${draft.lightTheme}`;
496
+ const pollOpt = `Poll interval (ms): ${draft.pollMs}`;
497
+ const saveOpt = "Save and apply";
498
+ const cancelOpt = "Cancel";
499
+
500
+ const choice = await ctx.ui.select("pi-system-theme-ssh-bridge", [
501
+ darkOpt,
502
+ lightOpt,
503
+ pollOpt,
504
+ saveOpt,
505
+ cancelOpt,
506
+ ]);
507
+
508
+ if (choice === undefined || choice === cancelOpt) return;
509
+
510
+ if (choice === darkOpt) {
511
+ const next = await promptTheme(ctx, "Dark theme", draft.darkTheme);
512
+ if (next !== undefined) draft.darkTheme = next;
513
+ continue;
514
+ }
515
+ if (choice === lightOpt) {
516
+ const next = await promptTheme(ctx, "Light theme", draft.lightTheme);
517
+ if (next !== undefined) draft.lightTheme = next;
518
+ continue;
519
+ }
520
+ if (choice === pollOpt) {
521
+ const next = await promptPollMs(ctx, draft.pollMs);
522
+ if (next !== undefined) draft.pollMs = next;
523
+ continue;
524
+ }
525
+ if (choice === saveOpt) {
526
+ config = { ...config, darkTheme: draft.darkTheme, lightTheme: draft.lightTheme, pollMs: draft.pollMs };
527
+ try {
528
+ await saveConfig(config);
529
+ ctx.ui.notify("Settings saved.", "info");
530
+ } catch (e) {
531
+ ctx.ui.notify(`Save failed: ${e instanceof Error ? e.message : String(e)}`, "error");
532
+ return;
533
+ }
534
+ await tick(ctx);
535
+ restartPolling(ctx);
536
+ maybeWarnCustomTheme(ctx);
537
+ return;
538
+ }
539
+ }
540
+ },
541
+ });
542
+
543
+ // -- /system-theme-push command (write override file) ---------------------
544
+
545
+ pi.registerCommand("system-theme-push", {
546
+ description: "Write override appearance: /system-theme-push dark|light|auto",
547
+ handler: async (args, ctx) => {
548
+ const first = String(args[0] ?? "").trim().toLowerCase();
549
+ const appearance = parseOverrideAppearance(first);
550
+ if (!appearance) {
551
+ if (ctx.hasUI) ctx.ui.notify("Usage: /system-theme-push dark|light|auto", "warning");
552
+ return;
553
+ }
554
+
555
+ const payload: OverridePayload = {
556
+ appearance,
557
+ updatedAt: new Date().toISOString(),
558
+ source: os.hostname(),
559
+ };
560
+
561
+ await mkdir(path.dirname(config.overrideFile), { recursive: true });
562
+ await writeFile(config.overrideFile, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
563
+ if (ctx.hasUI) ctx.ui.notify(`Override written: ${appearance}`, "info");
564
+ await tick(ctx);
565
+ },
566
+ });
567
+
568
+ // -- Lifecycle ------------------------------------------------------------
569
+
570
+ pi.on("session_start", async (_event, ctx) => {
571
+ config = await loadConfig();
572
+
573
+ if (!shouldAutoSync(ctx)) {
574
+ maybeWarnCustomTheme(ctx);
575
+ return;
576
+ }
577
+
578
+ await tick(ctx);
579
+ restartPolling(ctx);
580
+ });
581
+
582
+ pi.on("session_shutdown", () => {
583
+ if (intervalId) {
584
+ clearInterval(intervalId);
585
+ intervalId = null;
586
+ }
587
+ });
588
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "pi-system-theme-ssh-bridge",
3
+ "version": "0.1.0",
4
+ "description": "Sync pi theme with system appearance — works locally and over SSH via OSC 11 terminal queries",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "pi-coding-agent",
9
+ "theme",
10
+ "ssh",
11
+ "dark-mode",
12
+ "ghostty"
13
+ ],
14
+ "author": "mise42",
15
+ "license": "MIT",
16
+ "type": "module",
17
+ "files": [
18
+ "index.ts",
19
+ "push-theme-override.sh",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "pi": {
24
+ "extensions": [
25
+ "index.ts"
26
+ ]
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/mise42/pi-system-theme-ssh-bridge.git"
31
+ },
32
+ "homepage": "https://github.com/mise42/pi-system-theme-ssh-bridge#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/mise42/pi-system-theme-ssh-bridge/issues"
35
+ }
36
+ }
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Usage:
5
+ # ./push-theme-override.sh desktop-hostname
6
+ # ./push-theme-override.sh user@192.168.1.88
7
+
8
+ TARGET="${1:-}"
9
+ if [[ -z "$TARGET" ]]; then
10
+ echo "Usage: $0 <ssh-target>"
11
+ exit 1
12
+ fi
13
+
14
+ if /usr/bin/defaults read -g AppleInterfaceStyle >/dev/null 2>&1; then
15
+ APPEARANCE="dark"
16
+ else
17
+ APPEARANCE="light"
18
+ fi
19
+
20
+ UPDATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
21
+ SOURCE="$(hostname)"
22
+
23
+ ssh "$TARGET" "mkdir -p ~/.pi/agent && cat > ~/.pi/agent/system-theme-override.json <<'JSON'
24
+ {
25
+ \"appearance\": \"$APPEARANCE\",
26
+ \"updatedAt\": \"$UPDATED_AT\",
27
+ \"source\": \"$SOURCE\"
28
+ }
29
+ JSON"
30
+
31
+ echo "Pushed appearance=$APPEARANCE to $TARGET"