@pi-archimedes/footer 0.6.0 → 0.8.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 +5 -4
- 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.8.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.8.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
|
@@ -84,7 +84,7 @@ export function registerFooter(pi: ExtensionAPI): void {
|
|
|
84
84
|
worktreeBranch ? colorize("syntaxNumber", footerIcons.worktree + " " + worktreeBranch) : "",
|
|
85
85
|
].filter(Boolean);
|
|
86
86
|
|
|
87
|
-
const separator = theme.fg("dim", "
|
|
87
|
+
const separator = theme.fg("dim", " · ");
|
|
88
88
|
const leftSectionStr = leftSections.join(separator);
|
|
89
89
|
|
|
90
90
|
// Token stats with context percentage
|
|
@@ -124,7 +124,7 @@ export function registerFooter(pi: ExtensionAPI): void {
|
|
|
124
124
|
const rightSections: string[] = [];
|
|
125
125
|
if (statsSectionStr) rightSections.push(statsSectionStr);
|
|
126
126
|
if (contextBarStr) rightSections.push(contextBarStr);
|
|
127
|
-
const rightSectionStr = rightSections.join(theme.fg("dim", "
|
|
127
|
+
const rightSectionStr = rightSections.join(theme.fg("dim", " · "));
|
|
128
128
|
|
|
129
129
|
// Edge case: if both stats and bar are empty, return only line 1
|
|
130
130
|
if (!rightSectionStr) {
|
|
@@ -140,7 +140,7 @@ export function registerFooter(pi: ExtensionAPI): void {
|
|
|
140
140
|
// ── Single-line mode ───────────────────────────────────────────────
|
|
141
141
|
|
|
142
142
|
// Separator between left and right sections
|
|
143
|
-
const sectionSeparator = theme.fg("dim", "
|
|
143
|
+
const sectionSeparator = theme.fg("dim", " · ");
|
|
144
144
|
|
|
145
145
|
// Calculate available space for the context progress bar (after stats)
|
|
146
146
|
const availableBarSpace = Math.max(
|
|
@@ -155,10 +155,11 @@ export function registerFooter(pi: ExtensionAPI): void {
|
|
|
155
155
|
const rightSections: string[] = [];
|
|
156
156
|
if (statsSectionStr) rightSections.push(statsSectionStr);
|
|
157
157
|
if (contextBarStr) rightSections.push(contextBarStr);
|
|
158
|
-
const rightSectionStr = rightSections.join(theme.fg("dim", "
|
|
158
|
+
const rightSectionStr = rightSections.join(theme.fg("dim", " · "));
|
|
159
159
|
|
|
160
160
|
return [clampLine(leftSectionStr + sectionSeparator + rightSectionStr, width)];
|
|
161
161
|
} catch (e) {
|
|
162
|
+
console.error("[archimedes:footer] Render error:", e);
|
|
162
163
|
return [];
|
|
163
164
|
}
|
|
164
165
|
},
|
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
|
}
|