@zigai/pi-footer 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/README.md +11 -0
- package/package.json +34 -0
- package/src/constants.ts +29 -0
- package/src/footer-rendering.ts +357 -0
- package/src/index.ts +103 -0
- package/src/types.ts +47 -0
- package/src/worked-for-widget.ts +43 -0
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zigai/pi-footer",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Pi package for a custom footer and worked-for token speed widget.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"footer",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi-ui"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/zigai/pi-ui-tweaks.git",
|
|
14
|
+
"directory": "packages/pi-footer"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"README.md",
|
|
19
|
+
"*.json"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
27
|
+
"@earendil-works/pi-tui": "*"
|
|
28
|
+
},
|
|
29
|
+
"pi": {
|
|
30
|
+
"extensions": [
|
|
31
|
+
"./src/index.ts"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const WIDGET_KEY = "worked-for-widget";
|
|
2
|
+
|
|
3
|
+
export const ACTIVE_FOOTER_VARIANT = "plain" as const;
|
|
4
|
+
export const BRANCH_ICON = "";
|
|
5
|
+
|
|
6
|
+
export const FOOTER_LAYOUT = {
|
|
7
|
+
left: ["path", "branch", "provider", "model", "thinking"],
|
|
8
|
+
right: ["mcp", "context"],
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
export const BLOCK_COLORS = {
|
|
12
|
+
path: { bg: "#222222", fg: "#cccccc" },
|
|
13
|
+
branch: { bg: "#95ffa4", fg: "#222222" },
|
|
14
|
+
provider: { bg: "#24292f", fg: "#ffffff" },
|
|
15
|
+
model: { bg: "#007acc", fg: "#ffffff" },
|
|
16
|
+
thinking: { bg: "#8b5cf6", fg: "#ffffff" },
|
|
17
|
+
mcp: { bg: "#7c3aed", fg: "#ffffff" },
|
|
18
|
+
context: { bg: "#06b6d4", fg: "#062b33" },
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export const PLAIN_COLORS = {
|
|
22
|
+
bg: "#000000",
|
|
23
|
+
fg: "#9ca3af",
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
export const PLAIN_SEPARATOR_COLORS = {
|
|
27
|
+
bg: "#000000",
|
|
28
|
+
fg: "#6b7280",
|
|
29
|
+
} as const;
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ACTIVE_FOOTER_VARIANT,
|
|
7
|
+
BLOCK_COLORS,
|
|
8
|
+
BRANCH_ICON,
|
|
9
|
+
FOOTER_LAYOUT,
|
|
10
|
+
PLAIN_COLORS,
|
|
11
|
+
PLAIN_SEPARATOR_COLORS,
|
|
12
|
+
} from "./constants.ts";
|
|
13
|
+
import type {
|
|
14
|
+
ContextUsage,
|
|
15
|
+
FooterData,
|
|
16
|
+
FooterItem,
|
|
17
|
+
FooterKey,
|
|
18
|
+
FooterSide,
|
|
19
|
+
FooterVariant,
|
|
20
|
+
Rgb,
|
|
21
|
+
SegmentColors,
|
|
22
|
+
} from "./types.ts";
|
|
23
|
+
|
|
24
|
+
function sanitizeStatusText(text: string): string {
|
|
25
|
+
return text
|
|
26
|
+
.replace(/[\r\n\t]/g, " ")
|
|
27
|
+
.replace(/ +/g, " ")
|
|
28
|
+
.trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatTokens(count: number): string {
|
|
32
|
+
if (count < 1000) return count.toString();
|
|
33
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
34
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
35
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
36
|
+
return `${Math.round(count / 1000000)}M`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function collapseHome(path: string): string {
|
|
40
|
+
const home = process.env.HOME ?? process.env.USERPROFILE;
|
|
41
|
+
if (home !== undefined && home.length > 0 && path.startsWith(home)) {
|
|
42
|
+
return `~${path.slice(home.length)}`;
|
|
43
|
+
}
|
|
44
|
+
return path;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function hexToRgb(hex: string): Rgb {
|
|
48
|
+
const value = hex.replace(/^#/, "");
|
|
49
|
+
if (!/^[0-9a-fA-F]{6}$/.test(value)) {
|
|
50
|
+
throw new Error(`Invalid hex color: ${hex}`);
|
|
51
|
+
}
|
|
52
|
+
return [
|
|
53
|
+
parseInt(value.slice(0, 2), 16),
|
|
54
|
+
parseInt(value.slice(2, 4), 16),
|
|
55
|
+
parseInt(value.slice(4, 6), 16),
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function ansiColor(text: string, options: { fg?: string; bg?: string; bold?: boolean }): string {
|
|
60
|
+
const codes: string[] = [];
|
|
61
|
+
|
|
62
|
+
if (options.bold === true) codes.push("1");
|
|
63
|
+
if (options.fg !== undefined && options.fg.length > 0) {
|
|
64
|
+
const [r, g, b] = hexToRgb(options.fg);
|
|
65
|
+
codes.push(`38;2;${r};${g};${b}`);
|
|
66
|
+
}
|
|
67
|
+
if (options.bg !== undefined && options.bg.length > 0) {
|
|
68
|
+
const [r, g, b] = hexToRgb(options.bg);
|
|
69
|
+
codes.push(`48;2;${r};${g};${b}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (codes.length === 0) return text;
|
|
73
|
+
return `\x1b[${codes.join(";")}m${text}\x1b[0m`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function hyperlink(text: string, url: string): string {
|
|
77
|
+
if (!process.stdout.isTTY) {
|
|
78
|
+
return url;
|
|
79
|
+
}
|
|
80
|
+
return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function renderColoredText(text: string, colors: SegmentColors): string {
|
|
84
|
+
return ansiColor(text, { fg: colors.fg, bg: colors.bg });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function renderBlockItem(item: FooterItem): string {
|
|
88
|
+
let linked = ` ${item.text} `;
|
|
89
|
+
if (item.url !== undefined && item.url.length > 0) {
|
|
90
|
+
linked = hyperlink(` ${item.text} `, item.url);
|
|
91
|
+
}
|
|
92
|
+
return renderColoredText(linked, item.colors);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renderPlainItem(item: FooterItem): string {
|
|
96
|
+
let linked = item.text;
|
|
97
|
+
if (item.url !== undefined && item.url.length > 0) {
|
|
98
|
+
linked = hyperlink(item.text, item.url);
|
|
99
|
+
}
|
|
100
|
+
return renderColoredText(linked, PLAIN_COLORS);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getProviderDisplayName(provider: string): string {
|
|
104
|
+
switch (provider) {
|
|
105
|
+
case "github-copilot":
|
|
106
|
+
return "copilot";
|
|
107
|
+
default:
|
|
108
|
+
return provider;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getThinkingColors(level: string): SegmentColors {
|
|
113
|
+
switch (level) {
|
|
114
|
+
case "minimal":
|
|
115
|
+
return { bg: "#4338ca", fg: "#ffffff" };
|
|
116
|
+
case "low":
|
|
117
|
+
return { bg: "#0369a1", fg: "#ffffff" };
|
|
118
|
+
case "medium":
|
|
119
|
+
return { bg: "#0891b2", fg: "#062b33" };
|
|
120
|
+
case "high":
|
|
121
|
+
return { bg: "#8b5cf6", fg: "#ffffff" };
|
|
122
|
+
case "xhigh":
|
|
123
|
+
return { bg: "#dc2626", fg: "#ffffff" };
|
|
124
|
+
case "off":
|
|
125
|
+
default:
|
|
126
|
+
return { bg: "#374151", fg: "#e5e7eb" };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getContextColors(percent: number | null | undefined): SegmentColors {
|
|
131
|
+
if (percent !== null && percent !== undefined) {
|
|
132
|
+
if (percent > 90) return { bg: "#dc2626", fg: "#ffffff" };
|
|
133
|
+
if (percent > 70) return { bg: "#f59e0b", fg: "#1f1300" };
|
|
134
|
+
}
|
|
135
|
+
return { bg: "#06b6d4", fg: "#062b33" };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function getContextText(usage: ContextUsage, fallbackWindow?: number): string {
|
|
139
|
+
const contextWindow = usage?.contextWindow ?? fallbackWindow ?? 0;
|
|
140
|
+
const contextPercent = usage?.percent;
|
|
141
|
+
if (contextPercent === null || contextPercent === undefined) {
|
|
142
|
+
return `?/${formatTokens(contextWindow)}`;
|
|
143
|
+
}
|
|
144
|
+
return `${contextPercent.toFixed(1)}%/${formatTokens(contextWindow)}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function getMcpText(ctx: ExtensionContext, footerData: FooterData): string | null {
|
|
148
|
+
const statuses = Array.from(footerData.getExtensionStatuses().values())
|
|
149
|
+
.map(sanitizeStatusText)
|
|
150
|
+
.filter((status) => status.length > 0);
|
|
151
|
+
|
|
152
|
+
const mcpStatus = statuses.find((status) => /^MCP:/i.test(status));
|
|
153
|
+
if (mcpStatus !== undefined && mcpStatus.length > 0) return mcpStatus;
|
|
154
|
+
|
|
155
|
+
const serverCount = (ctx as ExtensionContext & { mcpServers?: unknown[] }).mcpServers?.length;
|
|
156
|
+
if (typeof serverCount === "number") {
|
|
157
|
+
return `MCP: ${serverCount} servers`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function getSeparator(variant: FooterVariant, side: FooterSide): string {
|
|
164
|
+
if (variant === "blocks") return "";
|
|
165
|
+
if (side === "left") return renderColoredText(" | ", PLAIN_SEPARATOR_COLORS);
|
|
166
|
+
return renderColoredText(" ", PLAIN_SEPARATOR_COLORS);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function renderItem(item: FooterItem, variant: FooterVariant): string {
|
|
170
|
+
if (variant === "blocks") {
|
|
171
|
+
return renderBlockItem(item);
|
|
172
|
+
}
|
|
173
|
+
return renderPlainItem(item);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function joinRenderedItems(rendered: string[], variant: FooterVariant, side: FooterSide): string {
|
|
177
|
+
return rendered.join(getSeparator(variant, side));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function buildSideVariants(
|
|
181
|
+
itemsByKey: Partial<Record<FooterKey, FooterItem>>,
|
|
182
|
+
keys: readonly FooterKey[],
|
|
183
|
+
variant: FooterVariant,
|
|
184
|
+
side: FooterSide,
|
|
185
|
+
): string[] {
|
|
186
|
+
const items = keys
|
|
187
|
+
.map((key) => itemsByKey[key])
|
|
188
|
+
.filter((item): item is FooterItem => item !== undefined);
|
|
189
|
+
if (items.length === 0) {
|
|
190
|
+
return [""];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const variants: string[] = [];
|
|
194
|
+
const seen = new Set<string>();
|
|
195
|
+
|
|
196
|
+
if (side === "left") {
|
|
197
|
+
for (let count = items.length; count >= 1; count--) {
|
|
198
|
+
const rendered = joinRenderedItems(
|
|
199
|
+
items.slice(0, count).map((item) => renderItem(item, variant)),
|
|
200
|
+
variant,
|
|
201
|
+
side,
|
|
202
|
+
);
|
|
203
|
+
if (!seen.has(rendered)) {
|
|
204
|
+
seen.add(rendered);
|
|
205
|
+
variants.push(rendered);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
for (let start = 0; start < items.length; start++) {
|
|
210
|
+
const rendered = joinRenderedItems(
|
|
211
|
+
items.slice(start).map((item) => renderItem(item, variant)),
|
|
212
|
+
variant,
|
|
213
|
+
side,
|
|
214
|
+
);
|
|
215
|
+
if (!seen.has(rendered)) {
|
|
216
|
+
seen.add(rendered);
|
|
217
|
+
variants.push(rendered);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
variants.push("");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return variants;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function renderPadding(width: number, variant: FooterVariant): string {
|
|
227
|
+
if (width <= 0) return "";
|
|
228
|
+
const padding = " ".repeat(width);
|
|
229
|
+
if (variant === "plain") {
|
|
230
|
+
return renderColoredText(padding, PLAIN_COLORS);
|
|
231
|
+
}
|
|
232
|
+
return padding;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function buildFooterItems(
|
|
236
|
+
ctx: ExtensionContext,
|
|
237
|
+
footerData: FooterData,
|
|
238
|
+
thinkingLevel: string,
|
|
239
|
+
): Partial<Record<FooterKey, FooterItem>> {
|
|
240
|
+
const branch = footerData.getGitBranch();
|
|
241
|
+
const pathText = collapseHome(ctx.cwd);
|
|
242
|
+
const pathUrl = pathToFileURL(ctx.cwd).href;
|
|
243
|
+
const providerId = ctx.model?.provider ?? "no-provider";
|
|
244
|
+
const providerLabel = getProviderDisplayName(providerId);
|
|
245
|
+
const modelLabel = ctx.model?.id ?? "no-model";
|
|
246
|
+
const usage = ctx.getContextUsage();
|
|
247
|
+
const contextText = getContextText(usage, ctx.model?.contextWindow);
|
|
248
|
+
const mcpText = getMcpText(ctx, footerData);
|
|
249
|
+
|
|
250
|
+
const items: Partial<Record<FooterKey, FooterItem>> = {
|
|
251
|
+
path: {
|
|
252
|
+
key: "path",
|
|
253
|
+
text: pathText,
|
|
254
|
+
url: pathUrl,
|
|
255
|
+
colors: BLOCK_COLORS.path,
|
|
256
|
+
},
|
|
257
|
+
provider: {
|
|
258
|
+
key: "provider",
|
|
259
|
+
text: providerLabel,
|
|
260
|
+
colors: BLOCK_COLORS.provider,
|
|
261
|
+
},
|
|
262
|
+
model: {
|
|
263
|
+
key: "model",
|
|
264
|
+
text: modelLabel,
|
|
265
|
+
colors: BLOCK_COLORS.model,
|
|
266
|
+
},
|
|
267
|
+
thinking: {
|
|
268
|
+
key: "thinking",
|
|
269
|
+
text: thinkingLevel,
|
|
270
|
+
colors: getThinkingColors(thinkingLevel),
|
|
271
|
+
},
|
|
272
|
+
context: {
|
|
273
|
+
key: "context",
|
|
274
|
+
text: contextText,
|
|
275
|
+
colors: getContextColors(usage?.percent),
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
if (branch !== null && branch.length > 0) {
|
|
280
|
+
items.branch = {
|
|
281
|
+
key: "branch",
|
|
282
|
+
text: `${BRANCH_ICON} ${branch}`,
|
|
283
|
+
colors: BLOCK_COLORS.branch,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (mcpText !== null && mcpText.length > 0) {
|
|
288
|
+
items.mcp = {
|
|
289
|
+
key: "mcp",
|
|
290
|
+
text: mcpText,
|
|
291
|
+
colors: BLOCK_COLORS.mcp,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return items;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function createFooterComponent(
|
|
299
|
+
ctx: ExtensionContext,
|
|
300
|
+
footerData: FooterData,
|
|
301
|
+
getThinkingLevel: () => string,
|
|
302
|
+
requestRender: () => void,
|
|
303
|
+
) {
|
|
304
|
+
const unsubscribe = footerData.onBranchChange(() => requestRender());
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
dispose: unsubscribe,
|
|
308
|
+
invalidate() {},
|
|
309
|
+
render(width: number): string[] {
|
|
310
|
+
// Keep one terminal cell unused as a guard against ambiguous-width glyphs
|
|
311
|
+
// (notably Nerd Font icons like the branch icon) and OSC/ANSI width
|
|
312
|
+
// edge-cases. If the terminal renders one of those glyphs wider than
|
|
313
|
+
// pi-tui's visibleWidth() calculation, a full-width footer can soft-wrap
|
|
314
|
+
// into an apparent blank line and make the bottom chrome jump.
|
|
315
|
+
const renderWidth = Math.max(1, width - 1);
|
|
316
|
+
const variant: FooterVariant = ACTIVE_FOOTER_VARIANT;
|
|
317
|
+
const itemsByKey = buildFooterItems(ctx, footerData, getThinkingLevel());
|
|
318
|
+
const leftVariants = buildSideVariants(itemsByKey, FOOTER_LAYOUT.left, variant, "left");
|
|
319
|
+
const rightVariants = buildSideVariants(
|
|
320
|
+
itemsByKey,
|
|
321
|
+
FOOTER_LAYOUT.right,
|
|
322
|
+
variant,
|
|
323
|
+
"right",
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
for (const left of leftVariants) {
|
|
327
|
+
for (const right of rightVariants) {
|
|
328
|
+
const rightWidth = visibleWidth(right);
|
|
329
|
+
let gap = 0;
|
|
330
|
+
if (right.length > 0) {
|
|
331
|
+
gap = 1;
|
|
332
|
+
}
|
|
333
|
+
const leftWidth = visibleWidth(left);
|
|
334
|
+
|
|
335
|
+
if (leftWidth + gap + rightWidth > renderWidth) {
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const paddingWidth = Math.max(0, renderWidth - leftWidth - rightWidth - 2);
|
|
340
|
+
const padding = renderPadding(paddingWidth, variant);
|
|
341
|
+
if (right.length > 0) {
|
|
342
|
+
return [` ${left}${padding}${right} `];
|
|
343
|
+
}
|
|
344
|
+
return [` ${left}${padding} `];
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const fallbackRight = rightVariants.find((value) => value.length > 0) ?? "";
|
|
349
|
+
if (fallbackRight.length > 0) {
|
|
350
|
+
return [truncateToWidth(fallbackRight, renderWidth, "")];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const fallbackLeft = leftVariants.find((value) => value.length > 0) ?? "";
|
|
354
|
+
return [truncateToWidth(fallbackLeft, renderWidth, "")];
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { createFooterComponent } from "./footer-rendering.ts";
|
|
4
|
+
import { formatDuration, setWorkedForWidget } from "./worked-for-widget.ts";
|
|
5
|
+
|
|
6
|
+
export default function uiEnhancements(pi: ExtensionAPI) {
|
|
7
|
+
let agentStartedAt: number | undefined;
|
|
8
|
+
let messageStart: number | undefined;
|
|
9
|
+
let streamStart: number | undefined;
|
|
10
|
+
let estimatedStreamedTokens = 0;
|
|
11
|
+
let totalOutputTokens = 0;
|
|
12
|
+
let totalStreamMs = 0;
|
|
13
|
+
|
|
14
|
+
const installFooter = (ctx: ExtensionContext) => {
|
|
15
|
+
ctx.ui.setFooter((tui, _theme, footerData) =>
|
|
16
|
+
createFooterComponent(
|
|
17
|
+
ctx,
|
|
18
|
+
footerData,
|
|
19
|
+
() => pi.getThinkingLevel(),
|
|
20
|
+
() => tui.requestRender(),
|
|
21
|
+
),
|
|
22
|
+
);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
26
|
+
installFooter(ctx);
|
|
27
|
+
setWorkedForWidget(ctx, undefined);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
31
|
+
agentStartedAt = Date.now();
|
|
32
|
+
messageStart = undefined;
|
|
33
|
+
streamStart = undefined;
|
|
34
|
+
estimatedStreamedTokens = 0;
|
|
35
|
+
totalOutputTokens = 0;
|
|
36
|
+
totalStreamMs = 0;
|
|
37
|
+
setWorkedForWidget(ctx, undefined);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
pi.on("message_start", async (event) => {
|
|
41
|
+
if (event.message.role !== "assistant") return;
|
|
42
|
+
messageStart = Date.now();
|
|
43
|
+
streamStart = undefined;
|
|
44
|
+
estimatedStreamedTokens = 0;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
pi.on("message_update", async (event) => {
|
|
48
|
+
if (event.message.role !== "assistant") return;
|
|
49
|
+
|
|
50
|
+
const streamEvent = event.assistantMessageEvent;
|
|
51
|
+
const isOutputDelta =
|
|
52
|
+
streamEvent.type === "text_delta" ||
|
|
53
|
+
streamEvent.type === "thinking_delta" ||
|
|
54
|
+
streamEvent.type === "toolcall_delta";
|
|
55
|
+
if (!isOutputDelta) return;
|
|
56
|
+
|
|
57
|
+
streamStart ??= Date.now();
|
|
58
|
+
estimatedStreamedTokens += Math.max(0, streamEvent.delta.length / 4);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
pi.on("message_end", async (event) => {
|
|
62
|
+
if (event.message.role !== "assistant") return;
|
|
63
|
+
|
|
64
|
+
const outputTokens = event.message.usage.output;
|
|
65
|
+
const timingStart = streamStart ?? messageStart;
|
|
66
|
+
if (timingStart === undefined || outputTokens <= 0) {
|
|
67
|
+
messageStart = undefined;
|
|
68
|
+
streamStart = undefined;
|
|
69
|
+
estimatedStreamedTokens = 0;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
totalOutputTokens += outputTokens;
|
|
74
|
+
totalStreamMs += Math.max(0, Date.now() - timingStart);
|
|
75
|
+
|
|
76
|
+
messageStart = undefined;
|
|
77
|
+
streamStart = undefined;
|
|
78
|
+
estimatedStreamedTokens = 0;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
82
|
+
if (agentStartedAt === undefined) return;
|
|
83
|
+
const duration = Date.now() - agentStartedAt;
|
|
84
|
+
const elapsedSeconds = totalStreamMs / 1000;
|
|
85
|
+
let tokensPerSecond: number | undefined;
|
|
86
|
+
if (totalOutputTokens > 0 && elapsedSeconds > 0) {
|
|
87
|
+
tokensPerSecond = Math.round(totalOutputTokens / elapsedSeconds);
|
|
88
|
+
}
|
|
89
|
+
agentStartedAt = undefined;
|
|
90
|
+
setWorkedForWidget(ctx, formatDuration(duration), tokensPerSecond);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
94
|
+
agentStartedAt = undefined;
|
|
95
|
+
messageStart = undefined;
|
|
96
|
+
streamStart = undefined;
|
|
97
|
+
estimatedStreamedTokens = 0;
|
|
98
|
+
totalOutputTokens = 0;
|
|
99
|
+
totalStreamMs = 0;
|
|
100
|
+
ctx.ui.setFooter(undefined);
|
|
101
|
+
setWorkedForWidget(ctx, undefined);
|
|
102
|
+
});
|
|
103
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type PauseInterval = {
|
|
4
|
+
start: number;
|
|
5
|
+
end?: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type LoaderPauseApi = {
|
|
9
|
+
pause(): void;
|
|
10
|
+
resume(): void;
|
|
11
|
+
isPaused(): boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type LoaderPauseGlobal = typeof globalThis & {
|
|
15
|
+
__piLoaderPauseIntervals__?: PauseInterval[];
|
|
16
|
+
__piLoaderPauseDepth__?: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type PatchedLoader = {
|
|
20
|
+
message?: string;
|
|
21
|
+
currentFrame?: number;
|
|
22
|
+
dotsIntervalId?: ReturnType<typeof setInterval> | null;
|
|
23
|
+
timeIntervalId?: ReturnType<typeof setInterval> | null;
|
|
24
|
+
ui?: { requestRender(): void } | null;
|
|
25
|
+
messageColorFn?: (text: string) => string;
|
|
26
|
+
setText(text: string): void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type FooterVariant = "blocks" | "plain";
|
|
30
|
+
export type FooterSide = "left" | "right";
|
|
31
|
+
export type FooterKey = "path" | "branch" | "provider" | "model" | "thinking" | "mcp" | "context";
|
|
32
|
+
export type Rgb = [number, number, number];
|
|
33
|
+
export type SegmentColors = { bg: string; fg: string };
|
|
34
|
+
export type ContextUsage = ReturnType<ExtensionContext["getContextUsage"]>;
|
|
35
|
+
|
|
36
|
+
export type FooterData = {
|
|
37
|
+
getGitBranch(): string | null;
|
|
38
|
+
getExtensionStatuses(): ReadonlyMap<string, string>;
|
|
39
|
+
onBranchChange(callback: () => void): () => void;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type FooterItem = {
|
|
43
|
+
key: FooterKey;
|
|
44
|
+
text: string;
|
|
45
|
+
url?: string;
|
|
46
|
+
colors: SegmentColors;
|
|
47
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
import { WIDGET_KEY } from "./constants.ts";
|
|
5
|
+
|
|
6
|
+
export function formatDuration(ms: number): string {
|
|
7
|
+
const wholeSeconds = Math.max(0, Math.round(ms / 1000));
|
|
8
|
+
if (wholeSeconds < 60) return `${wholeSeconds}s`;
|
|
9
|
+
|
|
10
|
+
const minutes = Math.floor(wholeSeconds / 60);
|
|
11
|
+
const remainingSeconds = wholeSeconds % 60;
|
|
12
|
+
if (minutes < 60) return `${minutes}m ${remainingSeconds.toString().padStart(2, "0")}s`;
|
|
13
|
+
|
|
14
|
+
const hours = Math.floor(minutes / 60);
|
|
15
|
+
const remainingMinutes = minutes % 60;
|
|
16
|
+
return `${hours}h ${remainingMinutes.toString().padStart(2, "0")}m`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function setWorkedForWidget(
|
|
20
|
+
ctx: ExtensionContext,
|
|
21
|
+
workedForText?: string,
|
|
22
|
+
tokensPerSecond?: number,
|
|
23
|
+
): void {
|
|
24
|
+
if (ctx.hasUI !== true) return;
|
|
25
|
+
|
|
26
|
+
if (workedForText === undefined || workedForText.length === 0) {
|
|
27
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
ctx.ui.setWidget(WIDGET_KEY, (_tui, theme) => ({
|
|
32
|
+
render(width: number): string[] {
|
|
33
|
+
if (width <= 0) return [""];
|
|
34
|
+
let text = `Worked for ${workedForText}.`;
|
|
35
|
+
if (tokensPerSecond !== undefined && tokensPerSecond > 0) {
|
|
36
|
+
text = `${text} [${tokensPerSecond} tok/s]`;
|
|
37
|
+
}
|
|
38
|
+
const truncated = truncateToWidth(text, Math.max(0, width - 1), "");
|
|
39
|
+
return [theme.fg("dim", ` ${truncated}`)];
|
|
40
|
+
},
|
|
41
|
+
invalidate() {},
|
|
42
|
+
}));
|
|
43
|
+
}
|