From f755f69e0ad5c4180f3a1d9cd83c232608fc82c7 Mon Sep 17 00:00:00 2001 From: Tudor Oancea Date: Tue, 6 Jan 2026 14:59:26 +0100 Subject: [PATCH] added custom header support and example extension --- packages/coding-agent/CHANGELOG.md | 1 + .../examples/extensions/custom-header.ts | 72 +++++++++++++++++++ .../src/core/extensions/loader.ts | 1 + .../src/core/extensions/runner.ts | 1 + .../coding-agent/src/core/extensions/types.ts | 3 + packages/coding-agent/src/core/sdk.ts | 1 + .../src/modes/interactive/interactive-mode.ts | 45 +++++++++++- .../coding-agent/src/modes/rpc/rpc-mode.ts | 4 ++ .../test/compaction-extensions.test.ts | 1 + 9 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/examples/extensions/custom-header.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 88c189c7..e6184fd1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/examples/extensions/custom-header.ts b/packages/coding-agent/examples/extensions/custom-header.ts new file mode 100644 index 00000000..fc08cb6d --- /dev/null +++ b/packages/coding-agent/examples/extensions/custom-header.ts @@ -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"); + }, + }); +} diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index cd1bb3f7..d65b7534 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -90,6 +90,7 @@ function createNoOpUIContext(): ExtensionUIContext { setStatus: () => {}, setWidget: () => {}, setFooter: () => {}, + setHeader: () => {}, 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 41ad7883..dd9ad66a 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -65,6 +65,7 @@ const noOpUIContext: ExtensionUIContext = { setStatus: () => {}, setWidget: () => {}, setFooter: () => {}, + setHeader: () => {}, 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 44bd7b6f..92bd7113 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -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; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index e15926e8..6440415a 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -525,6 +525,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} setStatus: () => {}, setWidget: () => {}, setFooter: () => {}, + setHeader: () => {}, 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 9f07af60..72537b8b 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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), diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index fb32de59..2a8850ed 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -164,6 +164,10 @@ export async function runRpcMode(session: AgentSession): Promise { // 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({ diff --git a/packages/coding-agent/test/compaction-extensions.test.ts b/packages/coding-agent/test/compaction-extensions.test.ts index e07df288..f98fe5cf 100644 --- a/packages/coding-agent/test/compaction-extensions.test.ts +++ b/packages/coding-agent/test/compaction-extensions.test.ts @@ -125,6 +125,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => { setStatus: () => {}, setWidget: () => {}, setFooter: () => {}, + setHeader: () => {}, setTitle: () => {}, custom: async () => undefined as never, setEditorText: () => {},