@pi-archimedes/footer 2.0.0 → 2.1.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/README.md +46 -0
- package/package.json +2 -2
- package/src/utils/git.test.ts +123 -0
- package/src/utils/stats.test.ts +229 -0
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# @pi-archimedes/footer
|
|
2
|
+
|
|
3
|
+
A status bar that shows what matters — at a glance, without getting in the way.
|
|
4
|
+
|
|
5
|
+
Your terminal is already full of information. The footer gives you exactly what you need to know about your session — where you are, what model you're using, how many tokens you've burned, and how close you are to the context limit — all in one clean line at the bottom. When subagents run, their costs merge seamlessly into the same view.
|
|
6
|
+
|
|
7
|
+
## What you get
|
|
8
|
+
|
|
9
|
+
- **Session context at a glance** — directory, git branch (with clean/dirty indicator), active model, thinking level, and worktree
|
|
10
|
+
- **Token stats** — input ↑, output ↓, cache read/write, and real-dollar cost, all in one compact display
|
|
11
|
+
- **Context window bar** — color-coded progress bar (green → yellow → red) showing how much of your context window is used
|
|
12
|
+
- **Smart layout** — single line on wide terminals, auto-splits to two lines when space is tight (threshold configurable)
|
|
13
|
+
- **Unified subagent costs** — when subagents run, their token usage and cost merge into the main footer automatically
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install npm:@pi-archimedes/footer
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or install full meta package:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi install npm:pi-archimedes
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
Footer renders automatically at the bottom of the Pi TUI. No commands to run.
|
|
30
|
+
|
|
31
|
+
On wide terminals, the footer displays as a single clean line containing all session context, token usage, cost, and context window progress. When the terminal width falls below the configured split threshold, the footer automatically splits into two lines to keep all information clear and readable without clipping.
|
|
32
|
+
|
|
33
|
+
## Settings
|
|
34
|
+
|
|
35
|
+
| Setting | Type | Default | Description |
|
|
36
|
+
|---------|------|---------|-------------|
|
|
37
|
+
| `splitThreshold` | number | `150` | Minimum terminal width (columns) to display footer on a single line. Below this width, the footer splits into two lines. |
|
|
38
|
+
|
|
39
|
+
Settings are stored in `~/.pi/agent/settings.json` under the `archimedes.footer` namespace.
|
|
40
|
+
|
|
41
|
+
## Integration
|
|
42
|
+
|
|
43
|
+
When installed via meta package, footer consumes cost events from subagents through the core bus, giving a unified token/cost view. Standalone install shows only the main agent's stats.
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
← Back to [pi-archimedes](../../README.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/footer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.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": "2.
|
|
18
|
+
"@pi-archimedes/core": "2.1.0"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
21
|
"@earendil-works/pi-coding-agent": ">=0.1.0",
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import * as fc from "fast-check";
|
|
3
|
+
|
|
4
|
+
// ── Type alias ──────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
interface GitStatus {
|
|
7
|
+
staged: number;
|
|
8
|
+
unstaged: number;
|
|
9
|
+
untracked: number;
|
|
10
|
+
ahead: number;
|
|
11
|
+
behind: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ── Tests (vi.resetModules + dynamic import to isolate module state) ────────
|
|
15
|
+
|
|
16
|
+
describe("getGitStatus", () => {
|
|
17
|
+
let getGitStatus: () => GitStatus;
|
|
18
|
+
let getWorktreeBranch: () => string | null;
|
|
19
|
+
|
|
20
|
+
async function loadModule(mockExecSync: ReturnType<typeof vi.fn>) {
|
|
21
|
+
vi.resetModules();
|
|
22
|
+
|
|
23
|
+
vi.doMock("child_process", () => ({
|
|
24
|
+
execSync: mockExecSync,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.doMock("fs", () => ({
|
|
28
|
+
realpathSync: vi.fn((p: string) => p),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
const mod = await import("./git.js");
|
|
32
|
+
getGitStatus = mod.getGitStatus;
|
|
33
|
+
getWorktreeBranch = mod.getWorktreeBranch;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
vi.restoreAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("empty output returns zero status", async () => {
|
|
41
|
+
const mockExecSync = vi.fn(() => "");
|
|
42
|
+
await loadModule(mockExecSync);
|
|
43
|
+
const result = getGitStatus();
|
|
44
|
+
expect(result).toEqual({ staged: 0, unstaged: 0, untracked: 0, ahead: 0, behind: 0 });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("parse scored format lines", async () => {
|
|
48
|
+
const mockExecSync = vi.fn(() => "1 AM file1.txt\n1 M file2.txt\n");
|
|
49
|
+
await loadModule(mockExecSync);
|
|
50
|
+
const result = getGitStatus();
|
|
51
|
+
// AM: index=A (staged), workTree=M (unstaged) → 1 staged, 1 unstaged
|
|
52
|
+
// " M": index=space (not staged), workTree=M (unstaged) → 1 unstaged
|
|
53
|
+
expect(result.staged).toBe(1);
|
|
54
|
+
expect(result.unstaged).toBe(2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("parse unscored format lines (note: trim() strips leading space)", async () => {
|
|
58
|
+
// TODO: parseGitOutput calls output.trim() which strips leading whitespace.
|
|
59
|
+
// This means unscored lines like " M file.txt" (index=space, workTree=M,
|
|
60
|
+
// meaning unstaged modification) become "M file.txt" after trim, and are
|
|
61
|
+
// incorrectly parsed as index=M (staged), workTree=space.
|
|
62
|
+
// This test codifies the current (buggy) behavior so future fixes to
|
|
63
|
+
// remove trim() won't break silently — update both the implementation
|
|
64
|
+
// and this test together when fixing.
|
|
65
|
+
const mockExecSync = vi.fn(() => "M file.txt\nA staged.txt\n");
|
|
66
|
+
await loadModule(mockExecSync);
|
|
67
|
+
const result = getGitStatus();
|
|
68
|
+
// "M ": index=M (staged), workTree=space → 1 staged
|
|
69
|
+
// "A ": index=A (staged), workTree=space → 1 staged
|
|
70
|
+
expect(result.staged).toBe(2);
|
|
71
|
+
expect(result.unstaged).toBe(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("parse untracked lines", async () => {
|
|
75
|
+
const mockExecSync = vi.fn(() => "? untracked1.txt\n? untracked2.txt\n");
|
|
76
|
+
await loadModule(mockExecSync);
|
|
77
|
+
const result = getGitStatus();
|
|
78
|
+
expect(result.untracked).toBe(2);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("parse branch summary (ahead/behind)", async () => {
|
|
82
|
+
const mockExecSync = vi.fn(() => "## main...origin/main 3 2\n");
|
|
83
|
+
await loadModule(mockExecSync);
|
|
84
|
+
const result = getGitStatus();
|
|
85
|
+
expect(result.ahead).toBe(3);
|
|
86
|
+
expect(result.behind).toBe(2);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("malformed lines ignored gracefully", async () => {
|
|
90
|
+
const mockExecSync = vi.fn(() => "this is not valid\n\n \n1 AM valid.txt\n");
|
|
91
|
+
await loadModule(mockExecSync);
|
|
92
|
+
const result = getGitStatus();
|
|
93
|
+
// Only the valid scored line should be parsed
|
|
94
|
+
expect(result.staged).toBe(1);
|
|
95
|
+
expect(result.unstaged).toBe(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("getWorktreeBranch returns null when not in worktree", async () => {
|
|
99
|
+
const mockExecSync = vi.fn(() => "head ref/heads/main\nworktree /path/to/repo\n");
|
|
100
|
+
await loadModule(mockExecSync);
|
|
101
|
+
const result = getWorktreeBranch();
|
|
102
|
+
expect(result).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("property: getGitStatus never throws (catches execSync errors gracefully)", async () => {
|
|
106
|
+
const mockExecSync = vi.fn(() => { throw new Error("git not found"); });
|
|
107
|
+
await loadModule(mockExecSync);
|
|
108
|
+
expect(() => getGitStatus()).not.toThrow();
|
|
109
|
+
const result = getGitStatus();
|
|
110
|
+
expect(result).toEqual({ staged: 0, unstaged: 0, untracked: 0, ahead: 0, behind: 0 });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("property: parseGitOutput handles arbitrary git porcelain output without throwing", async () => {
|
|
114
|
+
await fc.assert(
|
|
115
|
+
fc.asyncProperty(fc.string({ maxLength: 500 }), async (output) => {
|
|
116
|
+
const mockExecSync = vi.fn(() => output);
|
|
117
|
+
await loadModule(mockExecSync);
|
|
118
|
+
expect(() => getGitStatus()).not.toThrow();
|
|
119
|
+
}),
|
|
120
|
+
{ verbose: false },
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fc from "fast-check";
|
|
3
|
+
|
|
4
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
interface MessageUsage {
|
|
7
|
+
input: number;
|
|
8
|
+
output: number;
|
|
9
|
+
cacheRead: number;
|
|
10
|
+
cacheWrite: number;
|
|
11
|
+
cost: { total: number };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface AssistantMessage {
|
|
15
|
+
role: "assistant";
|
|
16
|
+
usage: MessageUsage;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface UserMessage {
|
|
20
|
+
role: "user";
|
|
21
|
+
content: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface SessionEntry {
|
|
25
|
+
type: "message";
|
|
26
|
+
message: AssistantMessage | UserMessage;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface MockContext {
|
|
30
|
+
sessionManager: {
|
|
31
|
+
getEntries: () => SessionEntry[];
|
|
32
|
+
};
|
|
33
|
+
getContextUsage: () => { contextWindow?: number; percent?: number } | undefined;
|
|
34
|
+
model?: { contextWindow?: number };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeAssistantEntry(usage: MessageUsage): SessionEntry {
|
|
38
|
+
return {
|
|
39
|
+
type: "message",
|
|
40
|
+
message: { role: "assistant", usage },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeUserEntry(): SessionEntry {
|
|
45
|
+
return {
|
|
46
|
+
type: "message",
|
|
47
|
+
message: { role: "user", content: "hello" },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeCtx(entries: SessionEntry[], contextUsage?: { contextWindow?: number; percent?: number }, modelContextWindow?: number): any {
|
|
52
|
+
return {
|
|
53
|
+
sessionManager: { getEntries: () => entries },
|
|
54
|
+
getContextUsage: () => contextUsage,
|
|
55
|
+
model: modelContextWindow ? { contextWindow: modelContextWindow } : undefined,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Tests (vi.resetModules + dynamic import to isolate module state) ────────
|
|
60
|
+
|
|
61
|
+
describe("getTokenUsageStats", () => {
|
|
62
|
+
let getTokenUsageStats: typeof import("./stats.js").getTokenUsageStats;
|
|
63
|
+
let invalidateStatsCache: typeof import("./stats.js").invalidateStatsCache;
|
|
64
|
+
|
|
65
|
+
async function loadModule() {
|
|
66
|
+
vi.resetModules();
|
|
67
|
+
const mod = await import("./stats.js");
|
|
68
|
+
getTokenUsageStats = mod.getTokenUsageStats;
|
|
69
|
+
invalidateStatsCache = mod.invalidateStatsCache;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
it("empty entries returns zero stats", async () => {
|
|
73
|
+
await loadModule();
|
|
74
|
+
const ctx = makeCtx([]);
|
|
75
|
+
const result = getTokenUsageStats(ctx);
|
|
76
|
+
expect(result).toEqual({
|
|
77
|
+
totalInput: 0,
|
|
78
|
+
totalOutput: 0,
|
|
79
|
+
totalCacheRead: 0,
|
|
80
|
+
totalCacheWrite: 0,
|
|
81
|
+
totalCost: 0,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("single assistant message accumulates correctly", async () => {
|
|
86
|
+
await loadModule();
|
|
87
|
+
const usage = { input: 100, output: 50, cacheRead: 200, cacheWrite: 100, cost: { total: 0.05 } };
|
|
88
|
+
const ctx = makeCtx([makeAssistantEntry(usage)]);
|
|
89
|
+
const result = getTokenUsageStats(ctx);
|
|
90
|
+
expect(result).toEqual({
|
|
91
|
+
totalInput: 100,
|
|
92
|
+
totalOutput: 50,
|
|
93
|
+
totalCacheRead: 200,
|
|
94
|
+
totalCacheWrite: 100,
|
|
95
|
+
totalCost: 0.05,
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("multiple messages sum correctly", async () => {
|
|
100
|
+
await loadModule();
|
|
101
|
+
const ctx = makeCtx([
|
|
102
|
+
makeAssistantEntry({ input: 100, output: 50, cacheRead: 10, cacheWrite: 5, cost: { total: 0.01 } }),
|
|
103
|
+
makeAssistantEntry({ input: 200, output: 100, cacheRead: 20, cacheWrite: 10, cost: { total: 0.02 } }),
|
|
104
|
+
]);
|
|
105
|
+
const result = getTokenUsageStats(ctx);
|
|
106
|
+
expect(result).toEqual({
|
|
107
|
+
totalInput: 300,
|
|
108
|
+
totalOutput: 150,
|
|
109
|
+
totalCacheRead: 30,
|
|
110
|
+
totalCacheWrite: 15,
|
|
111
|
+
totalCost: 0.03,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("non-assistant messages ignored", async () => {
|
|
116
|
+
await loadModule();
|
|
117
|
+
const ctx = makeCtx([
|
|
118
|
+
makeUserEntry(),
|
|
119
|
+
makeAssistantEntry({ input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } }),
|
|
120
|
+
makeUserEntry(),
|
|
121
|
+
]);
|
|
122
|
+
const result = getTokenUsageStats(ctx);
|
|
123
|
+
expect(result.totalInput).toBe(100);
|
|
124
|
+
expect(result.totalOutput).toBe(50);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("cache returns same result within TTL", async () => {
|
|
128
|
+
await loadModule();
|
|
129
|
+
const ctx = makeCtx([
|
|
130
|
+
makeAssistantEntry({ input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } }),
|
|
131
|
+
]);
|
|
132
|
+
const first = getTokenUsageStats(ctx);
|
|
133
|
+
const second = getTokenUsageStats(ctx);
|
|
134
|
+
expect(first).toBe(second); // same reference (cached)
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("invalidateStatsCache clears cache", async () => {
|
|
138
|
+
await loadModule();
|
|
139
|
+
const ctx = makeCtx([
|
|
140
|
+
makeAssistantEntry({ input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } }),
|
|
141
|
+
]);
|
|
142
|
+
const first = getTokenUsageStats(ctx);
|
|
143
|
+
invalidateStatsCache();
|
|
144
|
+
const second = getTokenUsageStats(ctx);
|
|
145
|
+
expect(first).toEqual(second);
|
|
146
|
+
expect(first).not.toBe(second); // different reference (cache cleared)
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("property: stats are monotonic (adding entries never decreases totals)", async () => {
|
|
150
|
+
await loadModule();
|
|
151
|
+
fc.assert(
|
|
152
|
+
fc.property(
|
|
153
|
+
fc.array(
|
|
154
|
+
fc.record({
|
|
155
|
+
input: fc.nat({ max: 1000 }),
|
|
156
|
+
output: fc.nat({ max: 1000 }),
|
|
157
|
+
cacheRead: fc.nat({ max: 1000 }),
|
|
158
|
+
cacheWrite: fc.nat({ max: 1000 }),
|
|
159
|
+
cost: fc.double({ min: 0, max: 1 }),
|
|
160
|
+
}),
|
|
161
|
+
),
|
|
162
|
+
(usages) => {
|
|
163
|
+
const entries: SessionEntry[] = [];
|
|
164
|
+
let prev = { totalInput: 0, totalOutput: 0, totalCacheRead: 0, totalCacheWrite: 0, totalCost: 0 };
|
|
165
|
+
for (const u of usages) {
|
|
166
|
+
entries.push(makeAssistantEntry({ input: u.input, output: u.output, cacheRead: u.cacheRead, cacheWrite: u.cacheWrite, cost: { total: u.cost } }));
|
|
167
|
+
const ctx = makeCtx(entries);
|
|
168
|
+
const curr = getTokenUsageStats(ctx);
|
|
169
|
+
// Monotonic: each total >= previous
|
|
170
|
+
if (
|
|
171
|
+
curr.totalInput < prev.totalInput ||
|
|
172
|
+
curr.totalOutput < prev.totalOutput ||
|
|
173
|
+
curr.totalCacheRead < prev.totalCacheRead ||
|
|
174
|
+
curr.totalCacheWrite < prev.totalCacheWrite ||
|
|
175
|
+
curr.totalCost < prev.totalCost
|
|
176
|
+
) {
|
|
177
|
+
throw new Error(`Monotonicity violated: ${JSON.stringify(prev)} -> ${JSON.stringify(curr)}`);
|
|
178
|
+
}
|
|
179
|
+
prev = curr;
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
),
|
|
183
|
+
{ verbose: false },
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("getContextWindowInfo", () => {
|
|
189
|
+
let getContextWindowInfo: typeof import("./stats.js").getContextWindowInfo;
|
|
190
|
+
|
|
191
|
+
async function loadModule() {
|
|
192
|
+
vi.resetModules();
|
|
193
|
+
const mod = await import("./stats.js");
|
|
194
|
+
getContextWindowInfo = mod.getContextWindowInfo;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
it("computes percentage correctly", async () => {
|
|
198
|
+
await loadModule();
|
|
199
|
+
const ctx = makeCtx(
|
|
200
|
+
[makeAssistantEntry({ input: 500, output: 500, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } })],
|
|
201
|
+
{ contextWindow: 10000, percent: 10 },
|
|
202
|
+
);
|
|
203
|
+
const result = getContextWindowInfo(ctx);
|
|
204
|
+
expect(result.percent).toBe("10.0");
|
|
205
|
+
expect(result.percentValue).toBe(10);
|
|
206
|
+
expect(result.windowSize).toBe(10000);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("handles missing context window", async () => {
|
|
210
|
+
await loadModule();
|
|
211
|
+
const ctx = makeCtx([], undefined);
|
|
212
|
+
const result = getContextWindowInfo(ctx);
|
|
213
|
+
expect(result.percent).toBe("?");
|
|
214
|
+
expect(result.percentValue).toBe(0);
|
|
215
|
+
expect(result.windowSize).toBe(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("computes percentage from tokens when contextUsage.percent missing", async () => {
|
|
219
|
+
await loadModule();
|
|
220
|
+
const ctx = makeCtx(
|
|
221
|
+
[makeAssistantEntry({ input: 500, output: 500, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } })],
|
|
222
|
+
{ contextWindow: 10000 },
|
|
223
|
+
);
|
|
224
|
+
const result = getContextWindowInfo(ctx);
|
|
225
|
+
expect(result.percent).toBe("?");
|
|
226
|
+
expect(result.percentValue).toBe(10);
|
|
227
|
+
expect(result.windowSize).toBe(10000);
|
|
228
|
+
});
|
|
229
|
+
});
|