feat: Replace git branch footer with jj status info
Built-in footer shows "(detached)" in colocated jj repos because
jj's git backend uses a detached HEAD. Extension shows bookmark,
distance, and change ID instead — matching p10k's jj prompt.

Assisted-by: Claude Opus 4 via Claude Code
change rmksupxwzmqrnymqnukxvzuttxvtwnoo
commit b1085c32ec20ef215ed17d31edabe744ca5684d9
author Alpha Chen <alpha@kejadlen.dev>
date
parent zmqylwqk
diff --git a/.pi/agent/extensions/jj-status.ts b/.pi/agent/extensions/jj-status.ts
new file mode 100644
index 00000000..c8c49a9d
--- /dev/null
+++ b/.pi/agent/extensions/jj-status.ts
@@ -0,0 +1,257 @@
+/**
+ * JJ Status Extension
+ *
+ * Replaces the default footer to show jj info instead of the git branch.
+ * The built-in footer shows "(detached)" in colocated jj repos because
+ * jj's git backend uses a detached HEAD. This extension swaps that for
+ * bookmark, distance, and change ID — matching p10k's jj prompt.
+ *
+ * All other footer behavior (token stats, context %, model, extension
+ * statuses) is preserved verbatim from the built-in footer.
+ */
+
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
+import { execSync } from "node:child_process";
+
+// --- jj info ---
+
+function getJJBranch(cwd: string): string | null {
+  try {
+    const bookmark = execSync(
+      `jj --ignore-working-copy --no-pager log --no-graph --limit 1 -r '
+        coalesce(
+          heads(::@ & (bookmarks() | remote_bookmarks() | tags())),
+          heads(@:: & (bookmarks() | remote_bookmarks() | tags())),
+          trunk()
+        )' -T 'separate(" ", bookmarks, tags)'`,
+      { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
+    ).trim().split(" ")[0]?.replace(/\*$/, "") ?? "";
+
+    let result = "";
+
+    if (bookmark) {
+      const where = bookmark.length > 32
+        ? bookmark.slice(0, 12) + "…" + bookmark.slice(-12)
+        : bookmark;
+      const after = execSync(
+        `jj --ignore-working-copy --no-pager log --no-graph -r '${bookmark}..@ & (~empty() | merges())' -T '"n"'`,
+        { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
+      ).length;
+      const before = execSync(
+        `jj --ignore-working-copy --no-pager log --no-graph -r '@..${bookmark} & (~empty() | merges())' -T '"n"'`,
+        { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
+      ).length;
+      result += where;
+      if (before > 0) result += `‹${before}`;
+      if (after > 0) result += `›${after}`;
+    }
+
+    const raw = execSync(
+      `jj --ignore-working-copy --no-pager log --no-graph --limit 1 -r @ -T '
+        separate("#",
+          change_id.shortest(4),
+          concat(
+            if(conflict, "!"),
+            if(divergent, "D"),
+            if(hidden, "H"),
+            if(immutable, "I"),
+          ),
+        )'`,
+      { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
+    ).trim();
+    const [changeId, flags] = raw.split("#");
+
+    if (changeId) {
+      if (result) result += " ";
+      result += changeId;
+    }
+    if (flags) result += ` ${flags}`;
+
+    return result || null;
+  } catch {
+    return null;
+  }
+}
+
+// --- footer (copied from built-in, replacing getGitBranch with getJJBranch) ---
+
+function sanitizeStatusText(text: string): string {
+  return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
+}
+
+function formatTokens(count: number): string {
+  if (count < 1000) return count.toString();
+  if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
+  if (count < 1000000) return `${Math.round(count / 1000)}k`;
+  if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
+  return `${Math.round(count / 1000000)}M`;
+}
+
+let cachedBranch: string | null = null;
+
+function refreshJJ(cwd: string) {
+  cachedBranch = getJJBranch(cwd);
+}
+
+export default function (pi: ExtensionAPI) {
+  pi.on("session_start", async (_event, ctx) => {
+    refreshJJ(ctx.cwd);
+
+    ctx.ui.setFooter((tui, theme, footerData) => ({
+      invalidate() {},
+      render(width: number): string[] {
+        // --- Line 1: pwd + jj branch + session name ---
+        let pwd = process.cwd();
+        const home = process.env.HOME || process.env.USERPROFILE;
+        if (home && pwd.startsWith(home)) {
+          pwd = `~${pwd.slice(home.length)}`;
+        }
+
+        // jj info instead of git branch
+        if (cachedBranch) {
+          pwd = `${pwd} (${cachedBranch})`;
+        }
+
+        const sessionName = ctx.sessionManager.getSessionName();
+        if (sessionName) {
+          pwd = `${pwd} • ${sessionName}`;
+        }
+
+        if (pwd.length > width) {
+          const half = Math.floor(width / 2) - 2;
+          if (half > 1) {
+            pwd = `${pwd.slice(0, half)}...${pwd.slice(-(half - 1))}`;
+          } else {
+            pwd = pwd.slice(0, Math.max(1, width));
+          }
+        }
+
+        // --- Line 2: token stats (left) + model (right) ---
+        let totalInput = 0;
+        let totalOutput = 0;
+        let totalCacheRead = 0;
+        let totalCacheWrite = 0;
+        let totalCost = 0;
+
+        for (const entry of ctx.sessionManager.getEntries()) {
+          if (entry.type === "message" && entry.message.role === "assistant") {
+            const m = entry.message as any;
+            totalInput += m.usage.input;
+            totalOutput += m.usage.output;
+            totalCacheRead += m.usage.cacheRead;
+            totalCacheWrite += m.usage.cacheWrite;
+            totalCost += m.usage.cost.total;
+          }
+        }
+
+        // Context % from last non-aborted assistant message
+        const branch = ctx.sessionManager.getBranch();
+        let contextTokens = 0;
+        for (let i = branch.length - 1; i >= 0; i--) {
+          const e = branch[i];
+          if (e.type === "message" && e.message.role === "assistant") {
+            const m = e.message as any;
+            if (m.stopReason !== "aborted") {
+              contextTokens = m.usage.input + m.usage.output + m.usage.cacheRead + m.usage.cacheWrite;
+              break;
+            }
+          }
+        }
+
+        const contextWindow = ctx.model?.contextWindow || 0;
+        const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
+
+        const statsParts: string[] = [];
+        if (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`);
+        if (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`);
+        if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`);
+        if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`);
+
+        const usingSubscription = ctx.model ? ctx.modelRegistry.isUsingOAuth(ctx.model) : false;
+        if (totalCost || usingSubscription) {
+          statsParts.push(`$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`);
+        }
+
+        const contextDisplay = `${contextPercentValue.toFixed(1)}%/${formatTokens(contextWindow)} (auto)`;
+        if (contextPercentValue > 90) {
+          statsParts.push(theme.fg("error", contextDisplay));
+        } else if (contextPercentValue > 70) {
+          statsParts.push(theme.fg("warning", contextDisplay));
+        } else {
+          statsParts.push(contextDisplay);
+        }
+
+        let statsLeft = statsParts.join(" ");
+        let statsLeftWidth = visibleWidth(statsLeft);
+        if (statsLeftWidth > width) {
+          const plain = statsLeft.replace(/\x1b\[[0-9;]*m/g, "");
+          statsLeft = `${plain.substring(0, width - 3)}...`;
+          statsLeftWidth = visibleWidth(statsLeft);
+        }
+
+        const minPadding = 2;
+        const modelName = ctx.model?.id || "no-model";
+        let rightSideWithoutProvider = modelName;
+        if (ctx.model?.reasoning) {
+          const level = pi.getThinkingLevel() || "off";
+          rightSideWithoutProvider =
+            level === "off" ? `${modelName} • thinking off` : `${modelName} • ${level}`;
+        }
+
+        let rightSide = rightSideWithoutProvider;
+        if (footerData.getAvailableProviderCount() > 1 && ctx.model) {
+          rightSide = `(${ctx.model.provider}) ${rightSideWithoutProvider}`;
+          if (statsLeftWidth + minPadding + visibleWidth(rightSide) > width) {
+            rightSide = rightSideWithoutProvider;
+          }
+        }
+
+        const rightSideWidth = visibleWidth(rightSide);
+        const totalNeeded = statsLeftWidth + minPadding + rightSideWidth;
+        let statsLine: string;
+        if (totalNeeded <= width) {
+          const padding = " ".repeat(width - statsLeftWidth - rightSideWidth);
+          statsLine = statsLeft + padding + rightSide;
+        } else {
+          const availableForRight = width - statsLeftWidth - minPadding;
+          if (availableForRight > 3) {
+            const plainRight = rightSide.replace(/\x1b\[[0-9;]*m/g, "");
+            const truncated = plainRight.substring(0, availableForRight);
+            const padding = " ".repeat(width - statsLeftWidth - truncated.length);
+            statsLine = statsLeft + padding + truncated;
+          } else {
+            statsLine = statsLeft;
+          }
+        }
+
+        const dimLeft = theme.fg("dim", statsLeft);
+        const remainder = statsLine.slice(statsLeft.length);
+        const dimRemainder = theme.fg("dim", remainder);
+        const lines = [theme.fg("dim", pwd), dimLeft + dimRemainder];
+
+        // --- Line 3 (optional): extension statuses ---
+        const extensionStatuses = footerData.getExtensionStatuses();
+        if (extensionStatuses.size > 0) {
+          const sorted = Array.from(extensionStatuses.entries())
+            .sort(([a], [b]) => a.localeCompare(b))
+            .map(([, text]) => sanitizeStatusText(text));
+          const statusLine = sorted.join(" ");
+          lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
+        }
+
+        return lines;
+      },
+    }));
+  });
+
+  pi.on("tool_result", async (event, ctx) => {
+    if (event.toolName === "bash") {
+      const cmd = String(event.input.command ?? "").trimStart();
+      if (cmd.startsWith("jj ")) refreshJJ(ctx.cwd);
+    }
+    if (event.toolName === "write" || event.toolName === "edit") {
+      refreshJJ(ctx.cwd);
+    }
+  });
+}
diff --git a/.pi/agent/extensions/model-identity.ts b/.pi/agent/extensions/model-identity.ts
index e0228747..aa881292 100644
--- a/.pi/agent/extensions/model-identity.ts
+++ b/.pi/agent/extensions/model-identity.ts
@@ -8,10 +8,10 @@
 import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
 
 export default function (pi: ExtensionAPI) {
-	pi.on("before_agent_start", async (event, ctx) => {
-		const model = ctx.model;
-		return {
-			systemPrompt: event.systemPrompt + `\n\nYou are running as: ${model.provider}/${model.id}`,
-		};
-	});
+  pi.on("before_agent_start", async (event, ctx) => {
+    const model = ctx.model;
+    return {
+      systemPrompt: event.systemPrompt + `\n\nYou are running as: ${model.provider}/${model.id}`,
+    };
+  });
 }