Add ctx.ui.setFooter() for extensions to replace footer component

Extensions can now replace the built-in footer with a custom component:
- setFooter(factory) replaces with custom component
- setFooter(undefined) restores built-in footer

Includes example extension demonstrating context usage display.

Closes #481
This commit is contained in:
Mario Zechner 2026-01-06 12:31:46 +01:00
parent 2bc445498a
commit f023af0dab
11 changed files with 147 additions and 1 deletions

View file

@ -0,0 +1,86 @@
/**
* Custom Footer Extension
*
* Demonstrates ctx.ui.setFooter() for replacing the built-in footer
* with a custom component showing session context usage.
*/
import type { AssistantMessage } from "@mariozechner/pi-ai";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
export default function (pi: ExtensionAPI) {
let isCustomFooter = false;
// Toggle custom footer with /footer command
pi.registerCommand("footer", {
description: "Toggle custom footer showing context usage",
handler: async (_args, ctx) => {
isCustomFooter = !isCustomFooter;
if (isCustomFooter) {
ctx.ui.setFooter((_tui, theme) => {
return {
render(width: number): string[] {
// Calculate usage from branch entries
let totalInput = 0;
let totalOutput = 0;
let totalCost = 0;
let lastAssistant: AssistantMessage | undefined;
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type === "message" && entry.message.role === "assistant") {
const msg = entry.message as AssistantMessage;
totalInput += msg.usage.input;
totalOutput += msg.usage.output;
totalCost += msg.usage.cost.total;
lastAssistant = msg;
}
}
// Context percentage from last assistant message
const contextTokens = lastAssistant
? lastAssistant.usage.input +
lastAssistant.usage.output +
lastAssistant.usage.cacheRead +
lastAssistant.usage.cacheWrite
: 0;
const contextWindow = ctx.model?.contextWindow || 0;
const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
// Format tokens
const fmt = (n: number) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`);
// Build footer line
const left = [
theme.fg("dim", `${fmt(totalInput)}`),
theme.fg("dim", `${fmt(totalOutput)}`),
theme.fg("dim", `$${totalCost.toFixed(3)}`),
].join(" ");
// Color context percentage based on usage
let contextStr = `${contextPercent.toFixed(1)}%`;
if (contextPercent > 90) {
contextStr = theme.fg("error", contextStr);
} else if (contextPercent > 70) {
contextStr = theme.fg("warning", contextStr);
} else {
contextStr = theme.fg("success", contextStr);
}
const right = `${contextStr} ${theme.fg("dim", ctx.model?.id || "no model")}`;
const padding = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
return [truncateToWidth(left + padding + right, width)];
},
invalidate() {},
};
});
ctx.ui.notify("Custom footer enabled", "info");
} else {
ctx.ui.setFooter(undefined);
ctx.ui.notify("Built-in footer restored", "info");
}
},
});
}