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}