mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 08:03:39 +00:00
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:
parent
2bc445498a
commit
f023af0dab
11 changed files with 147 additions and 1 deletions
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
86
packages/coding-agent/examples/extensions/custom-footer.ts
Normal file
86
packages/coding-agent/examples/extensions/custom-footer.ts
Normal 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");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -88,6 +88,7 @@ function createNoOpUIContext(): ExtensionUIContext {
|
|||
notify: () => {},
|
||||
setStatus: () => {},
|
||||
setWidget: () => {},
|
||||
setFooter: () => {},
|
||||
setTitle: () => {},
|
||||
custom: async () => undefined as never,
|
||||
setEditorText: () => {},
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ const noOpUIContext: ExtensionUIContext = {
|
|||
notify: () => {},
|
||||
setStatus: () => {},
|
||||
setWidget: () => {},
|
||||
setFooter: () => {},
|
||||
setTitle: () => {},
|
||||
custom: async () => undefined as never,
|
||||
setEditorText: () => {},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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: () => {},
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
|
|||
notify: () => {},
|
||||
setStatus: () => {},
|
||||
setWidget: () => {},
|
||||
setFooter: () => {},
|
||||
setTitle: () => {},
|
||||
custom: async () => undefined as never,
|
||||
setEditorText: () => {},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue