@albizures/pi-footer-status 0.1.1
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/README.md +44 -0
- package/extensions/index.test.ts +124 -0
- package/extensions/index.ts +303 -0
- package/package.json +28 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# @albizures/pi-footer-status
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 8b46ddd: Replace the scaffolded footer status extension with an always-on default-like Pi footer that adds a Context fill bar.
|
|
8
|
+
- 8b46ddd: Add the initial Pi footer status extension package scaffold.
|
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @albizures/pi-footer-status
|
|
2
|
+
|
|
3
|
+
A tiny Pi package that replaces Pi's footer with a default-like custom footer and adds a dedicated Context fill bar.
|
|
4
|
+
|
|
5
|
+
After installation, the package is always on. It is no longer a no-op scaffold, and it does not add slash commands or configuration.
|
|
6
|
+
|
|
7
|
+
## Behavior
|
|
8
|
+
|
|
9
|
+
The custom footer keeps the default-like Pi footer information:
|
|
10
|
+
|
|
11
|
+
- current working directory, git branch, and session name
|
|
12
|
+
- cumulative input/output token usage
|
|
13
|
+
- cache read/write stats and cache hit percentage when available
|
|
14
|
+
- cumulative cost or subscription-backed usage marker when available
|
|
15
|
+
- textual current context percentage on the stats line
|
|
16
|
+
- active model, provider when useful, and thinking level for reasoning models
|
|
17
|
+
|
|
18
|
+
It mirrors these default-like footer lines, then preserves extension status messages after the Context fill bar.
|
|
19
|
+
|
|
20
|
+
It inserts one additional Context fill bar line before extension statuses:
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
context ████████░░░░░░░░░░░░ 40.0%/128k
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The Context fill bar:
|
|
27
|
+
|
|
28
|
+
- is labeled `context`
|
|
29
|
+
- represents Pi's current context usage, not cumulative token totals
|
|
30
|
+
- uses Pi's public current context usage API
|
|
31
|
+
- is fixed at 20 cells
|
|
32
|
+
- uses `█` for filled cells and `░` for empty cells
|
|
33
|
+
- rounds the fill amount to the nearest cell
|
|
34
|
+
- shows at least one filled cell for any known non-zero usage
|
|
35
|
+
- uses the theme accent color normally, warning above 70%, and error above 90%
|
|
36
|
+
- keeps empty cells dim
|
|
37
|
+
- appends the numeric `percent/contextWindow` suffix on the same line
|
|
38
|
+
- does not include `(auto)` on the bar line
|
|
39
|
+
|
|
40
|
+
When current context usage is unknown, the footer renders a static placeholder instead of guessing:
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
context ░░░░░░░░░░░░░░░░░░░░ ?/128k
|
|
44
|
+
```
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { renderContextFillBar, renderFooter } from "./index.ts";
|
|
5
|
+
|
|
6
|
+
const identityTheme = {
|
|
7
|
+
fg: (_color: string, text: string) => text,
|
|
8
|
+
bold: (text: string) => text,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const taggedTheme = {
|
|
12
|
+
fg: (color: string, text: string) => `<${color}>${text}</${color}>`,
|
|
13
|
+
bold: (text: string) => `<bold>${text}</bold>`,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function assistantEntry(usage: Partial<any>) {
|
|
17
|
+
return {
|
|
18
|
+
type: "message",
|
|
19
|
+
message: {
|
|
20
|
+
role: "assistant",
|
|
21
|
+
usage: {
|
|
22
|
+
input: 1200,
|
|
23
|
+
output: 345,
|
|
24
|
+
cacheRead: 0,
|
|
25
|
+
cacheWrite: 0,
|
|
26
|
+
cost: { total: 0 },
|
|
27
|
+
...usage,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
} as any;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function baseState(overrides: Record<string, unknown> = {}) {
|
|
34
|
+
return {
|
|
35
|
+
cwd: "/home/alice/work/repo",
|
|
36
|
+
home: "/home/alice",
|
|
37
|
+
sessionName: "named-session",
|
|
38
|
+
gitBranch: "main",
|
|
39
|
+
extensionStatuses: new Map<string, string>(),
|
|
40
|
+
availableProviderCount: 1,
|
|
41
|
+
model: { id: "gpt-test", provider: "openai", contextWindow: 128000, reasoning: true },
|
|
42
|
+
thinkingLevel: "medium",
|
|
43
|
+
contextUsage: { tokens: 51200, contextWindow: 128000, percent: 40 },
|
|
44
|
+
entries: [assistantEntry({ cacheRead: 100, cacheWrite: 50, cost: { total: 0.0123 } })],
|
|
45
|
+
...overrides,
|
|
46
|
+
} as any;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test("renderFooter preserves default-like footer lines and inserts the Context fill bar before sorted extension statuses", () => {
|
|
50
|
+
const lines = renderFooter(
|
|
51
|
+
baseState({ extensionStatuses: new Map([["z-last", "second"], ["a-first", "first"]]) }),
|
|
52
|
+
identityTheme,
|
|
53
|
+
120,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
assert.equal(lines.length, 4);
|
|
57
|
+
assert.equal(lines[0], "~/work/repo (main) • named-session");
|
|
58
|
+
assert.match(lines[1], /↑1\.2k ↓345 R100 W50 CH7\.4% \$0\.012 40\.0%\/128k \(auto\)\s+gpt-test • medium/);
|
|
59
|
+
assert.equal(lines[2], "context ████████░░░░░░░░░░░░ 40.0%/128k");
|
|
60
|
+
assert.equal(lines[3], "first second");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("renderContextFillBar uses fixed 20-cell known-usage bars with required rounding behavior", () => {
|
|
64
|
+
const cases = [
|
|
65
|
+
{ percent: 0, expected: "░░░░░░░░░░░░░░░░░░░░ 0.0%/100k" },
|
|
66
|
+
{ percent: 1, expected: "█░░░░░░░░░░░░░░░░░░░ 1.0%/100k" },
|
|
67
|
+
{ percent: 40, expected: "████████░░░░░░░░░░░░ 40.0%/100k" },
|
|
68
|
+
{ percent: 99.6, expected: "████████████████████ 99.6%/100k" },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
for (const { percent, expected } of cases) {
|
|
72
|
+
assert.equal(renderContextFillBar({ percent, contextWindow: 100000 }, identityTheme, 120), `context ${expected}`);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("renderContextFillBar applies severity colors at the same thresholds as Pi context text", () => {
|
|
77
|
+
const normal = renderContextFillBar({ percent: 40, contextWindow: 100000 }, taggedTheme, 200);
|
|
78
|
+
assert.match(normal, /<accent>█/);
|
|
79
|
+
assert.match(normal, /<dim>░/);
|
|
80
|
+
assert.match(normal, /<accent>40\.0%\/100k<\/accent>/);
|
|
81
|
+
|
|
82
|
+
const warning = renderContextFillBar({ percent: 70.1, contextWindow: 100000 }, taggedTheme, 200);
|
|
83
|
+
assert.match(warning, /<warning>█/);
|
|
84
|
+
assert.match(warning, /<dim>░/);
|
|
85
|
+
assert.match(warning, /<warning>70\.1%\/100k<\/warning>/);
|
|
86
|
+
|
|
87
|
+
assert.match(renderContextFillBar({ percent: 90.1, contextWindow: 100000 }, taggedTheme, 200), /<error>█/);
|
|
88
|
+
assert.match(renderContextFillBar({ percent: 90.1, contextWindow: 100000 }, taggedTheme, 200), /<error>90\.1%\/100k<\/error>/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("renderContextFillBar renders unknown usage as a static dim placeholder with ? percentage", () => {
|
|
92
|
+
const line = renderContextFillBar({ percent: null, contextWindow: 200000 }, identityTheme, 120);
|
|
93
|
+
|
|
94
|
+
assert.equal(line, "context ░░░░░░░░░░░░░░░░░░░░ ?/200k");
|
|
95
|
+
|
|
96
|
+
const styledLine = renderContextFillBar({ percent: undefined, contextWindow: 200000 }, taggedTheme, 200);
|
|
97
|
+
assert.match(styledLine, /<dim>░░░░░░░░░░░░░░░░░░░░<\/dim>/);
|
|
98
|
+
assert.match(styledLine, /\?\/200k/);
|
|
99
|
+
assert.doesNotMatch(styledLine, /<accent>|<warning>|<error>/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("renderFooter renders unknown Context fill bar line without auto-compaction text", () => {
|
|
103
|
+
const lines = renderFooter(baseState({ contextUsage: { tokens: 0, contextWindow: 128000, percent: null } }), identityTheme, 120);
|
|
104
|
+
|
|
105
|
+
assert.equal(lines[2], "context ░░░░░░░░░░░░░░░░░░░░ ?/128k");
|
|
106
|
+
assert.doesNotMatch(lines[2], /auto/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("renderFooter truncates all rendered lines to terminal width", () => {
|
|
110
|
+
const lines = renderFooter(
|
|
111
|
+
baseState({
|
|
112
|
+
cwd: "/home/alice/a/very/long/path/to/a/repository",
|
|
113
|
+
sessionName: "a very long session name",
|
|
114
|
+
extensionStatuses: new Map([["status", "a very long extension status that must be truncated"]]),
|
|
115
|
+
}),
|
|
116
|
+
identityTheme,
|
|
117
|
+
24,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
assert.ok(lines.length >= 4);
|
|
121
|
+
for (const line of lines) {
|
|
122
|
+
assert.ok(visibleWidth(line) <= 24, `${line} exceeded width`);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import type { ContextUsage, ExtensionAPI, ExtensionContext, ReadonlyFooterDataProvider } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
const BAR_WIDTH = 20;
|
|
6
|
+
const WARNING_PERCENT = 70;
|
|
7
|
+
const ERROR_PERCENT = 90;
|
|
8
|
+
|
|
9
|
+
type ThemeColor = "accent" | "dim" | "error" | "warning";
|
|
10
|
+
|
|
11
|
+
type FooterTheme = {
|
|
12
|
+
fg(color: ThemeColor | string, text: string): string;
|
|
13
|
+
bold(text: string): string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type FooterModel = {
|
|
17
|
+
id: string;
|
|
18
|
+
provider: string;
|
|
19
|
+
contextWindow: number;
|
|
20
|
+
reasoning?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type FooterState = {
|
|
24
|
+
cwd: string;
|
|
25
|
+
sessionName?: string;
|
|
26
|
+
gitBranch: string | null;
|
|
27
|
+
extensionStatuses: ReadonlyMap<string, string>;
|
|
28
|
+
availableProviderCount: number;
|
|
29
|
+
model?: FooterModel;
|
|
30
|
+
thinkingLevel?: string;
|
|
31
|
+
contextUsage?: ContextUsage;
|
|
32
|
+
entries: ExtensionContext["sessionManager"] extends { getEntries(): infer Entries } ? Entries : never;
|
|
33
|
+
usingSubscription?: boolean;
|
|
34
|
+
experimental?: boolean;
|
|
35
|
+
home?: string;
|
|
36
|
+
autoCompactEnabled?: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type FooterUsage = {
|
|
40
|
+
input?: number;
|
|
41
|
+
output?: number;
|
|
42
|
+
cacheRead?: number;
|
|
43
|
+
cacheWrite?: number;
|
|
44
|
+
cost?: { total?: number };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type UsageTotals = {
|
|
48
|
+
input: number;
|
|
49
|
+
output: number;
|
|
50
|
+
cacheRead: number;
|
|
51
|
+
cacheWrite: number;
|
|
52
|
+
cost: number;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export default function (pi: ExtensionAPI) {
|
|
56
|
+
let requestRender: (() => void) | undefined;
|
|
57
|
+
|
|
58
|
+
pi.on("session_start", (_event, ctx) => {
|
|
59
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
60
|
+
requestRender = () => tui.requestRender();
|
|
61
|
+
const disposeBranch = footerData.onBranchChange(() => tui.requestRender());
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
dispose() {
|
|
65
|
+
disposeBranch();
|
|
66
|
+
requestRender = undefined;
|
|
67
|
+
},
|
|
68
|
+
invalidate() {},
|
|
69
|
+
render(width: number): string[] {
|
|
70
|
+
return renderFooter(
|
|
71
|
+
collectFooterState(ctx, footerData, process.env.HOME || process.env.USERPROFILE),
|
|
72
|
+
theme,
|
|
73
|
+
width,
|
|
74
|
+
);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const rerender = () => {
|
|
81
|
+
requestRender?.();
|
|
82
|
+
};
|
|
83
|
+
pi.on("model_select", rerender);
|
|
84
|
+
pi.on("thinking_level_select", rerender);
|
|
85
|
+
pi.on("session_info_changed", rerender);
|
|
86
|
+
pi.on("message_end", rerender);
|
|
87
|
+
pi.on("agent_settled", rerender);
|
|
88
|
+
pi.on("session_compact", rerender);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function collectFooterState(
|
|
92
|
+
ctx: ExtensionContext,
|
|
93
|
+
footerData: ReadonlyFooterDataProvider,
|
|
94
|
+
home: string | undefined,
|
|
95
|
+
): FooterState {
|
|
96
|
+
const model = ctx.model
|
|
97
|
+
? {
|
|
98
|
+
id: ctx.model.id,
|
|
99
|
+
provider: ctx.model.provider,
|
|
100
|
+
contextWindow: ctx.model.contextWindow,
|
|
101
|
+
reasoning: ctx.model.reasoning,
|
|
102
|
+
}
|
|
103
|
+
: undefined;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
cwd: ctx.sessionManager.getCwd(),
|
|
107
|
+
sessionName: ctx.sessionManager.getSessionName(),
|
|
108
|
+
gitBranch: footerData.getGitBranch(),
|
|
109
|
+
extensionStatuses: footerData.getExtensionStatuses(),
|
|
110
|
+
availableProviderCount: footerData.getAvailableProviderCount(),
|
|
111
|
+
model,
|
|
112
|
+
thinkingLevel: ctx.thinkingLevel,
|
|
113
|
+
contextUsage: ctx.getContextUsage(),
|
|
114
|
+
entries: ctx.sessionManager.getEntries(),
|
|
115
|
+
usingSubscription: ctx.model
|
|
116
|
+
? ctx.model.provider === "kimi-coding" || ctx.modelRegistry.isUsingOAuth(ctx.model)
|
|
117
|
+
: false,
|
|
118
|
+
experimental: process.env.PI_EXPERIMENTAL === "1",
|
|
119
|
+
home,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function renderFooter(state: FooterState, theme: FooterTheme, width: number): string[] {
|
|
124
|
+
const usage = getUsageStats(state.entries);
|
|
125
|
+
const contextUsage = state.contextUsage;
|
|
126
|
+
const contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;
|
|
127
|
+
const contextPercentValue = contextUsage?.percent ?? 0;
|
|
128
|
+
const contextPercent = contextUsage?.percent !== null && contextUsage?.percent !== undefined
|
|
129
|
+
? contextPercentValue.toFixed(1)
|
|
130
|
+
: "?";
|
|
131
|
+
|
|
132
|
+
let pwd = formatCwdForFooter(state.cwd, state.home);
|
|
133
|
+
if (state.gitBranch) pwd = `${pwd} (${state.gitBranch})`;
|
|
134
|
+
if (state.sessionName) pwd = `${pwd} • ${state.sessionName}`;
|
|
135
|
+
|
|
136
|
+
const statsParts: string[] = [];
|
|
137
|
+
if (usage.totals.input) statsParts.push(`↑${formatTokens(usage.totals.input)}`);
|
|
138
|
+
if (usage.totals.output) statsParts.push(`↓${formatTokens(usage.totals.output)}`);
|
|
139
|
+
if (usage.totals.cacheRead) statsParts.push(`R${formatTokens(usage.totals.cacheRead)}`);
|
|
140
|
+
if (usage.totals.cacheWrite) statsParts.push(`W${formatTokens(usage.totals.cacheWrite)}`);
|
|
141
|
+
if ((usage.totals.cacheRead > 0 || usage.totals.cacheWrite > 0) && usage.latestCacheHitRate !== undefined) {
|
|
142
|
+
statsParts.push(`CH${usage.latestCacheHitRate.toFixed(1)}%`);
|
|
143
|
+
}
|
|
144
|
+
if (usage.totals.cost || state.usingSubscription) {
|
|
145
|
+
statsParts.push(`$${usage.totals.cost.toFixed(3)}${state.usingSubscription ? " (sub)" : ""}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const autoIndicator = state.autoCompactEnabled === false ? "" : " (auto)";
|
|
149
|
+
const contextPercentDisplay = contextPercent === "?"
|
|
150
|
+
? `?/${formatTokens(contextWindow)}${autoIndicator}`
|
|
151
|
+
: `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;
|
|
152
|
+
statsParts.push(colorByContextSeverity(contextPercentValue, contextPercentDisplay, theme));
|
|
153
|
+
if (state.experimental) statsParts.push(`${theme.fg("dim", "•")} ${theme.bold(theme.fg("warning", "xp"))}`);
|
|
154
|
+
|
|
155
|
+
const statsLine = renderStatsLine(statsParts.join(" "), state, theme, width);
|
|
156
|
+
const contextBarLine = renderContextFillBar({ percent: contextUsage?.percent, contextWindow }, theme, width);
|
|
157
|
+
|
|
158
|
+
const lines = [
|
|
159
|
+
truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "...")),
|
|
160
|
+
statsLine,
|
|
161
|
+
contextBarLine,
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
const statuses = renderExtensionStatuses(state.extensionStatuses, theme, width);
|
|
165
|
+
if (statuses) lines.push(statuses);
|
|
166
|
+
return lines;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function renderStatsLine(statsLeftInput: string, state: FooterState, theme: FooterTheme, width: number): string {
|
|
170
|
+
let statsLeft = statsLeftInput;
|
|
171
|
+
let statsLeftWidth = visibleWidth(statsLeft);
|
|
172
|
+
if (statsLeftWidth > width) {
|
|
173
|
+
statsLeft = truncateToWidth(statsLeft, width, "...");
|
|
174
|
+
statsLeftWidth = visibleWidth(statsLeft);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const modelName = state.model?.id || "no-model";
|
|
178
|
+
let rightSideWithoutProvider = modelName;
|
|
179
|
+
if (state.model?.reasoning) {
|
|
180
|
+
const thinkingLevel = state.thinkingLevel || "off";
|
|
181
|
+
rightSideWithoutProvider = thinkingLevel === "off" ? `${modelName} • thinking off` : `${modelName} • ${thinkingLevel}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let rightSide = rightSideWithoutProvider;
|
|
185
|
+
if (state.availableProviderCount > 1 && state.model) {
|
|
186
|
+
rightSide = `(${state.model.provider}) ${rightSideWithoutProvider}`;
|
|
187
|
+
if (statsLeftWidth + 2 + visibleWidth(rightSide) > width) rightSide = rightSideWithoutProvider;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const rightSideWidth = visibleWidth(rightSide);
|
|
191
|
+
let statsLine: string;
|
|
192
|
+
if (statsLeftWidth + 2 + rightSideWidth <= width) {
|
|
193
|
+
statsLine = statsLeft + " ".repeat(width - statsLeftWidth - rightSideWidth) + rightSide;
|
|
194
|
+
} else {
|
|
195
|
+
const availableForRight = width - statsLeftWidth - 2;
|
|
196
|
+
if (availableForRight > 0) {
|
|
197
|
+
const truncatedRight = truncateToWidth(rightSide, availableForRight, "");
|
|
198
|
+
statsLine = statsLeft + " ".repeat(Math.max(0, width - statsLeftWidth - visibleWidth(truncatedRight))) + truncatedRight;
|
|
199
|
+
} else {
|
|
200
|
+
statsLine = statsLeft;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return theme.fg("dim", statsLeft) + theme.fg("dim", statsLine.slice(statsLeft.length));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function renderContextFillBar(
|
|
208
|
+
usage: { percent: number | null | undefined; contextWindow: number },
|
|
209
|
+
theme: FooterTheme,
|
|
210
|
+
width: number,
|
|
211
|
+
): string {
|
|
212
|
+
if (usage.percent === null || usage.percent === undefined) {
|
|
213
|
+
const placeholder = theme.fg("dim", "░".repeat(BAR_WIDTH));
|
|
214
|
+
return truncateToWidth(`context ${placeholder} ?/${formatTokens(usage.contextWindow)}`, width, theme.fg("dim", "..."));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const clampedPercent = Math.max(0, Math.min(100, usage.percent));
|
|
218
|
+
let filled = Math.round((clampedPercent / 100) * BAR_WIDTH);
|
|
219
|
+
if (clampedPercent > 0) filled = Math.max(1, filled);
|
|
220
|
+
if (clampedPercent >= 99.5) filled = BAR_WIDTH;
|
|
221
|
+
|
|
222
|
+
const filledCells = "█".repeat(filled);
|
|
223
|
+
const emptyCells = "░".repeat(BAR_WIDTH - filled);
|
|
224
|
+
const severityColor = clampedPercent > ERROR_PERCENT ? "error" : clampedPercent > WARNING_PERCENT ? "warning" : "accent";
|
|
225
|
+
const numeric = `${usage.percent.toFixed(1)}%/${formatTokens(usage.contextWindow)}`;
|
|
226
|
+
const line = `context ${theme.fg(severityColor, filledCells)}${theme.fg("dim", emptyCells)} ${theme.fg(severityColor, numeric)}`;
|
|
227
|
+
return truncateToWidth(line, width, theme.fg("dim", "..."));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function renderExtensionStatuses(statuses: ReadonlyMap<string, string>, theme: FooterTheme, width: number): string | undefined {
|
|
231
|
+
if (statuses.size === 0) return undefined;
|
|
232
|
+
const statusLine = Array.from(statuses.entries())
|
|
233
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
234
|
+
.map(([, text]) => sanitizeStatusText(text))
|
|
235
|
+
.join(" ");
|
|
236
|
+
return truncateToWidth(statusLine, width, theme.fg("dim", "..."));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function getUsageStats(entries: FooterState["entries"]): { totals: UsageTotals; latestCacheHitRate?: number } {
|
|
240
|
+
const totals: UsageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
241
|
+
let latestCacheHitRate: number | undefined;
|
|
242
|
+
|
|
243
|
+
for (const entry of entries) {
|
|
244
|
+
const usage = getEntryUsage(entry);
|
|
245
|
+
if (!usage) continue;
|
|
246
|
+
addUsage(totals, usage);
|
|
247
|
+
|
|
248
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
249
|
+
const cacheRead = usage.cacheRead ?? 0;
|
|
250
|
+
const promptTokens = (usage.input ?? 0) + cacheRead + (usage.cacheWrite ?? 0);
|
|
251
|
+
latestCacheHitRate = promptTokens > 0 ? (cacheRead / promptTokens) * 100 : undefined;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { totals, latestCacheHitRate };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function getEntryUsage(entry: FooterState["entries"][number]): FooterUsage | undefined {
|
|
259
|
+
if (entry.type === "message") {
|
|
260
|
+
if (entry.message.role === "assistant") return entry.message.usage;
|
|
261
|
+
if (entry.message.role === "toolResult") return entry.message.usage;
|
|
262
|
+
}
|
|
263
|
+
if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) return entry.usage;
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function addUsage(totals: UsageTotals, usage: FooterUsage): void {
|
|
268
|
+
totals.input += usage.input ?? 0;
|
|
269
|
+
totals.output += usage.output ?? 0;
|
|
270
|
+
totals.cacheRead += usage.cacheRead ?? 0;
|
|
271
|
+
totals.cacheWrite += usage.cacheWrite ?? 0;
|
|
272
|
+
totals.cost += usage.cost?.total ?? 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function colorByContextSeverity(percent: number, text: string, theme: FooterTheme): string {
|
|
276
|
+
if (percent > ERROR_PERCENT) return theme.fg("error", text);
|
|
277
|
+
if (percent > WARNING_PERCENT) return theme.fg("warning", text);
|
|
278
|
+
return text;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function sanitizeStatusText(text: string): string {
|
|
282
|
+
return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function formatTokens(count: number): string {
|
|
286
|
+
if (count < 1000) return count.toString();
|
|
287
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
288
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
289
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
290
|
+
return `${Math.round(count / 1000000)}M`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function formatCwdForFooter(cwd: string, home: string | undefined): string {
|
|
294
|
+
if (!home) return cwd;
|
|
295
|
+
const resolvedCwd = resolve(cwd);
|
|
296
|
+
const resolvedHome = resolve(home);
|
|
297
|
+
const relativeToHome = relative(resolvedHome, resolvedCwd);
|
|
298
|
+
const isInsideHome =
|
|
299
|
+
relativeToHome === "" ||
|
|
300
|
+
(relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome));
|
|
301
|
+
if (!isInsideHome) return cwd;
|
|
302
|
+
return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`;
|
|
303
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@albizures/pi-footer-status",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Pi package for customizing the footer status area.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"extension",
|
|
9
|
+
"footer",
|
|
10
|
+
"status"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"pi": {
|
|
18
|
+
"extensions": [
|
|
19
|
+
"./extensions"
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@earendil-works/pi-tui": "^0.82.0"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
27
|
+
}
|
|
28
|
+
}
|