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

@ -4,6 +4,7 @@
### Added
- Extensions can now replace the footer with `ctx.ui.setFooter()`, see `examples/extensions/custom-footer.ts` ([#481](https://github.com/badlogic/pi-mono/issues/481))
- Session ID is now forwarded to LLM providers for session-based caching (used by OpenAI Codex for prompt caching).
- Added `blockImages` setting to prevent images from being sent to LLM providers ([#492](https://github.com/badlogic/pi-mono/pull/492) by [@jsinge97](https://github.com/jsinge97))

View file

@ -1069,6 +1069,13 @@ ctx.ui.setStatus("my-ext", null); // Clear
// Widgets (above editor)
ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]);
// Custom footer (replaces built-in footer)
ctx.ui.setFooter((tui, theme) => ({
render(width) { return [theme.fg("dim", "Custom footer")]; },
invalidate() {},
}));
ctx.ui.setFooter(undefined); // Restore built-in footer
// Full custom component with keyboard handling
await ctx.ui.custom((tui, theme, done) => ({
render(width) {

View file

@ -934,7 +934,7 @@ const text = await ctx.ui.editor("Edit:", "prefilled text");
ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
```
### Widgets and Status
### Widgets, Status, and Footer
```typescript
// Status in footer (persistent until cleared)
@ -946,6 +946,13 @@ ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
ctx.ui.setWidget("my-widget", (tui, theme) => new Text(theme.fg("accent", "Custom"), 0, 0));
ctx.ui.setWidget("my-widget", undefined); // Clear
// Custom footer (replaces built-in footer entirely)
ctx.ui.setFooter((tui, theme) => ({
render(width) { return [theme.fg("dim", "Custom footer")]; },
invalidate() {},
}));
ctx.ui.setFooter(undefined); // Restore built-in footer
// Terminal title
ctx.ui.setTitle("pi - my-project");

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");
}
},
});
}

View file

@ -88,6 +88,7 @@ function createNoOpUIContext(): ExtensionUIContext {
notify: () => {},
setStatus: () => {},
setWidget: () => {},
setFooter: () => {},
setTitle: () => {},
custom: async () => undefined as never,
setEditorText: () => {},

View file

@ -63,6 +63,7 @@ const noOpUIContext: ExtensionUIContext = {
notify: () => {},
setStatus: () => {},
setWidget: () => {},
setFooter: () => {},
setTitle: () => {},
custom: async () => undefined as never,
setEditorText: () => {},

View file

@ -65,6 +65,9 @@ export interface ExtensionUIContext {
setWidget(key: string, content: string[] | undefined): void;
setWidget(key: string, content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;
/** Set a custom footer component, or undefined to restore the built-in footer. */
setFooter(factory: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;
/** Set the terminal window/tab title. */
setTitle(title: string): void;

View file

@ -474,6 +474,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
notify: () => {},
setStatus: () => {},
setWidget: () => {},
setFooter: () => {},
setTitle: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
@ -523,6 +524,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
notify: () => {},
setStatus: () => {},
setWidget: () => {},
setFooter: () => {},
setTitle: () => {},
custom: async () => undefined as never,
setEditorText: () => {},

View file

@ -157,6 +157,9 @@ export class InteractiveMode {
private extensionWidgets = new Map<string, Component & { dispose?(): void }>();
private widgetContainer!: Container;
// Custom footer from extension (undefined = use built-in footer)
private customFooter: (Component & { dispose?(): void }) | undefined = undefined;
// Convenience accessors
private get agent() {
return this.session.agent;
@ -646,6 +649,35 @@ export class InteractiveMode {
this.ui.requestRender();
}
/**
* Set a custom footer component, or restore the built-in footer.
*/
private setExtensionFooter(factory: ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined): void {
// Dispose existing custom footer
if (this.customFooter?.dispose) {
this.customFooter.dispose();
}
// Remove current footer from UI
if (this.customFooter) {
this.ui.removeChild(this.customFooter);
} else {
this.ui.removeChild(this.footer);
}
if (factory) {
// Create and add custom footer
this.customFooter = factory(this.ui, theme);
this.ui.addChild(this.customFooter);
} else {
// Restore built-in footer
this.customFooter = undefined;
this.ui.addChild(this.footer);
}
this.ui.requestRender();
}
/**
* Create the ExtensionUIContext for extensions.
*/
@ -657,6 +689,7 @@ export class InteractiveMode {
notify: (message, type) => this.showExtensionNotify(message, type),
setStatus: (key, text) => this.setExtensionStatus(key, text),
setWidget: (key, content) => this.setExtensionWidget(key, content),
setFooter: (factory) => this.setExtensionFooter(factory),
setTitle: (title) => this.ui.terminal.setTitle(title),
custom: (factory) => this.showExtensionCustom(factory),
setEditorText: (text) => this.editor.setText(text),

View file

@ -160,6 +160,10 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
// Component factories are not supported in RPC mode - would need TUI access
},
setFooter(_factory: unknown): void {
// Custom footer not supported in RPC mode - requires TUI access
},
setTitle(title: string): void {
// Fire and forget - host can implement terminal title control
output({

View file

@ -122,6 +122,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
notify: () => {},
setStatus: () => {},
setWidget: () => {},
setFooter: () => {},
setTitle: () => {},
custom: async () => undefined as never,
setEditorText: () => {},