Skip to main content

pi can create TUI components. Ask it to build one for your use case.

TUI Components

Extensions and custom tools can render custom TUI components for interactive user interfaces. This page covers the component system and available building blocks.

Source: @mariozechner/pi-tui

Component Interface

All components implement:

interface Component {
render(width: number): string[];
handleInput?(data: string): void;
wantsKeyRelease?: boolean;
invalidate(): void;
}
MethodDescription
render(width)Return array of strings (one per line). Each line must not exceed width.
handleInput?(data)Receive keyboard input when component has focus.
wantsKeyRelease?If true, component receives key release events (Kitty protocol). Default: false.
invalidate()Clear cached render state. Called on theme changes.

The TUI appends a full SGR reset and OSC 8 reset at the end of each rendered line. Styles do not carry across lines. If you emit multi-line text with styling, reapply styles per line or use wrapTextWithAnsi() so styles are preserved for each wrapped line.

Focusable Interface (IME Support)

Components that display a text cursor and need IME (Input Method Editor) support should implement the Focusable interface:

import { CURSOR_MARKER, type Component, type Focusable } from "@mariozechner/pi-tui";

class MyInput implements Component, Focusable {
focused: boolean = false; // Set by TUI when focus changes

render(width: number): string[] {
const marker = this.focused ? CURSOR_MARKER : "";
// Emit marker right before the fake cursor
return [`> ${beforeCursor}${marker}\x1b[7m${atCursor}\x1b[27m${afterCursor}`];
}
}

When a Focusable component has focus, TUI:

  1. Sets focused = true on the component
  2. Scans rendered output for CURSOR_MARKER (a zero-width APC escape sequence)
  3. Positions the hardware terminal cursor at that location
  4. Shows the hardware cursor

This enables IME candidate windows to appear at the correct position for CJK input methods. The Editor and Input built-in components already implement this interface.

Container Components with Embedded Inputs

When a container component (dialog, selector, etc.) contains an Input or Editor child, the container must implement Focusable and propagate the focus state to the child. Otherwise, the hardware cursor won't be positioned correctly for IME input.

import { Container, type Focusable, Input } from "@mariozechner/pi-tui";

class SearchDialog extends Container implements Focusable {
private searchInput: Input;

// Focusable implementation - propagate to child input for IME cursor positioning
private _focused = false;
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.searchInput.focused = value;
}

constructor() {
super();
this.searchInput = new Input();
this.addChild(this.searchInput);
}
}

Without this propagation, typing with an IME (Chinese, Japanese, Korean, etc.) will show the candidate window in the wrong position on screen.

Using Components

In extensions via ctx.ui.custom():

pi.on("session_start", async (_event, ctx) => {
const handle = ctx.ui.custom(myComponent);
// handle.requestRender() - trigger re-render
// handle.close() - restore normal UI
});

In custom tools via pi.ui.custom():

async execute(toolCallId, params, onUpdate, ctx, signal) {
const handle = pi.ui.custom(myComponent);
// ...
handle.close();
}

Overlays

Overlays render components on top of existing content without clearing the screen. Pass { overlay: true } to ctx.ui.custom():

const result = await ctx.ui.custom<string | null>(
(tui, theme, keybindings, done) => new MyDialog({ onClose: done }),
{ overlay: true }
);

For positioning and sizing, use overlayOptions:

const result = await ctx.ui.custom<string | null>(
(tui, theme, keybindings, done) => new SidePanel({ onClose: done }),
{
overlay: true,
overlayOptions: {
// Size: number or percentage string
width: "50%", // 50% of terminal width
minWidth: 40, // minimum 40 columns
maxHeight: "80%", // max 80% of terminal height

// Position: anchor-based (default: "center")
anchor: "right-center", // 9 positions: center, top-left, top-center, etc.
offsetX: -2, // offset from anchor
offsetY: 0,

// Or percentage/absolute positioning
row: "25%", // 25% from top
col: 10, // column 10

// Margins
margin: 2, // all sides, or { top, right, bottom, left }

// Responsive: hide on narrow terminals
visible: (termWidth, termHeight) => termWidth >= 80,
},
// Get handle for programmatic visibility control
onHandle: (handle) => {
// handle.setHidden(true/false) - toggle visibility
// handle.hide() - permanently remove
},
}
);

Overlay Lifecycle

Overlay components are disposed when closed. Don't reuse references - create fresh instances:

// Wrong - stale reference
let menu: MenuComponent;
await ctx.ui.custom((_, __, ___, done) => {
menu = new MenuComponent(done);
return menu;
}, { overlay: true });
setActiveComponent(menu); // Disposed

// Correct - re-call to re-show
const showMenu = () => ctx.ui.custom((_, __, ___, done) =>
new MenuComponent(done), { overlay: true });

await showMenu(); // First show
await showMenu(); // "Back" = just call again

See

overlay-qa-tests.ts
/**
* Overlay QA Tests - comprehensive overlay positioning and edge case tests
*
* Usage: pi --extension ./examples/extensions/overlay-qa-tests.ts
*
* Commands:
* /overlay-animation - Real-time animation demo (~30 FPS, proves DOOM-like rendering works)
* /overlay-anchors - Cycle through all 9 anchor positions
* /overlay-margins - Test margin and offset options
* /overlay-stack - Test stacked overlays
* /overlay-overflow - Test width overflow with streaming process output
* /overlay-edge - Test overlay positioned at terminal edge
* /overlay-percent - Test percentage-based positioning
* /overlay-maxheight - Test maxHeight truncation
* /overlay-sidepanel - Responsive sidepanel (hides when terminal < 100 cols)
* /overlay-toggle - Toggle visibility demo (demonstrates OverlayHandle.setHidden)
*/

import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
import type { OverlayAnchor, OverlayHandle, OverlayOptions, TUI } from "@mariozechner/pi-tui";
import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { spawn } from "child_process";

// Global handle for toggle demo (in real code, use a more elegant pattern)
let globalToggleHandle: OverlayHandle | null = null;

export default function (pi: ExtensionAPI) {
// Animation demo - proves overlays can handle real-time updates (like pi-doom would need)
pi.registerCommand("overlay-animation", {
description: "Test real-time animation in overlay (~30 FPS)",
handler: async (_args: string, ctx: ExtensionCommandContext) => {
await ctx.ui.custom<void>((tui, theme, _kb, done) => new AnimationDemoComponent(tui, theme, done), {
overlay: true,
overlayOptions: { anchor: "center", width: 50, maxHeight: 20 },
});
},
});

// Test all 9 anchor positions
pi.registerCommand("overlay-anchors", {
description: "Cycle through all anchor positions",
handler: async (_args: string, ctx: ExtensionCommandContext) => {
const anchors: OverlayAnchor[] = [
"top-left",
"top-center",
"top-right",
"left-center",
"center",
"right-center",
"bottom-left",
"bottom-center",
"bottom-right",
];

let index = 0;
while (true) {
const result = await ctx.ui.custom<"next" | "confirm" | "cancel">(
(_tui, theme, _kb, done) => new AnchorTestComponent(theme, anchors[index]!, done),
{
overlay: true,
overlayOptions: { anchor: anchors[index], width: 40 },
},
);

if (result === "next") {
index = (index + 1) % anchors.length;
continue;
}
if (result === "confirm") {
ctx.ui.notify(`Selected: ${anchors[index]}`, "info");
}
break;
}
},
});

// Test margins and offsets
pi.registerCommand("overlay-margins", {
description: "Test margin and offset options",
handler: async (_args: string, ctx: ExtensionCommandContext) => {
const configs: { name: string; options: OverlayOptions }[] = [
{ name: "No margin (top-left)", options: { anchor: "top-left", width: 35 } },
{ name: "Margin: 3 all sides", options: { anchor: "top-left", width: 35, margin: 3 } },
{
name: "Margin: top=5, left=10",
options: { anchor: "top-left", width: 35, margin: { top: 5, left: 10 } },
},
{ name: "Center + offset (10, -3)", options: { anchor: "center", width: 35, offsetX: 10, offsetY: -3 } },
{ name: "Bottom-right, margin: 2", options: { anchor: "bottom-right", width: 35, margin: 2 } },
];

let index = 0;
while (true) {
const result = await ctx.ui.custom<"next" | "close">(
(_tui, theme, _kb, done) => new MarginTestComponent(theme, configs[index]!, done),
{
overlay: true,
overlayOptions: configs[index]!.options,
},
);

if (result === "next") {
index = (index + 1) % configs.length;
continue;
}
break;
}
},
});

// Test stacked overlays
pi.registerCommand("overlay-stack", {
description: "Test stacked overlays",
handler: async (_args: string, ctx: ExtensionCommandContext) => {
// Three large overlays that overlap in the center area
// Each offset slightly so you can see the stacking

ctx.ui.notify("Showing overlay 1 (back)...", "info");
const p1 = ctx.ui.custom<string>(
(_tui, theme, _kb, done) => new StackOverlayComponent(theme, 1, "back (red border)", done),
{
overlay: true,
overlayOptions: { anchor: "center", width: 50, offsetX: -8, offsetY: -4, maxHeight: 15 },
},
);

await sleep(400);

ctx.ui.notify("Showing overlay 2 (middle)...", "info");
const p2 = ctx.ui.custom<string>(
(_tui, theme, _kb, done) => new StackOverlayComponent(theme, 2, "middle (green border)", done),
{
overlay: true,
overlayOptions: { anchor: "center", width: 50, offsetX: 0, offsetY: 0, maxHeight: 15 },
},
);

await sleep(400);

ctx.ui.notify("Showing overlay 3 (front)...", "info");
const p3 = ctx.ui.custom<string>(
(_tui, theme, _kb, done) => new StackOverlayComponent(theme, 3, "front (blue border)", done),
{
overlay: true,
overlayOptions: { anchor: "center", width: 50, offsetX: 8, offsetY: 4, maxHeight: 15 },
},
);

// Wait for all to close
const results = await Promise.all([p1, p2, p3]);
ctx.ui.notify(`Closed in order: ${results.join(", ")}`, "info");
},
});

// Test width overflow scenarios (original crash case) - streams real process output
pi.registerCommand("overlay-overflow", {
description: "Test width overflow with streaming process output",
handler: async (_args: string, ctx: ExtensionCommandContext) => {
await ctx.ui.custom<void>((tui, theme, _kb, done) => new StreamingOverflowComponent(tui, theme, done), {
overlay: true,
overlayOptions: { anchor: "center", width: 90, maxHeight: 20 },
});
},
});

// Test overlay at terminal edge
pi.registerCommand("overlay-edge", {
description: "Test overlay positioned at terminal edge",
handler: async (_args: string, ctx: ExtensionCommandContext) => {
await ctx.ui.custom<void>((_tui, theme, _kb, done) => new EdgeTestComponent(theme, done), {
overlay: true,
overlayOptions: { anchor: "right-center", width: 40, margin: { right: 0 } },
});
},
});

// Test percentage-based positioning
pi.registerCommand("overlay-percent", {
description: "Test percentage-based positioning",
handler: async (_args: string, ctx: ExtensionCommandContext) => {
const configs = [
{ name: "rowPercent: 0 (top)", row: 0, col: 50 },
{ name: "rowPercent: 50 (middle)", row: 50, col: 50 },
{ name: "rowPercent: 100 (bottom)", row: 100, col: 50 },
{ name: "colPercent: 0 (left)", row: 50, col: 0 },
{ name: "colPercent: 100 (right)", row: 50, col: 100 },
];

let index = 0;
while (true) {
const config = configs[index]!;
const result = await ctx.ui.custom<"next" | "close">(
(_tui, theme, _kb, done) => new PercentTestComponent(theme, config, done),
{
overlay: true,
overlayOptions: {
width: 30,
row: `${config.row}%`,
col: `${config.col}%`,
},
},
);

if (result === "next") {
index = (index + 1) % configs.length;
continue;
}
break;
}
},
});

// Test maxHeight
pi.registerCommand("overlay-maxheight", {
description: "Test maxHeight truncation",
handler: async (_args: string, ctx: ExtensionCommandContext) => {
await ctx.ui.custom<void>((_tui, theme, _kb, done) => new MaxHeightTestComponent(theme, done), {
overlay: true,
overlayOptions: { anchor: "center", width: 50, maxHeight: 10 },
});
},
});

// Test responsive sidepanel - only shows when terminal is wide enough
pi.registerCommand("overlay-sidepanel", {
description: "Test responsive sidepanel (hides when terminal < 100 cols)",
handler: async (_args: string, ctx: ExtensionCommandContext) => {
await ctx.ui.custom<void>((tui, theme, _kb, done) => new SidepanelComponent(tui, theme, done), {
overlay: true,
overlayOptions: {
anchor: "right-center",
width: "25%",
minWidth: 30,
margin: { right: 1 },
// Only show when terminal is wide enough
visible: (termWidth) => termWidth >= 100,
},
});
},
});

// Test toggle overlay - demonstrates OverlayHandle.setHidden() via onHandle callback
pi.registerCommand("overlay-toggle", {
description: "Test overlay toggle (press 't' to toggle visibility)",
handler: async (_args: string, ctx: ExtensionCommandContext) => {
await ctx.ui.custom<void>((tui, theme, _kb, done) => new ToggleDemoComponent(tui, theme, done), {
overlay: true,
overlayOptions: { anchor: "center", width: 50 },
// onHandle callback provides access to the OverlayHandle for visibility control
onHandle: (handle) => {
// Store handle globally so component can access it
// (In real code, you'd use a more elegant pattern like a store or event emitter)
globalToggleHandle = handle;
},
});
globalToggleHandle = null;
},
});
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

// Base overlay component with common rendering
abstract class BaseOverlay {
constructor(protected theme: Theme) {}

protected box(lines: string[], width: number, title?: string): string[] {
const th = this.theme;
const innerW = Math.max(1, width - 2);
const result: string[] = [];

const titleStr = title ? truncateToWidth(` ${title} `, innerW) : "";
const titleW = visibleWidth(titleStr);
const topLine = "─".repeat(Math.floor((innerW - titleW) / 2));
const topLine2 = "─".repeat(Math.max(0, innerW - titleW - topLine.length));
result.push(th.fg("border", `${topLine}`) + th.fg("accent", titleStr) + th.fg("border", `${topLine2}`));

for (const line of lines) {
result.push(th.fg("border", "│") + truncateToWidth(line, innerW, "...", true) + th.fg("border", "│"));
}

result.push(th.fg("border", `${"─".repeat(innerW)}`));
return result;
}

invalidate(): void {}
dispose(): void {}
}

// Anchor position test
class AnchorTestComponent extends BaseOverlay {
constructor(
theme: Theme,
private anchor: OverlayAnchor,
private done: (result: "next" | "confirm" | "cancel") => void,
) {
super(theme);
}

handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
this.done("cancel");
} else if (matchesKey(data, "return")) {
this.done("confirm");
} else if (matchesKey(data, "space") || matchesKey(data, "right")) {
this.done("next");
}
}

render(width: number): string[] {
const th = this.theme;
return this.box(
[
"",
` Current: ${th.fg("accent", this.anchor)}`,
"",
` ${th.fg("dim", "Space/→ = next anchor")}`,
` ${th.fg("dim", "Enter = confirm")}`,
` ${th.fg("dim", "Esc = cancel")}`,
"",
],
width,
"Anchor Test",
);
}
}

// Margin/offset test
class MarginTestComponent extends BaseOverlay {
constructor(
theme: Theme,
private config: { name: string; options: OverlayOptions },
private done: (result: "next" | "close") => void,
) {
super(theme);
}

handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
this.done("close");
} else if (matchesKey(data, "space") || matchesKey(data, "right")) {
this.done("next");
}
}

render(width: number): string[] {
const th = this.theme;
return this.box(
[
"",
` ${th.fg("accent", this.config.name)}`,
"",
` ${th.fg("dim", "Space/→ = next config")}`,
` ${th.fg("dim", "Esc = close")}`,
"",
],
width,
"Margin Test",
);
}
}

// Stacked overlay test
class StackOverlayComponent extends BaseOverlay {
constructor(
theme: Theme,
private num: number,
private position: string,
private done: (result: string) => void,
) {
super(theme);
}

handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || matchesKey(data, "return")) {
this.done(`Overlay ${this.num}`);
}
}

render(width: number): string[] {
const th = this.theme;
// Use different colors for each overlay to show stacking
const colors = ["error", "success", "accent"] as const;
const color = colors[(this.num - 1) % colors.length]!;
const innerW = Math.max(1, width - 2);
const border = (char: string) => th.fg(color, char);
const padLine = (s: string) => truncateToWidth(s, innerW, "...", true);
const lines: string[] = [];

lines.push(border(`${"─".repeat(innerW)}`));
lines.push(border("│") + padLine(` Overlay ${th.fg("accent", `#${this.num}`)}`) + border("│"));
lines.push(border("│") + padLine(` Layer: ${th.fg(color, this.position)}`) + border("│"));
lines.push(border("│") + padLine("") + border("│"));
// Add extra lines to make it taller
for (let i = 0; i < 5; i++) {
lines.push(border("│") + padLine(` ${"░".repeat(innerW - 2)} `) + border("│"));
}
lines.push(border("│") + padLine("") + border("│"));
lines.push(border("│") + padLine(th.fg("dim", " Press Enter/Esc to close")) + border("│"));
lines.push(border(`${"─".repeat(innerW)}`));

return lines;
}
}

// Streaming overflow test - spawns real process with colored output (original crash scenario)
class StreamingOverflowComponent extends BaseOverlay {
private lines: string[] = [];
private proc: ReturnType<typeof spawn> | null = null;
private scrollOffset = 0;
private maxVisibleLines = 15;
private finished = false;
private disposed = false;

constructor(
private tui: TUI,
theme: Theme,
private done: () => void,
) {
super(theme);
this.startProcess();
}

private startProcess(): void {
// Run a command that produces many lines with ANSI colors
// Using find with -ls produces file listings, or use ls --color
this.proc = spawn("bash", [
"-c",
`
echo "Starting streaming overflow test (30+ seconds)..."
echo "This simulates subagent output with colors, hyperlinks, and long paths"
echo ""
for i in $(seq 1 100); do
# Simulate long file paths with OSC 8 hyperlinks (clickable) - tests width overflow
DIR="/Users/nicobailon/Documents/development/pi-mono/packages/coding-agent/src/modes/interactive"
FILE="\${DIR}/components/very-long-component-name-that-exceeds-width-\${i}.ts"
echo -e "\\033]8;;file://\${FILE}\\007▶ read: \${FILE}\\033]8;;\\007"

# Add some colored status messages with long text
if [ $((i % 5)) -eq 0 ]; then
echo -e " \\033[32m✓ Successfully processed \${i} files in /Users/nicobailon/Documents/development/pi-mono\\033[0m"
fi
if [ $((i % 7)) -eq 0 ]; then
echo -e " \\033[33m⚠ Warning: potential issue detected at line \${i} in very-long-component-name-that-exceeds-width.ts\\033[0m"
fi
if [ $((i % 11)) -eq 0 ]; then
echo -e " \\033[31m✗ Error: file not found /some/really/long/path/that/definitely/exceeds/the/overlay/width/limit/file-\${i}.ts\\033[0m"
fi
sleep 0.3
done
echo ""
echo -e "\\033[32m✓ Complete - 100 files processed in 30 seconds\\033[0m"
echo "Press Esc to close"
`,
]);

this.proc.stdout?.on("data", (data: Buffer) => {
if (this.disposed) return; // Guard against callbacks after dispose
const text = data.toString();
const newLines = text.split("\n");
for (const line of newLines) {
if (line) this.lines.push(line);
}
// Auto-scroll to bottom
this.scrollOffset = Math.max(0, this.lines.length - this.maxVisibleLines);
this.tui.requestRender();
});

this.proc.stderr?.on("data", (data: Buffer) => {
if (this.disposed) return; // Guard against callbacks after dispose
this.lines.push(this.theme.fg("error", data.toString().trim()));
this.tui.requestRender();
});

this.proc.on("close", () => {
if (this.disposed) return; // Guard against callbacks after dispose
this.finished = true;
this.tui.requestRender();
});
}

handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
this.proc?.kill();
this.done();
} else if (matchesKey(data, "up")) {
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
this.tui.requestRender(); // Trigger re-render after scroll
} else if (matchesKey(data, "down")) {
this.scrollOffset = Math.min(Math.max(0, this.lines.length - this.maxVisibleLines), this.scrollOffset + 1);
this.tui.requestRender(); // Trigger re-render after scroll
}
}

render(width: number): string[] {
const th = this.theme;
const innerW = Math.max(1, width - 2);
const padLine = (s: string) => truncateToWidth(s, innerW, "...", true);
const border = (c: string) => th.fg("border", c);

const result: string[] = [];
const title = truncateToWidth(` Streaming Output (${this.lines.length} lines) `, innerW);
const titlePad = Math.max(0, innerW - visibleWidth(title));
result.push(border("╭") + th.fg("accent", title) + border(`${"─".repeat(titlePad)}`));

// Scroll indicators
const canScrollUp = this.scrollOffset > 0;
const canScrollDown = this.scrollOffset < this.lines.length - this.maxVisibleLines;
const scrollInfo = `${this.scrollOffset} | ↓${Math.max(0, this.lines.length - this.maxVisibleLines - this.scrollOffset)}`;

result.push(
border("│") + padLine(canScrollUp || canScrollDown ? th.fg("dim", ` ${scrollInfo}`) : "") + border("│"),
);

// Visible lines - truncate long lines to fit within border
const visibleLines = this.lines.slice(this.scrollOffset, this.scrollOffset + this.maxVisibleLines);
for (const line of visibleLines) {
result.push(border("│") + padLine(` ${line}`) + border("│"));
}

// Pad to maxVisibleLines
for (let i = visibleLines.length; i < this.maxVisibleLines; i++) {
result.push(border("│") + padLine("") + border("│"));
}

const status = this.finished ? th.fg("success", "✓ Done") : th.fg("warning", "● Running");
result.push(border("│") + padLine(` ${status} ${th.fg("dim", "| ↑↓ scroll | Esc close")}`) + border("│"));
result.push(border(`${"─".repeat(innerW)}`));

return result;
}

dispose(): void {
this.disposed = true;
this.proc?.kill();
}
}

// Edge position test
class EdgeTestComponent extends BaseOverlay {
constructor(
theme: Theme,
private done: () => void,
) {
super(theme);
}

handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
this.done();
}
}

render(width: number): string[] {
const th = this.theme;
return this.box(
[
"",
" This overlay is at the",
" right edge of terminal.",
"",
` ${th.fg("dim", "Verify right border")}`,
` ${th.fg("dim", "aligns with edge.")}`,
"",
` ${th.fg("dim", "Press Esc to close")}`,
"",
],
width,
"Edge Test",
);
}
}

// Percentage positioning test
class PercentTestComponent extends BaseOverlay {
constructor(
theme: Theme,
private config: { name: string; row: number; col: number },
private done: (result: "next" | "close") => void,
) {
super(theme);
}

handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
this.done("close");
} else if (matchesKey(data, "space") || matchesKey(data, "right")) {
this.done("next");
}
}

render(width: number): string[] {
const th = this.theme;
return this.box(
[
"",
` ${th.fg("accent", this.config.name)}`,
"",
` ${th.fg("dim", "Space/→ = next")}`,
` ${th.fg("dim", "Esc = close")}`,
"",
],
width,
"Percent Test",
);
}
}

// MaxHeight test - renders 20 lines, truncated to 10 by maxHeight
class MaxHeightTestComponent extends BaseOverlay {
constructor(
theme: Theme,
private done: () => void,
) {
super(theme);
}

handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
this.done();
}
}

render(width: number): string[] {
const th = this.theme;
// Intentionally render 21 lines - maxHeight: 10 will truncate to first 10
// You should see header + lines 1-6, with bottom border cut off
const contentLines: string[] = [
th.fg("warning", " ⚠ Rendering 21 lines, maxHeight: 10"),
th.fg("dim", " Lines 11-21 truncated (no bottom border)"),
"",
];

for (let i = 1; i <= 14; i++) {
contentLines.push(` Line ${i} of 14`);
}

contentLines.push("", th.fg("dim", " Press Esc to close"));

return this.box(contentLines, width, "MaxHeight Test");
}
}

// Responsive sidepanel - demonstrates percentage width and visibility callback
class SidepanelComponent extends BaseOverlay {
private items = ["Dashboard", "Messages", "Settings", "Help", "About"];
private selectedIndex = 0;

constructor(
private tui: TUI,
theme: Theme,
private done: () => void,
) {
super(theme);
}

handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
this.done();
} else if (matchesKey(data, "up")) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.tui.requestRender();
} else if (matchesKey(data, "down")) {
this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
this.tui.requestRender();
} else if (matchesKey(data, "return")) {
// Could trigger an action here
this.tui.requestRender();
}
}

render(width: number): string[] {
const th = this.theme;
const innerW = Math.max(1, width - 2);
const padLine = (s: string) => truncateToWidth(s, innerW, "...", true);
const border = (c: string) => th.fg("border", c);
const lines: string[] = [];

// Header
lines.push(border(`${"─".repeat(innerW)}`));
lines.push(border("│") + padLine(th.fg("accent", " Responsive Sidepanel")) + border("│"));
lines.push(border("├") + border("─".repeat(innerW)) + border("┤"));

// Menu items
for (let i = 0; i < this.items.length; i++) {
const item = this.items[i]!;
const isSelected = i === this.selectedIndex;
const prefix = isSelected ? th.fg("accent", "→ ") : " ";
const text = isSelected ? th.fg("accent", item) : item;
lines.push(border("│") + padLine(`${prefix}${text}`) + border("│"));
}

// Footer with responsive behavior info
lines.push(border("├") + border("─".repeat(innerW)) + border("┤"));
lines.push(border("│") + padLine(th.fg("warning", " ⚠ Resize terminal < 100 cols")) + border("│"));
lines.push(border("│") + padLine(th.fg("warning", " to see panel auto-hide")) + border("│"));
lines.push(border("│") + padLine(th.fg("dim", " Uses visible: (w) => w >= 100")) + border("│"));
lines.push(border("│") + padLine(th.fg("dim", " ↑↓ navigate | Esc close")) + border("│"));
lines.push(border(`${"─".repeat(innerW)}`));

return lines;
}
}

// Animation demo - proves overlays can handle real-time updates like pi-doom
class AnimationDemoComponent extends BaseOverlay {
private frame = 0;
private interval: ReturnType<typeof setInterval> | null = null;
private fps = 0;
private lastFpsUpdate = Date.now();
private framesSinceLastFps = 0;

constructor(
private tui: TUI,
theme: Theme,
private done: () => void,
) {
super(theme);
this.startAnimation();
}

private startAnimation(): void {
// Run at ~30 FPS (same as DOOM target)
this.interval = setInterval(() => {
this.frame++;
this.framesSinceLastFps++;

// Update FPS counter every second
const now = Date.now();
if (now - this.lastFpsUpdate >= 1000) {
this.fps = this.framesSinceLastFps;
this.framesSinceLastFps = 0;
this.lastFpsUpdate = now;
}

this.tui.requestRender();
}, 1000 / 30);
}

handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
this.dispose();
this.done();
}
}

render(width: number): string[] {
const th = this.theme;
const innerW = Math.max(1, width - 2);
const padLine = (s: string) => truncateToWidth(s, innerW, "...", true);
const border = (c: string) => th.fg("border", c);

const lines: string[] = [];
lines.push(border(`${"─".repeat(innerW)}`));
lines.push(border("│") + padLine(th.fg("accent", " Animation Demo (~30 FPS)")) + border("│"));
lines.push(border("│") + padLine(``) + border("│"));
lines.push(border("│") + padLine(` Frame: ${th.fg("accent", String(this.frame))}`) + border("│"));
lines.push(border("│") + padLine(` FPS: ${th.fg("success", String(this.fps))}`) + border("│"));
lines.push(border("│") + padLine(``) + border("│"));

// Animated content - bouncing bar
const barWidth = Math.max(12, innerW - 4); // Ensure enough space for bar
const pos = Math.max(0, Math.floor(((Math.sin(this.frame / 10) + 1) * (barWidth - 10)) / 2));
const bar = " ".repeat(pos) + th.fg("accent", "██████████") + " ".repeat(Math.max(0, barWidth - 10 - pos));
lines.push(border("│") + padLine(` ${bar}`) + border("│"));

// Spinning character
const spinChars = ["◐", "◓", "◑", "◒"];
const spin = spinChars[this.frame % spinChars.length];
lines.push(border("│") + padLine(` Spinner: ${th.fg("warning", spin!)}`) + border("│"));

// Color cycling
const hue = (this.frame * 3) % 360;
const rgb = hslToRgb(hue / 360, 0.8, 0.5);
const colorBlock = `\x1b[48;2;${rgb[0]};${rgb[1]};${rgb[2]}m${" ".repeat(10)}\x1b[0m`;
lines.push(border("│") + padLine(` Color: ${colorBlock}`) + border("│"));

lines.push(border("│") + padLine(``) + border("│"));
lines.push(border("│") + padLine(th.fg("dim", " This proves overlays can handle")) + border("│"));
lines.push(border("│") + padLine(th.fg("dim", " real-time game-like rendering.")) + border("│"));
lines.push(border("│") + padLine(th.fg("dim", " (pi-doom uses same approach)")) + border("│"));
lines.push(border("│") + padLine(``) + border("│"));
lines.push(border("│") + padLine(th.fg("dim", " Press Esc to close")) + border("│"));
lines.push(border(`${"─".repeat(innerW)}`));

return lines;
}

dispose(): void {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
}

// HSL to RGB helper for color cycling animation
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
let r: number, g: number, b: number;
if (s === 0) {
r = g = b = l;
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}

// Toggle demo - demonstrates OverlayHandle.setHidden() via onHandle callback
class ToggleDemoComponent extends BaseOverlay {
private toggleCount = 0;
private isToggling = false;

constructor(
private tui: TUI,
theme: Theme,
private done: () => void,
) {
super(theme);
}

handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
this.done();
} else if (matchesKey(data, "t") && globalToggleHandle && !this.isToggling) {
// Demonstrate toggle by hiding for 1 second then showing again
// (In real usage, a global keybinding would control visibility)
this.isToggling = true;
this.toggleCount++;
globalToggleHandle.setHidden(true);

// Auto-restore after 1 second to demonstrate the API
setTimeout(() => {
if (globalToggleHandle) {
globalToggleHandle.setHidden(false);
this.isToggling = false;
this.tui.requestRender();
}
}, 1000);
}
}

render(width: number): string[] {
const th = this.theme;
return this.box(
[
"",
th.fg("accent", " Toggle Demo"),
"",
" This overlay demonstrates the",
" onHandle callback API.",
"",
` Toggle count: ${th.fg("accent", String(this.toggleCount))}`,
"",
th.fg("dim", " Press 't' to hide for 1 second"),
th.fg("dim", " (demonstrates setHidden API)"),
"",
th.fg("dim", " In real usage, a global keybinding"),
th.fg("dim", " would toggle visibility externally."),
"",
th.fg("dim", " Press Esc to close"),
"",
],
width,
"Toggle Demo",
);
}
}

for comprehensive examples covering anchors, margins, stacking, responsive visibility, and animation.

Built-in Components

Import from @mariozechner/pi-tui:

import { Text, Box, Container, Spacer, Markdown } from "@mariozechner/pi-tui";

Text

Multi-line text with word wrapping.

const text = new Text(
"Hello World", // content
1, // paddingX (default: 1)
1, // paddingY (default: 1)
(s) => bgGray(s) // optional background function
);
text.setText("Updated");

Box

Container with padding and background color.

const box = new Box(
1, // paddingX
1, // paddingY
(s) => bgGray(s) // background function
);
box.addChild(new Text("Content", 0, 0));
box.setBgFn((s) => bgBlue(s));

Container

Groups child components vertically.

const container = new Container();
container.addChild(component1);
container.addChild(component2);
container.removeChild(component1);

Spacer

Empty vertical space.

const spacer = new Spacer(2);  // 2 empty lines

Markdown

Renders markdown with syntax highlighting.

const md = new Markdown(
"# Title\n\nSome **bold** text",
1, // paddingX
1, // paddingY
theme // MarkdownTheme (see below)
);
md.setText("Updated markdown");

Image

Renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm).

const image = new Image(
base64Data, // base64-encoded image
"image/png", // MIME type
theme, // ImageTheme
{ maxWidthCells: 80, maxHeightCells: 24 }
);

Keyboard Input

Use matchesKey() for key detection:

import { matchesKey, Key } from "@mariozechner/pi-tui";

handleInput(data: string) {
if (matchesKey(data, Key.up)) {
this.selectedIndex--;
} else if (matchesKey(data, Key.enter)) {
this.onSelect?.(this.selectedIndex);
} else if (matchesKey(data, Key.escape)) {
this.onCancel?.();
} else if (matchesKey(data, Key.ctrl("c"))) {
// Ctrl+C
}
}

Key identifiers (use Key.* for autocomplete, or string literals):

  • Basic keys: Key.enter, Key.escape, Key.tab, Key.space, Key.backspace, Key.delete, Key.home, Key.end
  • Arrow keys: Key.up, Key.down, Key.left, Key.right
  • With modifiers: Key.ctrl("c"), Key.shift("tab"), Key.alt("left"), Key.ctrlShift("p")
  • String format also works: "enter", "ctrl+c", "shift+tab", "ctrl+shift+p"

Line Width

Critical: Each line from render() must not exceed the width parameter.

import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";

render(width: number): string[] {
// Truncate long lines
return [truncateToWidth(this.text, width)];
}

Utilities:

  • visibleWidth(str) - Get display width (ignores ANSI codes)
  • truncateToWidth(str, width, ellipsis?) - Truncate with optional ellipsis
  • wrapTextWithAnsi(str, width) - Word wrap preserving ANSI codes

Creating Custom Components

Example: Interactive selector

import {
matchesKey, Key,
truncateToWidth, visibleWidth
} from "@mariozechner/pi-tui";

class MySelector {
private items: string[];
private selected = 0;
private cachedWidth?: number;
private cachedLines?: string[];

public onSelect?: (item: string) => void;
public onCancel?: () => void;

constructor(items: string[]) {
this.items = items;
}

handleInput(data: string): void {
if (matchesKey(data, Key.up) && this.selected > 0) {
this.selected--;
this.invalidate();
} else if (matchesKey(data, Key.down) && this.selected < this.items.length - 1) {
this.selected++;
this.invalidate();
} else if (matchesKey(data, Key.enter)) {
this.onSelect?.(this.items[this.selected]);
} else if (matchesKey(data, Key.escape)) {
this.onCancel?.();
}
}

render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}

this.cachedLines = this.items.map((item, i) => {
const prefix = i === this.selected ? "> " : " ";
return truncateToWidth(prefix + item, width);
});
this.cachedWidth = width;
return this.cachedLines;
}

invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
}

Usage in an extension:

pi.registerCommand("pick", {
description: "Pick an item",
handler: async (args, ctx) => {
const items = ["Option A", "Option B", "Option C"];
const selector = new MySelector(items);

let handle: { close: () => void; requestRender: () => void };

await new Promise<void>((resolve) => {
selector.onSelect = (item) => {
ctx.ui.notify(`Selected: ${item}`, "info");
handle.close();
resolve();
};
selector.onCancel = () => {
handle.close();
resolve();
};
handle = ctx.ui.custom(selector);
});
}
});

Theming

Components accept theme objects for styling.

In renderCall/renderResult, use the theme parameter:

renderResult(result, options, theme) {
// Use theme.fg() for foreground colors
return new Text(theme.fg("success", "Done!"), 0, 0);

// Use theme.bg() for background colors
const styled = theme.bg("toolPendingBg", theme.fg("accent", "text"));
}

Foreground colors (theme.fg(color, text)):

CategoryColors
Generaltext, accent, muted, dim
Statussuccess, error, warning
Bordersborder, borderAccent, borderMuted
MessagesuserMessageText, customMessageText, customMessageLabel
ToolstoolTitle, toolOutput
DiffstoolDiffAdded, toolDiffRemoved, toolDiffContext
MarkdownmdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet
SyntaxsyntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation
ThinkingthinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh
ModesbashMode

Background colors (theme.bg(color, text)):

selectedBg, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg

For Markdown, use getMarkdownTheme():

import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
import { Markdown } from "@mariozechner/pi-tui";

renderResult(result, options, theme) {
const mdTheme = getMarkdownTheme();
return new Markdown(result.details.markdown, 0, 0, mdTheme);
}

For custom components, define your own theme interface:

interface MyTheme {
selected: (s: string) => string;
normal: (s: string) => string;
}

Debug logging

Set PI_TUI_WRITE_LOG to capture the raw ANSI stream written to stdout.

PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx packages/tui/test/chat-simple.ts

Performance

Cache rendered output when possible:

class CachedComponent {
private cachedWidth?: number;
private cachedLines?: string[];

render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
// ... compute lines ...
this.cachedWidth = width;
this.cachedLines = lines;
return lines;
}

invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
}

Call invalidate() when state changes, then handle.requestRender() to trigger re-render.

Invalidation and Theme Changes

When the theme changes, the TUI calls invalidate() on all components to clear their caches. Components must properly implement invalidate() to ensure theme changes take effect.

The Problem

If a component pre-bakes theme colors into strings (via theme.fg(), theme.bg(), etc.) and caches them, the cached strings contain ANSI escape codes from the old theme. Simply clearing the render cache isn't enough if the component stores the themed content separately.

Wrong approach (theme colors won't update):

class BadComponent extends Container {
private content: Text;

constructor(message: string, theme: Theme) {
super();
// Pre-baked theme colors stored in Text component
this.content = new Text(theme.fg("accent", message), 1, 0);
this.addChild(this.content);
}
// No invalidate override - parent's invalidate only clears
// child render caches, not the pre-baked content
}

The Solution

Components that build content with theme colors must rebuild that content when invalidate() is called:

class GoodComponent extends Container {
private message: string;
private content: Text;

constructor(message: string) {
super();
this.message = message;
this.content = new Text("", 1, 0);
this.addChild(this.content);
this.updateDisplay();
}

private updateDisplay(): void {
// Rebuild content with current theme
this.content.setText(theme.fg("accent", this.message));
}

override invalidate(): void {
super.invalidate(); // Clear child caches
this.updateDisplay(); // Rebuild with new theme
}
}

Pattern: Rebuild on Invalidate

For components with complex content:

class ComplexComponent extends Container {
private data: SomeData;

constructor(data: SomeData) {
super();
this.data = data;
this.rebuild();
}

private rebuild(): void {
this.clear(); // Remove all children

// Build UI with current theme
this.addChild(new Text(theme.fg("accent", theme.bold("Title")), 1, 0));
this.addChild(new Spacer(1));

for (const item of this.data.items) {
const color = item.active ? "success" : "muted";
this.addChild(new Text(theme.fg(color, item.label), 1, 0));
}
}

override invalidate(): void {
super.invalidate();
this.rebuild();
}
}

When This Matters

This pattern is needed when:

  1. Pre-baking theme colors - Using theme.fg() or theme.bg() to create styled strings stored in child components
  2. Syntax highlighting - Using highlightCode() which applies theme-based syntax colors
  3. Complex layouts - Building child component trees that embed theme colors

This pattern is NOT needed when:

  1. Using theme callbacks - Passing functions like (text) => theme.fg("accent", text) that are called during render
  2. Simple containers - Just grouping other components without adding themed content
  3. Stateless render - Computing themed output fresh in every render() call (no caching)

Common Patterns

These patterns cover the most common UI needs in extensions. Copy these patterns instead of building from scratch.

Pattern 1: Selection Dialog (SelectList)

For letting users pick from a list of options. Use SelectList from @mariozechner/pi-tui with DynamicBorder for framing.

import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import { Container, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui";

pi.registerCommand("pick", {
handler: async (_args, ctx) => {
const items: SelectItem[] = [
{ value: "opt1", label: "Option 1", description: "First option" },
{ value: "opt2", label: "Option 2", description: "Second option" },
{ value: "opt3", label: "Option 3" }, // description is optional
];

const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
const container = new Container();

// Top border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));

// Title
container.addChild(new Text(theme.fg("accent", theme.bold("Pick an Option")), 1, 0));

// SelectList with theme
const selectList = new SelectList(items, Math.min(items.length, 10), {
selectedPrefix: (t) => theme.fg("accent", t),
selectedText: (t) => theme.fg("accent", t),
description: (t) => theme.fg("muted", t),
scrollInfo: (t) => theme.fg("dim", t),
noMatch: (t) => theme.fg("warning", t),
});
selectList.onSelect = (item) => done(item.value);
selectList.onCancel = () => done(null);
container.addChild(selectList);

// Help text
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));

// Bottom border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));

return {
render: (w) => container.render(w),
invalidate: () => container.invalidate(),
handleInput: (data) => { selectList.handleInput(data); tui.requestRender(); },
};
});

if (result) {
ctx.ui.notify(`Selected: ${result}`, "info");
}
},
});

Examples:

preset.ts
/**
* Preset Extension
*
* Allows defining named presets that configure model, thinking level, tools,
* and system prompt instructions. Presets are defined in JSON config files
* and can be activated via CLI flag, /preset command, or Ctrl+Shift+U to cycle.
*
* Config files (merged, project takes precedence):
* - ~/.pi/agent/presets.json (global)
* - <cwd>/.pi/presets.json (project-local)
*
* Example presets.json:
* ```json
* {
* "plan": {
* "provider": "openai-codex",
* "model": "gpt-5.2-codex",
* "thinkingLevel": "high",
* "tools": ["read", "grep", "find", "ls"],
* "instructions": "You are in PLANNING MODE. Your job is to deeply understand the problem and create a detailed implementation plan.\n\nRules:\n- DO NOT make any changes. You cannot edit or write files.\n- Read files IN FULL (no offset/limit) to get complete context. Partial reads miss critical details.\n- Explore thoroughly: grep for related code, find similar patterns, understand the architecture.\n- Ask clarifying questions if requirements are ambiguous. Do not assume.\n- Identify risks, edge cases, and dependencies before proposing solutions.\n\nOutput:\n- Create a structured plan with numbered steps.\n- For each step: what to change, why, and potential risks.\n- List files that will be modified.\n- Note any tests that should be added or updated.\n\nWhen done, ask the user if they want you to:\n1. Write the plan to a markdown file (e.g., PLAN.md)\n2. Create a GitHub issue with the plan\n3. Proceed to implementation (they should switch to 'implement' preset)"
* },
* "implement": {
* "provider": "anthropic",
* "model": "claude-sonnet-4-5",
* "thinkingLevel": "high",
* "tools": ["read", "bash", "edit", "write"],
* "instructions": "You are in IMPLEMENTATION MODE. Your job is to make focused, correct changes.\n\nRules:\n- Keep scope tight. Do exactly what was asked, no more.\n- Read files before editing to understand current state.\n- Make surgical edits. Prefer edit over write for existing files.\n- Explain your reasoning briefly before each change.\n- Run tests or type checks after changes if the project has them (npm test, npm run check, etc.).\n- If you encounter unexpected complexity, STOP and explain the issue rather than hacking around it.\n\nIf no plan exists:\n- Ask clarifying questions before starting.\n- Propose what you'll do and get confirmation for non-trivial changes.\n\nAfter completing changes:\n- Summarize what was done.\n- Note any follow-up work or tests that should be added."
* }
* }
* ```
*
* Usage:
* - `pi --preset plan` - start with plan preset
* - `/preset` - show selector to switch presets mid-session
* - `/preset implement` - switch to implement preset directly
* - `Ctrl+Shift+U` - cycle through presets
*
* CLI flags always override preset values.
*/

import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import { Container, Key, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui";

// Preset configuration
interface Preset {
/** Provider name (e.g., "anthropic", "openai") */
provider?: string;
/** Model ID (e.g., "claude-sonnet-4-5") */
model?: string;
/** Thinking level */
thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
/** Tools to enable (replaces default set) */
tools?: string[];
/** Instructions to append to system prompt */
instructions?: string;
}

interface PresetsConfig {
[name: string]: Preset;
}

/**
* Load presets from config files.
* Project-local presets override global presets with the same name.
*/
function loadPresets(cwd: string): PresetsConfig {
const globalPath = join(homedir(), ".pi", "agent", "presets.json");
const projectPath = join(cwd, ".pi", "presets.json");

let globalPresets: PresetsConfig = {};
let projectPresets: PresetsConfig = {};

// Load global presets
if (existsSync(globalPath)) {
try {
const content = readFileSync(globalPath, "utf-8");
globalPresets = JSON.parse(content);
} catch (err) {
console.error(`Failed to load global presets from ${globalPath}: ${err}`);
}
}

// Load project presets
if (existsSync(projectPath)) {
try {
const content = readFileSync(projectPath, "utf-8");
projectPresets = JSON.parse(content);
} catch (err) {
console.error(`Failed to load project presets from ${projectPath}: ${err}`);
}
}

// Merge (project overrides global)
return { ...globalPresets, ...projectPresets };
}

export default function presetExtension(pi: ExtensionAPI) {
let presets: PresetsConfig = {};
let activePresetName: string | undefined;
let activePreset: Preset | undefined;

// Register --preset CLI flag
pi.registerFlag("preset", {
description: "Preset configuration to use",
type: "string",
});

/**
* Apply a preset configuration.
*/
async function applyPreset(name: string, preset: Preset, ctx: ExtensionContext): Promise<boolean> {
// Apply model if specified
if (preset.provider && preset.model) {
const model = ctx.modelRegistry.find(preset.provider, preset.model);
if (model) {
const success = await pi.setModel(model);
if (!success) {
ctx.ui.notify(`Preset "${name}": No API key for ${preset.provider}/${preset.model}`, "warning");
}
} else {
ctx.ui.notify(`Preset "${name}": Model ${preset.provider}/${preset.model} not found`, "warning");
}
}

// Apply thinking level if specified
if (preset.thinkingLevel) {
pi.setThinkingLevel(preset.thinkingLevel);
}

// Apply tools if specified
if (preset.tools && preset.tools.length > 0) {
const allToolNames = pi.getAllTools().map((t) => t.name);
const validTools = preset.tools.filter((t) => allToolNames.includes(t));
const invalidTools = preset.tools.filter((t) => !allToolNames.includes(t));

if (invalidTools.length > 0) {
ctx.ui.notify(`Preset "${name}": Unknown tools: ${invalidTools.join(", ")}`, "warning");
}

if (validTools.length > 0) {
pi.setActiveTools(validTools);
}
}

// Store active preset for system prompt injection
activePresetName = name;
activePreset = preset;

return true;
}

/**
* Build description string for a preset.
*/
function buildPresetDescription(preset: Preset): string {
const parts: string[] = [];

if (preset.provider && preset.model) {
parts.push(`${preset.provider}/${preset.model}`);
}
if (preset.thinkingLevel) {
parts.push(`thinking:${preset.thinkingLevel}`);
}
if (preset.tools) {
parts.push(`tools:${preset.tools.join(",")}`);
}
if (preset.instructions) {
const truncated =
preset.instructions.length > 30 ? `${preset.instructions.slice(0, 27)}...` : preset.instructions;
parts.push(`"${truncated}"`);
}

return parts.join(" | ");
}

/**
* Show preset selector UI using custom SelectList component.
*/
async function showPresetSelector(ctx: ExtensionContext): Promise<void> {
const presetNames = Object.keys(presets);

if (presetNames.length === 0) {
ctx.ui.notify("No presets defined. Add presets to ~/.pi/agent/presets.json or .pi/presets.json", "warning");
return;
}

// Build select items with descriptions
const items: SelectItem[] = presetNames.map((name) => {
const preset = presets[name];
const isActive = name === activePresetName;
return {
value: name,
label: isActive ? `${name} (active)` : name,
description: buildPresetDescription(preset),
};
});

// Add "None" option to clear preset
items.push({
value: "(none)",
label: "(none)",
description: "Clear active preset, restore defaults",
});

const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
const container = new Container();
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));

// Header
container.addChild(new Text(theme.fg("accent", theme.bold("Select Preset"))));

// SelectList with themed styling
const selectList = new SelectList(items, Math.min(items.length, 10), {
selectedPrefix: (text) => theme.fg("accent", text),
selectedText: (text) => theme.fg("accent", text),
description: (text) => theme.fg("muted", text),
scrollInfo: (text) => theme.fg("dim", text),
noMatch: (text) => theme.fg("warning", text),
});

selectList.onSelect = (item) => done(item.value);
selectList.onCancel = () => done(null);

container.addChild(selectList);

// Footer hint
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel")));

container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));

return {
render(width: number) {
return container.render(width);
},
invalidate() {
container.invalidate();
},
handleInput(data: string) {
selectList.handleInput(data);
tui.requestRender();
},
};
});

if (!result) return;

if (result === "(none)") {
// Clear preset and restore defaults
activePresetName = undefined;
activePreset = undefined;
pi.setActiveTools(["read", "bash", "edit", "write"]);
ctx.ui.notify("Preset cleared, defaults restored", "info");
updateStatus(ctx);
return;
}

const preset = presets[result];
if (preset) {
await applyPreset(result, preset, ctx);
ctx.ui.notify(`Preset "${result}" activated`, "info");
updateStatus(ctx);
}
}

/**
* Update status indicator.
*/
function updateStatus(ctx: ExtensionContext) {
if (activePresetName) {
ctx.ui.setStatus("preset", ctx.ui.theme.fg("accent", `preset:${activePresetName}`));
} else {
ctx.ui.setStatus("preset", undefined);
}
}

function getPresetOrder(): string[] {
return Object.keys(presets).sort();
}

async function cyclePreset(ctx: ExtensionContext): Promise<void> {
const presetNames = getPresetOrder();
if (presetNames.length === 0) {
ctx.ui.notify("No presets defined. Add presets to ~/.pi/agent/presets.json or .pi/presets.json", "warning");
return;
}

const cycleList = ["(none)", ...presetNames];
const currentName = activePresetName ?? "(none)";
const currentIndex = cycleList.indexOf(currentName);
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % cycleList.length;
const nextName = cycleList[nextIndex];

if (nextName === "(none)") {
activePresetName = undefined;
activePreset = undefined;
pi.setActiveTools(["read", "bash", "edit", "write"]);
ctx.ui.notify("Preset cleared, defaults restored", "info");
updateStatus(ctx);
return;
}

const preset = presets[nextName];
if (!preset) return;

await applyPreset(nextName, preset, ctx);
ctx.ui.notify(`Preset "${nextName}" activated`, "info");
updateStatus(ctx);
}

pi.registerShortcut(Key.ctrlShift("u"), {
description: "Cycle presets",
handler: async (ctx) => {
await cyclePreset(ctx);
},
});

// Register /preset command
pi.registerCommand("preset", {
description: "Switch preset configuration",
handler: async (args, ctx) => {
// If preset name provided, apply directly
if (args?.trim()) {
const name = args.trim();
const preset = presets[name];

if (!preset) {
const available = Object.keys(presets).join(", ") || "(none defined)";
ctx.ui.notify(`Unknown preset "${name}". Available: ${available}`, "error");
return;
}

await applyPreset(name, preset, ctx);
ctx.ui.notify(`Preset "${name}" activated`, "info");
updateStatus(ctx);
return;
}

// Otherwise show selector
await showPresetSelector(ctx);
},
});

// Inject preset instructions into system prompt
pi.on("before_agent_start", async (event) => {
if (activePreset?.instructions) {
return {
systemPrompt: `${event.systemPrompt}\n\n${activePreset.instructions}`,
};
}
});

// Initialize on session start
pi.on("session_start", async (_event, ctx) => {
// Load presets from config files
presets = loadPresets(ctx.cwd);

// Check for --preset flag
const presetFlag = pi.getFlag("preset");
if (typeof presetFlag === "string" && presetFlag) {
const preset = presets[presetFlag];
if (preset) {
await applyPreset(presetFlag, preset, ctx);
ctx.ui.notify(`Preset "${presetFlag}" activated`, "info");
} else {
const available = Object.keys(presets).join(", ") || "(none defined)";
ctx.ui.notify(`Unknown preset "${presetFlag}". Available: ${available}`, "warning");
}
}

// Restore preset from session state
const entries = ctx.sessionManager.getEntries();
const presetEntry = entries
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "preset-state")
.pop() as { data?: { name: string } } | undefined;

if (presetEntry?.data?.name && !presetFlag) {
const preset = presets[presetEntry.data.name];
if (preset) {
activePresetName = presetEntry.data.name;
activePreset = preset;
// Don't re-apply model/tools on restore, just keep the name for instructions
}
}

updateStatus(ctx);
});

// Persist preset state
pi.on("turn_start", async () => {
if (activePresetName) {
pi.appendEntry("preset-state", { name: activePresetName });
}
});
}

,
tools.ts
/**
* Tools Extension
*
* Provides a /tools command to enable/disable tools interactively.
* Tool selection persists across session reloads and respects branch navigation.
*
* Usage:
* 1. Copy this file to ~/.pi/agent/extensions/ or your project's .pi/extensions/
* 2. Use /tools to open the tool selector
*/

import type { ExtensionAPI, ExtensionContext, ToolInfo } from "@mariozechner/pi-coding-agent";
import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
import { Container, type SettingItem, SettingsList } from "@mariozechner/pi-tui";

// State persisted to session
interface ToolsState {
enabledTools: string[];
}

export default function toolsExtension(pi: ExtensionAPI) {
// Track enabled tools
let enabledTools: Set<string> = new Set();
let allTools: ToolInfo[] = [];

// Persist current state
function persistState() {
pi.appendEntry<ToolsState>("tools-config", {
enabledTools: Array.from(enabledTools),
});
}

// Apply current tool selection
function applyTools() {
pi.setActiveTools(Array.from(enabledTools));
}

// Find the last tools-config entry in the current branch
function restoreFromBranch(ctx: ExtensionContext) {
allTools = pi.getAllTools();

// Get entries in current branch only
const branchEntries = ctx.sessionManager.getBranch();
let savedTools: string[] | undefined;

for (const entry of branchEntries) {
if (entry.type === "custom" && entry.customType === "tools-config") {
const data = entry.data as ToolsState | undefined;
if (data?.enabledTools) {
savedTools = data.enabledTools;
}
}
}

if (savedTools) {
// Restore saved tool selection (filter to only tools that still exist)
const allToolNames = allTools.map((t) => t.name);
enabledTools = new Set(savedTools.filter((t: string) => allToolNames.includes(t)));
applyTools();
} else {
// No saved state - sync with currently active tools
enabledTools = new Set(pi.getActiveTools());
}
}

// Register /tools command
pi.registerCommand("tools", {
description: "Enable/disable tools",
handler: async (_args, ctx) => {
// Refresh tool list
allTools = pi.getAllTools();

await ctx.ui.custom((tui, theme, _kb, done) => {
// Build settings items for each tool
const items: SettingItem[] = allTools.map((tool) => ({
id: tool.name,
label: tool.name,
currentValue: enabledTools.has(tool.name) ? "enabled" : "disabled",
values: ["enabled", "disabled"],
}));

const container = new Container();
container.addChild(
new (class {
render(_width: number) {
return [theme.fg("accent", theme.bold("Tool Configuration")), ""];
}
invalidate() {}
})(),
);

const settingsList = new SettingsList(
items,
Math.min(items.length + 2, 15),
getSettingsListTheme(),
(id, newValue) => {
// Update enabled state and apply immediately
if (newValue === "enabled") {
enabledTools.add(id);
} else {
enabledTools.delete(id);
}
applyTools();
persistState();
},
() => {
// Close dialog
done(undefined);
},
);

container.addChild(settingsList);

const component = {
render(width: number) {
return container.render(width);
},
invalidate() {
container.invalidate();
},
handleInput(data: string) {
settingsList.handleInput?.(data);
tui.requestRender();
},
};

return component;
});
},
});

// Restore state on session start
pi.on("session_start", async (_event, ctx) => {
restoreFromBranch(ctx);
});

// Restore state when navigating the session tree
pi.on("session_tree", async (_event, ctx) => {
restoreFromBranch(ctx);
});

// Restore state after forking
pi.on("session_fork", async (_event, ctx) => {
restoreFromBranch(ctx);
});
}

Pattern 2: Async Operation with Cancel (BorderedLoader)

For operations that take time and should be cancellable. BorderedLoader shows a spinner and handles escape to cancel.

import { BorderedLoader } from "@mariozechner/pi-coding-agent";

pi.registerCommand("fetch", {
handler: async (_args, ctx) => {
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
const loader = new BorderedLoader(tui, theme, "Fetching data...");
loader.onAbort = () => done(null);

// Do async work
fetchData(loader.signal)
.then((data) => done(data))
.catch(() => done(null));

return loader;
});

if (result === null) {
ctx.ui.notify("Cancelled", "info");
} else {
ctx.ui.setEditorText(result);
}
},
});

Examples:

qna.ts
/**
* Q&A extraction extension - extracts questions from assistant responses
*
* Demonstrates the "prompt generator" pattern:
* 1. /qna command gets the last assistant message
* 2. Shows a spinner while extracting (hides editor)
* 3. Loads the result into the editor for user to fill in answers
*/

import { complete, type UserMessage } from "@mariozechner/pi-ai";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { BorderedLoader } from "@mariozechner/pi-coding-agent";

const SYSTEM_PROMPT = `You are a question extractor. Given text from a conversation, extract any questions that need answering and format them for the user to fill in.

Output format:
- List each question on its own line, prefixed with "Q: "
- After each question, add a blank line for the answer prefixed with "A: "
- If no questions are found, output "No questions found in the last message."

Example output:
Q: What is your preferred database?
A:

Q: Should we use TypeScript or JavaScript?
A:

Keep questions in the order they appeared. Be concise.`;

export default function (pi: ExtensionAPI) {
pi.registerCommand("qna", {
description: "Extract questions from last assistant message into editor",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("qna requires interactive mode", "error");
return;
}

if (!ctx.model) {
ctx.ui.notify("No model selected", "error");
return;
}

// Find the last assistant message on the current branch
const branch = ctx.sessionManager.getBranch();
let lastAssistantText: string | undefined;

for (let i = branch.length - 1; i >= 0; i--) {
const entry = branch[i];
if (entry.type === "message") {
const msg = entry.message;
if ("role" in msg && msg.role === "assistant") {
if (msg.stopReason !== "stop") {
ctx.ui.notify(`Last assistant message incomplete (${msg.stopReason})`, "error");
return;
}
const textParts = msg.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text);
if (textParts.length > 0) {
lastAssistantText = textParts.join("\n");
break;
}
}
}
}

if (!lastAssistantText) {
ctx.ui.notify("No assistant messages found", "error");
return;
}

// Run extraction with loader UI
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
const loader = new BorderedLoader(tui, theme, `Extracting questions using ${ctx.model!.id}...`);
loader.onAbort = () => done(null);

// Do the work
const doExtract = async () => {
const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
const userMessage: UserMessage = {
role: "user",
content: [{ type: "text", text: lastAssistantText! }],
timestamp: Date.now(),
};

const response = await complete(
ctx.model!,
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
{ apiKey, signal: loader.signal },
);

if (response.stopReason === "aborted") {
return null;
}

return response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
};

doExtract()
.then(done)
.catch(() => done(null));

return loader;
});

if (result === null) {
ctx.ui.notify("Cancelled", "info");
return;
}

ctx.ui.setEditorText(result);
ctx.ui.notify("Questions loaded. Edit and submit when ready.", "info");
},
});
}

,
handoff.ts
/**
* Handoff extension - transfer context to a new focused session
*
* Instead of compacting (which is lossy), handoff extracts what matters
* for your next task and creates a new session with a generated prompt.
*
* Usage:
* /handoff now implement this for teams as well
* /handoff execute phase one of the plan
* /handoff check other places that need this fix
*
* The generated prompt appears as a draft in the editor for review/editing.
*/

import { complete, type Message } from "@mariozechner/pi-ai";
import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent";
import { BorderedLoader, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";

const SYSTEM_PROMPT = `You are a context transfer assistant. Given a conversation history and the user's goal for a new thread, generate a focused prompt that:

1. Summarizes relevant context from the conversation (decisions made, approaches taken, key findings)
2. Lists any relevant files that were discussed or modified
3. Clearly states the next task based on the user's goal
4. Is self-contained - the new thread should be able to proceed without the old conversation

Format your response as a prompt the user can send to start the new thread. Be concise but include all necessary context. Do not include any preamble like "Here's the prompt" - just output the prompt itself.

Example output format:
## Context
We've been working on X. Key decisions:
- Decision 1
- Decision 2

Files involved:
- path/to/file1.ts
- path/to/file2.ts

## Task
[Clear description of what to do next based on user's goal]`;

export default function (pi: ExtensionAPI) {
pi.registerCommand("handoff", {
description: "Transfer context to a new focused session",
handler: async (args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("handoff requires interactive mode", "error");
return;
}

if (!ctx.model) {
ctx.ui.notify("No model selected", "error");
return;
}

const goal = args.trim();
if (!goal) {
ctx.ui.notify("Usage: /handoff <goal for new thread>", "error");
return;
}

// Gather conversation context from current branch
const branch = ctx.sessionManager.getBranch();
const messages = branch
.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
.map((entry) => entry.message);

if (messages.length === 0) {
ctx.ui.notify("No conversation to hand off", "error");
return;
}

// Convert to LLM format and serialize
const llmMessages = convertToLlm(messages);
const conversationText = serializeConversation(llmMessages);
const currentSessionFile = ctx.sessionManager.getSessionFile();

// Generate the handoff prompt with loader UI
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
const loader = new BorderedLoader(tui, theme, `Generating handoff prompt...`);
loader.onAbort = () => done(null);

const doGenerate = async () => {
const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);

const userMessage: Message = {
role: "user",
content: [
{
type: "text",
text: `## Conversation History\n\n${conversationText}\n\n## User's Goal for New Thread\n\n${goal}`,
},
],
timestamp: Date.now(),
};

const response = await complete(
ctx.model!,
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
{ apiKey, signal: loader.signal },
);

if (response.stopReason === "aborted") {
return null;
}

return response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
};

doGenerate()
.then(done)
.catch((err) => {
console.error("Handoff generation failed:", err);
done(null);
});

return loader;
});

if (result === null) {
ctx.ui.notify("Cancelled", "info");
return;
}

// Let user edit the generated prompt
const editedPrompt = await ctx.ui.editor("Edit handoff prompt", result);

if (editedPrompt === undefined) {
ctx.ui.notify("Cancelled", "info");
return;
}

// Create new session with parent tracking
const newSessionResult = await ctx.newSession({
parentSession: currentSessionFile,
});

if (newSessionResult.cancelled) {
ctx.ui.notify("New session cancelled", "info");
return;
}

// Set the edited prompt in the main editor for submission
ctx.ui.setEditorText(editedPrompt);
ctx.ui.notify("Handoff ready. Submit when ready.", "info");
},
});
}

Pattern 3: Settings/Toggles (SettingsList)

For toggling multiple settings. Use SettingsList from @mariozechner/pi-tui with getSettingsListTheme().

import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
import { Container, type SettingItem, SettingsList, Text } from "@mariozechner/pi-tui";

pi.registerCommand("settings", {
handler: async (_args, ctx) => {
const items: SettingItem[] = [
{ id: "verbose", label: "Verbose mode", currentValue: "off", values: ["on", "off"] },
{ id: "color", label: "Color output", currentValue: "on", values: ["on", "off"] },
];

await ctx.ui.custom((_tui, theme, _kb, done) => {
const container = new Container();
container.addChild(new Text(theme.fg("accent", theme.bold("Settings")), 1, 1));

const settingsList = new SettingsList(
items,
Math.min(items.length + 2, 15),
getSettingsListTheme(),
(id, newValue) => {
// Handle value change
ctx.ui.notify(`${id} = ${newValue}`, "info");
},
() => done(undefined), // On close
{ enableSearch: true }, // Optional: enable fuzzy search by label
);
container.addChild(settingsList);

return {
render: (w) => container.render(w),
invalidate: () => container.invalidate(),
handleInput: (data) => settingsList.handleInput?.(data),
};
});
},
});

Examples:

tools.ts
/**
* Tools Extension
*
* Provides a /tools command to enable/disable tools interactively.
* Tool selection persists across session reloads and respects branch navigation.
*
* Usage:
* 1. Copy this file to ~/.pi/agent/extensions/ or your project's .pi/extensions/
* 2. Use /tools to open the tool selector
*/

import type { ExtensionAPI, ExtensionContext, ToolInfo } from "@mariozechner/pi-coding-agent";
import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
import { Container, type SettingItem, SettingsList } from "@mariozechner/pi-tui";

// State persisted to session
interface ToolsState {
enabledTools: string[];
}

export default function toolsExtension(pi: ExtensionAPI) {
// Track enabled tools
let enabledTools: Set<string> = new Set();
let allTools: ToolInfo[] = [];

// Persist current state
function persistState() {
pi.appendEntry<ToolsState>("tools-config", {
enabledTools: Array.from(enabledTools),
});
}

// Apply current tool selection
function applyTools() {
pi.setActiveTools(Array.from(enabledTools));
}

// Find the last tools-config entry in the current branch
function restoreFromBranch(ctx: ExtensionContext) {
allTools = pi.getAllTools();

// Get entries in current branch only
const branchEntries = ctx.sessionManager.getBranch();
let savedTools: string[] | undefined;

for (const entry of branchEntries) {
if (entry.type === "custom" && entry.customType === "tools-config") {
const data = entry.data as ToolsState | undefined;
if (data?.enabledTools) {
savedTools = data.enabledTools;
}
}
}

if (savedTools) {
// Restore saved tool selection (filter to only tools that still exist)
const allToolNames = allTools.map((t) => t.name);
enabledTools = new Set(savedTools.filter((t: string) => allToolNames.includes(t)));
applyTools();
} else {
// No saved state - sync with currently active tools
enabledTools = new Set(pi.getActiveTools());
}
}

// Register /tools command
pi.registerCommand("tools", {
description: "Enable/disable tools",
handler: async (_args, ctx) => {
// Refresh tool list
allTools = pi.getAllTools();

await ctx.ui.custom((tui, theme, _kb, done) => {
// Build settings items for each tool
const items: SettingItem[] = allTools.map((tool) => ({
id: tool.name,
label: tool.name,
currentValue: enabledTools.has(tool.name) ? "enabled" : "disabled",
values: ["enabled", "disabled"],
}));

const container = new Container();
container.addChild(
new (class {
render(_width: number) {
return [theme.fg("accent", theme.bold("Tool Configuration")), ""];
}
invalidate() {}
})(),
);

const settingsList = new SettingsList(
items,
Math.min(items.length + 2, 15),
getSettingsListTheme(),
(id, newValue) => {
// Update enabled state and apply immediately
if (newValue === "enabled") {
enabledTools.add(id);
} else {
enabledTools.delete(id);
}
applyTools();
persistState();
},
() => {
// Close dialog
done(undefined);
},
);

container.addChild(settingsList);

const component = {
render(width: number) {
return container.render(width);
},
invalidate() {
container.invalidate();
},
handleInput(data: string) {
settingsList.handleInput?.(data);
tui.requestRender();
},
};

return component;
});
},
});

// Restore state on session start
pi.on("session_start", async (_event, ctx) => {
restoreFromBranch(ctx);
});

// Restore state when navigating the session tree
pi.on("session_tree", async (_event, ctx) => {
restoreFromBranch(ctx);
});

// Restore state after forking
pi.on("session_fork", async (_event, ctx) => {
restoreFromBranch(ctx);
});
}

Pattern 4: Persistent Status Indicator

Show status in the footer that persists across renders. Good for mode indicators.

// Set status (shown in footer)
ctx.ui.setStatus("my-ext", ctx.ui.theme.fg("accent", "● active"));

// Clear status
ctx.ui.setStatus("my-ext", undefined);

Examples:

status-line.ts
/**
* Status Line Extension
*
* Demonstrates ctx.ui.setStatus() for displaying persistent status text in the footer.
* Shows turn progress with themed colors.
*/

import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";

export default function (pi: ExtensionAPI) {
let turnCount = 0;

pi.on("session_start", async (_event, ctx) => {
const theme = ctx.ui.theme;
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
});

pi.on("turn_start", async (_event, ctx) => {
turnCount++;
const theme = ctx.ui.theme;
const spinner = theme.fg("accent", "●");
const text = theme.fg("dim", ` Turn ${turnCount}...`);
ctx.ui.setStatus("status-demo", spinner + text);
});

pi.on("turn_end", async (_event, ctx) => {
const theme = ctx.ui.theme;
const check = theme.fg("success", "✓");
const text = theme.fg("dim", ` Turn ${turnCount} complete`);
ctx.ui.setStatus("status-demo", check + text);
});

pi.on("session_switch", async (event, ctx) => {
if (event.reason === "new") {
turnCount = 0;
const theme = ctx.ui.theme;
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
}
});
}

, plan-mode.ts,
preset.ts
/**
* Preset Extension
*
* Allows defining named presets that configure model, thinking level, tools,
* and system prompt instructions. Presets are defined in JSON config files
* and can be activated via CLI flag, /preset command, or Ctrl+Shift+U to cycle.
*
* Config files (merged, project takes precedence):
* - ~/.pi/agent/presets.json (global)
* - <cwd>/.pi/presets.json (project-local)
*
* Example presets.json:
* ```json
* {
* "plan": {
* "provider": "openai-codex",
* "model": "gpt-5.2-codex",
* "thinkingLevel": "high",
* "tools": ["read", "grep", "find", "ls"],
* "instructions": "You are in PLANNING MODE. Your job is to deeply understand the problem and create a detailed implementation plan.\n\nRules:\n- DO NOT make any changes. You cannot edit or write files.\n- Read files IN FULL (no offset/limit) to get complete context. Partial reads miss critical details.\n- Explore thoroughly: grep for related code, find similar patterns, understand the architecture.\n- Ask clarifying questions if requirements are ambiguous. Do not assume.\n- Identify risks, edge cases, and dependencies before proposing solutions.\n\nOutput:\n- Create a structured plan with numbered steps.\n- For each step: what to change, why, and potential risks.\n- List files that will be modified.\n- Note any tests that should be added or updated.\n\nWhen done, ask the user if they want you to:\n1. Write the plan to a markdown file (e.g., PLAN.md)\n2. Create a GitHub issue with the plan\n3. Proceed to implementation (they should switch to 'implement' preset)"
* },
* "implement": {
* "provider": "anthropic",
* "model": "claude-sonnet-4-5",
* "thinkingLevel": "high",
* "tools": ["read", "bash", "edit", "write"],
* "instructions": "You are in IMPLEMENTATION MODE. Your job is to make focused, correct changes.\n\nRules:\n- Keep scope tight. Do exactly what was asked, no more.\n- Read files before editing to understand current state.\n- Make surgical edits. Prefer edit over write for existing files.\n- Explain your reasoning briefly before each change.\n- Run tests or type checks after changes if the project has them (npm test, npm run check, etc.).\n- If you encounter unexpected complexity, STOP and explain the issue rather than hacking around it.\n\nIf no plan exists:\n- Ask clarifying questions before starting.\n- Propose what you'll do and get confirmation for non-trivial changes.\n\nAfter completing changes:\n- Summarize what was done.\n- Note any follow-up work or tests that should be added."
* }
* }
* ```
*
* Usage:
* - `pi --preset plan` - start with plan preset
* - `/preset` - show selector to switch presets mid-session
* - `/preset implement` - switch to implement preset directly
* - `Ctrl+Shift+U` - cycle through presets
*
* CLI flags always override preset values.
*/

import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import { Container, Key, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui";

// Preset configuration
interface Preset {
/** Provider name (e.g., "anthropic", "openai") */
provider?: string;
/** Model ID (e.g., "claude-sonnet-4-5") */
model?: string;
/** Thinking level */
thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
/** Tools to enable (replaces default set) */
tools?: string[];
/** Instructions to append to system prompt */
instructions?: string;
}

interface PresetsConfig {
[name: string]: Preset;
}

/**
* Load presets from config files.
* Project-local presets override global presets with the same name.
*/
function loadPresets(cwd: string): PresetsConfig {
const globalPath = join(homedir(), ".pi", "agent", "presets.json");
const projectPath = join(cwd, ".pi", "presets.json");

let globalPresets: PresetsConfig = {};
let projectPresets: PresetsConfig = {};

// Load global presets
if (existsSync(globalPath)) {
try {
const content = readFileSync(globalPath, "utf-8");
globalPresets = JSON.parse(content);
} catch (err) {
console.error(`Failed to load global presets from ${globalPath}: ${err}`);
}
}

// Load project presets
if (existsSync(projectPath)) {
try {
const content = readFileSync(projectPath, "utf-8");
projectPresets = JSON.parse(content);
} catch (err) {
console.error(`Failed to load project presets from ${projectPath}: ${err}`);
}
}

// Merge (project overrides global)
return { ...globalPresets, ...projectPresets };
}

export default function presetExtension(pi: ExtensionAPI) {
let presets: PresetsConfig = {};
let activePresetName: string | undefined;
let activePreset: Preset | undefined;

// Register --preset CLI flag
pi.registerFlag("preset", {
description: "Preset configuration to use",
type: "string",
});

/**
* Apply a preset configuration.
*/
async function applyPreset(name: string, preset: Preset, ctx: ExtensionContext): Promise<boolean> {
// Apply model if specified
if (preset.provider && preset.model) {
const model = ctx.modelRegistry.find(preset.provider, preset.model);
if (model) {
const success = await pi.setModel(model);
if (!success) {
ctx.ui.notify(`Preset "${name}": No API key for ${preset.provider}/${preset.model}`, "warning");
}
} else {
ctx.ui.notify(`Preset "${name}": Model ${preset.provider}/${preset.model} not found`, "warning");
}
}

// Apply thinking level if specified
if (preset.thinkingLevel) {
pi.setThinkingLevel(preset.thinkingLevel);
}

// Apply tools if specified
if (preset.tools && preset.tools.length > 0) {
const allToolNames = pi.getAllTools().map((t) => t.name);
const validTools = preset.tools.filter((t) => allToolNames.includes(t));
const invalidTools = preset.tools.filter((t) => !allToolNames.includes(t));

if (invalidTools.length > 0) {
ctx.ui.notify(`Preset "${name}": Unknown tools: ${invalidTools.join(", ")}`, "warning");
}

if (validTools.length > 0) {
pi.setActiveTools(validTools);
}
}

// Store active preset for system prompt injection
activePresetName = name;
activePreset = preset;

return true;
}

/**
* Build description string for a preset.
*/
function buildPresetDescription(preset: Preset): string {
const parts: string[] = [];

if (preset.provider && preset.model) {
parts.push(`${preset.provider}/${preset.model}`);
}
if (preset.thinkingLevel) {
parts.push(`thinking:${preset.thinkingLevel}`);
}
if (preset.tools) {
parts.push(`tools:${preset.tools.join(",")}`);
}
if (preset.instructions) {
const truncated =
preset.instructions.length > 30 ? `${preset.instructions.slice(0, 27)}...` : preset.instructions;
parts.push(`"${truncated}"`);
}

return parts.join(" | ");
}

/**
* Show preset selector UI using custom SelectList component.
*/
async function showPresetSelector(ctx: ExtensionContext): Promise<void> {
const presetNames = Object.keys(presets);

if (presetNames.length === 0) {
ctx.ui.notify("No presets defined. Add presets to ~/.pi/agent/presets.json or .pi/presets.json", "warning");
return;
}

// Build select items with descriptions
const items: SelectItem[] = presetNames.map((name) => {
const preset = presets[name];
const isActive = name === activePresetName;
return {
value: name,
label: isActive ? `${name} (active)` : name,
description: buildPresetDescription(preset),
};
});

// Add "None" option to clear preset
items.push({
value: "(none)",
label: "(none)",
description: "Clear active preset, restore defaults",
});

const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
const container = new Container();
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));

// Header
container.addChild(new Text(theme.fg("accent", theme.bold("Select Preset"))));

// SelectList with themed styling
const selectList = new SelectList(items, Math.min(items.length, 10), {
selectedPrefix: (text) => theme.fg("accent", text),
selectedText: (text) => theme.fg("accent", text),
description: (text) => theme.fg("muted", text),
scrollInfo: (text) => theme.fg("dim", text),
noMatch: (text) => theme.fg("warning", text),
});

selectList.onSelect = (item) => done(item.value);
selectList.onCancel = () => done(null);

container.addChild(selectList);

// Footer hint
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel")));

container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));

return {
render(width: number) {
return container.render(width);
},
invalidate() {
container.invalidate();
},
handleInput(data: string) {
selectList.handleInput(data);
tui.requestRender();
},
};
});

if (!result) return;

if (result === "(none)") {
// Clear preset and restore defaults
activePresetName = undefined;
activePreset = undefined;
pi.setActiveTools(["read", "bash", "edit", "write"]);
ctx.ui.notify("Preset cleared, defaults restored", "info");
updateStatus(ctx);
return;
}

const preset = presets[result];
if (preset) {
await applyPreset(result, preset, ctx);
ctx.ui.notify(`Preset "${result}" activated`, "info");
updateStatus(ctx);
}
}

/**
* Update status indicator.
*/
function updateStatus(ctx: ExtensionContext) {
if (activePresetName) {
ctx.ui.setStatus("preset", ctx.ui.theme.fg("accent", `preset:${activePresetName}`));
} else {
ctx.ui.setStatus("preset", undefined);
}
}

function getPresetOrder(): string[] {
return Object.keys(presets).sort();
}

async function cyclePreset(ctx: ExtensionContext): Promise<void> {
const presetNames = getPresetOrder();
if (presetNames.length === 0) {
ctx.ui.notify("No presets defined. Add presets to ~/.pi/agent/presets.json or .pi/presets.json", "warning");
return;
}

const cycleList = ["(none)", ...presetNames];
const currentName = activePresetName ?? "(none)";
const currentIndex = cycleList.indexOf(currentName);
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % cycleList.length;
const nextName = cycleList[nextIndex];

if (nextName === "(none)") {
activePresetName = undefined;
activePreset = undefined;
pi.setActiveTools(["read", "bash", "edit", "write"]);
ctx.ui.notify("Preset cleared, defaults restored", "info");
updateStatus(ctx);
return;
}

const preset = presets[nextName];
if (!preset) return;

await applyPreset(nextName, preset, ctx);
ctx.ui.notify(`Preset "${nextName}" activated`, "info");
updateStatus(ctx);
}

pi.registerShortcut(Key.ctrlShift("u"), {
description: "Cycle presets",
handler: async (ctx) => {
await cyclePreset(ctx);
},
});

// Register /preset command
pi.registerCommand("preset", {
description: "Switch preset configuration",
handler: async (args, ctx) => {
// If preset name provided, apply directly
if (args?.trim()) {
const name = args.trim();
const preset = presets[name];

if (!preset) {
const available = Object.keys(presets).join(", ") || "(none defined)";
ctx.ui.notify(`Unknown preset "${name}". Available: ${available}`, "error");
return;
}

await applyPreset(name, preset, ctx);
ctx.ui.notify(`Preset "${name}" activated`, "info");
updateStatus(ctx);
return;
}

// Otherwise show selector
await showPresetSelector(ctx);
},
});

// Inject preset instructions into system prompt
pi.on("before_agent_start", async (event) => {
if (activePreset?.instructions) {
return {
systemPrompt: `${event.systemPrompt}\n\n${activePreset.instructions}`,
};
}
});

// Initialize on session start
pi.on("session_start", async (_event, ctx) => {
// Load presets from config files
presets = loadPresets(ctx.cwd);

// Check for --preset flag
const presetFlag = pi.getFlag("preset");
if (typeof presetFlag === "string" && presetFlag) {
const preset = presets[presetFlag];
if (preset) {
await applyPreset(presetFlag, preset, ctx);
ctx.ui.notify(`Preset "${presetFlag}" activated`, "info");
} else {
const available = Object.keys(presets).join(", ") || "(none defined)";
ctx.ui.notify(`Unknown preset "${presetFlag}". Available: ${available}`, "warning");
}
}

// Restore preset from session state
const entries = ctx.sessionManager.getEntries();
const presetEntry = entries
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "preset-state")
.pop() as { data?: { name: string } } | undefined;

if (presetEntry?.data?.name && !presetFlag) {
const preset = presets[presetEntry.data.name];
if (preset) {
activePresetName = presetEntry.data.name;
activePreset = preset;
// Don't re-apply model/tools on restore, just keep the name for instructions
}
}

updateStatus(ctx);
});

// Persist preset state
pi.on("turn_start", async () => {
if (activePresetName) {
pi.appendEntry("preset-state", { name: activePresetName });
}
});
}

Pattern 5: Widgets Above/Below Editor

Show persistent content above or below the input editor. Good for todo lists, progress.

// Simple string array (above editor by default)
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);

// Render below the editor
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" });

// Or with theme
ctx.ui.setWidget("my-widget", (_tui, theme) => {
const lines = items.map((item, i) =>
item.done
? theme.fg("success", "✓ ") + theme.fg("muted", item.text)
: theme.fg("dim", "○ ") + item.text
);
return {
render: () => lines,
invalidate: () => {},
};
});

// Clear
ctx.ui.setWidget("my-widget", undefined);

Examples: plan-mode.ts

Replace the footer. footerData exposes data not otherwise accessible to extensions.

ctx.ui.setFooter((tui, theme, footerData) => ({
invalidate() {},
render(width: number): string[] {
// footerData.getGitBranch(): string | null
// footerData.getExtensionStatuses(): ReadonlyMap<string, string>
return [`${ctx.model?.id} (${footerData.getGitBranch() || "no git"})`];
},
dispose: footerData.onBranchChange(() => tui.requestRender()), // reactive
}));

ctx.ui.setFooter(undefined); // restore default

Token stats available via ctx.sessionManager.getBranch() and ctx.model.

Examples:

custom-footer.ts
/**
* Custom Footer Extension - demonstrates ctx.ui.setFooter()
*
* footerData exposes data not otherwise accessible:
* - getGitBranch(): current git branch
* - getExtensionStatuses(): texts from ctx.ui.setStatus()
*
* Token stats come from ctx.sessionManager/ctx.model (already accessible).
*/

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 enabled = false;

pi.registerCommand("footer", {
description: "Toggle custom footer",
handler: async (_args, ctx) => {
enabled = !enabled;

if (enabled) {
ctx.ui.setFooter((tui, theme, footerData) => {
const unsub = footerData.onBranchChange(() => tui.requestRender());

return {
dispose: unsub,
invalidate() {},
render(width: number): string[] {
// Compute tokens from ctx (already accessible to extensions)
let input = 0,
output = 0,
cost = 0;
for (const e of ctx.sessionManager.getBranch()) {
if (e.type === "message" && e.message.role === "assistant") {
const m = e.message as AssistantMessage;
input += m.usage.input;
output += m.usage.output;
cost += m.usage.cost.total;
}
}

// Get git branch (not otherwise accessible)
const branch = footerData.getGitBranch();
const fmt = (n: number) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`);

const left = theme.fg("dim", `${fmt(input)}${fmt(output)} $${cost.toFixed(3)}`);
const branchStr = branch ? ` (${branch})` : "";
const right = theme.fg("dim", `${ctx.model?.id || "no-model"}${branchStr}`);

const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
return [truncateToWidth(left + pad + right, width)];
},
};
});
ctx.ui.notify("Custom footer enabled", "info");
} else {
ctx.ui.setFooter(undefined);
ctx.ui.notify("Default footer restored", "info");
}
},
});
}

Pattern 7: Custom Editor (vim mode, etc.)

Replace the main input editor with a custom implementation. Useful for modal editing (vim), different keybindings (emacs), or specialized input handling.

import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { matchesKey, truncateToWidth } from "@mariozechner/pi-tui";

type Mode = "normal" | "insert";

class VimEditor extends CustomEditor {
private mode: Mode = "insert";

handleInput(data: string): void {
// Escape: switch to normal mode, or pass through for app handling
if (matchesKey(data, "escape")) {
if (this.mode === "insert") {
this.mode = "normal";
return;
}
// In normal mode, escape aborts agent (handled by CustomEditor)
super.handleInput(data);
return;
}

// Insert mode: pass everything to CustomEditor
if (this.mode === "insert") {
super.handleInput(data);
return;
}

// Normal mode: vim-style navigation
switch (data) {
case "i": this.mode = "insert"; return;
case "h": super.handleInput("\x1b[D"); return; // Left
case "j": super.handleInput("\x1b[B"); return; // Down
case "k": super.handleInput("\x1b[A"); return; // Up
case "l": super.handleInput("\x1b[C"); return; // Right
}
// Pass unhandled keys to super (ctrl+c, etc.), but filter printable chars
if (data.length === 1 && data.charCodeAt(0) >= 32) return;
super.handleInput(data);
}

render(width: number): string[] {
const lines = super.render(width);
// Add mode indicator to bottom border (use truncateToWidth for ANSI-safe truncation)
if (lines.length > 0) {
const label = this.mode === "normal" ? " NORMAL " : " INSERT ";
const lastLine = lines[lines.length - 1]!;
// Pass "" as ellipsis to avoid adding "..." when truncating
lines[lines.length - 1] = truncateToWidth(lastLine, width - label.length, "") + label;
}
return lines;
}
}

export default function (pi: ExtensionAPI) {
pi.on("session_start", (_event, ctx) => {
// Factory receives theme and keybindings from the app
ctx.ui.setEditorComponent((tui, theme, keybindings) =>
new VimEditor(theme, keybindings)
);
});
}

Key points:

  • Extend CustomEditor (not base Editor) to get app keybindings (escape to abort, ctrl+d to exit, model switching, etc.)
  • Call super.handleInput(data) for keys you don't handle
  • Factory pattern: setEditorComponent receives a factory function that gets tui, theme, and keybindings
  • Pass undefined to restore the default editor: ctx.ui.setEditorComponent(undefined)

Examples:

modal-editor.ts
/**
* Modal Editor - vim-like modal editing example
*
* Usage: pi --extension ./examples/extensions/modal-editor.ts
*
* - Escape: insert → normal mode (in normal mode, aborts agent)
* - i: normal → insert mode
* - hjkl: navigation in normal mode
* - ctrl+c, ctrl+d, etc. work in both modes
*/

import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";

// Normal mode key mappings: key -> escape sequence (or null for mode switch)
const NORMAL_KEYS: Record<string, string | null> = {
h: "\x1b[D", // left
j: "\x1b[B", // down
k: "\x1b[A", // up
l: "\x1b[C", // right
"0": "\x01", // line start
$: "\x05", // line end
x: "\x1b[3~", // delete char
i: null, // insert mode
a: null, // append (insert + right)
};

class ModalEditor extends CustomEditor {
private mode: "normal" | "insert" = "insert";

handleInput(data: string): void {
// Escape toggles to normal mode, or passes through for app handling
if (matchesKey(data, "escape")) {
if (this.mode === "insert") {
this.mode = "normal";
} else {
super.handleInput(data); // abort agent, etc.
}
return;
}

// Insert mode: pass everything through
if (this.mode === "insert") {
super.handleInput(data);
return;
}

// Normal mode: check mapped keys
if (data in NORMAL_KEYS) {
const seq = NORMAL_KEYS[data];
if (data === "i") {
this.mode = "insert";
} else if (data === "a") {
this.mode = "insert";
super.handleInput("\x1b[C"); // move right first
} else if (seq) {
super.handleInput(seq);
}
return;
}

// Pass control sequences (ctrl+c, etc.) to super, ignore printable chars
if (data.length === 1 && data.charCodeAt(0) >= 32) return;
super.handleInput(data);
}

render(width: number): string[] {
const lines = super.render(width);
if (lines.length === 0) return lines;

// Add mode indicator to bottom border
const label = this.mode === "normal" ? " NORMAL " : " INSERT ";
const last = lines.length - 1;
if (visibleWidth(lines[last]!) >= label.length) {
lines[last] = truncateToWidth(lines[last]!, width - label.length, "") + label;
}
return lines;
}
}

export default function (pi: ExtensionAPI) {
pi.on("session_start", (_event, ctx) => {
ctx.ui.setEditorComponent((tui, theme, kb) => new ModalEditor(tui, theme, kb));
});
}

Key Rules

  1. Always use theme from callback - Don't import theme directly. Use theme from the ctx.ui.custom((tui, theme, keybindings, done) => ...) callback.

  2. Always type DynamicBorder color param - Write (s: string) => theme.fg("accent", s), not (s) => theme.fg("accent", s).

  3. Call tui.requestRender() after state changes - In handleInput, call tui.requestRender() after updating state.

  4. Return the three-method object - Custom components need { render, invalidate, handleInput }.

  5. Use existing components - SelectList, SettingsList, BorderedLoader cover 90% of cases. Don't rebuild them.

Examples

  • Selection UI:
    examples/extensions/preset.ts
    /**
    * Preset Extension
    *
    * Allows defining named presets that configure model, thinking level, tools,
    * and system prompt instructions. Presets are defined in JSON config files
    * and can be activated via CLI flag, /preset command, or Ctrl+Shift+U to cycle.
    *
    * Config files (merged, project takes precedence):
    * - ~/.pi/agent/presets.json (global)
    * - <cwd>/.pi/presets.json (project-local)
    *
    * Example presets.json:
    * ```json
    * {
    * "plan": {
    * "provider": "openai-codex",
    * "model": "gpt-5.2-codex",
    * "thinkingLevel": "high",
    * "tools": ["read", "grep", "find", "ls"],
    * "instructions": "You are in PLANNING MODE. Your job is to deeply understand the problem and create a detailed implementation plan.\n\nRules:\n- DO NOT make any changes. You cannot edit or write files.\n- Read files IN FULL (no offset/limit) to get complete context. Partial reads miss critical details.\n- Explore thoroughly: grep for related code, find similar patterns, understand the architecture.\n- Ask clarifying questions if requirements are ambiguous. Do not assume.\n- Identify risks, edge cases, and dependencies before proposing solutions.\n\nOutput:\n- Create a structured plan with numbered steps.\n- For each step: what to change, why, and potential risks.\n- List files that will be modified.\n- Note any tests that should be added or updated.\n\nWhen done, ask the user if they want you to:\n1. Write the plan to a markdown file (e.g., PLAN.md)\n2. Create a GitHub issue with the plan\n3. Proceed to implementation (they should switch to 'implement' preset)"
    * },
    * "implement": {
    * "provider": "anthropic",
    * "model": "claude-sonnet-4-5",
    * "thinkingLevel": "high",
    * "tools": ["read", "bash", "edit", "write"],
    * "instructions": "You are in IMPLEMENTATION MODE. Your job is to make focused, correct changes.\n\nRules:\n- Keep scope tight. Do exactly what was asked, no more.\n- Read files before editing to understand current state.\n- Make surgical edits. Prefer edit over write for existing files.\n- Explain your reasoning briefly before each change.\n- Run tests or type checks after changes if the project has them (npm test, npm run check, etc.).\n- If you encounter unexpected complexity, STOP and explain the issue rather than hacking around it.\n\nIf no plan exists:\n- Ask clarifying questions before starting.\n- Propose what you'll do and get confirmation for non-trivial changes.\n\nAfter completing changes:\n- Summarize what was done.\n- Note any follow-up work or tests that should be added."
    * }
    * }
    * ```
    *
    * Usage:
    * - `pi --preset plan` - start with plan preset
    * - `/preset` - show selector to switch presets mid-session
    * - `/preset implement` - switch to implement preset directly
    * - `Ctrl+Shift+U` - cycle through presets
    *
    * CLI flags always override preset values.
    */

    import { existsSync, readFileSync } from "node:fs";
    import { homedir } from "node:os";
    import { join } from "node:path";
    import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
    import { DynamicBorder } from "@mariozechner/pi-coding-agent";
    import { Container, Key, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui";

    // Preset configuration
    interface Preset {
    /** Provider name (e.g., "anthropic", "openai") */
    provider?: string;
    /** Model ID (e.g., "claude-sonnet-4-5") */
    model?: string;
    /** Thinking level */
    thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
    /** Tools to enable (replaces default set) */
    tools?: string[];
    /** Instructions to append to system prompt */
    instructions?: string;
    }

    interface PresetsConfig {
    [name: string]: Preset;
    }

    /**
    * Load presets from config files.
    * Project-local presets override global presets with the same name.
    */
    function loadPresets(cwd: string): PresetsConfig {
    const globalPath = join(homedir(), ".pi", "agent", "presets.json");
    const projectPath = join(cwd, ".pi", "presets.json");

    let globalPresets: PresetsConfig = {};
    let projectPresets: PresetsConfig = {};

    // Load global presets
    if (existsSync(globalPath)) {
    try {
    const content = readFileSync(globalPath, "utf-8");
    globalPresets = JSON.parse(content);
    } catch (err) {
    console.error(`Failed to load global presets from ${globalPath}: ${err}`);
    }
    }

    // Load project presets
    if (existsSync(projectPath)) {
    try {
    const content = readFileSync(projectPath, "utf-8");
    projectPresets = JSON.parse(content);
    } catch (err) {
    console.error(`Failed to load project presets from ${projectPath}: ${err}`);
    }
    }

    // Merge (project overrides global)
    return { ...globalPresets, ...projectPresets };
    }

    export default function presetExtension(pi: ExtensionAPI) {
    let presets: PresetsConfig = {};
    let activePresetName: string | undefined;
    let activePreset: Preset | undefined;

    // Register --preset CLI flag
    pi.registerFlag("preset", {
    description: "Preset configuration to use",
    type: "string",
    });

    /**
    * Apply a preset configuration.
    */
    async function applyPreset(name: string, preset: Preset, ctx: ExtensionContext): Promise<boolean> {
    // Apply model if specified
    if (preset.provider && preset.model) {
    const model = ctx.modelRegistry.find(preset.provider, preset.model);
    if (model) {
    const success = await pi.setModel(model);
    if (!success) {
    ctx.ui.notify(`Preset "${name}": No API key for ${preset.provider}/${preset.model}`, "warning");
    }
    } else {
    ctx.ui.notify(`Preset "${name}": Model ${preset.provider}/${preset.model} not found`, "warning");
    }
    }

    // Apply thinking level if specified
    if (preset.thinkingLevel) {
    pi.setThinkingLevel(preset.thinkingLevel);
    }

    // Apply tools if specified
    if (preset.tools && preset.tools.length > 0) {
    const allToolNames = pi.getAllTools().map((t) => t.name);
    const validTools = preset.tools.filter((t) => allToolNames.includes(t));
    const invalidTools = preset.tools.filter((t) => !allToolNames.includes(t));

    if (invalidTools.length > 0) {
    ctx.ui.notify(`Preset "${name}": Unknown tools: ${invalidTools.join(", ")}`, "warning");
    }

    if (validTools.length > 0) {
    pi.setActiveTools(validTools);
    }
    }

    // Store active preset for system prompt injection
    activePresetName = name;
    activePreset = preset;

    return true;
    }

    /**
    * Build description string for a preset.
    */
    function buildPresetDescription(preset: Preset): string {
    const parts: string[] = [];

    if (preset.provider && preset.model) {
    parts.push(`${preset.provider}/${preset.model}`);
    }
    if (preset.thinkingLevel) {
    parts.push(`thinking:${preset.thinkingLevel}`);
    }
    if (preset.tools) {
    parts.push(`tools:${preset.tools.join(",")}`);
    }
    if (preset.instructions) {
    const truncated =
    preset.instructions.length > 30 ? `${preset.instructions.slice(0, 27)}...` : preset.instructions;
    parts.push(`"${truncated}"`);
    }

    return parts.join(" | ");
    }

    /**
    * Show preset selector UI using custom SelectList component.
    */
    async function showPresetSelector(ctx: ExtensionContext): Promise<void> {
    const presetNames = Object.keys(presets);

    if (presetNames.length === 0) {
    ctx.ui.notify("No presets defined. Add presets to ~/.pi/agent/presets.json or .pi/presets.json", "warning");
    return;
    }

    // Build select items with descriptions
    const items: SelectItem[] = presetNames.map((name) => {
    const preset = presets[name];
    const isActive = name === activePresetName;
    return {
    value: name,
    label: isActive ? `${name} (active)` : name,
    description: buildPresetDescription(preset),
    };
    });

    // Add "None" option to clear preset
    items.push({
    value: "(none)",
    label: "(none)",
    description: "Clear active preset, restore defaults",
    });

    const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
    const container = new Container();
    container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));

    // Header
    container.addChild(new Text(theme.fg("accent", theme.bold("Select Preset"))));

    // SelectList with themed styling
    const selectList = new SelectList(items, Math.min(items.length, 10), {
    selectedPrefix: (text) => theme.fg("accent", text),
    selectedText: (text) => theme.fg("accent", text),
    description: (text) => theme.fg("muted", text),
    scrollInfo: (text) => theme.fg("dim", text),
    noMatch: (text) => theme.fg("warning", text),
    });

    selectList.onSelect = (item) => done(item.value);
    selectList.onCancel = () => done(null);

    container.addChild(selectList);

    // Footer hint
    container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel")));

    container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));

    return {
    render(width: number) {
    return container.render(width);
    },
    invalidate() {
    container.invalidate();
    },
    handleInput(data: string) {
    selectList.handleInput(data);
    tui.requestRender();
    },
    };
    });

    if (!result) return;

    if (result === "(none)") {
    // Clear preset and restore defaults
    activePresetName = undefined;
    activePreset = undefined;
    pi.setActiveTools(["read", "bash", "edit", "write"]);
    ctx.ui.notify("Preset cleared, defaults restored", "info");
    updateStatus(ctx);
    return;
    }

    const preset = presets[result];
    if (preset) {
    await applyPreset(result, preset, ctx);
    ctx.ui.notify(`Preset "${result}" activated`, "info");
    updateStatus(ctx);
    }
    }

    /**
    * Update status indicator.
    */
    function updateStatus(ctx: ExtensionContext) {
    if (activePresetName) {
    ctx.ui.setStatus("preset", ctx.ui.theme.fg("accent", `preset:${activePresetName}`));
    } else {
    ctx.ui.setStatus("preset", undefined);
    }
    }

    function getPresetOrder(): string[] {
    return Object.keys(presets).sort();
    }

    async function cyclePreset(ctx: ExtensionContext): Promise<void> {
    const presetNames = getPresetOrder();
    if (presetNames.length === 0) {
    ctx.ui.notify("No presets defined. Add presets to ~/.pi/agent/presets.json or .pi/presets.json", "warning");
    return;
    }

    const cycleList = ["(none)", ...presetNames];
    const currentName = activePresetName ?? "(none)";
    const currentIndex = cycleList.indexOf(currentName);
    const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % cycleList.length;
    const nextName = cycleList[nextIndex];

    if (nextName === "(none)") {
    activePresetName = undefined;
    activePreset = undefined;
    pi.setActiveTools(["read", "bash", "edit", "write"]);
    ctx.ui.notify("Preset cleared, defaults restored", "info");
    updateStatus(ctx);
    return;
    }

    const preset = presets[nextName];
    if (!preset) return;

    await applyPreset(nextName, preset, ctx);
    ctx.ui.notify(`Preset "${nextName}" activated`, "info");
    updateStatus(ctx);
    }

    pi.registerShortcut(Key.ctrlShift("u"), {
    description: "Cycle presets",
    handler: async (ctx) => {
    await cyclePreset(ctx);
    },
    });

    // Register /preset command
    pi.registerCommand("preset", {
    description: "Switch preset configuration",
    handler: async (args, ctx) => {
    // If preset name provided, apply directly
    if (args?.trim()) {
    const name = args.trim();
    const preset = presets[name];

    if (!preset) {
    const available = Object.keys(presets).join(", ") || "(none defined)";
    ctx.ui.notify(`Unknown preset "${name}". Available: ${available}`, "error");
    return;
    }

    await applyPreset(name, preset, ctx);
    ctx.ui.notify(`Preset "${name}" activated`, "info");
    updateStatus(ctx);
    return;
    }

    // Otherwise show selector
    await showPresetSelector(ctx);
    },
    });

    // Inject preset instructions into system prompt
    pi.on("before_agent_start", async (event) => {
    if (activePreset?.instructions) {
    return {
    systemPrompt: `${event.systemPrompt}\n\n${activePreset.instructions}`,
    };
    }
    });

    // Initialize on session start
    pi.on("session_start", async (_event, ctx) => {
    // Load presets from config files
    presets = loadPresets(ctx.cwd);

    // Check for --preset flag
    const presetFlag = pi.getFlag("preset");
    if (typeof presetFlag === "string" && presetFlag) {
    const preset = presets[presetFlag];
    if (preset) {
    await applyPreset(presetFlag, preset, ctx);
    ctx.ui.notify(`Preset "${presetFlag}" activated`, "info");
    } else {
    const available = Object.keys(presets).join(", ") || "(none defined)";
    ctx.ui.notify(`Unknown preset "${presetFlag}". Available: ${available}`, "warning");
    }
    }

    // Restore preset from session state
    const entries = ctx.sessionManager.getEntries();
    const presetEntry = entries
    .filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "preset-state")
    .pop() as { data?: { name: string } } | undefined;

    if (presetEntry?.data?.name && !presetFlag) {
    const preset = presets[presetEntry.data.name];
    if (preset) {
    activePresetName = presetEntry.data.name;
    activePreset = preset;
    // Don't re-apply model/tools on restore, just keep the name for instructions
    }
    }

    updateStatus(ctx);
    });

    // Persist preset state
    pi.on("turn_start", async () => {
    if (activePresetName) {
    pi.appendEntry("preset-state", { name: activePresetName });
    }
    });
    }

    - SelectList with DynamicBorder framing
  • Async with cancel:
    examples/extensions/qna.ts
    /**
    * Q&A extraction extension - extracts questions from assistant responses
    *
    * Demonstrates the "prompt generator" pattern:
    * 1. /qna command gets the last assistant message
    * 2. Shows a spinner while extracting (hides editor)
    * 3. Loads the result into the editor for user to fill in answers
    */

    import { complete, type UserMessage } from "@mariozechner/pi-ai";
    import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
    import { BorderedLoader } from "@mariozechner/pi-coding-agent";

    const SYSTEM_PROMPT = `You are a question extractor. Given text from a conversation, extract any questions that need answering and format them for the user to fill in.

    Output format:
    - List each question on its own line, prefixed with "Q: "
    - After each question, add a blank line for the answer prefixed with "A: "
    - If no questions are found, output "No questions found in the last message."

    Example output:
    Q: What is your preferred database?
    A:

    Q: Should we use TypeScript or JavaScript?
    A:

    Keep questions in the order they appeared. Be concise.`;

    export default function (pi: ExtensionAPI) {
    pi.registerCommand("qna", {
    description: "Extract questions from last assistant message into editor",
    handler: async (_args, ctx) => {
    if (!ctx.hasUI) {
    ctx.ui.notify("qna requires interactive mode", "error");
    return;
    }

    if (!ctx.model) {
    ctx.ui.notify("No model selected", "error");
    return;
    }

    // Find the last assistant message on the current branch
    const branch = ctx.sessionManager.getBranch();
    let lastAssistantText: string | undefined;

    for (let i = branch.length - 1; i >= 0; i--) {
    const entry = branch[i];
    if (entry.type === "message") {
    const msg = entry.message;
    if ("role" in msg && msg.role === "assistant") {
    if (msg.stopReason !== "stop") {
    ctx.ui.notify(`Last assistant message incomplete (${msg.stopReason})`, "error");
    return;
    }
    const textParts = msg.content
    .filter((c): c is { type: "text"; text: string } => c.type === "text")
    .map((c) => c.text);
    if (textParts.length > 0) {
    lastAssistantText = textParts.join("\n");
    break;
    }
    }
    }
    }

    if (!lastAssistantText) {
    ctx.ui.notify("No assistant messages found", "error");
    return;
    }

    // Run extraction with loader UI
    const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
    const loader = new BorderedLoader(tui, theme, `Extracting questions using ${ctx.model!.id}...`);
    loader.onAbort = () => done(null);

    // Do the work
    const doExtract = async () => {
    const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
    const userMessage: UserMessage = {
    role: "user",
    content: [{ type: "text", text: lastAssistantText! }],
    timestamp: Date.now(),
    };

    const response = await complete(
    ctx.model!,
    { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
    { apiKey, signal: loader.signal },
    );

    if (response.stopReason === "aborted") {
    return null;
    }

    return response.content
    .filter((c): c is { type: "text"; text: string } => c.type === "text")
    .map((c) => c.text)
    .join("\n");
    };

    doExtract()
    .then(done)
    .catch(() => done(null));

    return loader;
    });

    if (result === null) {
    ctx.ui.notify("Cancelled", "info");
    return;
    }

    ctx.ui.setEditorText(result);
    ctx.ui.notify("Questions loaded. Edit and submit when ready.", "info");
    },
    });
    }

    - BorderedLoader for LLM calls
  • Settings toggles:
    examples/extensions/tools.ts
    /**
    * Tools Extension
    *
    * Provides a /tools command to enable/disable tools interactively.
    * Tool selection persists across session reloads and respects branch navigation.
    *
    * Usage:
    * 1. Copy this file to ~/.pi/agent/extensions/ or your project's .pi/extensions/
    * 2. Use /tools to open the tool selector
    */

    import type { ExtensionAPI, ExtensionContext, ToolInfo } from "@mariozechner/pi-coding-agent";
    import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
    import { Container, type SettingItem, SettingsList } from "@mariozechner/pi-tui";

    // State persisted to session
    interface ToolsState {
    enabledTools: string[];
    }

    export default function toolsExtension(pi: ExtensionAPI) {
    // Track enabled tools
    let enabledTools: Set<string> = new Set();
    let allTools: ToolInfo[] = [];

    // Persist current state
    function persistState() {
    pi.appendEntry<ToolsState>("tools-config", {
    enabledTools: Array.from(enabledTools),
    });
    }

    // Apply current tool selection
    function applyTools() {
    pi.setActiveTools(Array.from(enabledTools));
    }

    // Find the last tools-config entry in the current branch
    function restoreFromBranch(ctx: ExtensionContext) {
    allTools = pi.getAllTools();

    // Get entries in current branch only
    const branchEntries = ctx.sessionManager.getBranch();
    let savedTools: string[] | undefined;

    for (const entry of branchEntries) {
    if (entry.type === "custom" && entry.customType === "tools-config") {
    const data = entry.data as ToolsState | undefined;
    if (data?.enabledTools) {
    savedTools = data.enabledTools;
    }
    }
    }

    if (savedTools) {
    // Restore saved tool selection (filter to only tools that still exist)
    const allToolNames = allTools.map((t) => t.name);
    enabledTools = new Set(savedTools.filter((t: string) => allToolNames.includes(t)));
    applyTools();
    } else {
    // No saved state - sync with currently active tools
    enabledTools = new Set(pi.getActiveTools());
    }
    }

    // Register /tools command
    pi.registerCommand("tools", {
    description: "Enable/disable tools",
    handler: async (_args, ctx) => {
    // Refresh tool list
    allTools = pi.getAllTools();

    await ctx.ui.custom((tui, theme, _kb, done) => {
    // Build settings items for each tool
    const items: SettingItem[] = allTools.map((tool) => ({
    id: tool.name,
    label: tool.name,
    currentValue: enabledTools.has(tool.name) ? "enabled" : "disabled",
    values: ["enabled", "disabled"],
    }));

    const container = new Container();
    container.addChild(
    new (class {
    render(_width: number) {
    return [theme.fg("accent", theme.bold("Tool Configuration")), ""];
    }
    invalidate() {}
    })(),
    );

    const settingsList = new SettingsList(
    items,
    Math.min(items.length + 2, 15),
    getSettingsListTheme(),
    (id, newValue) => {
    // Update enabled state and apply immediately
    if (newValue === "enabled") {
    enabledTools.add(id);
    } else {
    enabledTools.delete(id);
    }
    applyTools();
    persistState();
    },
    () => {
    // Close dialog
    done(undefined);
    },
    );

    container.addChild(settingsList);

    const component = {
    render(width: number) {
    return container.render(width);
    },
    invalidate() {
    container.invalidate();
    },
    handleInput(data: string) {
    settingsList.handleInput?.(data);
    tui.requestRender();
    },
    };

    return component;
    });
    },
    });

    // Restore state on session start
    pi.on("session_start", async (_event, ctx) => {
    restoreFromBranch(ctx);
    });

    // Restore state when navigating the session tree
    pi.on("session_tree", async (_event, ctx) => {
    restoreFromBranch(ctx);
    });

    // Restore state after forking
    pi.on("session_fork", async (_event, ctx) => {
    restoreFromBranch(ctx);
    });
    }

    - SettingsList for tool enable/disable
  • Status indicators: examples/extensions/plan-mode.ts - setStatus and setWidget
  • Custom footer:
    examples/extensions/custom-footer.ts
    /**
    * Custom Footer Extension - demonstrates ctx.ui.setFooter()
    *
    * footerData exposes data not otherwise accessible:
    * - getGitBranch(): current git branch
    * - getExtensionStatuses(): texts from ctx.ui.setStatus()
    *
    * Token stats come from ctx.sessionManager/ctx.model (already accessible).
    */

    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 enabled = false;

    pi.registerCommand("footer", {
    description: "Toggle custom footer",
    handler: async (_args, ctx) => {
    enabled = !enabled;

    if (enabled) {
    ctx.ui.setFooter((tui, theme, footerData) => {
    const unsub = footerData.onBranchChange(() => tui.requestRender());

    return {
    dispose: unsub,
    invalidate() {},
    render(width: number): string[] {
    // Compute tokens from ctx (already accessible to extensions)
    let input = 0,
    output = 0,
    cost = 0;
    for (const e of ctx.sessionManager.getBranch()) {
    if (e.type === "message" && e.message.role === "assistant") {
    const m = e.message as AssistantMessage;
    input += m.usage.input;
    output += m.usage.output;
    cost += m.usage.cost.total;
    }
    }

    // Get git branch (not otherwise accessible)
    const branch = footerData.getGitBranch();
    const fmt = (n: number) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`);

    const left = theme.fg("dim", `${fmt(input)}${fmt(output)} $${cost.toFixed(3)}`);
    const branchStr = branch ? ` (${branch})` : "";
    const right = theme.fg("dim", `${ctx.model?.id || "no-model"}${branchStr}`);

    const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
    return [truncateToWidth(left + pad + right, width)];
    },
    };
    });
    ctx.ui.notify("Custom footer enabled", "info");
    } else {
    ctx.ui.setFooter(undefined);
    ctx.ui.notify("Default footer restored", "info");
    }
    },
    });
    }

    - setFooter with stats
  • Custom editor:
    examples/extensions/modal-editor.ts
    /**
    * Modal Editor - vim-like modal editing example
    *
    * Usage: pi --extension ./examples/extensions/modal-editor.ts
    *
    * - Escape: insert → normal mode (in normal mode, aborts agent)
    * - i: normal → insert mode
    * - hjkl: navigation in normal mode
    * - ctrl+c, ctrl+d, etc. work in both modes
    */

    import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
    import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";

    // Normal mode key mappings: key -> escape sequence (or null for mode switch)
    const NORMAL_KEYS: Record<string, string | null> = {
    h: "\x1b[D", // left
    j: "\x1b[B", // down
    k: "\x1b[A", // up
    l: "\x1b[C", // right
    "0": "\x01", // line start
    $: "\x05", // line end
    x: "\x1b[3~", // delete char
    i: null, // insert mode
    a: null, // append (insert + right)
    };

    class ModalEditor extends CustomEditor {
    private mode: "normal" | "insert" = "insert";

    handleInput(data: string): void {
    // Escape toggles to normal mode, or passes through for app handling
    if (matchesKey(data, "escape")) {
    if (this.mode === "insert") {
    this.mode = "normal";
    } else {
    super.handleInput(data); // abort agent, etc.
    }
    return;
    }

    // Insert mode: pass everything through
    if (this.mode === "insert") {
    super.handleInput(data);
    return;
    }

    // Normal mode: check mapped keys
    if (data in NORMAL_KEYS) {
    const seq = NORMAL_KEYS[data];
    if (data === "i") {
    this.mode = "insert";
    } else if (data === "a") {
    this.mode = "insert";
    super.handleInput("\x1b[C"); // move right first
    } else if (seq) {
    super.handleInput(seq);
    }
    return;
    }

    // Pass control sequences (ctrl+c, etc.) to super, ignore printable chars
    if (data.length === 1 && data.charCodeAt(0) >= 32) return;
    super.handleInput(data);
    }

    render(width: number): string[] {
    const lines = super.render(width);
    if (lines.length === 0) return lines;

    // Add mode indicator to bottom border
    const label = this.mode === "normal" ? " NORMAL " : " INSERT ";
    const last = lines.length - 1;
    if (visibleWidth(lines[last]!) >= label.length) {
    lines[last] = truncateToWidth(lines[last]!, width - label.length, "") + label;
    }
    return lines;
    }
    }

    export default function (pi: ExtensionAPI) {
    pi.on("session_start", (_event, ctx) => {
    ctx.ui.setEditorComponent((tui, theme, kb) => new ModalEditor(tui, theme, kb));
    });
    }

    - Vim-like modal editing
  • Snake game:
    examples/extensions/snake.ts
    /**
    * Snake game extension - play snake with /snake command
    */

    import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
    import { matchesKey, visibleWidth } from "@mariozechner/pi-tui";

    const GAME_WIDTH = 40;
    const GAME_HEIGHT = 15;
    const TICK_MS = 100;

    type Direction = "up" | "down" | "left" | "right";
    type Point = { x: number; y: number };

    interface GameState {
    snake: Point[];
    food: Point;
    direction: Direction;
    nextDirection: Direction;
    score: number;
    gameOver: boolean;
    highScore: number;
    }

    function createInitialState(): GameState {
    const startX = Math.floor(GAME_WIDTH / 2);
    const startY = Math.floor(GAME_HEIGHT / 2);
    return {
    snake: [
    { x: startX, y: startY },
    { x: startX - 1, y: startY },
    { x: startX - 2, y: startY },
    ],
    food: spawnFood([{ x: startX, y: startY }]),
    direction: "right",
    nextDirection: "right",
    score: 0,
    gameOver: false,
    highScore: 0,
    };
    }

    function spawnFood(snake: Point[]): Point {
    let food: Point;
    do {
    food = {
    x: Math.floor(Math.random() * GAME_WIDTH),
    y: Math.floor(Math.random() * GAME_HEIGHT),
    };
    } while (snake.some((s) => s.x === food.x && s.y === food.y));
    return food;
    }

    class SnakeComponent {
    private state: GameState;
    private interval: ReturnType<typeof setInterval> | null = null;
    private onClose: () => void;
    private onSave: (state: GameState | null) => void;
    private tui: { requestRender: () => void };
    private cachedLines: string[] = [];
    private cachedWidth = 0;
    private version = 0;
    private cachedVersion = -1;
    private paused: boolean;

    constructor(
    tui: { requestRender: () => void },
    onClose: () => void,
    onSave: (state: GameState | null) => void,
    savedState?: GameState,
    ) {
    this.tui = tui;
    if (savedState && !savedState.gameOver) {
    // Resume from saved state, start paused
    this.state = savedState;
    this.paused = true;
    } else {
    // New game or saved game was over
    this.state = createInitialState();
    if (savedState) {
    this.state.highScore = savedState.highScore;
    }
    this.paused = false;
    this.startGame();
    }
    this.onClose = onClose;
    this.onSave = onSave;
    }

    private startGame(): void {
    this.interval = setInterval(() => {
    if (!this.state.gameOver) {
    this.tick();
    this.version++;
    this.tui.requestRender();
    }
    }, TICK_MS);
    }

    private tick(): void {
    // Apply queued direction change
    this.state.direction = this.state.nextDirection;

    // Calculate new head position
    const head = this.state.snake[0];
    let newHead: Point;

    switch (this.state.direction) {
    case "up":
    newHead = { x: head.x, y: head.y - 1 };
    break;
    case "down":
    newHead = { x: head.x, y: head.y + 1 };
    break;
    case "left":
    newHead = { x: head.x - 1, y: head.y };
    break;
    case "right":
    newHead = { x: head.x + 1, y: head.y };
    break;
    }

    // Check wall collision
    if (newHead.x < 0 || newHead.x >= GAME_WIDTH || newHead.y < 0 || newHead.y >= GAME_HEIGHT) {
    this.state.gameOver = true;
    return;
    }

    // Check self collision
    if (this.state.snake.some((s) => s.x === newHead.x && s.y === newHead.y)) {
    this.state.gameOver = true;
    return;
    }

    // Move snake
    this.state.snake.unshift(newHead);

    // Check food collision
    if (newHead.x === this.state.food.x && newHead.y === this.state.food.y) {
    this.state.score += 10;
    if (this.state.score > this.state.highScore) {
    this.state.highScore = this.state.score;
    }
    this.state.food = spawnFood(this.state.snake);
    } else {
    this.state.snake.pop();
    }
    }

    handleInput(data: string): void {
    // If paused (resuming), wait for any key
    if (this.paused) {
    if (matchesKey(data, "escape") || data === "q" || data === "Q") {
    // Quit without clearing save
    this.dispose();
    this.onClose();
    return;
    }
    // Any other key resumes
    this.paused = false;
    this.startGame();
    return;
    }

    // ESC to pause and save
    if (matchesKey(data, "escape")) {
    this.dispose();
    this.onSave(this.state);
    this.onClose();
    return;
    }

    // Q to quit without saving (clears saved state)
    if (data === "q" || data === "Q") {
    this.dispose();
    this.onSave(null); // Clear saved state
    this.onClose();
    return;
    }

    // Arrow keys or WASD
    if (matchesKey(data, "up") || data === "w" || data === "W") {
    if (this.state.direction !== "down") this.state.nextDirection = "up";
    } else if (matchesKey(data, "down") || data === "s" || data === "S") {
    if (this.state.direction !== "up") this.state.nextDirection = "down";
    } else if (matchesKey(data, "right") || data === "d" || data === "D") {
    if (this.state.direction !== "left") this.state.nextDirection = "right";
    } else if (matchesKey(data, "left") || data === "a" || data === "A") {
    if (this.state.direction !== "right") this.state.nextDirection = "left";
    }

    // Restart on game over
    if (this.state.gameOver && (data === "r" || data === "R" || data === " ")) {
    const highScore = this.state.highScore;
    this.state = createInitialState();
    this.state.highScore = highScore;
    this.onSave(null); // Clear saved state on restart
    this.version++;
    this.tui.requestRender();
    }
    }

    invalidate(): void {
    this.cachedWidth = 0;
    }

    render(width: number): string[] {
    if (width === this.cachedWidth && this.cachedVersion === this.version) {
    return this.cachedLines;
    }

    const lines: string[] = [];

    // Each game cell is 2 chars wide to appear square (terminal cells are ~2:1 aspect)
    const cellWidth = 2;
    const effectiveWidth = Math.min(GAME_WIDTH, Math.floor((width - 4) / cellWidth));
    const effectiveHeight = GAME_HEIGHT;

    // Colors
    const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
    const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
    const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
    const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
    const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;

    const boxWidth = effectiveWidth * cellWidth;

    // Helper to pad content inside box
    const boxLine = (content: string) => {
    const contentLen = visibleWidth(content);
    const padding = Math.max(0, boxWidth - contentLen);
    return dim(" │") + content + " ".repeat(padding) + dim("│");
    };

    // Top border
    lines.push(this.padLine(dim(`${"─".repeat(boxWidth)}`), width));

    // Header with score
    const scoreText = `Score: ${bold(yellow(String(this.state.score)))}`;
    const highText = `High: ${bold(yellow(String(this.state.highScore)))}`;
    const title = `${bold(green("SNAKE"))}${scoreText}${highText}`;
    lines.push(this.padLine(boxLine(title), width));

    // Separator
    lines.push(this.padLine(dim(`${"─".repeat(boxWidth)}`), width));

    // Game grid
    for (let y = 0; y < effectiveHeight; y++) {
    let row = "";
    for (let x = 0; x < effectiveWidth; x++) {
    const isHead = this.state.snake[0].x === x && this.state.snake[0].y === y;
    const isBody = this.state.snake.slice(1).some((s) => s.x === x && s.y === y);
    const isFood = this.state.food.x === x && this.state.food.y === y;

    if (isHead) {
    row += green("██"); // Snake head (2 chars)
    } else if (isBody) {
    row += green("▓▓"); // Snake body (2 chars)
    } else if (isFood) {
    row += red("◆ "); // Food (2 chars)
    } else {
    row += " "; // Empty cell (2 spaces)
    }
    }
    lines.push(this.padLine(dim(" │") + row + dim("│"), width));
    }

    // Separator
    lines.push(this.padLine(dim(`${"─".repeat(boxWidth)}`), width));

    // Footer
    let footer: string;
    if (this.paused) {
    footer = `${yellow(bold("PAUSED"))} Press any key to continue, ${bold("Q")} to quit`;
    } else if (this.state.gameOver) {
    footer = `${red(bold("GAME OVER!"))} Press ${bold("R")} to restart, ${bold("Q")} to quit`;
    } else {
    footer = `↑↓←→ or WASD to move, ${bold("ESC")} pause, ${bold("Q")} quit`;
    }
    lines.push(this.padLine(boxLine(footer), width));

    // Bottom border
    lines.push(this.padLine(dim(`${"─".repeat(boxWidth)}`), width));

    this.cachedLines = lines;
    this.cachedWidth = width;
    this.cachedVersion = this.version;

    return lines;
    }

    private padLine(line: string, width: number): string {
    // Calculate visible length (strip ANSI codes)
    const visibleLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
    const padding = Math.max(0, width - visibleLen);
    return line + " ".repeat(padding);
    }

    dispose(): void {
    if (this.interval) {
    clearInterval(this.interval);
    this.interval = null;
    }
    }
    }

    const SNAKE_SAVE_TYPE = "snake-save";

    export default function (pi: ExtensionAPI) {
    pi.registerCommand("snake", {
    description: "Play Snake!",

    handler: async (_args, ctx) => {
    if (!ctx.hasUI) {
    ctx.ui.notify("Snake requires interactive mode", "error");
    return;
    }

    // Load saved state from session
    const entries = ctx.sessionManager.getEntries();
    let savedState: GameState | undefined;
    for (let i = entries.length - 1; i >= 0; i--) {
    const entry = entries[i];
    if (entry.type === "custom" && entry.customType === SNAKE_SAVE_TYPE) {
    savedState = entry.data as GameState;
    break;
    }
    }

    await ctx.ui.custom((tui, _theme, _kb, done) => {
    return new SnakeComponent(
    tui,
    () => done(undefined),
    (state) => {
    // Save or clear state
    pi.appendEntry(SNAKE_SAVE_TYPE, state);
    },
    savedState,
    );
    });
    },
    });
    }

    - Full game with keyboard input, game loop
  • Custom tool rendering:
    examples/extensions/todo.ts
    /**
    * Todo Extension - Demonstrates state management via session entries
    *
    * This extension:
    * - Registers a `todo` tool for the LLM to manage todos
    * - Registers a `/todos` command for users to view the list
    *
    * State is stored in tool result details (not external files), which allows
    * proper branching - when you branch, the todo state is automatically
    * correct for that point in history.
    */

    import { StringEnum } from "@mariozechner/pi-ai";
    import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
    import { matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
    import { Type } from "@sinclair/typebox";

    interface Todo {
    id: number;
    text: string;
    done: boolean;
    }

    interface TodoDetails {
    action: "list" | "add" | "toggle" | "clear";
    todos: Todo[];
    nextId: number;
    error?: string;
    }

    const TodoParams = Type.Object({
    action: StringEnum(["list", "add", "toggle", "clear"] as const),
    text: Type.Optional(Type.String({ description: "Todo text (for add)" })),
    id: Type.Optional(Type.Number({ description: "Todo ID (for toggle)" })),
    });

    /**
    * UI component for the /todos command
    */
    class TodoListComponent {
    private todos: Todo[];
    private theme: Theme;
    private onClose: () => void;
    private cachedWidth?: number;
    private cachedLines?: string[];

    constructor(todos: Todo[], theme: Theme, onClose: () => void) {
    this.todos = todos;
    this.theme = theme;
    this.onClose = onClose;
    }

    handleInput(data: string): void {
    if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
    this.onClose();
    }
    }

    render(width: number): string[] {
    if (this.cachedLines && this.cachedWidth === width) {
    return this.cachedLines;
    }

    const lines: string[] = [];
    const th = this.theme;

    lines.push("");
    const title = th.fg("accent", " Todos ");
    const headerLine =
    th.fg("borderMuted", "─".repeat(3)) + title + th.fg("borderMuted", "─".repeat(Math.max(0, width - 10)));
    lines.push(truncateToWidth(headerLine, width));
    lines.push("");

    if (this.todos.length === 0) {
    lines.push(truncateToWidth(` ${th.fg("dim", "No todos yet. Ask the agent to add some!")}`, width));
    } else {
    const done = this.todos.filter((t) => t.done).length;
    const total = this.todos.length;
    lines.push(truncateToWidth(` ${th.fg("muted", `${done}/${total} completed`)}`, width));
    lines.push("");

    for (const todo of this.todos) {
    const check = todo.done ? th.fg("success", "✓") : th.fg("dim", "○");
    const id = th.fg("accent", `#${todo.id}`);
    const text = todo.done ? th.fg("dim", todo.text) : th.fg("text", todo.text);
    lines.push(truncateToWidth(` ${check} ${id} ${text}`, width));
    }
    }

    lines.push("");
    lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
    lines.push("");

    this.cachedWidth = width;
    this.cachedLines = lines;
    return lines;
    }

    invalidate(): void {
    this.cachedWidth = undefined;
    this.cachedLines = undefined;
    }
    }

    export default function (pi: ExtensionAPI) {
    // In-memory state (reconstructed from session on load)
    let todos: Todo[] = [];
    let nextId = 1;

    /**
    * Reconstruct state from session entries.
    * Scans tool results for this tool and applies them in order.
    */
    const reconstructState = (ctx: ExtensionContext) => {
    todos = [];
    nextId = 1;

    for (const entry of ctx.sessionManager.getBranch()) {
    if (entry.type !== "message") continue;
    const msg = entry.message;
    if (msg.role !== "toolResult" || msg.toolName !== "todo") continue;

    const details = msg.details as TodoDetails | undefined;
    if (details) {
    todos = details.todos;
    nextId = details.nextId;
    }
    }
    };

    // Reconstruct state on session events
    pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
    pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
    pi.on("session_fork", async (_event, ctx) => reconstructState(ctx));
    pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));

    // Register the todo tool for the LLM
    pi.registerTool({
    name: "todo",
    label: "Todo",
    description: "Manage a todo list. Actions: list, add (text), toggle (id), clear",
    parameters: TodoParams,

    async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
    switch (params.action) {
    case "list":
    return {
    content: [
    {
    type: "text",
    text: todos.length
    ? todos.map((t) => `[${t.done ? "x" : " "}] #${t.id}: ${t.text}`).join("\n")
    : "No todos",
    },
    ],
    details: { action: "list", todos: [...todos], nextId } as TodoDetails,
    };

    case "add": {
    if (!params.text) {
    return {
    content: [{ type: "text", text: "Error: text required for add" }],
    details: { action: "add", todos: [...todos], nextId, error: "text required" } as TodoDetails,
    };
    }
    const newTodo: Todo = { id: nextId++, text: params.text, done: false };
    todos.push(newTodo);
    return {
    content: [{ type: "text", text: `Added todo #${newTodo.id}: ${newTodo.text}` }],
    details: { action: "add", todos: [...todos], nextId } as TodoDetails,
    };
    }

    case "toggle": {
    if (params.id === undefined) {
    return {
    content: [{ type: "text", text: "Error: id required for toggle" }],
    details: { action: "toggle", todos: [...todos], nextId, error: "id required" } as TodoDetails,
    };
    }
    const todo = todos.find((t) => t.id === params.id);
    if (!todo) {
    return {
    content: [{ type: "text", text: `Todo #${params.id} not found` }],
    details: {
    action: "toggle",
    todos: [...todos],
    nextId,
    error: `#${params.id} not found`,
    } as TodoDetails,
    };
    }
    todo.done = !todo.done;
    return {
    content: [{ type: "text", text: `Todo #${todo.id} ${todo.done ? "completed" : "uncompleted"}` }],
    details: { action: "toggle", todos: [...todos], nextId } as TodoDetails,
    };
    }

    case "clear": {
    const count = todos.length;
    todos = [];
    nextId = 1;
    return {
    content: [{ type: "text", text: `Cleared ${count} todos` }],
    details: { action: "clear", todos: [], nextId: 1 } as TodoDetails,
    };
    }

    default:
    return {
    content: [{ type: "text", text: `Unknown action: ${params.action}` }],
    details: {
    action: "list",
    todos: [...todos],
    nextId,
    error: `unknown action: ${params.action}`,
    } as TodoDetails,
    };
    }
    },

    renderCall(args, theme) {
    let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
    if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
    if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
    return new Text(text, 0, 0);
    },

    renderResult(result, { expanded }, theme) {
    const details = result.details as TodoDetails | undefined;
    if (!details) {
    const text = result.content[0];
    return new Text(text?.type === "text" ? text.text : "", 0, 0);
    }

    if (details.error) {
    return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
    }

    const todoList = details.todos;

    switch (details.action) {
    case "list": {
    if (todoList.length === 0) {
    return new Text(theme.fg("dim", "No todos"), 0, 0);
    }
    let listText = theme.fg("muted", `${todoList.length} todo(s):`);
    const display = expanded ? todoList : todoList.slice(0, 5);
    for (const t of display) {
    const check = t.done ? theme.fg("success", "✓") : theme.fg("dim", "○");
    const itemText = t.done ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
    listText += `\n${check} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
    }
    if (!expanded && todoList.length > 5) {
    listText += `\n${theme.fg("dim", `... ${todoList.length - 5} more`)}`;
    }
    return new Text(listText, 0, 0);
    }

    case "add": {
    const added = todoList[todoList.length - 1];
    return new Text(
    theme.fg("success", "✓ Added ") +
    theme.fg("accent", `#${added.id}`) +
    " " +
    theme.fg("muted", added.text),
    0,
    0,
    );
    }

    case "toggle": {
    const text = result.content[0];
    const msg = text?.type === "text" ? text.text : "";
    return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
    }

    case "clear":
    return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0);
    }
    },
    });

    // Register the /todos command for users
    pi.registerCommand("todos", {
    description: "Show all todos on the current branch",
    handler: async (_args, ctx) => {
    if (!ctx.hasUI) {
    ctx.ui.notify("/todos requires interactive mode", "error");
    return;
    }

    await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
    return new TodoListComponent(todos, theme, () => done());
    });
    },
    });
    }

    - renderCall and renderResult