detailed-footer.ts
7734 bytes
1import { isAbsolute, relative, resolve, sep } from "node:path";
2import type {
3 ExtensionAPI,
4 ExtensionContext,
5} from "@earendil-works/pi-coding-agent";
6import {
7 truncateToWidth,
8 visibleWidth,
9 wrapTextWithAnsi,
10} from "@earendil-works/pi-tui";
11
12type Usage = {
13 input?: number;
14 output?: number;
15 cacheRead?: number;
16 cacheWrite?: number;
17 cost?: { total?: number };
18};
19
20type UsageTotals = {
21 input: number;
22 output: number;
23 cacheRead: number;
24 cacheWrite: number;
25 cost: number;
26};
27
28const formatTokens = (count: number): string => {
29 if (count < 1_000) return count.toString();
30 if (count < 10_000) return `${(count / 1_000).toFixed(1)}k`;
31 if (count < 1_000_000) return `${Math.round(count / 1_000)}k`;
32 if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
33 return `${Math.round(count / 1_000_000)}M`;
34};
35
36const addUsage = (totals: UsageTotals, usage: Usage | undefined): void => {
37 if (usage === undefined) return;
38 totals.input += usage.input ?? 0;
39 totals.output += usage.output ?? 0;
40 totals.cacheRead += usage.cacheRead ?? 0;
41 totals.cacheWrite += usage.cacheWrite ?? 0;
42 totals.cost += usage.cost?.total ?? 0;
43};
44
45const getUsageTotals = (ctx: ExtensionContext): UsageTotals => {
46 const totals: UsageTotals = {
47 input: 0,
48 output: 0,
49 cacheRead: 0,
50 cacheWrite: 0,
51 cost: 0,
52 };
53
54 for (const entry of ctx.sessionManager.getEntries()) {
55 if (entry.type === "message") {
56 if (
57 entry.message.role === "assistant" ||
58 entry.message.role === "toolResult"
59 ) {
60 addUsage(totals, entry.message.usage);
61 }
62 } else if (entry.type === "branch_summary" || entry.type === "compaction") {
63 addUsage(totals, entry.usage);
64 }
65 }
66
67 return totals;
68};
69
70const formatCwd = (cwd: string): string => {
71 const home = process.env.HOME ?? process.env.USERPROFILE;
72 if (home === undefined) return cwd;
73
74 const relativeToHome = relative(resolve(home), resolve(cwd));
75 const isInsideHome =
76 relativeToHome === "" ||
77 (relativeToHome !== ".." &&
78 !relativeToHome.startsWith(`..${sep}`) &&
79 !isAbsolute(relativeToHome));
80
81 return isInsideHome
82 ? relativeToHome === ""
83 ? "~"
84 : `~${sep}${relativeToHome}`
85 : cwd;
86};
87
88const isSubscription = (ctx: ExtensionContext): boolean => {
89 const model = ctx.model;
90 if (model === undefined) return false;
91 if (model.provider === "kimi-coding") return true;
92
93 return (
94 ctx.modelRegistry.isUsingOAuth(model) &&
95 ctx.modelRegistry.getProvider(model.provider)?.auth.oauth
96 ?.isSubscription === true
97 );
98};
99
100const sanitizeStatus = (status: string): string =>
101 status
102 .replace(/[\r\n\t]/g, " ")
103 .replace(/ +/g, " ")
104 .trim();
105
106export default function detailedFooter(pi: ExtensionAPI): void {
107 pi.on("session_start", (_event, ctx) => {
108 if (ctx.mode !== "tui") return;
109
110 ctx.ui.setFooter((tui, theme, footerData) => ({
111 dispose: footerData.onBranchChange(() => tui.requestRender()),
112 invalidate() {},
113 render(width: number): string[] {
114 let location = formatCwd(ctx.cwd);
115 const branch = footerData.getGitBranch();
116 const sessionName = ctx.sessionManager.getSessionName();
117 if (branch) location += ` (${branch})`;
118 if (sessionName) location += ` • ${sessionName}`;
119
120 const totals = getUsageTotals(ctx);
121 const metrics: string[] = [];
122 const inputOutput = [
123 totals.input > 0
124 ? `${theme.fg("dim", "↑")}${theme.fg("text", formatTokens(totals.input))}`
125 : undefined,
126 totals.output > 0
127 ? `${theme.fg("dim", "↓")}${theme.fg("text", formatTokens(totals.output))}`
128 : undefined,
129 ].filter((metric): metric is string => metric !== undefined);
130 if (inputOutput.length > 0) metrics.push(inputOutput.join(" "));
131
132 let latestCacheHitRate: string | undefined;
133 const latestAssistant = [...ctx.sessionManager.getEntries()]
134 .reverse()
135 .find(
136 (entry) =>
137 entry.type === "message" && entry.message.role === "assistant",
138 );
139 if (
140 latestAssistant?.type === "message" &&
141 latestAssistant.message.role === "assistant"
142 ) {
143 const usage = latestAssistant.message.usage;
144 const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
145 if (
146 promptTokens > 0 &&
147 (totals.cacheRead > 0 || totals.cacheWrite > 0)
148 ) {
149 latestCacheHitRate = `${((usage.cacheRead / promptTokens) * 100).toFixed(1)}%`;
150 }
151 }
152
153 if (totals.cacheRead > 0) {
154 const cacheHitRate = latestCacheHitRate
155 ? theme.fg("dim", ` (${latestCacheHitRate})`)
156 : "";
157 metrics.push(
158 `${theme.fg("muted", "cache r: ")}${theme.fg("text", formatTokens(totals.cacheRead))}${cacheHitRate}`,
159 );
160 }
161 if (totals.cacheWrite > 0) {
162 metrics.push(
163 `${theme.fg("muted", "cache w: ")}${theme.fg("text", formatTokens(totals.cacheWrite))}`,
164 );
165 }
166
167 if (isSubscription(ctx)) {
168 metrics.push(theme.fg("muted", "subscription"));
169 } else if (totals.cost > 0) {
170 metrics.push(theme.fg("text", `$${totals.cost.toFixed(3)}`));
171 }
172
173 const contextUsage = ctx.getContextUsage();
174 const contextWindow =
175 contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
176 const contextTokens = contextUsage?.tokens;
177 const contextPercent = contextUsage?.percent;
178 const contextValue =
179 contextTokens === null || contextTokens === undefined
180 ? `unknown/${formatTokens(contextWindow)}`
181 : `${formatTokens(contextTokens)}/${formatTokens(contextWindow)} (${contextPercent?.toFixed(1)}%)`;
182 const contextColor =
183 contextPercent !== null &&
184 contextPercent !== undefined &&
185 contextPercent > 90
186 ? "error"
187 : contextPercent !== null &&
188 contextPercent !== undefined &&
189 contextPercent > 70
190 ? "warning"
191 : "text";
192 metrics.push(
193 `${theme.fg("muted", "ctx: ")}${theme.fg(contextColor, contextValue)}`,
194 );
195
196 if (process.env.PI_EXPERIMENTAL === "1") {
197 metrics.push(theme.fg("muted", "experimental"));
198 }
199
200 const model = ctx.model;
201 let modelLabel = model?.id ?? "no model";
202 if (model?.reasoning) modelLabel += ` • ${ctx.thinkingLevel ?? "off"}`;
203
204 const separator = theme.fg("dim", " • ");
205 const metricLine = metrics.join(separator);
206 const modelText = theme.fg("dim", modelLabel);
207 const modelWidth = visibleWidth(modelText);
208 const lines = [
209 truncateToWidth(
210 theme.fg("dim", location),
211 width,
212 theme.fg("dim", "..."),
213 ),
214 ];
215 if (modelWidth + 2 > width) {
216 lines.push(truncateToWidth(modelText, width));
217 } else {
218 const metricsText = truncateToWidth(metricLine, width - modelWidth - 2);
219 const padding = " ".repeat(
220 Math.max(2, width - visibleWidth(metricsText) - modelWidth),
221 );
222 lines.push(metricsText + padding + modelText);
223 }
224
225 const statuses = Array.from(footerData.getExtensionStatuses().entries())
226 .sort(([left], [right]) => left.localeCompare(right))
227 .map(([, status]) => sanitizeStatus(status));
228 if (statuses.length > 0)
229 lines.push(...wrapTextWithAnsi(statuses.join(" "), width));
230
231 return lines;
232 },
233 }));
234 });
235}