@pi-archimedes/footer 0.7.0 → 0.9.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/config.ts +5 -24
- package/src/index.ts +1 -0
- package/src/utils/git.ts +68 -36
- package/src/utils/stats.ts +43 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/footer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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.9.0"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
21
|
"@earendil-works/pi-coding-agent": ">=0.1.0",
|
package/src/config.ts
CHANGED
|
@@ -1,41 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import { loadConfig, saveConfig } from "@pi-archimedes/core/settings-io";
|
|
4
2
|
import type { SettingItem } from "@earendil-works/pi-tui";
|
|
5
3
|
|
|
6
4
|
export interface FooterConfig {
|
|
7
5
|
splitThreshold: number;
|
|
8
6
|
}
|
|
9
7
|
|
|
10
|
-
const SETTINGS_PATH = join(getAgentDir(), "settings.json");
|
|
11
|
-
|
|
12
8
|
export const DEFAULT_FOOTER_CONFIG: FooterConfig = {
|
|
13
9
|
splitThreshold: 150,
|
|
14
10
|
};
|
|
15
11
|
|
|
12
|
+
const NAMESPACE = "archimedes.footer";
|
|
13
|
+
|
|
16
14
|
export function loadFooterConfig(): FooterConfig {
|
|
17
|
-
|
|
18
|
-
try {
|
|
19
|
-
const full = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
|
|
20
|
-
return { ...DEFAULT_FOOTER_CONFIG, ...(full["archimedes.footer"] ?? {}) };
|
|
21
|
-
} catch {
|
|
22
|
-
/* ignore corrupt file */
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
return DEFAULT_FOOTER_CONFIG;
|
|
15
|
+
return loadConfig(NAMESPACE, DEFAULT_FOOTER_CONFIG);
|
|
26
16
|
}
|
|
27
17
|
|
|
28
18
|
export function saveFooterConfig(config: FooterConfig): void {
|
|
29
|
-
|
|
30
|
-
if (existsSync(SETTINGS_PATH)) {
|
|
31
|
-
try {
|
|
32
|
-
full = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
|
|
33
|
-
} catch {
|
|
34
|
-
/* ignore corrupt file */
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
full["archimedes.footer"] = config;
|
|
38
|
-
writeFileSync(SETTINGS_PATH, JSON.stringify(full, null, 2), "utf-8");
|
|
19
|
+
saveConfig(NAMESPACE, config);
|
|
39
20
|
}
|
|
40
21
|
|
|
41
22
|
export function getFooterSettingsItems(): SettingItem[] {
|
package/src/index.ts
CHANGED
package/src/utils/git.ts
CHANGED
|
@@ -12,8 +12,8 @@ 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:
|
|
16
|
-
const GIT_CACHE_TTL_MS =
|
|
15
|
+
// Cache TTL: 2 seconds — git status doesn't change on every render
|
|
16
|
+
const GIT_CACHE_TTL_MS = 2_000;
|
|
17
17
|
|
|
18
18
|
interface GitCacheEntry {
|
|
19
19
|
value: GitStatus;
|
|
@@ -27,6 +27,7 @@ interface WorktreeCacheEntry {
|
|
|
27
27
|
|
|
28
28
|
let gitStatusCache: GitCacheEntry | undefined;
|
|
29
29
|
let worktreeCache: WorktreeCacheEntry | undefined;
|
|
30
|
+
let gitRefreshTimer: ReturnType<typeof setTimeout> | undefined;
|
|
30
31
|
|
|
31
32
|
interface FileStates {
|
|
32
33
|
indexField: string;
|
|
@@ -48,55 +49,86 @@ function parseGitStatusLine(line: string): FileStates | null {
|
|
|
48
49
|
return { indexField: untrackedMatch[1]!, workTreeField: untrackedMatch[2]! };
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
function parseGitOutput(output: string): GitStatus {
|
|
53
|
+
const status: GitStatus = { staged: 0, unstaged: 0, untracked: 0, ahead: 0, behind: 0 };
|
|
54
|
+
|
|
55
|
+
for (const line of output.trim().split("\n")) {
|
|
56
|
+
// Branch summary: "## branch_name ... upstream ahead behind"
|
|
57
|
+
if (/^## /.test(line)) {
|
|
58
|
+
const branchParts = line.slice(3).trim().split(/\s+/);
|
|
59
|
+
if (branchParts.length >= 3) {
|
|
60
|
+
const commitsAhead = Number(branchParts[branchParts.length - 2]);
|
|
61
|
+
const commitsBehind = Number(branchParts[branchParts.length - 1]);
|
|
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
|
+
}
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const fileStates = parseGitStatusLine(line);
|
|
71
|
+
if (!fileStates) continue;
|
|
72
|
+
|
|
73
|
+
if (STAGED_INDEX_STATES.includes(fileStates.indexField as unknown as typeof STAGED_INDEX_STATES[number])) {
|
|
74
|
+
status.staged++;
|
|
75
|
+
}
|
|
76
|
+
if (fileStates.indexField === "?") {
|
|
77
|
+
status.untracked++;
|
|
78
|
+
} else if (UNSTAGED_WORKTREE_STATES.includes(fileStates.workTreeField as unknown as typeof UNSTAGED_WORKTREE_STATES[number])) {
|
|
79
|
+
status.unstaged++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return status;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Schedule a background git status refresh (debounced). */
|
|
86
|
+
function scheduleGitRefresh(): void {
|
|
87
|
+
if (gitRefreshTimer) return; // Already scheduled
|
|
88
|
+
gitRefreshTimer = setTimeout(() => {
|
|
89
|
+
gitRefreshTimer = undefined;
|
|
90
|
+
try {
|
|
91
|
+
const output = execSync("git status --porcelain=v2 -uall", {
|
|
92
|
+
cwd: process.cwd(),
|
|
93
|
+
encoding: "utf8",
|
|
94
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
95
|
+
timeout: 2_000,
|
|
96
|
+
});
|
|
97
|
+
gitStatusCache = { value: parseGitOutput(output), timestamp: Date.now() };
|
|
98
|
+
} catch {
|
|
99
|
+
/* not a git repo or command failed — keep stale cache */
|
|
100
|
+
}
|
|
101
|
+
}, 0);
|
|
102
|
+
}
|
|
103
|
+
|
|
51
104
|
export function getGitStatus(): GitStatus {
|
|
52
105
|
// Return cached result if still fresh
|
|
53
106
|
if (gitStatusCache && Date.now() - gitStatusCache.timestamp < GIT_CACHE_TTL_MS) {
|
|
54
107
|
return gitStatusCache.value;
|
|
55
108
|
}
|
|
56
109
|
|
|
57
|
-
|
|
110
|
+
// Return stale cache while background refresh kicks in
|
|
111
|
+
if (gitStatusCache) {
|
|
112
|
+
scheduleGitRefresh();
|
|
113
|
+
return gitStatusCache.value;
|
|
114
|
+
}
|
|
58
115
|
|
|
116
|
+
// First call — sync fetch + schedule background refresh
|
|
117
|
+
const emptyStatus: GitStatus = { staged: 0, unstaged: 0, untracked: 0, ahead: 0, behind: 0 };
|
|
59
118
|
try {
|
|
60
|
-
const
|
|
119
|
+
const output = execSync("git status --porcelain=v2 -uall", {
|
|
61
120
|
cwd: process.cwd(),
|
|
62
121
|
encoding: "utf8",
|
|
63
122
|
stdio: ["pipe", "pipe", "pipe"],
|
|
64
|
-
timeout: 2_000,
|
|
123
|
+
timeout: 2_000,
|
|
65
124
|
});
|
|
66
|
-
|
|
67
|
-
for (const line of gitOutput.trim().split("\n")) {
|
|
68
|
-
// Branch summary: "## branch_name ... upstream ahead behind"
|
|
69
|
-
if (/^## /.test(line)) {
|
|
70
|
-
const branchParts = line.slice(3).trim().split(/\s+/);
|
|
71
|
-
if (branchParts.length >= 3) {
|
|
72
|
-
const commitsAhead = Number(branchParts[branchParts.length - 2]);
|
|
73
|
-
const commitsBehind = Number(branchParts[branchParts.length - 1]);
|
|
74
|
-
if (!isNaN(commitsAhead) && !isNaN(commitsBehind) && branchParts[branchParts.length - 3]) {
|
|
75
|
-
status.ahead = Math.max(0, commitsAhead);
|
|
76
|
-
status.behind = Math.max(0, commitsBehind);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const fileStates = parseGitStatusLine(line);
|
|
83
|
-
if (!fileStates) continue;
|
|
84
|
-
|
|
85
|
-
if (STAGED_INDEX_STATES.includes(fileStates.indexField as unknown as typeof STAGED_INDEX_STATES[number])) {
|
|
86
|
-
status.staged++;
|
|
87
|
-
}
|
|
88
|
-
if (fileStates.indexField === "?") {
|
|
89
|
-
status.untracked++;
|
|
90
|
-
} else if (UNSTAGED_WORKTREE_STATES.includes(fileStates.workTreeField as unknown as typeof UNSTAGED_WORKTREE_STATES[number])) {
|
|
91
|
-
status.unstaged++;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
125
|
+
gitStatusCache = { value: parseGitOutput(output), timestamp: Date.now() };
|
|
94
126
|
} catch {
|
|
95
127
|
/* not a git repo or command failed */
|
|
128
|
+
gitStatusCache = { value: emptyStatus, timestamp: Date.now() };
|
|
96
129
|
}
|
|
97
|
-
|
|
98
|
-
gitStatusCache
|
|
99
|
-
return status;
|
|
130
|
+
scheduleGitRefresh();
|
|
131
|
+
return gitStatusCache.value;
|
|
100
132
|
}
|
|
101
133
|
|
|
102
134
|
export function getWorktreeBranch(): string | null {
|
package/src/utils/stats.ts
CHANGED
|
@@ -31,6 +31,10 @@ interface StatsCacheEntry {
|
|
|
31
31
|
|
|
32
32
|
let statsCache: StatsCacheEntry | undefined;
|
|
33
33
|
|
|
34
|
+
// Running total from initial scan — avoids re-scanning old entries
|
|
35
|
+
let runningTotal: TokenUsageStats | undefined;
|
|
36
|
+
let runningTotalEntryCount = 0;
|
|
37
|
+
|
|
34
38
|
export function getTokenUsageStats(ctx: ExtensionContext): TokenUsageStats {
|
|
35
39
|
const entries = ctx.sessionManager.getEntries();
|
|
36
40
|
|
|
@@ -49,18 +53,49 @@ export function getTokenUsageStats(ctx: ExtensionContext): TokenUsageStats {
|
|
|
49
53
|
totalCacheWrite = 0,
|
|
50
54
|
totalCost = 0;
|
|
51
55
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
// If we have a running total, only scan new entries
|
|
57
|
+
const hasRunningTotal = runningTotal !== undefined && runningTotalEntryCount < entries.length;
|
|
58
|
+
const startIdx = hasRunningTotal ? runningTotalEntryCount : 0;
|
|
59
|
+
|
|
60
|
+
if (startIdx === 0) {
|
|
61
|
+
// Full scan — no running total yet
|
|
62
|
+
for (const sessionEntry of entries) {
|
|
63
|
+
if (sessionEntry?.type === "message" && sessionEntry.message?.role === "assistant") {
|
|
64
|
+
const assistantMessage = sessionEntry.message as AssistantMessage;
|
|
65
|
+
totalInput += assistantMessage.usage.input;
|
|
66
|
+
totalOutput += assistantMessage.usage.output;
|
|
67
|
+
totalCacheRead += assistantMessage.usage.cacheRead;
|
|
68
|
+
totalCacheWrite += assistantMessage.usage.cacheWrite;
|
|
69
|
+
totalCost += assistantMessage.usage.cost.total;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
runningTotal = { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalCost };
|
|
73
|
+
runningTotalEntryCount = entries.length;
|
|
74
|
+
} else {
|
|
75
|
+
// Incremental: start from running total + scan new entries
|
|
76
|
+
const rt = runningTotal!; // Safe: hasRunningTotal guard above
|
|
77
|
+
totalInput = rt.totalInput;
|
|
78
|
+
totalOutput = rt.totalOutput;
|
|
79
|
+
totalCacheRead = rt.totalCacheRead;
|
|
80
|
+
totalCacheWrite = rt.totalCacheWrite;
|
|
81
|
+
totalCost = rt.totalCost;
|
|
82
|
+
|
|
83
|
+
for (let i = startIdx; i < entries.length; i++) {
|
|
84
|
+
const sessionEntry = entries[i];
|
|
85
|
+
if (sessionEntry?.type === "message" && sessionEntry.message?.role === "assistant") {
|
|
86
|
+
const assistantMessage = sessionEntry.message as AssistantMessage;
|
|
87
|
+
totalInput += assistantMessage.usage.input;
|
|
88
|
+
totalOutput += assistantMessage.usage.output;
|
|
89
|
+
totalCacheRead += assistantMessage.usage.cacheRead;
|
|
90
|
+
totalCacheWrite += assistantMessage.usage.cacheWrite;
|
|
91
|
+
totalCost += assistantMessage.usage.cost.total;
|
|
92
|
+
}
|
|
60
93
|
}
|
|
94
|
+
runningTotalEntryCount = entries.length;
|
|
61
95
|
}
|
|
62
96
|
|
|
63
97
|
const result: TokenUsageStats = { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalCost };
|
|
98
|
+
runningTotal = result;
|
|
64
99
|
statsCache = { value: result, timestamp: Date.now(), entryCount: entries.length };
|
|
65
100
|
return result;
|
|
66
101
|
}
|