@pi-archimedes/footer 2.9.1 → 2.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,7 @@ After installing Pi, choose one installation command above, then `cd` into your
28
28
 
29
29
  ## What you get
30
30
 
31
- - **Session line** — working directory, active git branch with clean/dirty indicator, worktree badge, active model, and thinking level.
31
+ - **Session line** — working directory, active git branch (or nearest jj bookmark in a jj repo) with clean/dirty indicator, worktree badge, active model, and thinking level.
32
32
  - **Token ledger** — input (↑) and output (↓) tokens, cache read/write, and the live accumulated dollar cost of the session.
33
33
  - **Context bar** — colour-coded (green → yellow → red). It shows **consumption**: how much of the context window is used, not how much headroom is left.
34
34
  - **Adaptive layout** — one compact line on wide viewports; it wraps to the lines it needs as the terminal narrows instead of truncating essential data.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/footer",
3
- "version": "2.9.1",
3
+ "version": "2.9.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -19,7 +19,7 @@
19
19
  "./config": "./src/config.ts"
20
20
  },
21
21
  "dependencies": {
22
- "@pi-archimedes/core": "2.9.1"
22
+ "@pi-archimedes/core": "2.9.2"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "@earendil-works/pi-coding-agent": ">=0.1.0",
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ import { clampLine } from "@pi-archimedes/core/text";
16
16
  import { loadFooterConfig } from "./config.js";
17
17
  import { CostAccumulator } from "./cost-accumulator.js";
18
18
  import { getGitStatus, isInsideLinkedWorktree } from "./utils/git.js";
19
+ import { getJjBookmark } from "./utils/jj.js";
19
20
  import { getContextWindowInfo, getTokenUsageStats, type TokenUsageStats } from "./utils/stats.js";
20
21
  import { formatContextBar, formatGitStatusIndicators, formatThinkingIndicator, formatTokenCount, wrapStatusToChunks } from "./utils/format.js";
21
22
  import { footerIcons } from "./utils/icons.js";
@@ -51,7 +52,7 @@ export function registerFooter(pi: ExtensionAPI): void {
51
52
  try {
52
53
  const colorize = (token: string, s: string) => theme.fg(token as any, s);
53
54
  const activeModel = ctx.model?.id || "no-model";
54
- const currentBranch = footerData.getGitBranch();
55
+ const currentBranch = getJjBookmark() ?? footerData.getGitBranch();
55
56
  const currentDirectory = process.cwd().split("/").pop() || process.cwd();
56
57
  const gitStatus = getGitStatus();
57
58
  const inWorktree = isInsideLinkedWorktree();
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { resolve } from "path";
3
+
4
+ describe("getJjBookmark", () => {
5
+ let getJjBookmark: () => string | null;
6
+ let execFileSync: ReturnType<typeof vi.fn>;
7
+ let execFile: ReturnType<typeof vi.fn>;
8
+ let existing: Set<string>;
9
+
10
+ // `paths` = repo markers that exist, relative to cwd. Default is a colocated jj repo.
11
+ async function load(opts: { paths?: string[]; output?: string; throws?: boolean } = {}) {
12
+ vi.resetModules();
13
+ execFileSync = vi.fn(() => {
14
+ if (opts.throws) throw new Error("ENOENT");
15
+ return opts.output ?? "";
16
+ });
17
+ execFile = vi.fn();
18
+ vi.doMock("child_process", () => ({ execFileSync, execFile }));
19
+ existing = new Set((opts.paths ?? [".jj", ".git"]).map((p) => resolve(process.cwd(), p)));
20
+ vi.doMock("fs", () => ({ existsSync: vi.fn((p: string) => existing.has(p)) }));
21
+ getJjBookmark = (await import("./jj.js")).getJjBookmark;
22
+ }
23
+
24
+ afterEach(() => {
25
+ vi.restoreAllMocks();
26
+ });
27
+
28
+ it("returns null without spawning jj outside a jj repo", async () => {
29
+ await load({ paths: [".git"] });
30
+ expect(getJjBookmark()).toBeNull();
31
+ expect(execFileSync).not.toHaveBeenCalled();
32
+ });
33
+
34
+ it("detects a jj repo initialised after a cached negative result", async () => {
35
+ await load({ paths: [".git"], output: "main\n" });
36
+ const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
37
+ expect(getJjBookmark()).toBeNull();
38
+
39
+ existing.add(resolve(process.cwd(), ".jj")); // `jj git init --colocate`
40
+ now.mockReturnValue(5_000);
41
+ expect(getJjBookmark()).toBe("main");
42
+ });
43
+
44
+ it("leaves a git repo nested inside a jj tree to git", async () => {
45
+ await load({ paths: [".git", "../.jj"] });
46
+ expect(getJjBookmark()).toBeNull();
47
+ expect(execFileSync).not.toHaveBeenCalled();
48
+ });
49
+
50
+ it("finds a jj repo from a subdirectory", async () => {
51
+ await load({ paths: ["../.jj"], output: "main\n" });
52
+ expect(getJjBookmark()).toBe("main");
53
+ });
54
+
55
+ it("prefers the first bookmark line over the @ change-id line", async () => {
56
+ await load({ output: "@mooqvmmx\nmain\nother\n" });
57
+ expect(getJjBookmark()).toBe("main");
58
+ });
59
+
60
+ it("returns the bookmark when @ itself is bookmarked", async () => {
61
+ await load({ output: "feature-x\n" });
62
+ expect(getJjBookmark()).toBe("feature-x");
63
+ });
64
+
65
+ it("falls back to the change id when no ancestor is bookmarked", async () => {
66
+ await load({ output: "@mooqvmmx\n" });
67
+ expect(getJjBookmark()).toBe("mooqvmmx");
68
+ });
69
+
70
+ it("returns null when jj fails", async () => {
71
+ await load({ throws: true });
72
+ expect(getJjBookmark()).toBeNull();
73
+ });
74
+
75
+ it("serves the cache within the TTL and refreshes in the background after", async () => {
76
+ await load({ output: "main\n" });
77
+ const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
78
+ expect(getJjBookmark()).toBe("main");
79
+ expect(getJjBookmark()).toBe("main");
80
+ expect(execFileSync).toHaveBeenCalledTimes(1);
81
+ // Never snapshot the working copy from a render (op-log writes, repo lock)
82
+ expect(execFileSync.mock.calls[0]![1]).toContain("--ignore-working-copy");
83
+ expect(execFile).not.toHaveBeenCalled();
84
+
85
+ now.mockReturnValue(5_000);
86
+ expect(getJjBookmark()).toBe("main"); // stale value while refreshing
87
+ expect(execFile).toHaveBeenCalledTimes(1);
88
+
89
+ const done = execFile.mock.calls[0]![3] as (e: Error | null, out: string) => void;
90
+ done(new Error("lock timeout"), "");
91
+ expect(getJjBookmark()).toBe("main"); // failed refresh keeps stale value
92
+
93
+ now.mockReturnValue(10_000);
94
+ getJjBookmark();
95
+ (execFile.mock.calls[1]![3] as typeof done)(null, "feature-x\n");
96
+ expect(getJjBookmark()).toBe("feature-x");
97
+ });
98
+ });
@@ -0,0 +1,85 @@
1
+ import { execFile, execFileSync } from "child_process";
2
+ import { existsSync } from "fs";
3
+ import { dirname, join } from "path";
4
+
5
+ // Same TTL as git status: bookmarks (and repo markers) don't change on every render
6
+ const JJ_CACHE_TTL_MS = 2_000;
7
+
8
+ // Nearest bookmarked commit at or below @, plus @ itself as a change-id fallback.
9
+ // --ignore-working-copy: never snapshot (no op-log writes, no repo lock) from a render.
10
+ const JJ_ARGS = [
11
+ "log",
12
+ "-r", "heads(::@ & bookmarks()) | @",
13
+ "--no-graph",
14
+ "--ignore-working-copy",
15
+ "--color", "never",
16
+ "-T", 'if(local_bookmarks, local_bookmarks.map(|b| b.name()).join(","), "@" ++ change_id.shortest(8)) ++ "\\n"',
17
+ ];
18
+
19
+ let jjRepoCache: { cwd: string; isJj: boolean; timestamp: number } | undefined;
20
+ let bookmarkCache: { cwd: string; value: string | null; timestamp: number } | undefined;
21
+ let refreshInFlight = false;
22
+
23
+ function isJjRepo(cwd: string): boolean {
24
+ // Expires so a mid-session `jj git init` (or a removed .jj) is picked up
25
+ if (jjRepoCache?.cwd === cwd && Date.now() - jjRepoCache.timestamp < JJ_CACHE_TTL_MS) return jjRepoCache.isJj;
26
+ let dir = cwd;
27
+ let isJj = false;
28
+ while (true) {
29
+ // Stop at the nearest repo root of either kind: a git repo nested in a jj
30
+ // tree must keep showing its own git branch (colocated has both here).
31
+ isJj = existsSync(join(dir, ".jj"));
32
+ if (isJj || existsSync(join(dir, ".git"))) break;
33
+ const parent = dirname(dir);
34
+ if (parent === dir) break;
35
+ dir = parent;
36
+ }
37
+ jjRepoCache = { cwd, isJj, timestamp: Date.now() };
38
+ return isJj;
39
+ }
40
+
41
+ /** First bookmark line wins; otherwise the `@<change-id>` line without its marker. */
42
+ function parseJjOutput(output: string): string | null {
43
+ const lines = output.split("\n").filter(Boolean);
44
+ return lines.find((l) => !l.startsWith("@")) ?? (lines[0]?.slice(1) || null);
45
+ }
46
+
47
+ function refreshInBackground(cwd: string): void {
48
+ if (refreshInFlight) return;
49
+ refreshInFlight = true;
50
+ execFile("jj", JJ_ARGS, { cwd, encoding: "utf8", timeout: 2_000 }, (error, stdout) => {
51
+ refreshInFlight = false;
52
+ if (bookmarkCache?.cwd !== cwd) return; // cwd moved on; don't clobber the new entry
53
+ // On failure keep the stale value (transient lock/timeout shouldn't flash "detached")
54
+ bookmarkCache = { cwd, value: error ? bookmarkCache.value : parseJjOutput(stdout), timestamp: Date.now() };
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Current jj bookmark for the footer branch section, or null when cwd isn't in
60
+ * a jj repo or jj can't be run (caller falls back to pi's git branch).
61
+ */
62
+ export function getJjBookmark(): string | null {
63
+ const cwd = process.cwd();
64
+ if (!isJjRepo(cwd)) return null;
65
+
66
+ if (bookmarkCache?.cwd === cwd) {
67
+ if (Date.now() - bookmarkCache.timestamp >= JJ_CACHE_TTL_MS) refreshInBackground(cwd);
68
+ return bookmarkCache.value;
69
+ }
70
+
71
+ let value: string | null = null;
72
+ try {
73
+ const output = execFileSync("jj", JJ_ARGS, {
74
+ cwd,
75
+ encoding: "utf8",
76
+ stdio: ["ignore", "pipe", "ignore"],
77
+ timeout: 2_000,
78
+ });
79
+ value = parseJjOutput(output);
80
+ } catch {
81
+ /* jj missing or failed */
82
+ }
83
+ bookmarkCache = { cwd, value, timestamp: Date.now() };
84
+ return value;
85
+ }