Commit 4bba668b24b5

Vincent Demeester <vincent@sbr.pm>
2026-02-06 22:22:14
feat(pi): auto-theme extension with live switching and modus theme support
Added extension that syncs pi theme with system color scheme in real-time. Features: - Automatically detects system color scheme from dconf on startup - Watches dbus signals for live color scheme changes - Instantly switches theme without restart using ctx.ui.setTheme() - Silent background operation with clean output - Commands: /theme-sync, /theme-status, /theme-watch System Integration: - Reads: /org/gnome/desktop/interface/color-scheme - Watches: ca.desrt.dconf.Writer.Notify signals via dbus-monitor - Compatible with toggle-color-scheme script Theme Mapping: - System dark (prefer-dark) → modus-vivendi - System light (prefer-light) → modus-operandi - Fallback to built-in 'dark'/'light' if modus themes unavailable Implementation: - Uses ctx.ui.setTheme() for instant theme switching - Debounced dbus signals (500ms) to avoid rapid changes - Tracks current theme to avoid redundant updates - Single cleanup handler (pi.on('session_shutdown')) to prevent duplicate messages - No console.log spam - only notifications in commands Commands: - /theme-sync - Manually sync with system color scheme - /theme-status - Show system scheme, current theme, watching status - /theme-watch - Toggle dbus watching on/off Inspired by: - pkgs/toggle-color-scheme/toggle-color-scheme.sh - dots/config/emacs/init.el (vde/color-scheme-sync) - mac-system-theme.ts example from pi Changes from initial version: - Live theme switching (no restart required) - Modus theme support (vivendi/operandi) - Cleaned up duplicate cleanup messages - Removed console.log statements - Removed settings.json modification (uses runtime API instead)
1 parent 5e606a6
Changed files (1)
dots
pi
agent
extensions
dots/pi/agent/extensions/auto-theme.ts
@@ -2,11 +2,12 @@
  * Auto Theme Extension - Sync pi theme with system color scheme
  * 
  * Automatically switches between light/dark theme based on system preference.
+ * Theme switches happen live without requiring restart.
  * 
  * Features:
  * - Reads system color scheme from dconf on startup
  * - Watches for dbus signals when color scheme changes
- * - Automatically updates pi theme
+ * - Automatically updates pi theme (live)
  * - Commands: /theme-sync, /theme-status
  * 
  * System integration:
@@ -15,18 +16,16 @@
  * - Compatible with toggle-color-scheme script
  */
 
-import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
 import { exec } from "node:child_process";
 import { promisify } from "node:util";
-import { promises as fs } from "node:fs";
-import * as path from "node:path";
-import * as os from "node:os";
 
 const execAsync = promisify(exec);
 
 // State
-let isWatching = false;
+let currentContext: ExtensionContext | null = null;
 let watchProcess: any = null;
+let currentTheme: "dark" | "light" | null = null;
 
 // =============================================================================
 // System Color Scheme Detection
@@ -44,44 +43,44 @@ async function getSystemColorScheme(): Promise<"dark" | "light" | null> {
 		}
 		
 		return null;
-	} catch (error) {
-		console.error("Failed to read system color scheme:", error);
+	} catch {
 		return null;
 	}
 }
 
-async function applyTheme(pi: ExtensionAPI, theme: "dark" | "light"): Promise<void> {
+async function applyTheme(theme: "dark" | "light"): Promise<void> {
+	if (!currentContext) {
+		return;
+	}
+	
+	if (currentTheme === theme) {
+		return; // Already set
+	}
+	
 	try {
-		// Update settings.json directly
-		const settingsPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
-		
-		// Read current settings
-		let settings: any = {};
-		try {
-			const content = await fs.readFile(settingsPath, "utf-8");
-			settings = JSON.parse(content);
-		} catch {
-			// File might not exist yet
-		}
-		
-		// Update theme
-		settings.theme = theme;
-		
-		// Write back
-		await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2), "utf-8");
-		
-		console.log(`✓ Theme setting updated to: ${theme} (will apply on next restart or /reload)`);
+		// Map to actual theme names
+		// You have: modus-vivendi (dark) and modus-operandi (light)
+		// Pi also has built-in: "dark" and "light"
+		// Prefer modus themes if available, fallback to built-in
+		const themeName = theme === "dark" ? "modus-vivendi" : "modus-operandi";
+		currentContext.ui.setTheme(themeName);
+		currentTheme = theme;
 	} catch (error) {
-		console.error("Failed to update theme:", error);
-		throw error;
+		// Fallback to built-in themes if modus themes not found
+		try {
+			currentContext.ui.setTheme(theme);
+			currentTheme = theme;
+		} catch {
+			// Ignore errors - context might not be ready
+		}
 	}
 }
 
-async function syncTheme(pi: ExtensionAPI): Promise<"dark" | "light" | null> {
+async function syncTheme(): Promise<"dark" | "light" | null> {
 	const systemScheme = await getSystemColorScheme();
 	
 	if (systemScheme) {
-		await applyTheme(pi, systemScheme);
+		await applyTheme(systemScheme);
 		return systemScheme;
 	}
 	
@@ -92,14 +91,12 @@ async function syncTheme(pi: ExtensionAPI): Promise<"dark" | "light" | null> {
 // D-Bus Watcher
 // =============================================================================
 
-function startWatching(pi: ExtensionAPI): void {
-	if (isWatching) {
-		console.log("Already watching for color scheme changes");
-		return;
+function startWatching(): void {
+	if (watchProcess) {
+		return; // Already watching
 	}
 
 	// Use dbus-monitor to watch for color-scheme changes
-	// This is simpler than using dbus library and works reliably
 	const dbusMonitor = exec(
 		"dbus-monitor --session \"type='signal',interface='ca.desrt.dconf.Writer',member='Notify'\"",
 		{ maxBuffer: 1024 * 1024 }
@@ -115,43 +112,30 @@ function startWatching(pi: ExtensionAPI): void {
 		for (const line of lines) {
 			// Look for color-scheme in the signal
 			if (line.includes("color-scheme")) {
-				console.log("Color scheme changed, syncing theme...");
 				// Debounce: wait a bit for dconf to settle
-				setTimeout(() => {
-					syncTheme(pi).then((theme) => {
-						if (theme) {
-							console.log(`Theme synced to: ${theme}`);
-						}
-					});
+				setTimeout(async () => {
+					await syncTheme();
 				}, 500);
 				break;
 			}
 		}
 	});
 
-	dbusMonitor.on("error", (error) => {
-		console.error("D-Bus monitor error:", error);
-		isWatching = false;
+	dbusMonitor.on("error", () => {
 		watchProcess = null;
 	});
 
-	dbusMonitor.on("exit", (code) => {
-		console.log(`D-Bus monitor exited with code ${code}`);
-		isWatching = false;
+	dbusMonitor.on("exit", () => {
 		watchProcess = null;
 	});
 
 	watchProcess = dbusMonitor;
-	isWatching = true;
-	console.log("✓ Watching for system color scheme changes");
 }
 
 function stopWatching(): void {
 	if (watchProcess) {
 		watchProcess.kill();
 		watchProcess = null;
-		isWatching = false;
-		console.log("✓ Stopped watching for color scheme changes");
 	}
 }
 
@@ -160,35 +144,35 @@ function stopWatching(): void {
 // =============================================================================
 
 export default function (pi: ExtensionAPI) {
-	// Sync theme on startup
-	syncTheme(pi).then((theme) => {
-		if (theme) {
-			console.log(`✓ Initial theme set to: ${theme} (from system)`);
-		} else {
-			console.log("Could not detect system color scheme, using current theme");
+	// On session start, sync theme and start watching
+	pi.on("session_start", async (_event, ctx) => {
+		currentContext = ctx;
+		
+		const theme = await syncTheme();
+		if (!theme) {
+			// Couldn't detect system scheme, use default
+			currentTheme = "dark"; // Assume dark as fallback
 		}
+		
+		// Start watching for changes
+		startWatching();
 	});
 
-	// Start watching for changes
-	startWatching(pi);
-
-	// Cleanup on extension unload
-	process.on("exit", () => {
+	// Cleanup on shutdown
+	pi.on("session_shutdown", () => {
 		stopWatching();
-	});
-
-	process.on("SIGINT", () => {
-		stopWatching();
-		process.exit();
+		currentContext = null;
+		currentTheme = null;
 	});
 
 	// Command: /theme-sync
 	pi.registerCommand("theme-sync", {
-		description: "Sync pi theme with system color scheme (applies on next restart)",
+		description: "Sync pi theme with system color scheme",
 		handler: async (_args, ctx) => {
-			const theme = await syncTheme(pi);
+			currentContext = ctx;
+			const theme = await syncTheme();
 			if (theme) {
-				ctx.ui.notify(`Theme set to: ${theme} (restart pi to apply)`, "success");
+				ctx.ui.notify(`Theme synced to: ${theme}`, "success");
 			} else {
 				ctx.ui.notify("Could not detect system color scheme", "error");
 			}
@@ -200,26 +184,11 @@ export default function (pi: ExtensionAPI) {
 		description: "Show current theme sync status",
 		handler: async (_args, ctx) => {
 			const systemScheme = await getSystemColorScheme();
-			
-			// Read current theme from settings.json
-			let currentTheme = "default";
-			try {
-				const settingsPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
-				const content = await fs.readFile(settingsPath, "utf-8");
-				const settings = JSON.parse(content);
-				currentTheme = settings.theme || "default";
-			} catch {
-				// Ignore
-			}
+			const watching = watchProcess !== null;
+			const themeName = currentTheme === "dark" ? "modus-vivendi" : currentTheme === "light" ? "modus-operandi" : "unknown";
 
-			const status = `
-System color scheme: ${systemScheme || "unknown"}
-Pi theme (in settings.json): ${currentTheme}
-Watching for changes: ${isWatching ? "yes" : "no"}
-			`.trim();
-
-			ctx.ui.notify(status, "info");
-			console.log(status);
+			const message = `System: ${systemScheme || "unknown"} | Theme: ${themeName} | Watching: ${watching ? "yes" : "no"}`;
+			ctx.ui.notify(message, "info");
 		},
 	});
 
@@ -227,11 +196,12 @@ Watching for changes: ${isWatching ? "yes" : "no"}
 	pi.registerCommand("theme-watch", {
 		description: "Toggle watching for system color scheme changes",
 		handler: async (_args, ctx) => {
-			if (isWatching) {
+			if (watchProcess) {
 				stopWatching();
 				ctx.ui.notify("Stopped watching for color scheme changes", "info");
 			} else {
-				startWatching(pi);
+				currentContext = ctx;
+				startWatching();
 				ctx.ui.notify("Started watching for color scheme changes", "info");
 			}
 		},