@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/footer",
3
- "version": "0.6.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.6.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 { 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
@@ -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: 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
  }