flake-update-20260505
  1/**
  2 * Pi Terminal Status Extension
  3 *
  4 * Manages terminal status updates:
  5 * 1. Terminal tab title - shows project, branch, model, current tool, and status
  6 * 2. Desktop notifications - alerts when agent is ready for input
  7 *
  8 * Terminal title format: "π <project> (<branch>) [<model>] • <context>"
  9 * Examples:
 10 *   - "π home (main) [sonnet]" (idle)
 11 *   - "π home (main) [sonnet] • bash" (running bash)
 12 *   - "π home (main) [sonnet] • ✗ bash" (tool error)
 13 *   - "π home (main) [sonnet] • 📓 Journal" (using skill)
 14 *   - "π home (main) [sonnet] • Ready" (waiting for input)
 15 *
 16 * Notification protocols supported:
 17 * - OSC 777: Ghostty, iTerm2, WezTerm, rxvt-unicode
 18 * - OSC 99: Kitty
 19 * - notify-send: Linux desktop fallback (libnotify)
 20 * - Windows toast: Windows Terminal (WSL)
 21 *
 22 * Migrated from: notify.ts + Go-based claude-hooks-update-terminal-title
 23 */
 24
 25import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
 26import { execSync } from "node:child_process";
 27import { hostname } from "node:os";
 28import path from "node:path";
 29
 30// =============================================================================
 31// State
 32// =============================================================================
 33
 34let currentModel = "";
 35let currentBranch = "";
 36
 37// =============================================================================
 38// Terminal Title
 39// =============================================================================
 40
 41/** Whether we're running in interactive TUI mode (not RPC/JSON/print) */
 42let isInteractive = !process.argv.includes("--mode");
 43
 44/**
 45 * Set terminal tab/window title using ANSI escape codes
 46 * OSC 0 = icon + title, OSC 2 = window title, OSC 30 = tab title
 47 * Skipped in non-interactive modes (RPC, JSON) to avoid corrupting output
 48 */
 49function setTerminalTitle(title: string): void {
 50  if (!isInteractive) return;
 51  process.stderr.write(`\x1b]0;${title}\x07`);
 52  process.stderr.write(`\x1b]2;${title}\x07`);
 53  process.stderr.write(`\x1b]30;${title}\x07`);
 54}
 55
 56/**
 57 * Get project name from current working directory
 58 */
 59function getProjectName(): string {
 60  try {
 61    return path.basename(process.cwd());
 62  } catch {
 63    return "pi";
 64  }
 65}
 66
 67/**
 68 * Get current git branch
 69 */
 70function getGitBranch(): string {
 71  try {
 72    const branch = execSync("git rev-parse --abbrev-ref HEAD 2>/dev/null", {
 73      encoding: "utf-8",
 74      timeout: 1000,
 75    }).trim();
 76    return branch || "";
 77  } catch {
 78    return "";
 79  }
 80}
 81
 82/**
 83 * Get short model name for display
 84 */
 85function getShortModelName(modelId: string): string {
 86  // Map common model names to short versions
 87  const shortNames: Record<string, string> = {
 88    "claude-sonnet-4-6": "sonnet",
 89    "claude-sonnet-4-5-20250514": "sonnet",
 90    "claude-sonnet-4-20250514": "sonnet",
 91    "claude-3-5-sonnet-20241022": "sonnet",
 92    "claude-3-5-sonnet": "sonnet",
 93    "claude-sonnet": "sonnet",
 94    "claude-opus-4-20250514": "opus",
 95    "claude-3-opus": "opus",
 96    "claude-opus": "opus",
 97    "claude-haiku": "haiku",
 98    "claude-3-haiku": "haiku",
 99    "gpt-4o": "4o",
100    "gpt-4o-mini": "4o-mini",
101    "gpt-4-turbo": "4-turbo",
102    "gpt-4": "gpt4",
103    "gpt-3.5-turbo": "3.5",
104    "gemini-2.0-flash": "flash",
105    "gemini-2.0-pro": "pro",
106    "gemini-1.5-pro": "1.5-pro",
107    "gemini-1.5-flash": "1.5-flash",
108    "deepseek-chat": "deepseek",
109    "deepseek-reasoner": "r1",
110  };
111
112  // Check exact match first
113  if (shortNames[modelId]) {
114    return shortNames[modelId];
115  }
116
117  // Check partial match
118  for (const [pattern, short] of Object.entries(shortNames)) {
119    if (modelId.includes(pattern)) {
120      return short;
121    }
122  }
123
124  // Fallback: take last part after slash or dash, limit to 10 chars
125  const parts = modelId.split(/[/-]/);
126  const lastPart = parts[parts.length - 1];
127  return lastPart.slice(0, 10);
128}
129
130/**
131 * Get emoji icon for known skills
132 */
133function getSkillIcon(skillName: string): string {
134  const icons: Record<string, string> = {
135    Journal: "📓",
136    Notes: "📝",
137    TODOs: "✅",
138    Org: "📋",
139    Git: "🔀",
140    GitHub: "🐙",
141    Email: "📧",
142    Python: "🐍",
143    golang: "🐹",
144    Rust: "🦀",
145    Nix: "❄️",
146    Kubernetes: "☸️",
147    Tekton: "🔧",
148    Docker: "🐳",
149    Homelab: "🏠",
150  };
151
152  return icons[skillName] || "📚";
153}
154
155/**
156 * Build terminal title with optional context
157 */
158function buildTitle(context?: string, isError?: boolean): string {
159  const project = getProjectName();
160  const host = hostname();
161  const prefix = "π";
162
163  let parts = [prefix, `${host}:${project}`];
164
165  // Add git branch if available
166  if (currentBranch) {
167    parts.push(`(${currentBranch})`);
168  }
169
170  // Add model if available
171  if (currentModel) {
172    parts.push(`[${currentModel}]`);
173  }
174
175  let title = parts.join(" ");
176
177  // Add context (tool name, status, etc.)
178  if (context) {
179    const errorPrefix = isError ? "✗ " : "";
180    title += `${errorPrefix}${context}`;
181  }
182
183  return title;
184}
185
186// =============================================================================
187// Desktop Notifications
188// =============================================================================
189
190function windowsToastScript(title: string, body: string): string {
191  const type = "Windows.UI.Notifications";
192  const mgr = `[${type}.ToastNotificationManager, ${type}, ContentType = WindowsRuntime]`;
193  const template = `[${type}.ToastTemplateType]::ToastText01`;
194  const toast = `[${type}.ToastNotification]::new($xml)`;
195  return [
196    `${mgr} > $null`,
197    `$xml = [${type}.ToastNotificationManager]::GetTemplateContent(${template})`,
198    `$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode('${body}')) > $null`,
199    `[${type}.ToastNotificationManager]::CreateToastNotifier('${title}').Show(${toast})`,
200  ].join("; ");
201}
202
203function notifyOSC777(title: string, body: string): void {
204  process.stdout.write(`\x1b]777;notify;${title};${body}\x07`);
205}
206
207function notifyOSC99(title: string, body: string): void {
208  // Kitty OSC 99: i=notification id, d=0 means not done yet, p=body for second part
209  process.stdout.write(`\x1b]99;i=1:d=0;${title}\x1b\\`);
210  process.stdout.write(`\x1b]99;i=1:p=body;${body}\x1b\\`);
211}
212
213function notifyWindows(title: string, body: string): void {
214  const { execFile } = require("child_process");
215  execFile("powershell.exe", [
216    "-NoProfile",
217    "-Command",
218    windowsToastScript(title, body),
219  ]);
220}
221
222function notifyLinuxDesktop(title: string, body: string): void {
223  const { execFile } = require("child_process");
224  // Use notify-send (libnotify) for Linux desktop notifications
225  // -u low = low urgency, -t 5000 = 5 second timeout
226  execFile(
227    "notify-send",
228    ["-u", "low", "-t", "5000", "-a", "pi", title, body],
229    (err: Error | null) => {
230      if (err) {
231        // Fallback to OSC 777 if notify-send fails
232        notifyOSC777(title, body);
233      }
234    }
235  );
236}
237
238function detectTerminal(): "kitty" | "wsl" | "osc777" | "linux-desktop" {
239  // Windows Terminal (WSL)
240  if (process.env.WT_SESSION) {
241    return "wsl";
242  }
243  // Kitty terminal
244  if (process.env.KITTY_WINDOW_ID) {
245    return "kitty";
246  }
247  // Ghostty, WezTerm, iTerm2 - these support OSC 777
248  if (
249    process.env.GHOSTTY_RESOURCES_DIR ||
250    process.env.WEZTERM_PANE ||
251    process.env.ITERM_SESSION_ID
252  ) {
253    return "osc777";
254  }
255  // Alacritty, foot, and other terminals that don't support OSC notifications
256  // Use notify-send on Linux
257  if (
258    process.platform === "linux" &&
259    (process.env.ALACRITTY_WINDOW_ID ||
260      process.env.WAYLAND_DISPLAY ||
261      process.env.DISPLAY)
262  ) {
263    return "linux-desktop";
264  }
265  // Default fallback
266  return "osc777";
267}
268
269function notify(title: string, body: string): void {
270  if (!isInteractive) return;
271  const terminal = detectTerminal();
272
273  switch (terminal) {
274    case "wsl":
275      notifyWindows(title, body);
276      break;
277    case "kitty":
278      notifyOSC99(title, body);
279      break;
280    case "linux-desktop":
281      notifyLinuxDesktop(title, body);
282      break;
283    case "osc777":
284    default:
285      notifyOSC777(title, body);
286      break;
287  }
288}
289
290// =============================================================================
291// Bell Notification
292// =============================================================================
293
294/**
295 * Ring terminal bell (works with kitty bell_on_tab, etc.)
296 * Skipped in non-interactive modes to avoid corrupting output
297 */
298function ringBell(): void {
299  if (!isInteractive) return;
300  process.stderr.write("\x07");
301}
302
303// =============================================================================
304// Extension Entry Point
305// =============================================================================
306
307export default function (pi: ExtensionAPI) {
308  // Set initial title on session start
309  pi.on("session_start", async (_event, ctx) => {
310    // Detect non-interactive modes (RPC, JSON, print) to skip terminal output
311    // process.argv check in init covers most cases; ctx.hasUI is a backup
312    if (ctx.hasUI === false) isInteractive = false;
313    currentBranch = getGitBranch();
314    // Get initial model if available
315    if (ctx.model) {
316      currentModel = getShortModelName(ctx.model.id);
317    }
318    setTerminalTitle(buildTitle());
319    ringBell();
320  });
321
322  // Update model indicator when model changes
323  pi.on("model_select", async (event) => {
324    currentModel = getShortModelName(event.model.id);
325    setTerminalTitle(buildTitle());
326  });
327
328  // Update title when tool starts executing
329  pi.on("tool_call", async (event) => {
330    const toolName = event.toolName.toLowerCase();
331
332    // Check if this is a skill invocation
333    if (toolName === "skill" && event.input?.skill) {
334      const skillName = event.input.skill;
335      const icon = getSkillIcon(skillName);
336      setTerminalTitle(buildTitle(`${icon} ${skillName}`));
337    } else {
338      setTerminalTitle(buildTitle(toolName));
339    }
340  });
341
342  // Update title when tool finishes (show error if failed)
343  pi.on("tool_result", async (event) => {
344    if (event.isError) {
345      const toolName = event.toolName.toLowerCase();
346      setTerminalTitle(buildTitle(toolName, true));
347    } else {
348      setTerminalTitle(buildTitle());
349    }
350  });
351
352  // Show "Ready" and send notification when agent is done
353  pi.on("agent_end", async () => {
354    // Refresh branch in case it changed during session
355    currentBranch = getGitBranch();
356    setTerminalTitle(buildTitle("Ready"));
357    notify("Pi", "Ready for input");
358    ringBell();
359  });
360}