@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 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.0.0",
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.0.0"
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
+ });