Parent directory

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}