Skip to content

Instantly share code, notes, and snippets.

@nicobailon
Created January 15, 2026 22:59
Show Gist options
  • Select an option

  • Save nicobailon/ee8a65353b9103ad5d149e7eeb452b10 to your computer and use it in GitHub Desktop.

Select an option

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/
/**
* 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