Commit a2e2b7a0c366

Vincent Demeester <vincent@sbr.pm>
2026-02-06 09:43:37
feat(pi): merge notify and terminal title into terminal-status
Renamed notify.ts to terminal-status.ts and added terminal title updates from Go-based claude-hooks-update-terminal-title. Now shows project name and current tool in terminal tab. Desktop notifications still sent when agent is ready for input.
1 parent 0582e90
Changed files (3)
dots/pi/agent/extensions/claude-hooks.ts
@@ -7,8 +7,11 @@
  * Events mapped:
  * - session_start -> claude-hooks-initialize-session
  * - session_shutdown -> claude-hooks-save-session
- * - tool_call -> claude-hooks-validate-git-push (PreToolUse equivalent)
- * - tool_result -> claude-hooks-capture-tool-output, claude-hooks-update-terminal-title
+ * - tool_result -> claude-hooks-capture-tool-output
+ *
+ * Migrated to native TypeScript extensions:
+ * - validate-git-push.ts (was: claude-hooks-validate-git-push)
+ * - terminal-status.ts (was: claude-hooks-update-terminal-title)
  *
  * Requirements:
  * - claude-hooks-* binaries must be in PATH (installed via home-manager)
@@ -128,15 +131,11 @@ export default function (pi: ExtensionAPI) {
   // No need to call Go binary here anymore
 
   // Post-tool-use capture
+  // NOTE: terminal title is now handled by terminal-status.ts extension
   pi.on("tool_result", async (event, _ctx) => {
-    const jsonInput = toClaudePostToolUse(event);
-
-    // Run hooks in parallel
-    const hooks = [
-      "claude-hooks-capture-tool-output",
-      "claude-hooks-update-terminal-title",
-    ].filter(binaryExists);
-
-    await Promise.allSettled(hooks.map((hook) => runHook(hook, jsonInput)));
+    if (binaryExists("claude-hooks-capture-tool-output")) {
+      const jsonInput = toClaudePostToolUse(event);
+      await runHook("claude-hooks-capture-tool-output", jsonInput);
+    }
   });
 }
dots/pi/agent/extensions/notify.ts
@@ -1,102 +0,0 @@
-/**
- * Pi Notify Extension
- *
- * Sends a native terminal/desktop notification when Pi agent is done and waiting for input.
- * Supports multiple terminal protocols:
- * - OSC 777: Ghostty, iTerm2, WezTerm, rxvt-unicode
- * - OSC 99: Kitty
- * - notify-send: Linux desktop fallback (libnotify)
- * - Windows toast: Windows Terminal (WSL)
- *
- * Based on: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/examples/extensions/notify.ts
- */
-
-import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
-
-function windowsToastScript(title: string, body: string): string {
-	const type = "Windows.UI.Notifications";
-	const mgr = `[${type}.ToastNotificationManager, ${type}, ContentType = WindowsRuntime]`;
-	const template = `[${type}.ToastTemplateType]::ToastText01`;
-	const toast = `[${type}.ToastNotification]::new($xml)`;
-	return [
-		`${mgr} > $null`,
-		`$xml = [${type}.ToastNotificationManager]::GetTemplateContent(${template})`,
-		`$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode('${body}')) > $null`,
-		`[${type}.ToastNotificationManager]::CreateToastNotifier('${title}').Show(${toast})`,
-	].join("; ");
-}
-
-function notifyOSC777(title: string, body: string): void {
-	process.stdout.write(`\x1b]777;notify;${title};${body}\x07`);
-}
-
-function notifyOSC99(title: string, body: string): void {
-	// Kitty OSC 99: i=notification id, d=0 means not done yet, p=body for second part
-	process.stdout.write(`\x1b]99;i=1:d=0;${title}\x1b\\`);
-	process.stdout.write(`\x1b]99;i=1:p=body;${body}\x1b\\`);
-}
-
-function notifyWindows(title: string, body: string): void {
-	const { execFile } = require("child_process");
-	execFile("powershell.exe", ["-NoProfile", "-Command", windowsToastScript(title, body)]);
-}
-
-function notifyLinuxDesktop(title: string, body: string): void {
-	const { execFile } = require("child_process");
-	// Use notify-send (libnotify) for Linux desktop notifications
-	// -u low = low urgency, -t 5000 = 5 second timeout
-	execFile("notify-send", ["-u", "low", "-t", "5000", "-a", "pi", title, body], (err: Error | null) => {
-		if (err) {
-			// Fallback to OSC 777 if notify-send fails
-			notifyOSC777(title, body);
-		}
-	});
-}
-
-function detectTerminal(): "kitty" | "wsl" | "osc777" | "linux-desktop" {
-	// Windows Terminal (WSL)
-	if (process.env.WT_SESSION) {
-		return "wsl";
-	}
-	// Kitty terminal
-	if (process.env.KITTY_WINDOW_ID) {
-		return "kitty";
-	}
-	// Ghostty, WezTerm, iTerm2 - these support OSC 777
-	if (process.env.GHOSTTY_RESOURCES_DIR || process.env.WEZTERM_PANE || process.env.ITERM_SESSION_ID) {
-		return "osc777";
-	}
-	// Alacritty, foot, and other terminals that don't support OSC notifications
-	// Use notify-send on Linux
-	if (process.platform === "linux" && (process.env.ALACRITTY_WINDOW_ID || process.env.WAYLAND_DISPLAY || process.env.DISPLAY)) {
-		return "linux-desktop";
-	}
-	// Default fallback
-	return "osc777";
-}
-
-function notify(title: string, body: string): void {
-	const terminal = detectTerminal();
-
-	switch (terminal) {
-		case "wsl":
-			notifyWindows(title, body);
-			break;
-		case "kitty":
-			notifyOSC99(title, body);
-			break;
-		case "linux-desktop":
-			notifyLinuxDesktop(title, body);
-			break;
-		case "osc777":
-		default:
-			notifyOSC777(title, body);
-			break;
-	}
-}
-
-export default function (pi: ExtensionAPI) {
-	pi.on("agent_end", async () => {
-		notify("Pi", "Ready for input");
-	});
-}
dots/pi/agent/extensions/terminal-status.ts
@@ -0,0 +1,193 @@
+/**
+ * Pi Terminal Status Extension
+ *
+ * Manages terminal status updates:
+ * 1. Terminal tab title - shows project name, current tool, and status
+ * 2. Desktop notifications - alerts when agent is ready for input
+ *
+ * Terminal title format: "π <project> • <context>"
+ * Examples:
+ *   - "π home" (idle)
+ *   - "π home • bash" (running bash)
+ *   - "π home • Ready" (waiting for input)
+ *
+ * Notification protocols supported:
+ * - OSC 777: Ghostty, iTerm2, WezTerm, rxvt-unicode
+ * - OSC 99: Kitty
+ * - notify-send: Linux desktop fallback (libnotify)
+ * - Windows toast: Windows Terminal (WSL)
+ *
+ * Migrated from: notify.ts + Go-based claude-hooks-update-terminal-title
+ */
+
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import path from "node:path";
+
+// =============================================================================
+// Terminal Title
+// =============================================================================
+
+/**
+ * Set terminal tab/window title using ANSI escape codes
+ * OSC 0 = icon + title, OSC 2 = window title, OSC 30 = tab title
+ */
+function setTerminalTitle(title: string): void {
+  process.stderr.write(`\x1b]0;${title}\x07`);
+  process.stderr.write(`\x1b]2;${title}\x07`);
+  process.stderr.write(`\x1b]30;${title}\x07`);
+}
+
+/**
+ * Get project name from current working directory
+ */
+function getProjectName(): string {
+  try {
+    return path.basename(process.cwd());
+  } catch {
+    return "pi";
+  }
+}
+
+/**
+ * Build terminal title with optional context
+ */
+function buildTitle(context?: string): string {
+  const project = getProjectName();
+  const prefix = "π";
+
+  if (context) {
+    return `${prefix} ${project} • ${context}`;
+  }
+  return `${prefix} ${project}`;
+}
+
+// =============================================================================
+// Desktop Notifications
+// =============================================================================
+
+function windowsToastScript(title: string, body: string): string {
+  const type = "Windows.UI.Notifications";
+  const mgr = `[${type}.ToastNotificationManager, ${type}, ContentType = WindowsRuntime]`;
+  const template = `[${type}.ToastTemplateType]::ToastText01`;
+  const toast = `[${type}.ToastNotification]::new($xml)`;
+  return [
+    `${mgr} > $null`,
+    `$xml = [${type}.ToastNotificationManager]::GetTemplateContent(${template})`,
+    `$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode('${body}')) > $null`,
+    `[${type}.ToastNotificationManager]::CreateToastNotifier('${title}').Show(${toast})`,
+  ].join("; ");
+}
+
+function notifyOSC777(title: string, body: string): void {
+  process.stdout.write(`\x1b]777;notify;${title};${body}\x07`);
+}
+
+function notifyOSC99(title: string, body: string): void {
+  // Kitty OSC 99: i=notification id, d=0 means not done yet, p=body for second part
+  process.stdout.write(`\x1b]99;i=1:d=0;${title}\x1b\\`);
+  process.stdout.write(`\x1b]99;i=1:p=body;${body}\x1b\\`);
+}
+
+function notifyWindows(title: string, body: string): void {
+  const { execFile } = require("child_process");
+  execFile("powershell.exe", [
+    "-NoProfile",
+    "-Command",
+    windowsToastScript(title, body),
+  ]);
+}
+
+function notifyLinuxDesktop(title: string, body: string): void {
+  const { execFile } = require("child_process");
+  // Use notify-send (libnotify) for Linux desktop notifications
+  // -u low = low urgency, -t 5000 = 5 second timeout
+  execFile(
+    "notify-send",
+    ["-u", "low", "-t", "5000", "-a", "pi", title, body],
+    (err: Error | null) => {
+      if (err) {
+        // Fallback to OSC 777 if notify-send fails
+        notifyOSC777(title, body);
+      }
+    }
+  );
+}
+
+function detectTerminal(): "kitty" | "wsl" | "osc777" | "linux-desktop" {
+  // Windows Terminal (WSL)
+  if (process.env.WT_SESSION) {
+    return "wsl";
+  }
+  // Kitty terminal
+  if (process.env.KITTY_WINDOW_ID) {
+    return "kitty";
+  }
+  // Ghostty, WezTerm, iTerm2 - these support OSC 777
+  if (
+    process.env.GHOSTTY_RESOURCES_DIR ||
+    process.env.WEZTERM_PANE ||
+    process.env.ITERM_SESSION_ID
+  ) {
+    return "osc777";
+  }
+  // Alacritty, foot, and other terminals that don't support OSC notifications
+  // Use notify-send on Linux
+  if (
+    process.platform === "linux" &&
+    (process.env.ALACRITTY_WINDOW_ID ||
+      process.env.WAYLAND_DISPLAY ||
+      process.env.DISPLAY)
+  ) {
+    return "linux-desktop";
+  }
+  // Default fallback
+  return "osc777";
+}
+
+function notify(title: string, body: string): void {
+  const terminal = detectTerminal();
+
+  switch (terminal) {
+    case "wsl":
+      notifyWindows(title, body);
+      break;
+    case "kitty":
+      notifyOSC99(title, body);
+      break;
+    case "linux-desktop":
+      notifyLinuxDesktop(title, body);
+      break;
+    case "osc777":
+    default:
+      notifyOSC777(title, body);
+      break;
+  }
+}
+
+// =============================================================================
+// Extension Entry Point
+// =============================================================================
+
+export default function (pi: ExtensionAPI) {
+  // Set initial title on session start
+  pi.on("session_start", async () => {
+    setTerminalTitle(buildTitle());
+  });
+
+  // Update title when tool starts executing
+  pi.on("tool_call", async (event) => {
+    const toolName = event.toolName.toLowerCase();
+    setTerminalTitle(buildTitle(toolName));
+  });
+
+  // Reset title when tool finishes
+  pi.on("tool_result", async () => {
+    setTerminalTitle(buildTitle());
+  });
+
+  // Show "Ready" and send notification when agent is done
+  pi.on("agent_end", async () => {
+    setTerminalTitle(buildTitle("Ready"));
+    notify("Pi", "Ready for input");
+  });
+}