@agnishc/edb-global-footer 0.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/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +27 -0
- package/package.json +27 -0
- package/src/footer.ts +122 -0
- package/src/format.ts +71 -0
- package/src/git.ts +45 -0
- package/src/index.ts +60 -0
- package/src/types.ts +10 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Agnish Chakraborty
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# @agnishc/edb-global-footer
|
|
2
|
+
|
|
3
|
+
A Pi CLI extension that renders a persistent two-line (plus optional third line) status footer at the bottom of every session.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
~/Developer/my-project main * ↑2 my-session-name
|
|
7
|
+
↑12.4k ↓3.2k R8.1k $0.042 (anthropic) claude-sonnet-4
|
|
8
|
+
⊕ 2 snippets → 3 active ✓ 5 done
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Line 1** — Working directory · git branch (dirty `*`, ahead `↑N`, behind `↓N`) · session name
|
|
12
|
+
|
|
13
|
+
**Line 2** — Token usage (input/output/cache-read/cache-write) · cost · context window % · model name · thinking level
|
|
14
|
+
|
|
15
|
+
**Line 3** — Active extension statuses (shown only when extensions set a status)
|
|
16
|
+
|
|
17
|
+
Git status is refreshed after every turn and on branch change events.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pi install npm:@agnishc/edb-global-footer
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## License
|
|
26
|
+
|
|
27
|
+
[MIT](LICENSE) © Agnish Chakraborty
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agnishc/edb-global-footer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension: two-line status footer showing path, git branch, token usage, and model",
|
|
5
|
+
"keywords": ["pi-package", "pi-extension", "edb"],
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "Agnish Chakraborty",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/agnishcc/pi-extention-monorepo.git",
|
|
12
|
+
"directory": "packages/edb-global-footer"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/agnishcc/pi-extention-monorepo/tree/main/packages/edb-global-footer#readme",
|
|
15
|
+
"bugs": { "url": "https://github.com/agnishcc/pi-extention-monorepo/issues" },
|
|
16
|
+
"publishConfig": { "access": "public" },
|
|
17
|
+
"scripts": { "test": "vitest run" },
|
|
18
|
+
"files": ["src", "README.md", "LICENSE", "CHANGELOG.md"],
|
|
19
|
+
"pi": {
|
|
20
|
+
"extensions": ["./src/index.ts"]
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@mariozechner/pi-ai": "*",
|
|
24
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
25
|
+
"@mariozechner/pi-tui": "*"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/footer.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
|
2
|
+
import { truncateToWidth } from "@mariozechner/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
formatThinkingLabel,
|
|
5
|
+
formatTokens,
|
|
6
|
+
getThinkingLevel,
|
|
7
|
+
renderFooterLine,
|
|
8
|
+
sanitizeStatusText,
|
|
9
|
+
shortenPath,
|
|
10
|
+
} from "./format";
|
|
11
|
+
import type { GitStatus } from "./types";
|
|
12
|
+
|
|
13
|
+
// ── Footer renderer factory ────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Returns the footer descriptor object accepted by ctx.ui.setFooter().
|
|
17
|
+
* Encapsulates all render logic for the two-line status footer.
|
|
18
|
+
*/
|
|
19
|
+
export function createFooterRenderer(ctx: any, getGitStatus: () => GitStatus | null, requestRender: () => void) {
|
|
20
|
+
return (_tui: any, theme: any, footerData: any) => {
|
|
21
|
+
const unsub = footerData.onBranchChange(() => {
|
|
22
|
+
requestRender();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
dispose() {
|
|
27
|
+
unsub();
|
|
28
|
+
},
|
|
29
|
+
invalidate() {},
|
|
30
|
+
render(width: number): string[] {
|
|
31
|
+
const sep = theme.fg("dim", " · ");
|
|
32
|
+
const sessionName = ctx.sessionManager.getSessionName();
|
|
33
|
+
const gitStatus = getGitStatus();
|
|
34
|
+
|
|
35
|
+
// ── Line 1: path · branch · session name ──────────────────────
|
|
36
|
+
const locationParts: string[] = [theme.fg("accent", shortenPath(ctx.cwd))];
|
|
37
|
+
|
|
38
|
+
if (gitStatus?.branch) {
|
|
39
|
+
let gitText = theme.fg(gitStatus.dirty ? "warning" : "success", gitStatus.branch);
|
|
40
|
+
if (gitStatus.dirty) gitText += theme.fg("warning", " *");
|
|
41
|
+
if (gitStatus.ahead) gitText += theme.fg("success", ` ↑${gitStatus.ahead}`);
|
|
42
|
+
if (gitStatus.behind) gitText += theme.fg("error", ` ↓${gitStatus.behind}`);
|
|
43
|
+
locationParts.push(gitText);
|
|
44
|
+
} else {
|
|
45
|
+
const branch = footerData.getGitBranch();
|
|
46
|
+
if (branch) locationParts.push(theme.fg("muted", branch));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (sessionName) locationParts.push(theme.fg("muted", sessionName));
|
|
50
|
+
const locationLine = truncateToWidth(locationParts.join(sep), width);
|
|
51
|
+
|
|
52
|
+
// ── Line 2: token stats · model ───────────────────────────────
|
|
53
|
+
let totalInput = 0;
|
|
54
|
+
let totalOutput = 0;
|
|
55
|
+
let totalCacheRead = 0;
|
|
56
|
+
let totalCacheWrite = 0;
|
|
57
|
+
let totalCost = 0;
|
|
58
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
59
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
60
|
+
const msg = entry.message as AssistantMessage;
|
|
61
|
+
totalInput += msg.usage.input;
|
|
62
|
+
totalOutput += msg.usage.output;
|
|
63
|
+
totalCacheRead += msg.usage.cacheRead;
|
|
64
|
+
totalCacheWrite += msg.usage.cacheWrite;
|
|
65
|
+
totalCost += msg.usage.cost.total;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const statsParts: string[] = [];
|
|
70
|
+
if (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`);
|
|
71
|
+
if (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`);
|
|
72
|
+
if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`);
|
|
73
|
+
if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`);
|
|
74
|
+
|
|
75
|
+
const usingSubscription = ctx.model ? ctx.modelRegistry.isUsingOAuth(ctx.model) : false;
|
|
76
|
+
if (totalCost || usingSubscription) {
|
|
77
|
+
statsParts.push(`$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const contextUsage = ctx.getContextUsage();
|
|
81
|
+
if (contextUsage) {
|
|
82
|
+
const percentValue = contextUsage.percent ?? 0;
|
|
83
|
+
const percent = contextUsage.percent !== null ? `${contextUsage.percent.toFixed(1)}%` : "?";
|
|
84
|
+
const contextWindow = formatTokens(contextUsage.contextWindow);
|
|
85
|
+
const contextText = `${percent}/${contextWindow}`;
|
|
86
|
+
if (percentValue > 90) statsParts.push(theme.fg("error", contextText));
|
|
87
|
+
else if (percentValue > 70) statsParts.push(theme.fg("warning", contextText));
|
|
88
|
+
else statsParts.push(contextText);
|
|
89
|
+
}
|
|
90
|
+
const leftStats = statsParts.join(" ");
|
|
91
|
+
|
|
92
|
+
const model = ctx.model;
|
|
93
|
+
const rightParts: string[] = [];
|
|
94
|
+
if (model) {
|
|
95
|
+
if (model.provider && footerData.getAvailableProviderCount() > 1) {
|
|
96
|
+
rightParts.push(theme.fg("muted", `(${model.provider})`));
|
|
97
|
+
}
|
|
98
|
+
let modelText = theme.bold(model.id || "unknown");
|
|
99
|
+
if (model.reasoning) {
|
|
100
|
+
const thinking = formatThinkingLabel(getThinkingLevel(ctx));
|
|
101
|
+
if (thinking) modelText += `${sep}${theme.fg("dim", thinking)}`;
|
|
102
|
+
}
|
|
103
|
+
rightParts.push(modelText);
|
|
104
|
+
}
|
|
105
|
+
const statsLine = renderFooterLine(width, leftStats, rightParts.join(sep));
|
|
106
|
+
|
|
107
|
+
// ── Line 3 (optional): extension statuses ─────────────────────
|
|
108
|
+
const lines = [locationLine, statsLine];
|
|
109
|
+
const extensionStatuses = footerData.getExtensionStatuses();
|
|
110
|
+
if (extensionStatuses.size > 0) {
|
|
111
|
+
const statusLine = (Array.from(extensionStatuses.entries()) as [string, string][])
|
|
112
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
113
|
+
.map(([, text]) => sanitizeStatusText(text))
|
|
114
|
+
.join(" ");
|
|
115
|
+
lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { buildSessionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
3
|
+
import type { ThinkingLevel } from "./types";
|
|
4
|
+
|
|
5
|
+
// ── Format helpers ─────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export function formatTokens(count: number): string {
|
|
8
|
+
if (count < 1000) return count.toString();
|
|
9
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
10
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
11
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
12
|
+
return `${Math.round(count / 1000000)}M`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function shortenPath(cwd: string): string {
|
|
16
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
17
|
+
if (home && cwd.startsWith(home)) return `~${cwd.slice(home.length)}`;
|
|
18
|
+
return cwd;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getThinkingLevel(ctx: {
|
|
22
|
+
sessionManager: {
|
|
23
|
+
getEntries(): unknown[];
|
|
24
|
+
getLeafId(): string | null;
|
|
25
|
+
};
|
|
26
|
+
}): ThinkingLevel {
|
|
27
|
+
const context = buildSessionContext(ctx.sessionManager.getEntries() as any, ctx.sessionManager.getLeafId());
|
|
28
|
+
return (context.thinkingLevel || "off") as ThinkingLevel;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function formatThinkingLabel(level: ThinkingLevel): string {
|
|
32
|
+
switch (level) {
|
|
33
|
+
case "minimal":
|
|
34
|
+
return "MI";
|
|
35
|
+
case "low":
|
|
36
|
+
return "L";
|
|
37
|
+
case "medium":
|
|
38
|
+
return "M";
|
|
39
|
+
case "high":
|
|
40
|
+
return "H";
|
|
41
|
+
case "xhigh":
|
|
42
|
+
return "XH";
|
|
43
|
+
default:
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function sanitizeStatusText(text: string): string {
|
|
49
|
+
return text
|
|
50
|
+
.replace(/[\r\n\t]/g, " ")
|
|
51
|
+
.replace(/ +/g, " ")
|
|
52
|
+
.trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function renderFooterLine(width: number, left: string, right: string): string {
|
|
56
|
+
const leftWidth = visibleWidth(left);
|
|
57
|
+
const rightWidth = visibleWidth(right);
|
|
58
|
+
const minGap = 2;
|
|
59
|
+
|
|
60
|
+
if (leftWidth + minGap + rightWidth <= width) {
|
|
61
|
+
return left + " ".repeat(width - leftWidth - rightWidth) + right;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (leftWidth + minGap + 10 <= width) {
|
|
65
|
+
const available = width - leftWidth - minGap;
|
|
66
|
+
const truncated = truncateToWidth(right, available, "");
|
|
67
|
+
return left + " ".repeat(Math.max(1, width - leftWidth - visibleWidth(truncated))) + truncated;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return truncateToWidth(left, width, "");
|
|
71
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import type { GitStatus } from "./types";
|
|
3
|
+
|
|
4
|
+
// ── Git ────────────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export function parseGitStatus(output: string): GitStatus {
|
|
7
|
+
let branch: string | null = null;
|
|
8
|
+
let dirty = false;
|
|
9
|
+
let ahead = 0;
|
|
10
|
+
let behind = 0;
|
|
11
|
+
|
|
12
|
+
for (const line of output.split("\n")) {
|
|
13
|
+
if (!line) continue;
|
|
14
|
+
if (line.startsWith("# branch.head ")) {
|
|
15
|
+
const head = line.slice("# branch.head ".length).trim();
|
|
16
|
+
branch = head && head !== "(detached)" ? head : null;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (line.startsWith("# branch.ab ")) {
|
|
20
|
+
const match = line.match(/^# branch\.ab \+(\d+) -(\d+)$/);
|
|
21
|
+
if (match) {
|
|
22
|
+
ahead = Number(match[1]) || 0;
|
|
23
|
+
behind = Number(match[2]) || 0;
|
|
24
|
+
}
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (!line.startsWith("# ")) dirty = true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { branch, dirty, ahead, behind };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readGitStatus(cwd: string): GitStatus | null {
|
|
34
|
+
try {
|
|
35
|
+
const output = execSync("git status --porcelain=v2 --branch 2>/dev/null", {
|
|
36
|
+
cwd,
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
timeout: 1000,
|
|
39
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
40
|
+
}).trimEnd();
|
|
41
|
+
return parseGitStatus(output);
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-global-footer
|
|
3
|
+
*
|
|
4
|
+
* Renders a two-line status footer for every session:
|
|
5
|
+
* Line 1 — working directory · git branch (dirty/ahead/behind) · session name
|
|
6
|
+
* Line 2 — token usage · context window % · model name · thinking level
|
|
7
|
+
* Line 3 — extension statuses (when any are active)
|
|
8
|
+
*
|
|
9
|
+
* Git status is refreshed after every turn and on branch change events.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
13
|
+
import { createFooterRenderer } from "./footer";
|
|
14
|
+
import { readGitStatus } from "./git";
|
|
15
|
+
import type { GitStatus } from "./types";
|
|
16
|
+
|
|
17
|
+
// ── Extension ──────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export default function globalFooterExtension(pi: ExtensionAPI): void {
|
|
20
|
+
let tuiRef: { requestRender: () => void } | null = null;
|
|
21
|
+
let gitStatus: GitStatus | null = null;
|
|
22
|
+
let currentCwd = process.cwd();
|
|
23
|
+
|
|
24
|
+
const requestRender = () => tuiRef?.requestRender();
|
|
25
|
+
const refreshGit = () => {
|
|
26
|
+
gitStatus = readGitStatus(currentCwd);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
30
|
+
if (!ctx.hasUI) return;
|
|
31
|
+
currentCwd = ctx.cwd;
|
|
32
|
+
refreshGit();
|
|
33
|
+
|
|
34
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
35
|
+
tuiRef = tui;
|
|
36
|
+
|
|
37
|
+
const descriptor = createFooterRenderer(ctx, () => gitStatus, requestRender)(tui, theme, footerData);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
dispose() {
|
|
41
|
+
descriptor.dispose();
|
|
42
|
+
if (tuiRef === tui) tuiRef = null;
|
|
43
|
+
},
|
|
44
|
+
invalidate: descriptor.invalidate,
|
|
45
|
+
render: descriptor.render,
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
51
|
+
currentCwd = ctx.cwd;
|
|
52
|
+
refreshGit();
|
|
53
|
+
requestRender();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
pi.on("model_select", async (_event, ctx) => {
|
|
57
|
+
currentCwd = ctx.cwd;
|
|
58
|
+
requestRender();
|
|
59
|
+
});
|
|
60
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
4
|
+
|
|
5
|
+
export interface GitStatus {
|
|
6
|
+
branch: string | null;
|
|
7
|
+
dirty: boolean;
|
|
8
|
+
ahead: number;
|
|
9
|
+
behind: number;
|
|
10
|
+
}
|