@pi-archimedes/footer 2.9.1 → 2.9.3
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 +1 -1
- package/package.json +2 -2
- package/src/index.ts +2 -1
- package/src/utils/git.test.ts +11 -4
- package/src/utils/git.ts +12 -13
- package/src/utils/jj.test.ts +70 -0
- package/src/utils/jj.ts +93 -0
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.
|
|
3
|
+
"version": "2.9.3",
|
|
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.
|
|
22
|
+
"@pi-archimedes/core": "2.9.3"
|
|
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();
|
package/src/utils/git.test.ts
CHANGED
|
@@ -71,6 +71,12 @@ describe("getGitStatus", () => {
|
|
|
71
71
|
expect(result.unstaged).toBe(0);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
it("counts intent-to-add files (jj's new files) as unstaged", async () => {
|
|
75
|
+
const mockExecSync = vi.fn(() => "1 .A N... 000000 000000 100644 0000 0000 new.txt\n");
|
|
76
|
+
await loadModule(mockExecSync);
|
|
77
|
+
expect(getGitStatus()).toMatchObject({ staged: 0, unstaged: 1, untracked: 0 });
|
|
78
|
+
});
|
|
79
|
+
|
|
74
80
|
it("parse untracked lines", async () => {
|
|
75
81
|
const mockExecSync = vi.fn(() => "? untracked1.txt\n? untracked2.txt\n");
|
|
76
82
|
await loadModule(mockExecSync);
|
|
@@ -78,12 +84,13 @@ describe("getGitStatus", () => {
|
|
|
78
84
|
expect(result.untracked).toBe(2);
|
|
79
85
|
});
|
|
80
86
|
|
|
81
|
-
it("parse branch
|
|
82
|
-
const mockExecSync = vi.fn(() =>
|
|
87
|
+
it("parse branch headers (ahead/behind)", async () => {
|
|
88
|
+
const mockExecSync = vi.fn((_cmd: string) =>
|
|
89
|
+
"# branch.oid abc123\n# branch.head main\n# branch.upstream origin/main\n# branch.ab +3 -2\n");
|
|
83
90
|
await loadModule(mockExecSync);
|
|
84
91
|
const result = getGitStatus();
|
|
85
|
-
expect(
|
|
86
|
-
expect(result
|
|
92
|
+
expect(mockExecSync.mock.calls[0]![0]).toContain("--branch"); // headers only print with --branch
|
|
93
|
+
expect(result).toEqual({ staged: 0, unstaged: 0, untracked: 0, ahead: 3, behind: 2 });
|
|
87
94
|
});
|
|
88
95
|
|
|
89
96
|
it("malformed lines ignored gracefully", async () => {
|
package/src/utils/git.ts
CHANGED
|
@@ -10,7 +10,10 @@ export interface GitStatus {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
const STAGED_INDEX_STATES = ["A", "M", "D", "R", "C", "U", "T"] as const;
|
|
13
|
-
|
|
13
|
+
// "A" = intent-to-add (`git add -N`); jj marks every new file this way in colocated repos
|
|
14
|
+
const UNSTAGED_WORKTREE_STATES = ["M", "D", "U", "A"] as const;
|
|
15
|
+
|
|
16
|
+
const GIT_STATUS_CMD = "git status --porcelain=v2 --branch -uall";
|
|
14
17
|
|
|
15
18
|
// Cache TTL: 2 seconds — git status doesn't change on every render
|
|
16
19
|
const GIT_CACHE_TTL_MS = 2_000;
|
|
@@ -53,16 +56,12 @@ function parseGitOutput(output: string): GitStatus {
|
|
|
53
56
|
const status: GitStatus = { staged: 0, unstaged: 0, untracked: 0, ahead: 0, behind: 0 };
|
|
54
57
|
|
|
55
58
|
for (const line of output.trim().split("\n")) {
|
|
56
|
-
// Branch
|
|
57
|
-
if (
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (!isNaN(commitsAhead) && !isNaN(commitsBehind) && branchParts[branchParts.length - 3]) {
|
|
63
|
-
status.ahead = Math.max(0, commitsAhead);
|
|
64
|
-
status.behind = Math.max(0, commitsBehind);
|
|
65
|
-
}
|
|
59
|
+
// Branch headers: "# branch.ab +<ahead> -<behind>" (only when an upstream is set)
|
|
60
|
+
if (line.startsWith("# ")) {
|
|
61
|
+
const ab = line.match(/^# branch\.ab \+(\d+) -(\d+)/);
|
|
62
|
+
if (ab) {
|
|
63
|
+
status.ahead = Number(ab[1]);
|
|
64
|
+
status.behind = Number(ab[2]);
|
|
66
65
|
}
|
|
67
66
|
continue;
|
|
68
67
|
}
|
|
@@ -88,7 +87,7 @@ function scheduleGitRefresh(): void {
|
|
|
88
87
|
gitRefreshTimer = setTimeout(() => {
|
|
89
88
|
gitRefreshTimer = undefined;
|
|
90
89
|
try {
|
|
91
|
-
const output = execSync(
|
|
90
|
+
const output = execSync(GIT_STATUS_CMD, {
|
|
92
91
|
cwd: process.cwd(),
|
|
93
92
|
encoding: "utf8",
|
|
94
93
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -116,7 +115,7 @@ export function getGitStatus(): GitStatus {
|
|
|
116
115
|
// First call — sync fetch + schedule background refresh
|
|
117
116
|
const emptyStatus: GitStatus = { staged: 0, unstaged: 0, untracked: 0, ahead: 0, behind: 0 };
|
|
118
117
|
try {
|
|
119
|
-
const output = execSync(
|
|
118
|
+
const output = execSync(GIT_STATUS_CMD, {
|
|
120
119
|
cwd: process.cwd(),
|
|
121
120
|
encoding: "utf8",
|
|
122
121
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
|
|
4
|
+
// `paths` = repo markers that exist, relative to cwd. Default: colocated jj repo.
|
|
5
|
+
async function load({ paths = [".jj", ".git"], output = "main\n", throws = false } = {}) {
|
|
6
|
+
vi.resetModules();
|
|
7
|
+
const existing = new Set(paths.map((p) => resolve(process.cwd(), p)));
|
|
8
|
+
const execFileSync = vi.fn((_cmd: string, _args: string[]) => {
|
|
9
|
+
if (throws) throw new Error("ENOENT");
|
|
10
|
+
return output;
|
|
11
|
+
});
|
|
12
|
+
const execFile = vi.fn();
|
|
13
|
+
vi.doMock("child_process", () => ({ execFileSync, execFile }));
|
|
14
|
+
vi.doMock("fs", () => ({ existsSync: (p: string) => existing.has(p) }));
|
|
15
|
+
const { getJjBookmark } = await import("./jj.js");
|
|
16
|
+
return { getJjBookmark, execFileSync, execFile, existing };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("getJjBookmark", () => {
|
|
20
|
+
it.each([
|
|
21
|
+
{ name: "plain git repo", paths: [".git"], expected: null },
|
|
22
|
+
{ name: "git repo nested in a jj tree", paths: [".git", "../.jj"], expected: null },
|
|
23
|
+
{ name: "colocated jj repo", paths: [".jj", ".git"], expected: "main" },
|
|
24
|
+
{ name: "subdirectory of a jj repo", paths: ["../.jj"], expected: "main" },
|
|
25
|
+
])("detects $name", async ({ paths, expected }) => {
|
|
26
|
+
const { getJjBookmark, execFileSync } = await load({ paths });
|
|
27
|
+
expect(getJjBookmark()).toBe(expected);
|
|
28
|
+
if (!expected) expect(execFileSync).not.toHaveBeenCalled(); // git-only users never spawn jj
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it.each([
|
|
32
|
+
{ output: "@mooqvmmx\nmain\nother\n", expected: "main" }, // first bookmark line beats @
|
|
33
|
+
{ output: "feature-x\n", expected: "feature-x" }, // @ itself bookmarked
|
|
34
|
+
{ output: "main release v2\n", expected: "main +2" }, // capped for layout width
|
|
35
|
+
{ output: "@mooqvmmx\n", expected: "mooqvmmx" }, // no bookmark → change id
|
|
36
|
+
])("parses $output", async ({ output, expected }) => {
|
|
37
|
+
const { getJjBookmark } = await load({ output });
|
|
38
|
+
expect(getJjBookmark()).toBe(expected);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns null when jj fails", async () => {
|
|
42
|
+
const { getJjBookmark } = await load({ throws: true });
|
|
43
|
+
expect(getJjBookmark()).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("caches, refreshes in the background, and notices a late `jj git init`", async () => {
|
|
47
|
+
const { getJjBookmark, execFileSync, execFile, existing } = await load({ paths: [".git"] });
|
|
48
|
+
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
|
49
|
+
expect(getJjBookmark()).toBeNull();
|
|
50
|
+
|
|
51
|
+
existing.add(resolve(process.cwd(), ".jj")); // negative detection expires
|
|
52
|
+
now.mockReturnValue(5_000);
|
|
53
|
+
expect(getJjBookmark()).toBe("main");
|
|
54
|
+
expect(getJjBookmark()).toBe("main"); // within TTL: no second spawn
|
|
55
|
+
expect(execFileSync).toHaveBeenCalledTimes(1);
|
|
56
|
+
expect(execFileSync.mock.calls[0]![1]).toContain("--ignore-working-copy"); // never snapshot from a render
|
|
57
|
+
|
|
58
|
+
type Done = (e: Error | null, out: string) => void;
|
|
59
|
+
now.mockReturnValue(10_000);
|
|
60
|
+
expect(getJjBookmark()).toBe("main"); // stale while refreshing
|
|
61
|
+
(execFile.mock.calls[0]![3] as Done)(new Error("lock timeout"), "");
|
|
62
|
+
expect(getJjBookmark()).toBe("main"); // failed refresh keeps stale value
|
|
63
|
+
|
|
64
|
+
now.mockReturnValue(15_000);
|
|
65
|
+
getJjBookmark();
|
|
66
|
+
(execFile.mock.calls[1]![3] as Done)(null, "feature-x\n");
|
|
67
|
+
expect(getJjBookmark()).toBe("feature-x");
|
|
68
|
+
now.mockRestore();
|
|
69
|
+
});
|
|
70
|
+
});
|
package/src/utils/jj.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
/**
|
|
42
|
+
* First bookmark line wins; otherwise the `@<change-id>` line without its marker.
|
|
43
|
+
* Several bookmarks on one commit collapse to `first +N` — footer chunks are
|
|
44
|
+
* atomic, so an unbounded list could push other sections onto their own lines.
|
|
45
|
+
*/
|
|
46
|
+
function parseJjOutput(output: string): string | null {
|
|
47
|
+
const lines = output.split("\n").filter(Boolean);
|
|
48
|
+
const bookmarks = lines.find((l) => !l.startsWith("@"));
|
|
49
|
+
if (!bookmarks) return lines[0]?.slice(1) || null;
|
|
50
|
+
// Space-joined by the template: git ref names can't contain spaces
|
|
51
|
+
const [first, ...rest] = bookmarks.split(" ");
|
|
52
|
+
return rest.length ? `${first} +${rest.length}` : first!;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function refreshInBackground(cwd: string): void {
|
|
56
|
+
if (refreshInFlight) return;
|
|
57
|
+
refreshInFlight = true;
|
|
58
|
+
execFile("jj", JJ_ARGS, { cwd, encoding: "utf8", timeout: 2_000 }, (error, stdout) => {
|
|
59
|
+
refreshInFlight = false;
|
|
60
|
+
if (bookmarkCache?.cwd !== cwd) return; // cwd moved on; don't clobber the new entry
|
|
61
|
+
// On failure keep the stale value (transient lock/timeout shouldn't flash "detached")
|
|
62
|
+
bookmarkCache = { cwd, value: error ? bookmarkCache.value : parseJjOutput(stdout), timestamp: Date.now() };
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Current jj bookmark for the footer branch section, or null when cwd isn't in
|
|
68
|
+
* a jj repo or jj can't be run (caller falls back to pi's git branch).
|
|
69
|
+
*/
|
|
70
|
+
export function getJjBookmark(): string | null {
|
|
71
|
+
const cwd = process.cwd();
|
|
72
|
+
if (!isJjRepo(cwd)) return null;
|
|
73
|
+
|
|
74
|
+
if (bookmarkCache?.cwd === cwd) {
|
|
75
|
+
if (Date.now() - bookmarkCache.timestamp >= JJ_CACHE_TTL_MS) refreshInBackground(cwd);
|
|
76
|
+
return bookmarkCache.value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let value: string | null = null;
|
|
80
|
+
try {
|
|
81
|
+
const output = execFileSync("jj", JJ_ARGS, {
|
|
82
|
+
cwd,
|
|
83
|
+
encoding: "utf8",
|
|
84
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
85
|
+
timeout: 2_000,
|
|
86
|
+
});
|
|
87
|
+
value = parseJjOutput(output);
|
|
88
|
+
} catch {
|
|
89
|
+
/* jj missing or failed */
|
|
90
|
+
}
|
|
91
|
+
bookmarkCache = { cwd, value, timestamp: Date.now() };
|
|
92
|
+
return value;
|
|
93
|
+
}
|