added custom header support and example extension

This commit is contained in:
Tudor Oancea 2026-01-06 14:59:26 +01:00 committed by Mario Zechner
parent 512ae4b4c0
commit f755f69e0a
9 changed files with 127 additions and 2 deletions

View file

@ -5,6 +5,7 @@
### Added
- Session picker (`pi -r`) and `--session` flag now support searching/resuming by session ID (UUID prefix) ([#495](https://github.com/badlogic/pi-mono/issues/495) by [@arunsathiya](https://github.com/arunsathiya))
- Extensions can now replace the startup header with `ctx.ui.setHeader()`, see `examples/extensions/custom-header.ts` ([#500](https://github.com/badlogic/pi-mono/pull/500) by [@tudoroancea](https://github.com/tudoroancea))
### Fixed

View file

@ -0,0 +1,72 @@
/**
* Custom Header Extension
*
* Demonstrates ctx.ui.setHeader() for replacing the built-in header
* (logo + keybinding hints) with a custom component showing the pi mascot.
*/
import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
// --- PI MASCOT ---
// Based on pi_mascot.ts - the pi agent character
function getPiMascot(theme: Theme): string[] {
// --- COLORS ---
// 3b1b Blue: R=80, G=180, B=230
const piBlue = (text: string) => theme.fg("accent", text);
const white = (text: string) => text; // Use plain white (or theme.fg("text", text))
const black = (text: string) => theme.fg("dim", text); // Use dim for contrast
// --- GLYPHS ---
const BLOCK = "█";
const PUPIL = "▌"; // Vertical half-block for the pupil
// --- CONSTRUCTION ---
// 1. The Eye Unit: [White Full Block][Black Vertical Sliver]
// This creates the "looking sideways" effect
const eye = `${white(BLOCK)}${black(PUPIL)}`;
// 2. Line 1: The Eyes
// 5 spaces indent aligns them with the start of the legs
const lineEyes = ` ${eye} ${eye}`;
// 3. Line 2: The Wide Top Bar (The "Overhang")
// 14 blocks wide for that serif-style roof
const lineBar = ` ${piBlue(BLOCK.repeat(14))}`;
// 4. Lines 3-6: The Legs
// Indented 5 spaces relative to the very left edge
// Leg width: 2 blocks | Gap: 4 blocks
const lineLeg = ` ${piBlue(BLOCK.repeat(2))} ${piBlue(BLOCK.repeat(2))}`;
// --- ASSEMBLY ---
return ["", lineEyes, lineBar, lineLeg, lineLeg, lineLeg, lineLeg, ""];
}
export default function (pi: ExtensionAPI) {
// Set custom header immediately on load (if UI is available)
pi.on("session_start", async (_event, ctx) => {
if (ctx.hasUI) {
ctx.ui.setHeader((_tui, theme) => {
return {
render(_width: number): string[] {
const mascotLines = getPiMascot(theme);
// Add a subtitle with hint
const subtitle = theme.fg("muted", " shitty coding agent");
return [...mascotLines, subtitle];
},
invalidate() {},
};
});
}
});
// Command to restore built-in header
pi.registerCommand("builtin-header", {
description: "Restore built-in header with keybinding hints",
handler: async (_args, ctx) => {
ctx.ui.setHeader(undefined);
ctx.ui.notify("Built-in header restored", "info");
},
});
}

View file

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

View file

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

View file

@ -68,6 +68,9 @@ export interface ExtensionUIContext {
/** 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 a custom header component (shown at startup, above chat), or undefined to restore the built-in header. */
setHeader(factory: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;
/** Set the terminal window/tab title. */
setTitle(title: string): void;

View file

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

View file

@ -160,6 +160,12 @@ export class InteractiveMode {
// Custom footer from extension (undefined = use built-in footer)
private customFooter: (Component & { dispose?(): void }) | undefined = undefined;
// Built-in header (logo + keybinding hints + changelog)
private builtInHeader: Component | undefined = undefined;
// Custom header from extension (undefined = use built-in header)
private customHeader: (Component & { dispose?(): void }) | undefined = undefined;
// Convenience accessors
private get agent() {
return this.session.agent;
@ -317,11 +323,11 @@ export class InteractiveMode {
"\n" +
theme.fg("dim", "drop files") +
theme.fg("muted", " to attach");
const header = new Text(`${logo}\n${instructions}`, 1, 0);
this.builtInHeader = new Text(`${logo}\n${instructions}`, 1, 0);
// Setup UI layout
this.ui.addChild(new Spacer(1));
this.ui.addChild(header);
this.ui.addChild(this.builtInHeader);
this.ui.addChild(new Spacer(1));
// Add changelog if provided
@ -683,6 +689,40 @@ export class InteractiveMode {
this.ui.requestRender();
}
/**
* Set a custom header component, or restore the built-in header.
*/
private setExtensionHeader(factory: ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined): void {
// Header may not be initialized yet if called during early initialization
if (!this.builtInHeader) {
return;
}
// Dispose existing custom header
if (this.customHeader?.dispose) {
this.customHeader.dispose();
}
// Remove current header from UI
if (this.customHeader) {
this.ui.removeChild(this.customHeader);
} else {
this.ui.removeChild(this.builtInHeader);
}
if (factory) {
// Create and add custom header at position 1 (after initial spacer)
this.customHeader = factory(this.ui, theme);
this.ui.children.splice(1, 0, this.customHeader);
} else {
// Restore built-in header at position 1
this.customHeader = undefined;
this.ui.children.splice(1, 0, this.builtInHeader);
}
this.ui.requestRender();
}
/**
* Create the ExtensionUIContext for extensions.
*/
@ -695,6 +735,7 @@ export class InteractiveMode {
setStatus: (key, text) => this.setExtensionStatus(key, text),
setWidget: (key, content) => this.setExtensionWidget(key, content),
setFooter: (factory) => this.setExtensionFooter(factory),
setHeader: (factory) => this.setExtensionHeader(factory),
setTitle: (title) => this.ui.terminal.setTitle(title),
custom: (factory) => this.showExtensionCustom(factory),
setEditorText: (text) => this.editor.setText(text),

View file

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

View file

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