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 +21 -0
- package/README.md +92 -0
- package/index.ts +588 -0
- package/package.json +36 -0
- package/push-theme-override.sh +31 -0
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"
|