@pi-archimedes/footer 0.3.2 → 0.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/footer",
3
- "version": "0.3.2",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -15,7 +15,7 @@
15
15
  "./config": "./src/config.ts"
16
16
  },
17
17
  "dependencies": {
18
- "@pi-archimedes/core": "0.3.2"
18
+ "@pi-archimedes/core": "0.5.0"
19
19
  },
20
20
  "peerDependencies": {
21
21
  "@earendil-works/pi-coding-agent": ">=0.1.0",
package/src/index.ts CHANGED
@@ -16,13 +16,24 @@ import { formatContextBar, formatGitStatusIndicators, formatThinkingIndicator, f
16
16
  import { footerIcons } from "./utils/icons.js";
17
17
 
18
18
  export function registerFooter(pi: ExtensionAPI): void {
19
+ // Module-level state for session lifecycle (shared between session_start and session_shutdown)
20
+ let footerAccumulator: CostAccumulator | undefined;
21
+
22
+ // session_shutdown handler (top-level to prevent accumulation on /reload)
23
+ pi.on("session_shutdown", (_event, _ctx) => {
24
+ if (footerAccumulator) {
25
+ footerAccumulator.dispose();
26
+ footerAccumulator.reset();
27
+ footerAccumulator = undefined;
28
+ }
29
+ });
19
30
 
20
31
  pi.on("session_start", (_event, ctx: ExtensionContext) => {
21
32
  const splitThreshold = loadFooterConfig().splitThreshold;
22
33
 
23
34
  // Create cost accumulator for subagent costs
24
- const accumulator = new CostAccumulator();
25
- accumulator.subscribe();
35
+ footerAccumulator = new CostAccumulator();
36
+ footerAccumulator.subscribe();
26
37
 
27
38
  ctx.ui.setFooter((tui, theme, footerData) => {
28
39
  const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
@@ -42,12 +53,13 @@ export function registerFooter(pi: ExtensionAPI): void {
42
53
 
43
54
  // Merge main agent stats with subagent stats from accumulator
44
55
  const mainStats = getTokenUsageStats(ctx);
56
+ const acc = footerAccumulator;
45
57
  const mergedStats: TokenUsageStats = {
46
- totalInput: mainStats.totalInput + accumulator.inputTokens,
47
- totalOutput: mainStats.totalOutput + accumulator.outputTokens,
48
- totalCacheRead: mainStats.totalCacheRead + accumulator.cacheReadTokens,
49
- totalCacheWrite: mainStats.totalCacheWrite + accumulator.cacheWriteTokens,
50
- totalCost: mainStats.totalCost + accumulator.cost,
58
+ totalInput: mainStats.totalInput + (acc?.inputTokens ?? 0),
59
+ totalOutput: mainStats.totalOutput + (acc?.outputTokens ?? 0),
60
+ totalCacheRead: mainStats.totalCacheRead + (acc?.cacheReadTokens ?? 0),
61
+ totalCacheWrite: mainStats.totalCacheWrite + (acc?.cacheWriteTokens ?? 0),
62
+ totalCost: mainStats.totalCost + (acc?.cost ?? 0),
51
63
  };
52
64
 
53
65
  const { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalCost } = mergedStats;
@@ -152,11 +164,6 @@ export function registerFooter(pi: ExtensionAPI): void {
152
164
  },
153
165
  };
154
166
  });
155
-
156
- pi.on("session_shutdown", (_event, _ctx) => {
157
- accumulator.dispose();
158
- accumulator.reset();
159
- });
160
167
  });
161
168
  }
162
169
 
@@ -1,14 +1,15 @@
1
1
  import { footerIcons, gitDisplayIcons, gitStatusColors, thinkingLevelColors, type ColorFn } from "./icons.js";
2
2
 
3
+ // Token counts use SI units (1K = 1000), not binary (1024)
4
+ const TOKEN_K = 1_000;
5
+ const TOKEN_M = 1_000_000;
6
+
3
7
  export function formatTokenCount(count: number): string {
4
- const K = 1024;
5
- const M = 1048576; // 1024 * 1024
6
-
7
- if (count < K) return count.toString();
8
- if (count < K * 10) return (count / K).toFixed(1) + "k";
9
- if (count < M) return Math.round(count / K) + "k";
10
- if (count < M * 10) return (count / M).toFixed(1) + "M";
11
- return Math.round(count / M) + "M";
8
+ if (count < TOKEN_K) return count.toString();
9
+ if (count < TOKEN_K * 10) return (count / TOKEN_K).toFixed(1) + "k";
10
+ if (count < TOKEN_M) return Math.round(count / TOKEN_K) + "k";
11
+ if (count < TOKEN_M * 10) return (count / TOKEN_M).toFixed(1) + "M";
12
+ return Math.round(count / TOKEN_M) + "M";
12
13
  }
13
14
 
14
15
  export function formatContextBar(colorize: ColorFn, percentValue: number, availableSpace: number): string {
package/src/utils/git.ts CHANGED
@@ -12,6 +12,22 @@ export interface GitStatus {
12
12
  const STAGED_INDEX_STATES = ["A", "M", "D", "R", "C", "U", "T"] as const;
13
13
  const UNSTAGED_WORKTREE_STATES = ["M", "D", "U"] as const;
14
14
 
15
+ // Cache TTL: 1 second — git status doesn't change on every render
16
+ const GIT_CACHE_TTL_MS = 1_000;
17
+
18
+ interface GitCacheEntry {
19
+ value: GitStatus;
20
+ timestamp: number;
21
+ }
22
+
23
+ interface WorktreeCacheEntry {
24
+ value: string | null;
25
+ timestamp: number;
26
+ }
27
+
28
+ let gitStatusCache: GitCacheEntry | undefined;
29
+ let worktreeCache: WorktreeCacheEntry | undefined;
30
+
15
31
  interface FileStates {
16
32
  indexField: string;
17
33
  workTreeField: string;
@@ -20,19 +36,24 @@ interface FileStates {
20
36
  function parseGitStatusLine(line: string): FileStates | null {
21
37
  // Scored format: "<score> XY..."
22
38
  const scoredMatch = line.match(/^\d+ (..) /);
23
- if (scoredMatch) return { indexField: scoredMatch[1][0], workTreeField: scoredMatch[1][1] };
39
+ if (scoredMatch) return { indexField: scoredMatch[1]![0]!, workTreeField: scoredMatch[1]![1]! };
24
40
 
25
41
  // Unscored format: "XY..."
26
42
  const noScoreMatch = line.match(/^(..) /);
27
- if (noScoreMatch) return { indexField: noScoreMatch[1][0], workTreeField: noScoreMatch[1][1] };
43
+ if (noScoreMatch) return { indexField: noScoreMatch[1]![0]!, workTreeField: noScoreMatch[1]![1]! };
28
44
 
29
45
  // Untracked format: "? ..."
30
46
  const untrackedMatch = line.match(/^(.) (.)/);
31
47
  if (!untrackedMatch) return null;
32
- return { indexField: untrackedMatch[1], workTreeField: untrackedMatch[2] };
48
+ return { indexField: untrackedMatch[1]!, workTreeField: untrackedMatch[2]! };
33
49
  }
34
50
 
35
51
  export function getGitStatus(): GitStatus {
52
+ // Return cached result if still fresh
53
+ if (gitStatusCache && Date.now() - gitStatusCache.timestamp < GIT_CACHE_TTL_MS) {
54
+ return gitStatusCache.value;
55
+ }
56
+
36
57
  const status: GitStatus = { staged: 0, unstaged: 0, untracked: 0, ahead: 0, behind: 0 };
37
58
 
38
59
  try {
@@ -40,6 +61,7 @@ export function getGitStatus(): GitStatus {
40
61
  cwd: process.cwd(),
41
62
  encoding: "utf8",
42
63
  stdio: ["pipe", "pipe", "pipe"],
64
+ timeout: 2_000, // Kill hung git processes after 2s
43
65
  });
44
66
 
45
67
  for (const line of gitOutput.trim().split("\n")) {
@@ -73,21 +95,32 @@ export function getGitStatus(): GitStatus {
73
95
  /* not a git repo or command failed */
74
96
  }
75
97
 
98
+ gitStatusCache = { value: status, timestamp: Date.now() };
76
99
  return status;
77
100
  }
78
101
 
79
102
  export function getWorktreeBranch(): string | null {
103
+ // Return cached result if still fresh
104
+ if (worktreeCache && Date.now() - worktreeCache.timestamp < GIT_CACHE_TTL_MS) {
105
+ return worktreeCache.value;
106
+ }
107
+
80
108
  try {
81
109
  const worktreeOutput = execSync("git worktree list --porcelain", {
82
110
  cwd: process.cwd(),
83
111
  encoding: "utf8",
84
112
  stdio: ["pipe", "pipe", "pipe"],
113
+ timeout: 2_000,
85
114
  });
86
115
 
87
116
  const worktreeEntries = worktreeOutput.trim().split("\n\n").filter(Boolean);
88
- if (worktreeEntries.length <= 1) return null;
117
+ if (worktreeEntries.length <= 1) {
118
+ worktreeCache = { value: null, timestamp: Date.now() };
119
+ return null;
120
+ }
89
121
 
90
122
  const currentDirectoryPath = realpathSync(process.cwd());
123
+ let result: string | null = null;
91
124
  for (const entry of worktreeEntries) {
92
125
  const entryLines = entry.split("\n");
93
126
  const pathLine = entryLines.find((l) => l.startsWith("worktree "));
@@ -95,12 +128,15 @@ export function getWorktreeBranch(): string | null {
95
128
  const worktreePath = pathLine?.replace("worktree ", "");
96
129
 
97
130
  if (worktreePath && (currentDirectoryPath === worktreePath || currentDirectoryPath.startsWith(worktreePath + "/"))) {
98
- return branchLine?.replace("branch refs/heads/", "") ?? null;
131
+ result = branchLine?.replace("branch refs/heads/", "") ?? null;
132
+ break;
99
133
  }
100
134
  }
135
+ worktreeCache = { value: result, timestamp: Date.now() };
136
+ return result;
101
137
  } catch {
102
138
  /* not a git repo */
139
+ worktreeCache = { value: null, timestamp: Date.now() };
140
+ return null;
103
141
  }
104
-
105
- return null;
106
142
  }
@@ -20,14 +20,36 @@ export interface TokenUsageStats {
20
20
  totalCost: number;
21
21
  }
22
22
 
23
+ // Cache TTL: 500ms — stats don't change more frequently than message_end events
24
+ const STATS_CACHE_TTL_MS = 500;
25
+
26
+ interface StatsCacheEntry {
27
+ value: TokenUsageStats;
28
+ timestamp: number;
29
+ entryCount: number;
30
+ }
31
+
32
+ let statsCache: StatsCacheEntry | undefined;
33
+
23
34
  export function getTokenUsageStats(ctx: ExtensionContext): TokenUsageStats {
35
+ const entries = ctx.sessionManager.getEntries();
36
+
37
+ // Return cached result if entry count hasn't changed and cache is fresh
38
+ if (
39
+ statsCache &&
40
+ statsCache.entryCount === entries.length &&
41
+ Date.now() - statsCache.timestamp < STATS_CACHE_TTL_MS
42
+ ) {
43
+ return statsCache.value;
44
+ }
45
+
24
46
  let totalInput = 0,
25
47
  totalOutput = 0,
26
48
  totalCacheRead = 0,
27
49
  totalCacheWrite = 0,
28
50
  totalCost = 0;
29
51
 
30
- for (const sessionEntry of ctx.sessionManager.getEntries()) {
52
+ for (const sessionEntry of entries) {
31
53
  if (sessionEntry.type === "message" && sessionEntry.message.role === "assistant") {
32
54
  const assistantMessage = sessionEntry.message as AssistantMessage;
33
55
  totalInput += assistantMessage.usage.input;
@@ -38,7 +60,14 @@ export function getTokenUsageStats(ctx: ExtensionContext): TokenUsageStats {
38
60
  }
39
61
  }
40
62
 
41
- return { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalCost };
63
+ const result: TokenUsageStats = { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalCost };
64
+ statsCache = { value: result, timestamp: Date.now(), entryCount: entries.length };
65
+ return result;
66
+ }
67
+
68
+ /** Clear the stats cache — call when a new message arrives. */
69
+ export function invalidateStatsCache(): void {
70
+ statsCache = undefined;
42
71
  }
43
72
 
44
73
  export interface ContextWindowInfo {