/** * pi-tmux-status - Show pi panel status in the tmux status bar * * Each pi instance writes its state to /tmp/pi-tmux-{pane_id}.txt. * A companion shell script (auto-installed to ~/.config/tmux/pi-status.sh) * reads these files and renders colored icons in tmux's window-status-format. * * States: * idle (grey ○) - waiting for user input * working (yellow ●, blinking) - processing (LLM streaming / tools) * asking (red ●) - waiting for user response (interactive tool / question) * * Setup (add to ~/.tmux.conf): * set -g status-interval 1 * set -g window-status-format " #I:#W#(~/.config/tmux/pi-status.sh #{window_id}) " * set -g window-status-current-format " #I:#W*#(~/.config/tmux/pi-status.sh #{window_id}) " * * Requires: tmux 3.2+, running inside tmux ($TMUX_PANE) */ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { writeFileSync, unlinkSync, existsSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { homedir } from "node:os"; import { spawn } from "node:child_process"; const STATUS_DIR = "/tmp"; const PREFIX = "pi-tmux-"; const SCRIPT_PATH = `${homedir()}/.config/tmux/pi-status.sh`; // Tools that block on user input - while these run, pi is "asking" const INTERACTIVE_TOOLS = new Set(["ask_user_question"]); let currentState: "idle" | "working" | "asking" = "idle"; let interactiveCount = 0; // ── Status file helpers ───────────────────────────────────────── function statusFile(): string | null { const pane = process.env.TMUX_PANE; return pane ? `${STATUS_DIR}/${PREFIX}${pane}.txt` : null; } function flush(): void { const f = statusFile(); if (!f) return; try { writeFileSync(f, `${currentState} ${process.pid}\n`); } catch { /* ok */ } } function removeStatus(): void { const f = statusFile(); if (!f) return; try { if (existsSync(f)) unlinkSync(f); } catch { /* ok */ } } // ── tmux refresh (fire-and-forget) ────────────────────────────── function pokeTmux(): void { try { spawn("tmux", ["refresh-client", "-S"], { stdio: "ignore", detached: true }).unref(); } catch { /* ok */ } } // ── Auto-install the companion shell script ───────────────────── const SCRIPT_CONTENT = `#!/usr/bin/env bash # pi-status.sh - auto-generated by pi-tmux-status extension # Renders colored icons for each pi instance in the current tmux window. set -euo pipefail WINDOW_ID="\${1:-}" [[ -n "$WINDOW_ID" ]] || exit 0 DIR="/tmp" PFX="pi-tmux-" read_state() { local f="$1" [[ -f "$f" ]] || { echo "none"; return; } local state pid read -r state pid 2>/dev/null < "$f" || { echo "none"; return; } if [[ -n "$pid" && "$pid" != "0" ]] && ! kill -0 "$pid" 2>/dev/null; then rm -f "$f" 2>/dev/null || true echo "none" return fi echo "\${state:-idle}" } panes=$(tmux list-panes -t "$WINDOW_ID" -F "#{pane_id} #{pane_index}" 2>/dev/null | sort -k2 -n | awk '{print $1}' 2>/dev/null) || panes="" [[ -z "$panes" ]] && exit 0 icons=() for pane in $panes; do f="\${DIR}/\${PFX}\${pane}.txt" s=$(read_state "$f") case "$s" in working) icons+=("#[fg=colour220,blink]\u25cf#[default]") ;; asking) icons+=("#[fg=colour196]\u25cf#[default]") ;; idle) icons+=("#[fg=colour245]\u25cb#[default]") ;; *) ;; esac done [[ \${#icons[@]} -eq 0 ]] && exit 0 printf '[%s]' "$(IFS=' '; echo "\${icons[*]}")" `; function ensureScript(): void { try { mkdirSync(dirname(SCRIPT_PATH), { recursive: true }); writeFileSync(SCRIPT_PATH, SCRIPT_CONTENT, { mode: 0o755 }); } catch { /* ok */ } } // ── Asking detection ──────────────────────────────────────────── function looksLikeQuestion(text: string): boolean { if (!text) return false; return /(^|\n)\s*[^\n]*\?\s*$/m.test(text.trim()); } function detectAsking(ctx: ExtensionContext): boolean { try { const entries = (ctx as any).sessionManager?.getBranch?.(); if (!entries?.length) return false; for (let i = entries.length - 1; i >= 0; i--) { const e = entries[i]; if (e?.type === "message" && e?.message?.role === "assistant") { const c = e.message.content; if (Array.isArray(c)) { const text = c.filter((b: any) => b.type === "text").map((b: any) => b.text).join("\n"); return looksLikeQuestion(text); } return false; } } } catch { /* ok */ } return false; } // ── Extension ─────────────────────────────────────────────────── export default function (pi: ExtensionAPI) { if (!process.env.TMUX_PANE) return; ensureScript(); function set(s: typeof currentState) { if (currentState === s) return; currentState = s; flush(); pokeTmux(); } pi.on("session_start", async () => { currentState = "idle"; interactiveCount = 0; flush(); pokeTmux(); }); pi.on("agent_start", () => set("working")); pi.on("tool_call", async (event) => { if (INTERACTIVE_TOOLS.has(event.toolName)) { interactiveCount++; set("asking"); } }); pi.on("tool_execution_end", async (event) => { if (INTERACTIVE_TOOLS.has(event.toolName)) { interactiveCount = Math.max(0, interactiveCount - 1); if (interactiveCount === 0 && currentState === "asking") { set("working"); } } }); pi.on("agent_end", async (_ev, ctx) => { interactiveCount = 0; set(detectAsking(ctx) ? "asking" : "idle"); }); pi.on("session_shutdown", () => { removeStatus(); pokeTmux(); }); process.on("exit", () => removeStatus()); }