@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/footer",
3
- "version": "0.7.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.7.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 { readFileSync, writeFileSync, existsSync } from "node:fs";
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
- if (existsSync(SETTINGS_PATH)) {
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
- let full: Record<string, unknown> = {};
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
@@ -159,6 +159,7 @@ export function registerFooter(pi: ExtensionAPI): void {
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: 1 second — git status doesn't change on every render
16
- const GIT_CACHE_TTL_MS = 1_000;
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
- const status: GitStatus = { staged: 0, unstaged: 0, untracked: 0, ahead: 0, behind: 0 };
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 gitOutput = execSync("git status --porcelain=v2 -uall", {
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, // Kill hung git processes after 2s
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 = { value: status, timestamp: Date.now() };
99
- return status;
130
+ scheduleGitRefresh();
131
+ return gitStatusCache.value;
100
132
  }
101
133
 
102
134
  export function getWorktreeBranch(): string | null {
@@ -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
- for (const sessionEntry of entries) {
53
- if (sessionEntry.type === "message" && sessionEntry.message.role === "assistant") {
54
- const assistantMessage = sessionEntry.message as AssistantMessage;
55
- totalInput += assistantMessage.usage.input;
56
- totalOutput += assistantMessage.usage.output;
57
- totalCacheRead += assistantMessage.usage.cacheRead;
58
- totalCacheWrite += assistantMessage.usage.cacheWrite;
59
- totalCost += assistantMessage.usage.cost.total;
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
  }