diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2579e02d..79149923 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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)) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 4e6189f0..c975fbf0 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -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) { diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 724e1896..85f344e6 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -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"); diff --git a/packages/coding-agent/examples/extensions/custom-footer.ts b/packages/coding-agent/examples/extensions/custom-footer.ts new file mode 100644 index 00000000..857cfba8 --- /dev/null +++ b/packages/coding-agent/examples/extensions/custom-footer.ts @@ -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"); + } + }, + }); +} diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index acd446f2..b64169ae 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -88,6 +88,7 @@ function createNoOpUIContext(): ExtensionUIContext { notify: () => {}, setStatus: () => {}, setWidget: () => {}, + setFooter: () => {}, setTitle: () => {}, custom: async () => undefined as never, setEditorText: () => {}, diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index c6a3528f..a00ab5bd 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -63,6 +63,7 @@ const noOpUIContext: ExtensionUIContext = { notify: () => {}, setStatus: () => {}, setWidget: () => {}, + setFooter: () => {}, setTitle: () => {}, custom: async () => undefined as never, setEditorText: () => {}, diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 6eaa5be3..4e2af386 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -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; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index a95ba544..e15926e8 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -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: () => {}, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index d2679bed..1784c6fa 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -157,6 +157,9 @@ export class InteractiveMode { private extensionWidgets = new Map(); 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), diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index a4cfe9bc..77e9ce80 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -160,6 +160,10 @@ export async function runRpcMode(session: AgentSession): Promise { // 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({ diff --git a/packages/coding-agent/test/compaction-extensions.test.ts b/packages/coding-agent/test/compaction-extensions.test.ts index d6158fd6..064b54bc 100644 --- a/packages/coding-agent/test/compaction-extensions.test.ts +++ b/packages/coding-agent/test/compaction-extensions.test.ts @@ -122,6 +122,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => { notify: () => {}, setStatus: () => {}, setWidget: () => {}, + setFooter: () => {}, setTitle: () => {}, custom: async () => undefined as never, setEditorText: () => {},