@pi-archimedes/footer 0.3.2 → 0.4.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 +2 -2
- package/src/index.ts +19 -12
- package/src/utils/format.ts +9 -8
- package/src/utils/git.ts +43 -7
- package/src/utils/stats.ts +31 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/footer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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.
|
|
18
|
+
"@pi-archimedes/core": "0.4.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
|
-
|
|
25
|
-
|
|
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 +
|
|
47
|
-
totalOutput: mainStats.totalOutput +
|
|
48
|
-
totalCacheRead: mainStats.totalCacheRead +
|
|
49
|
-
totalCacheWrite: mainStats.totalCacheWrite +
|
|
50
|
-
totalCost: mainStats.totalCost +
|
|
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
|
|
package/src/utils/format.ts
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
if (count <
|
|
8
|
-
|
|
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]
|
|
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]
|
|
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]
|
|
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)
|
|
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
|
-
|
|
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
|
}
|
package/src/utils/stats.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 {
|