Created
January 15, 2026 22:59
-
-
Save nicobailon/ee8a65353b9103ad5d149e7eeb452b10 to your computer and use it in GitHub Desktop.
Pi coding agent extension: Dynamic terminal tab titles with emoji identifiers and status tracking. Shows session emoji, project name, status (running/done/waiting/error), and current tool. Place in ~/.pi/agent/extensions/
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Enhanced terminal tab status extension with emoji identifiers. | |
| * | |
| * Features: | |
| * - Unique emoji identifier per session (hashed from session ID) | |
| * - Status tracking: new → running → done/waiting/error/timeout | |
| * - Interview tool detection for "waiting for input" state | |
| * - 180s inactivity timeout detection | |
| * - Current tool name display during execution | |
| * - Resets title to "Terminal" on shutdown | |
| * | |
| * Title format: [emoji-id] pi: [project] [status-emoji] [tool-name] | |
| * | |
| * Examples: | |
| * 🦊 pi: mono ← New session | |
| * 🦊 pi: mono 🚧 ← Working | |
| * 🐙 pi: mono 🚧 bash ← Working (running bash) | |
| * 🦉 pi: other ✅ ← Done | |
| * 🐳 pi: mono ❓ ← Waiting for input | |
| * 🐺 pi: mono 🛑 ← Error or timeout | |
| */ | |
| import type { | |
| ExtensionAPI, | |
| ExtensionContext, | |
| SessionStartEvent, | |
| SessionSwitchEvent, | |
| AgentStartEvent, | |
| AgentEndEvent, | |
| ToolCallEvent, | |
| ToolResultEvent, | |
| SessionShutdownEvent, | |
| } from "@mariozechner/pi-coding-agent"; | |
| import type { AgentMessage } from "@mariozechner/pi-agent-core"; | |
| import type { AssistantMessage, StopReason } from "@mariozechner/pi-ai"; | |
| import { basename } from "node:path"; | |
| // Status states | |
| type Status = "new" | "running" | "done" | "waiting" | "error" | "timeout"; | |
| // State tracker | |
| interface State { | |
| status: Status; | |
| running: boolean; | |
| sawCommit: boolean; | |
| sawInterview: boolean; | |
| currentTool: string | null; | |
| sessionEmoji: string; | |
| } | |
| // Emoji identifier pool (16 options for tab differentiation) | |
| const IDENTIFIER_EMOJIS = [ | |
| "🦊", "🐙", "🦉", "🐳", "🐺", "🦁", "🐯", "🐻", | |
| "🦈", "🦅", "🐘", "🦋", "🐧", "🦜", "🔷", "🔶" | |
| ]; | |
| // Status emojis (second position in title) | |
| const STATUS_EMOJIS: Record<Status, string> = { | |
| new: "", // Fresh session, no emoji | |
| running: "🚧", // Agent actively processing | |
| done: "✅", // Completed | |
| waiting: "❓", // Waiting for user input | |
| error: "❌", // Stopped due to error | |
| timeout: "🛑", // Stuck/inactive | |
| }; | |
| // Configuration | |
| const INACTIVITY_TIMEOUT_MS = 180_000; // 180 seconds | |
| // Match actual git commit commands, not just mentions of "git" and "commit" | |
| // Matches: git commit, git -C /path commit, && git commit, ; git commit | |
| const GIT_COMMIT_REGEX = /(?:^|[;&|]\s*)git\s+(?:-\S+\s+)*commit\b/m; | |
| /** | |
| * Hash session ID to deterministic emoji identifier. | |
| * Uses simple string hash to select from 16 emoji options. | |
| */ | |
| function sessionEmoji(sessionId: string): string { | |
| let hash = 0; | |
| for (const char of sessionId) { | |
| hash = ((hash << 5) - hash + char.charCodeAt(0)) | 0; | |
| } | |
| return IDENTIFIER_EMOJIS[Math.abs(hash) % IDENTIFIER_EMOJIS.length]; | |
| } | |
| /** | |
| * Extract stop reason from agent messages. | |
| */ | |
| function getStopReason(messages: AgentMessage[]): StopReason | undefined { | |
| for (let i = messages.length - 1; i >= 0; i--) { | |
| const message = messages[i]; | |
| if (message.role === "assistant") { | |
| return (message as AssistantMessage).stopReason; | |
| } | |
| } | |
| return undefined; | |
| } | |
| export default function (pi: ExtensionAPI) { | |
| // Session state | |
| const state: State = { | |
| status: "new", | |
| running: false, | |
| sawCommit: false, | |
| sawInterview: false, | |
| currentTool: null, | |
| sessionEmoji: "🔷", // Default, will be set on session_start | |
| }; | |
| // Timeout tracking | |
| let timeoutId: ReturnType<typeof setTimeout> | undefined; | |
| /** | |
| * Get project name from current working directory. | |
| */ | |
| const getProjectName = (ctx: ExtensionContext): string => { | |
| return basename(ctx.cwd || "pi"); | |
| }; | |
| /** | |
| * Build and set the terminal title. | |
| * Format: [emoji-id] pi: [project] [status-emoji] [tool-name] | |
| */ | |
| const setTitle = (ctx: ExtensionContext): void => { | |
| if (!ctx.hasUI) return; | |
| const parts: string[] = []; | |
| // Identifier emoji (always first) | |
| parts.push(state.sessionEmoji); | |
| // Base: "pi: project" | |
| parts.push(`pi: ${getProjectName(ctx)}`); | |
| // Status emoji (if applicable) | |
| const statusEmoji = STATUS_EMOJIS[state.status]; | |
| if (statusEmoji) { | |
| parts.push(statusEmoji); | |
| } | |
| // Tool name (only during running state) | |
| if (state.status === "running" && state.currentTool) { | |
| parts.push(state.currentTool); | |
| } | |
| ctx.ui.setTitle(parts.join(" ")); | |
| }; | |
| /** | |
| * Clear the inactivity timeout. | |
| */ | |
| const clearInactivityTimeout = (): void => { | |
| if (timeoutId !== undefined) { | |
| clearTimeout(timeoutId); | |
| timeoutId = undefined; | |
| } | |
| }; | |
| /** | |
| * Start/reset the inactivity timeout. | |
| * If timeout fires while running, mark as timeout state. | |
| */ | |
| const resetInactivityTimeout = (ctx: ExtensionContext): void => { | |
| clearInactivityTimeout(); | |
| if (!state.running) return; | |
| timeoutId = setTimeout(() => { | |
| if (state.running && state.status === "running") { | |
| state.status = "timeout"; | |
| state.currentTool = null; | |
| setTitle(ctx); | |
| } | |
| }, INACTIVITY_TIMEOUT_MS); | |
| }; | |
| /** | |
| * Mark activity detected - reset timeout and recover from timeout state. | |
| */ | |
| const markActivity = (ctx: ExtensionContext): void => { | |
| // Recover from timeout state | |
| if (state.status === "timeout") { | |
| state.status = "running"; | |
| setTitle(ctx); | |
| } | |
| // Reset timeout if running | |
| if (state.running) { | |
| resetInactivityTimeout(ctx); | |
| } | |
| }; | |
| /** | |
| * Reset state to initial/new. | |
| */ | |
| const resetState = (ctx: ExtensionContext): void => { | |
| state.status = "new"; | |
| state.running = false; | |
| state.sawCommit = false; | |
| state.sawInterview = false; | |
| state.currentTool = null; | |
| clearInactivityTimeout(); | |
| setTitle(ctx); | |
| }; | |
| // Event handlers | |
| pi.on("session_start", async (_event: SessionStartEvent, ctx: ExtensionContext) => { | |
| // Set emoji identifier from session ID | |
| state.sessionEmoji = sessionEmoji(ctx.sessionManager.getSessionId()); | |
| resetState(ctx); | |
| }); | |
| pi.on("session_switch", async (event: SessionSwitchEvent, ctx: ExtensionContext) => { | |
| // Update emoji for switched session | |
| state.sessionEmoji = sessionEmoji(ctx.sessionManager.getSessionId()); | |
| // Reset state - use "new" for new sessions | |
| if (event.reason === "new") { | |
| resetState(ctx); | |
| } else { | |
| // For resumed/switched sessions, assume done state | |
| state.status = "done"; | |
| state.running = false; | |
| state.sawCommit = false; | |
| state.sawInterview = false; | |
| state.currentTool = null; | |
| clearInactivityTimeout(); | |
| setTitle(ctx); | |
| } | |
| }); | |
| pi.on("agent_start", async (_event: AgentStartEvent, ctx: ExtensionContext) => { | |
| // Begin agent run | |
| state.status = "running"; | |
| state.running = true; | |
| state.sawCommit = false; | |
| state.sawInterview = false; | |
| state.currentTool = null; | |
| setTitle(ctx); | |
| resetInactivityTimeout(ctx); | |
| }); | |
| pi.on("tool_call", async (event: ToolCallEvent, ctx: ExtensionContext) => { | |
| // Update current tool | |
| state.currentTool = event.toolName; | |
| setTitle(ctx); | |
| // Detect git commits in bash commands | |
| if (event.toolName === "bash") { | |
| const command = typeof event.input.command === "string" ? event.input.command : ""; | |
| if (command && GIT_COMMIT_REGEX.test(command)) { | |
| state.sawCommit = true; | |
| } | |
| } | |
| // Detect interview tool usage (waiting for input) | |
| if (event.toolName === "interview") { | |
| state.sawInterview = true; | |
| } | |
| // Mark activity | |
| markActivity(ctx); | |
| }); | |
| pi.on("tool_result", async (_event: ToolResultEvent, ctx: ExtensionContext) => { | |
| // Clear current tool after result | |
| state.currentTool = null; | |
| setTitle(ctx); | |
| // Mark activity | |
| markActivity(ctx); | |
| }); | |
| pi.on("agent_end", async (event: AgentEndEvent, ctx: ExtensionContext) => { | |
| // Stop running state | |
| state.running = false; | |
| state.currentTool = null; | |
| clearInactivityTimeout(); | |
| // Determine final state based on stop reason and tool usage | |
| const stopReason = getStopReason(event.messages); | |
| if (stopReason === "error") { | |
| state.status = "error"; | |
| } else if (state.sawInterview) { | |
| state.status = "waiting"; | |
| } else { | |
| state.status = "done"; | |
| } | |
| setTitle(ctx); | |
| }); | |
| pi.on("session_shutdown", async (_event: SessionShutdownEvent, ctx: ExtensionContext) => { | |
| // Reset to generic "Terminal" on shutdown | |
| clearInactivityTimeout(); | |
| if (ctx.hasUI) { | |
| ctx.ui.setTitle("Terminal"); | |
| } | |
| }); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment