@pi-archimedes/footer 2.10.3 → 2.11.0

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.
@@ -17,11 +17,12 @@ describe("getGitStatus", () => {
17
17
  let getGitStatus: () => GitStatus;
18
18
  let isInsideLinkedWorktree: () => boolean;
19
19
 
20
- async function loadModule(mockExecSync: ReturnType<typeof vi.fn>) {
20
+ async function loadModule(mockExecSync: ReturnType<typeof vi.fn>, mockExecFile = vi.fn()) {
21
21
  vi.resetModules();
22
22
 
23
23
  vi.doMock("child_process", () => ({
24
24
  execSync: mockExecSync,
25
+ execFile: mockExecFile,
25
26
  }));
26
27
 
27
28
  vi.doMock("fs", () => ({
@@ -31,6 +32,8 @@ describe("getGitStatus", () => {
31
32
  const mod = await import("./git.js");
32
33
  getGitStatus = mod.getGitStatus;
33
34
  isInsideLinkedWorktree = mod.isInsideLinkedWorktree;
35
+ // Same module registry generation as git.js, so both see one listener set
36
+ return await import("./vcs-change-events.js");
34
37
  }
35
38
 
36
39
  afterEach(() => {
@@ -93,6 +96,107 @@ describe("getGitStatus", () => {
93
96
  expect(result).toEqual({ staged: 0, unstaged: 0, untracked: 0, ahead: 3, behind: 2 });
94
97
  });
95
98
 
99
+ it("refreshes stale status asynchronously and timestamps failed refreshes", async () => {
100
+ const mockExecSync = vi.fn(() => "1 AM file.txt\n");
101
+ const mockExecFile = vi.fn();
102
+ await loadModule(mockExecSync, mockExecFile);
103
+ const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
104
+
105
+ expect(getGitStatus()).toEqual({ staged: 1, unstaged: 1, untracked: 0, ahead: 0, behind: 0 });
106
+ now.mockReturnValue(3_000);
107
+ expect(getGitStatus()).toEqual({ staged: 1, unstaged: 1, untracked: 0, ahead: 0, behind: 0 });
108
+ getGitStatus(); // renders while the refresh is pending
109
+ getGitStatus();
110
+ expect(mockExecFile).toHaveBeenCalledTimes(1);
111
+ expect(mockExecSync).toHaveBeenCalledTimes(1);
112
+
113
+ type Done = (error: Error | null, stdout: string) => void;
114
+ (mockExecFile.mock.calls[0]![3] as Done)(null, "? new.txt\n");
115
+ expect(getGitStatus()).toEqual({ staged: 0, unstaged: 0, untracked: 1, ahead: 0, behind: 0 });
116
+
117
+ now.mockReturnValue(5_001);
118
+ expect(getGitStatus()).toEqual({ staged: 0, unstaged: 0, untracked: 1, ahead: 0, behind: 0 });
119
+ (mockExecFile.mock.calls[1]![3] as Done)(new Error("git failed"), "");
120
+ expect(getGitStatus()).toEqual({ staged: 0, unstaged: 0, untracked: 1, ahead: 0, behind: 0 });
121
+
122
+ now.mockReturnValue(6_000);
123
+ getGitStatus();
124
+ expect(mockExecFile).toHaveBeenCalledTimes(2);
125
+ });
126
+
127
+ it("publishes a VCS change when a background refresh moves the counts", async () => {
128
+ const mockExecSync = vi.fn(() => "1 AM file.txt\n");
129
+ const mockExecFile = vi.fn();
130
+ const events = await loadModule(mockExecSync, mockExecFile);
131
+ const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
132
+ const listener = vi.fn();
133
+ const unsubscribe = events.onVcsChange(listener);
134
+
135
+ getGitStatus(); // first call is synchronous, nothing to announce yet
136
+ expect(listener).not.toHaveBeenCalled();
137
+
138
+ now.mockReturnValue(3_000);
139
+ getGitStatus(); // stale returned, refresh scheduled
140
+ expect(listener).not.toHaveBeenCalled();
141
+
142
+ type Done = (error: Error | null, stdout: string) => void;
143
+ (mockExecFile.mock.calls[0]![3] as Done)(null, "1 AM file.txt\n1 AM other.txt\n");
144
+ expect(listener).toHaveBeenCalledTimes(1);
145
+ expect(getGitStatus().unstaged).toBe(2);
146
+
147
+ unsubscribe();
148
+ now.mockReturnValue(6_000);
149
+ getGitStatus();
150
+ (mockExecFile.mock.calls[1]![3] as Done)(null, "1 AM a\n1 AM b\n1 AM c\n");
151
+ expect(listener).toHaveBeenCalledTimes(1); // unsubscribed: silent
152
+ });
153
+
154
+ it("stays silent when a refresh returns identical counts or fails", async () => {
155
+ const mockExecSync = vi.fn(() => "1 AM file.txt\n");
156
+ const mockExecFile = vi.fn();
157
+ const events = await loadModule(mockExecSync, mockExecFile);
158
+ const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
159
+ const listener = vi.fn();
160
+ events.onVcsChange(listener);
161
+
162
+ getGitStatus();
163
+ now.mockReturnValue(3_000);
164
+ getGitStatus();
165
+ type Done = (error: Error | null, stdout: string) => void;
166
+ (mockExecFile.mock.calls[0]![3] as Done)(null, "1 AM file.txt\n"); // same numbers
167
+ expect(listener).not.toHaveBeenCalled();
168
+
169
+ now.mockReturnValue(6_000);
170
+ getGitStatus();
171
+ (mockExecFile.mock.calls[1]![3] as Done)(new Error("git failed"), ""); // keeps stale
172
+ expect(listener).not.toHaveBeenCalled();
173
+ });
174
+
175
+ it("publishes a VCS change when the worktree answer flips", async () => {
176
+ const cwd = process.cwd();
177
+ const mockExecSync = vi.fn(() => "worktree /nope/main\n\n" + `worktree ${cwd}\n`);
178
+ const mockExecFile = vi.fn();
179
+ const events = await loadModule(mockExecSync, mockExecFile);
180
+ const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
181
+ const listener = vi.fn();
182
+ events.onVcsChange(listener);
183
+
184
+ expect(isInsideLinkedWorktree()).toBe(true);
185
+ expect(listener).not.toHaveBeenCalled();
186
+
187
+ now.mockReturnValue(3_000);
188
+ isInsideLinkedWorktree(); // schedules the refresh
189
+ type Done = (error: Error | null, stdout: string) => void;
190
+ (mockExecFile.mock.calls[0]![3] as Done)(null, `worktree ${cwd}\n`); // now the main worktree
191
+ expect(listener).toHaveBeenCalledTimes(1);
192
+ expect(isInsideLinkedWorktree()).toBe(false);
193
+
194
+ now.mockReturnValue(6_000);
195
+ isInsideLinkedWorktree();
196
+ (mockExecFile.mock.calls[1]![3] as Done)(null, `worktree ${cwd}\n`); // same answer
197
+ expect(listener).toHaveBeenCalledTimes(1);
198
+ });
199
+
96
200
  it("malformed lines ignored gracefully", async () => {
97
201
  const mockExecSync = vi.fn(() => "this is not valid\n\n \n1 AM valid.txt\n");
98
202
  await loadModule(mockExecSync);
@@ -134,6 +238,26 @@ describe("getGitStatus", () => {
134
238
  expect(isInsideLinkedWorktree()).toBe(true);
135
239
  });
136
240
 
241
+ it("refreshes stale worktree status asynchronously", async () => {
242
+ const cwd = process.cwd();
243
+ const mockExecSync = vi.fn(() => "worktree /nope/main\n\n" + `worktree ${cwd}\n`);
244
+ const mockExecFile = vi.fn();
245
+ await loadModule(mockExecSync, mockExecFile);
246
+ const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
247
+
248
+ expect(isInsideLinkedWorktree()).toBe(true);
249
+ now.mockReturnValue(3_000);
250
+ expect(isInsideLinkedWorktree()).toBe(true);
251
+ isInsideLinkedWorktree(); // renders while the refresh is pending
252
+ isInsideLinkedWorktree();
253
+ expect(mockExecFile).toHaveBeenCalledTimes(1);
254
+ expect(mockExecSync).toHaveBeenCalledTimes(1);
255
+
256
+ type Done = (error: Error | null, stdout: string) => void;
257
+ (mockExecFile.mock.calls[0]![3] as Done)(null, `worktree ${cwd}\n`);
258
+ expect(isInsideLinkedWorktree()).toBe(false);
259
+ });
260
+
137
261
  it("property: getGitStatus never throws (catches execSync errors gracefully)", async () => {
138
262
  const mockExecSync = vi.fn(() => { throw new Error("git not found"); });
139
263
  await loadModule(mockExecSync);
@@ -142,7 +266,12 @@ describe("getGitStatus", () => {
142
266
  expect(result).toEqual({ staged: 0, unstaged: 0, untracked: 0, ahead: 0, behind: 0 });
143
267
  });
144
268
 
145
- it("property: parseGitOutput handles arbitrary git porcelain output without throwing", async () => {
269
+ // Each of the 100 default runs re-imports the module (`vi.resetModules` +
270
+ // dynamic import in `loadModule`), so the cost is module-graph work, not
271
+ // parsing: 1217 ms idle, but up to 8247 ms with the repo's own type-check
272
+ // looped over it, and it crossed the 5 s default in 2 of 5 pre-fix loaded runs
273
+ // (5118 ms, 5178 ms). Same shield as the subagent width audit — see #131.
274
+ it("property: parseGitOutput handles arbitrary git porcelain output without throwing", { timeout: 30_000 }, async () => {
146
275
  await fc.assert(
147
276
  fc.asyncProperty(fc.string({ maxLength: 500 }), async (output) => {
148
277
  const mockExecSync = vi.fn(() => output);
package/src/utils/git.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { execSync } from "child_process";
1
+ import { execFile, execSync } from "child_process";
2
2
  import { realpathSync } from "fs";
3
+ import { notifyVcsChange } from "./vcs-change-events.js";
3
4
 
4
5
  export interface GitStatus {
5
6
  staged: number;
@@ -30,7 +31,8 @@ interface WorktreeCacheEntry {
30
31
 
31
32
  let gitStatusCache: GitCacheEntry | undefined;
32
33
  let worktreeCache: WorktreeCacheEntry | undefined;
33
- let gitRefreshTimer: ReturnType<typeof setTimeout> | undefined;
34
+ let gitRefreshInFlight = false;
35
+ let worktreeRefreshInFlight = false;
34
36
 
35
37
  interface FileStates {
36
38
  indexField: string;
@@ -81,23 +83,35 @@ function parseGitOutput(output: string): GitStatus {
81
83
  return status;
82
84
  }
83
85
 
84
- /** Schedule a background git status refresh (debounced). */
86
+ function gitStatusEquals(a: GitStatus, b: GitStatus): boolean {
87
+ return (
88
+ a.staged === b.staged &&
89
+ a.unstaged === b.unstaged &&
90
+ a.untracked === b.untracked &&
91
+ a.ahead === b.ahead &&
92
+ a.behind === b.behind
93
+ );
94
+ }
95
+
96
+ /** Refresh git status without blocking the render loop. */
85
97
  function scheduleGitRefresh(): void {
86
- if (gitRefreshTimer) return; // Already scheduled
87
- gitRefreshTimer = setTimeout(() => {
88
- gitRefreshTimer = undefined;
89
- try {
90
- const output = execSync(GIT_STATUS_CMD, {
91
- cwd: process.cwd(),
92
- encoding: "utf8",
93
- stdio: ["pipe", "pipe", "pipe"],
94
- timeout: 2_000,
95
- });
96
- gitStatusCache = { value: parseGitOutput(output), timestamp: Date.now() };
97
- } catch {
98
- /* not a git repo or command failed — keep stale cache */
99
- }
100
- }, 0);
98
+ if (gitRefreshInFlight || !gitStatusCache) return;
99
+ gitRefreshInFlight = true;
100
+ const staleValue = gitStatusCache.value;
101
+ execFile(
102
+ "git",
103
+ ["status", "--porcelain=v2", "--branch", "-uall"],
104
+ { cwd: process.cwd(), encoding: "utf8", timeout: 2_000 },
105
+ (error, stdout) => {
106
+ gitRefreshInFlight = false;
107
+ const next = error ? staleValue : parseGitOutput(stdout);
108
+ gitStatusCache = { value: next, timestamp: Date.now() };
109
+ // Ask for a frame only when a displayed count actually moved: the footer
110
+ // painted the stale value, and pi draws no frame unless one is requested.
111
+ // An unchanged reply must stay silent, or the TTL drives a render forever.
112
+ if (!gitStatusEquals(next, staleValue)) notifyVcsChange();
113
+ },
114
+ );
101
115
  }
102
116
 
103
117
  export function getGitStatus(): GitStatus {
@@ -112,7 +126,7 @@ export function getGitStatus(): GitStatus {
112
126
  return gitStatusCache.value;
113
127
  }
114
128
 
115
- // First call — sync fetch + schedule background refresh
129
+ // First call — sync fetch so the first render is correct
116
130
  const emptyStatus: GitStatus = { staged: 0, unstaged: 0, untracked: 0, ahead: 0, behind: 0 };
117
131
  try {
118
132
  const output = execSync(GIT_STATUS_CMD, {
@@ -126,10 +140,48 @@ export function getGitStatus(): GitStatus {
126
140
  /* not a git repo or command failed */
127
141
  gitStatusCache = { value: emptyStatus, timestamp: Date.now() };
128
142
  }
129
- scheduleGitRefresh();
130
143
  return gitStatusCache.value;
131
144
  }
132
145
 
146
+ function parseWorktreeOutput(output: string, realCwd: string): boolean {
147
+ const worktreeEntries = output.trim().split("\n\n").filter(Boolean);
148
+ if (worktreeEntries.length <= 1) return false;
149
+
150
+ // The first porcelain entry is always the main worktree: only linked ones
151
+ // can match.
152
+ for (let i = 1; i < worktreeEntries.length; i++) {
153
+ const pathLine = worktreeEntries[i]!.split("\n").find((line) => line.startsWith("worktree "));
154
+ const worktreePath = pathLine?.replace("worktree ", "");
155
+ if (worktreePath && (realCwd === worktreePath || realCwd.startsWith(worktreePath + "/"))) return true;
156
+ }
157
+ return false;
158
+ }
159
+
160
+ function scheduleWorktreeRefresh(): void {
161
+ if (worktreeRefreshInFlight || !worktreeCache) return;
162
+ worktreeRefreshInFlight = true;
163
+ const staleValue = worktreeCache.value;
164
+ execFile(
165
+ "git",
166
+ ["worktree", "list", "--porcelain"],
167
+ { cwd: process.cwd(), encoding: "utf8", timeout: 2_000 },
168
+ (error, stdout) => {
169
+ worktreeRefreshInFlight = false;
170
+ let value = staleValue;
171
+ if (!error) {
172
+ try {
173
+ value = parseWorktreeOutput(stdout, realpathSync(process.cwd()));
174
+ } catch {
175
+ /* keep stale value */
176
+ }
177
+ }
178
+ worktreeCache = { value, timestamp: Date.now() };
179
+ // Same rule as git status: repaint only when the answer changed.
180
+ if (value !== staleValue) notifyVcsChange();
181
+ },
182
+ );
183
+ }
184
+
133
185
  /**
134
186
  * Returns true when cwd is inside a linked worktree (not the main clone).
135
187
  * Returns false when cwd is the main clone or not a git repo.
@@ -140,6 +192,11 @@ export function isInsideLinkedWorktree(): boolean {
140
192
  return worktreeCache.value;
141
193
  }
142
194
 
195
+ if (worktreeCache) {
196
+ scheduleWorktreeRefresh();
197
+ return worktreeCache.value;
198
+ }
199
+
143
200
  try {
144
201
  const worktreeOutput = execSync("git worktree list --porcelain", {
145
202
  cwd: process.cwd(),
@@ -147,29 +204,7 @@ export function isInsideLinkedWorktree(): boolean {
147
204
  stdio: ["pipe", "pipe", "pipe"],
148
205
  timeout: 2_000,
149
206
  });
150
-
151
- const worktreeEntries = worktreeOutput.trim().split("\n\n").filter(Boolean);
152
- if (worktreeEntries.length <= 1) {
153
- worktreeCache = { value: false, timestamp: Date.now() };
154
- return false;
155
- }
156
-
157
- const currentDirectoryPath = realpathSync(process.cwd());
158
- let result = false;
159
- // The first porcelain entry is always the main worktree: being in the
160
- // main clone isn't "being in a worktree", so only linked ones (index >= 1)
161
- // can match.
162
- for (let i = 1; i < worktreeEntries.length; i++) {
163
- const entry = worktreeEntries[i]!;
164
- const entryLines = entry.split("\n");
165
- const pathLine = entryLines.find((l) => l.startsWith("worktree "));
166
- const worktreePath = pathLine?.replace("worktree ", "");
167
-
168
- if (worktreePath && (currentDirectoryPath === worktreePath || currentDirectoryPath.startsWith(worktreePath + "/"))) {
169
- result = true;
170
- break;
171
- }
172
- }
207
+ const result = parseWorktreeOutput(worktreeOutput, realpathSync(process.cwd()));
173
208
  worktreeCache = { value: result, timestamp: Date.now() };
174
209
  return result;
175
210
  } catch {
@@ -12,8 +12,9 @@ async function load({ paths = [".jj", ".git"], output = "main\n", throws = false
12
12
  const execFile = vi.fn();
13
13
  vi.doMock("child_process", () => ({ execFileSync, execFile }));
14
14
  vi.doMock("fs", () => ({ existsSync: (p: string) => existing.has(p) }));
15
- const { getJjBookmark } = await import("./jj.js");
16
- return { getJjBookmark, execFileSync, execFile, existing };
15
+ const { getJjBookmark, getJjChangedFiles } = await import("./jj.js");
16
+ const events = await import("./vcs-change-events.js");
17
+ return { getJjBookmark, getJjChangedFiles, execFileSync, execFile, existing, events };
17
18
  }
18
19
 
19
20
  describe("getJjBookmark", () => {
@@ -38,6 +39,18 @@ describe("getJjBookmark", () => {
38
39
  expect(getJjBookmark()).toBe(expected);
39
40
  });
40
41
 
42
+ it("parses the jj changed-file count", async () => {
43
+ const { getJjBookmark, getJjChangedFiles } = await load({ output: "@mooqvmmx\nmain\n\t3\n" });
44
+ expect(getJjBookmark()).toBe("main");
45
+ expect(getJjChangedFiles()).toBe(3);
46
+ });
47
+
48
+ it("keeps bookmarks that start with !", async () => {
49
+ const { getJjBookmark, getJjChangedFiles } = await load({ output: "!feature\n\t3\n" });
50
+ expect(getJjBookmark()).toBe("!feature");
51
+ expect(getJjChangedFiles()).toBe(3);
52
+ });
53
+
41
54
  it("returns null when jj fails", async () => {
42
55
  const { getJjBookmark } = await load({ throws: true });
43
56
  expect(getJjBookmark()).toBeNull();
@@ -67,4 +80,57 @@ describe("getJjBookmark", () => {
67
80
  expect(getJjBookmark()).toBe("feature-x");
68
81
  now.mockRestore();
69
82
  });
83
+
84
+ it("publishes a VCS change when a refresh moves the bookmark or the file count", async () => {
85
+ const { getJjBookmark, execFile, events } = await load({ output: "main\n\t1\n" });
86
+ const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
87
+ const listener = vi.fn();
88
+ events.onVcsChange(listener);
89
+
90
+ expect(getJjBookmark()).toBe("main"); // synchronous first call: nothing to announce
91
+ expect(listener).not.toHaveBeenCalled();
92
+
93
+ type Done = (e: Error | null, out: string) => void;
94
+ now.mockReturnValue(10_000);
95
+ getJjBookmark(); // stale while refreshing
96
+ (execFile.mock.calls[0]![3] as Done)(null, "main\n\t4\n"); // same bookmark, more files
97
+ expect(listener).toHaveBeenCalledTimes(1);
98
+
99
+ now.mockReturnValue(20_000);
100
+ getJjBookmark();
101
+ (execFile.mock.calls[1]![3] as Done)(null, "main\n\t4\n"); // identical → silent
102
+ expect(listener).toHaveBeenCalledTimes(1);
103
+ now.mockRestore();
104
+ });
105
+
106
+ it("stays silent on a failed refresh and on a reply that raced a cwd change", async () => {
107
+ // The other repo also looks like a jj repo to the mocked fs, so a call
108
+ // after chdir replaces the cache entry with one keyed to the new cwd.
109
+ const other = "/somewhere/else";
110
+ const { getJjBookmark, getJjChangedFiles, execFile, events } = await load({
111
+ output: "main\n\t1\n",
112
+ paths: [".jj", ".git", `${other}/.jj`],
113
+ });
114
+ const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
115
+ const listener = vi.fn();
116
+ events.onVcsChange(listener);
117
+ getJjBookmark();
118
+
119
+ type Done = (e: Error | null, out: string) => void;
120
+ now.mockReturnValue(10_000);
121
+ getJjBookmark(); // schedules the refresh for the original cwd
122
+ (execFile.mock.calls[0]![3] as Done)(new Error("lock timeout"), "");
123
+ expect(listener).not.toHaveBeenCalled();
124
+
125
+ now.mockReturnValue(20_000);
126
+ getJjBookmark(); // still the original cwd, request in flight
127
+ const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(other);
128
+ expect(getJjBookmark()).toBe("main"); // moved: cache rewritten for the new cwd
129
+ expect(getJjChangedFiles()).toBe(1);
130
+ (execFile.mock.calls[1]![3] as Done)(null, "other-branch\n\t9\n"); // late reply for the old cwd
131
+ expect(getJjBookmark()).toBe("main"); // the dropped reply must not resurface
132
+ cwdSpy.mockRestore();
133
+ expect(listener).not.toHaveBeenCalled();
134
+ now.mockRestore();
135
+ });
70
136
  });
package/src/utils/jj.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { execFile, execFileSync } from "child_process";
2
2
  import { existsSync } from "fs";
3
3
  import { dirname, join } from "path";
4
+ import { notifyVcsChange } from "./vcs-change-events.js";
4
5
 
5
6
  // Same TTL as git status: bookmarks (and repo markers) don't change on every render
6
7
  const JJ_CACHE_TTL_MS = 2_000;
@@ -13,11 +14,12 @@ const JJ_ARGS = [
13
14
  "--no-graph",
14
15
  "--ignore-working-copy",
15
16
  "--color", "never",
16
- "-T", 'if(local_bookmarks, local_bookmarks.map(|b| b.name()).join(" "), "@" ++ change_id.shortest(8)) ++ "\\n"',
17
+ // Count line is tab-tagged (git refs can't contain tabs); it comes from the last snapshot, so it lags until jj snapshots.
18
+ "-T", 'if(local_bookmarks, local_bookmarks.map(|b| b.name()).join(" "), "@" ++ change_id.shortest(8)) ++ "\\n" ++ if(current_working_copy, "\\t" ++ self.diff().files().len() ++ "\\n")',
17
19
  ];
18
20
 
19
21
  let jjRepoCache: { cwd: string; isJj: boolean; timestamp: number } | undefined;
20
- let bookmarkCache: { cwd: string; value: string | null; timestamp: number } | undefined;
22
+ let bookmarkCache: { cwd: string; value: string | null; changedFiles: number; timestamp: number } | undefined;
21
23
  let refreshInFlight = false;
22
24
 
23
25
  function isJjRepo(cwd: string): boolean {
@@ -43,13 +45,19 @@ function isJjRepo(cwd: string): boolean {
43
45
  * Several bookmarks on one commit collapse to `first +N` — footer chunks are
44
46
  * atomic, so an unbounded list could push other sections onto their own lines.
45
47
  */
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;
48
+ function parseJjOutput(output: string): { value: string | null; changedFiles: number } {
49
+ const outputLines = output.split("\n").filter(Boolean);
50
+ const changedFilesLine = outputLines.find((line) => line.startsWith("\t"));
51
+ const changedFiles = Number(changedFilesLine?.slice(1)) || 0;
52
+ const revisionLines = outputLines.filter((line) => !line.startsWith("\t"));
53
+ const bookmarks = revisionLines.find((line) => !line.startsWith("@"));
54
+ if (!bookmarks) return { value: revisionLines[0]?.slice(1) || null, changedFiles };
50
55
  // 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!;
56
+ const [firstBookmark, ...remainingBookmarks] = bookmarks.split(" ");
57
+ return {
58
+ value: remainingBookmarks.length ? `${firstBookmark} +${remainingBookmarks.length}` : firstBookmark!,
59
+ changedFiles,
60
+ };
53
61
  }
54
62
 
55
63
  function refreshInBackground(cwd: string): void {
@@ -57,12 +65,28 @@ function refreshInBackground(cwd: string): void {
57
65
  refreshInFlight = true;
58
66
  execFile("jj", JJ_ARGS, { cwd, encoding: "utf8", timeout: 2_000 }, (error, stdout) => {
59
67
  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() };
68
+ const previous = bookmarkCache;
69
+ if (!previous || previous.cwd !== cwd) return; // cwd moved on; don't clobber the new entry
70
+ // On failure keep stale values (transient lock/timeout shouldn't flash "detached")
71
+ if (error) {
72
+ bookmarkCache = { ...previous, timestamp: Date.now() };
73
+ return;
74
+ }
75
+ const parsedOutput = parseJjOutput(stdout);
76
+ bookmarkCache = { cwd, ...parsedOutput, timestamp: Date.now() };
77
+ // The footer painted the stale bookmark/count and pi draws no frame unless
78
+ // one is requested, so a reply that moved either has to ask for a repaint.
79
+ if (previous.value !== parsedOutput.value || previous.changedFiles !== parsedOutput.changedFiles) {
80
+ notifyVcsChange();
81
+ }
63
82
  });
64
83
  }
65
84
 
85
+ /** @'s changed-file count; only meaningful when getJjBookmark() returned non-null this render. */
86
+ export function getJjChangedFiles(): number {
87
+ return bookmarkCache?.cwd === process.cwd() ? bookmarkCache.changedFiles : 0;
88
+ }
89
+
66
90
  /**
67
91
  * Current jj bookmark for the footer branch section, or null when cwd isn't in
68
92
  * a jj repo or jj can't be run (caller falls back to pi's git branch).
@@ -77,6 +101,7 @@ export function getJjBookmark(): string | null {
77
101
  }
78
102
 
79
103
  let value: string | null = null;
104
+ let changedFiles = 0;
80
105
  try {
81
106
  const output = execFileSync("jj", JJ_ARGS, {
82
107
  cwd,
@@ -84,10 +109,12 @@ export function getJjBookmark(): string | null {
84
109
  stdio: ["ignore", "pipe", "ignore"],
85
110
  timeout: 2_000,
86
111
  });
87
- value = parseJjOutput(output);
112
+ const parsedOutput = parseJjOutput(output);
113
+ value = parsedOutput.value;
114
+ changedFiles = parsedOutput.changedFiles;
88
115
  } catch {
89
116
  /* jj missing or failed */
90
117
  }
91
- bookmarkCache = { cwd, value, timestamp: Date.now() };
118
+ bookmarkCache = { cwd, value, changedFiles, timestamp: Date.now() };
92
119
  return value;
93
120
  }
@@ -85,7 +85,7 @@ function makeCompactionEntry(id: string, usage?: MessageUsage): SessionEntry {
85
85
 
86
86
  function makeCtx(entries: SessionEntry[], contextUsage?: { contextWindow?: number; percent?: number }, modelContextWindow?: number): any {
87
87
  return {
88
- sessionManager: { getEntries: () => entries },
88
+ sessionManager: { getEntries: () => entries, getSessionId: () => "session-1", getLeafId: () => entries.at(-1)?.id ?? null },
89
89
  getContextUsage: () => contextUsage,
90
90
  model: modelContextWindow ? { contextWindow: modelContextWindow } : undefined,
91
91
  };
@@ -366,11 +366,13 @@ describe("getTokenUsageStats", () => {
366
366
 
367
367
  describe("getContextWindowInfo", () => {
368
368
  let getContextWindowInfo: typeof import("./stats.js").getContextWindowInfo;
369
+ let resetStatsState: typeof import("./stats.js").resetStatsState;
369
370
 
370
371
  async function loadModule() {
371
372
  vi.resetModules();
372
373
  const mod = await import("./stats.js");
373
374
  getContextWindowInfo = mod.getContextWindowInfo;
375
+ resetStatsState = mod.resetStatsState;
374
376
  }
375
377
 
376
378
  it("computes percentage correctly", async () => {
@@ -405,4 +407,36 @@ describe("getContextWindowInfo", () => {
405
407
  expect(result.percentValue).toBe(10);
406
408
  expect(result.windowSize).toBe(10000);
407
409
  });
410
+
411
+ it("reuses context usage until the session moves", async () => {
412
+ await loadModule();
413
+ const entries = [makeAssistantEntry({ input: 500, output: 500, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } })];
414
+ const ctx = makeCtx(entries, { contextWindow: 10000, percent: 10 });
415
+ const getContextUsageSpy = vi.spyOn(ctx, "getContextUsage");
416
+
417
+ getContextWindowInfo(ctx);
418
+ getContextWindowInfo(ctx);
419
+ expect(getContextUsageSpy).toHaveBeenCalledTimes(1);
420
+
421
+ entries.push(makeAssistantEntry({ input: 100, output: 100, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } }));
422
+ getContextWindowInfo(ctx);
423
+ expect(getContextUsageSpy).toHaveBeenCalledTimes(2);
424
+ });
425
+
426
+ // Each key alone must invalidate: same entry count, new usage -> new percent
427
+ it.each([
428
+ ["session", (ctx: any) => { ctx.sessionManager.getSessionId = () => "session-2"; }],
429
+ ["leaf (same entry count)", (ctx: any) => { ctx.sessionManager.getLeafId = () => "other-leaf"; }],
430
+ ["model", (ctx: any) => { ctx.model = { contextWindow: 10000 }; }],
431
+ ["reset", () => resetStatsState()],
432
+ ])("recomputes context usage after a %s change", async (_change, applyChange) => {
433
+ await loadModule();
434
+ const ctx = makeCtx([makeAssistantEntry({ input: 500, output: 500, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } })], { contextWindow: 10000, percent: 10 });
435
+ expect(getContextWindowInfo(ctx).percentValue).toBe(10);
436
+
437
+ ctx.getContextUsage = () => ({ contextWindow: 10000, percent: 25 });
438
+ expect(getContextWindowInfo(ctx).percentValue).toBe(10); // cached
439
+ applyChange(ctx);
440
+ expect(getContextWindowInfo(ctx).percentValue).toBe(25);
441
+ });
408
442
  });
@@ -195,6 +195,25 @@ export function getTokenUsageStats(ctx: ExtensionContext): TokenUsageStats {
195
195
  return runningTotal;
196
196
  }
197
197
 
198
+ // getContextUsage() rebuilds the session projection; only recompute when the session moves
199
+ let contextUsageCache:
200
+ | { sessionId: string; leafId: string | null; entryCount: number; model: unknown; value: ReturnType<ExtensionContext["getContextUsage"]> }
201
+ | undefined;
202
+
203
+ function getCachedContextUsage(ctx: ExtensionContext): ReturnType<ExtensionContext["getContextUsage"]> {
204
+ const sessionId = ctx.sessionManager.getSessionId();
205
+ const leafId = ctx.sessionManager.getLeafId();
206
+ const entryCount = ctx.sessionManager.getEntries().length;
207
+ const model = ctx.model;
208
+ const cached = contextUsageCache;
209
+ if (cached && cached.sessionId === sessionId && cached.leafId === leafId && cached.entryCount === entryCount && cached.model === model) {
210
+ return cached.value;
211
+ }
212
+ const value = ctx.getContextUsage();
213
+ contextUsageCache = { sessionId, leafId, entryCount, model, value };
214
+ return value;
215
+ }
216
+
198
217
  /** Clear the stats cache — call when a new message arrives. */
199
218
  export function invalidateStatsCache(): void {
200
219
  statsCache = undefined;
@@ -208,6 +227,7 @@ export function resetStatsState(): void {
208
227
  lastAnchorEntryId = undefined;
209
228
  lastTailUsage = undefined;
210
229
  statsCache = undefined;
230
+ contextUsageCache = undefined;
211
231
  }
212
232
 
213
233
  export interface ContextWindowInfo {
@@ -217,7 +237,7 @@ export interface ContextWindowInfo {
217
237
  }
218
238
 
219
239
  export function getContextWindowInfo(ctx: ExtensionContext): ContextWindowInfo {
220
- const contextUsage = ctx.getContextUsage();
240
+ const contextUsage = getCachedContextUsage(ctx);
221
241
  const modelContextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
222
242
  const tokenStats = getTokenUsageStats(ctx);
223
243
 
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { onVcsChange, notifyVcsChange } from "./vcs-change-events.js";
3
+
4
+ describe("vcs-change-events", () => {
5
+ it("notifies every listener", () => {
6
+ const a = vi.fn();
7
+ const b = vi.fn();
8
+ onVcsChange(a);
9
+ onVcsChange(b);
10
+ notifyVcsChange();
11
+ expect(a).toHaveBeenCalledTimes(1);
12
+ expect(b).toHaveBeenCalledTimes(1);
13
+ });
14
+
15
+ it("stops notifying after unsubscribe", () => {
16
+ const listener = vi.fn();
17
+ const unsubscribe = onVcsChange(listener);
18
+ unsubscribe();
19
+ unsubscribe(); // idempotent
20
+ notifyVcsChange();
21
+ expect(listener).not.toHaveBeenCalled();
22
+ });
23
+
24
+ it("a throwing listener does not break the others or the publisher", () => {
25
+ const bad = vi.fn(() => {
26
+ throw new Error("dead footer");
27
+ });
28
+ const good = vi.fn();
29
+ onVcsChange(bad);
30
+ onVcsChange(good);
31
+ expect(() => notifyVcsChange()).not.toThrow();
32
+ expect(good).toHaveBeenCalledTimes(1);
33
+ });
34
+ });