Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/footer/index.ts

Raw
/**
 * Footer renderer extension.
 *
 * Listens for "footer:segment" events from producer extensions and renders
 * a compact footer:
 *   line 0 = workspace zone left-aligned, extension statuses right-aligned
 *   line 1 = llm zone (model, thinking, context, tokens, cost, timing)
 *   line 2 = llm overflow, present only when needed
 *
 * Producers use semantic zones ("workspace" | "llm"). Footer decides row mapping.
 * Valid updates are acknowledged synchronously so producers can choose between
 * the shared footer and their ctx.ui.setStatus() fallback without startup races.
 * Uses setFooter() as the single footer owner. Producers are decoupled.
 *
 * /footer or ctrl+alt+f toggles visibility by swapping between
 * the real footer and an empty component via setFooter().
 */

import type {
	ExtensionAPI,
	ExtensionContext,
	Theme,
	ThemeColor,
} from "@earendil-works/pi-coding-agent";
import { Key, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import { renderBlocksBar } from "./progress.ts";
import { closeDebug, dbg, span } from "./src/debug.ts";

// ── Constants ─────────────────────────────────────────────────────

const SEPARATOR_COLOR = "borderMuted" as ThemeColor;
const DEFAULT_SEGMENT_COLOR = "muted" as ThemeColor;
const STATUS_COLOR = "dim" as ThemeColor;
const BAR_WIDTH = 10;
const THEME_COLORS: ReadonlySet<string> = new Set<ThemeColor>([
	"accent",
	"border",
	"borderAccent",
	"borderMuted",
	"success",
	"error",
	"warning",
	"muted",
	"dim",
	"text",
	"thinkingText",
	"scrollbarTrack",
	"scrollbarThumb",
	"searchMatchText",
	"userMessageText",
	"customMessageText",
	"customMessageLabel",
	"toolTitle",
	"toolOutput",
	"mdHeading",
	"mdLink",
	"mdLinkUrl",
	"mdCode",
	"mdCodeBlock",
	"mdCodeBlockBorder",
	"mdQuote",
	"mdQuoteBorder",
	"mdHr",
	"mdListBullet",
	"toolDiffAdded",
	"toolDiffRemoved",
	"toolDiffContext",
	"syntaxComment",
	"syntaxKeyword",
	"syntaxFunction",
	"syntaxVariable",
	"syntaxString",
	"syntaxNumber",
	"syntaxType",
	"syntaxOperator",
	"syntaxPunctuation",
	"thinkingOff",
	"thinkingMinimal",
	"thinkingLow",
	"thinkingMedium",
	"thinkingHigh",
	"thinkingXhigh",
	"thinkingMax",
	"bashMode",
]);

// ── Types ─────────────────────────────────────────────────────────

type FooterZone = "workspace" | "llm";

type Segment = {
	id: string;
	text: string;
	variants?: string[];
	icon?: string;
	color?: ThemeColor;
	bar?: number;
	suffix?: string;
	zone: FooterZone;
	group?: string;
	separator?: string;
	gap?: number;
	order?: number;
};

type SegmentUpdate = Segment | { id: string; text: undefined };

// ── Pure helpers ──────────────────────────────────────────────────

function segmentUpdate(data: unknown): SegmentUpdate | undefined {
	if (!data || typeof data !== "object") return undefined;
	const value = data as Record<string, unknown>;
	if (typeof value.id !== "string" || value.id.length === 0) return undefined;
	if (!("text" in value) || value.text === undefined)
		return { id: value.id, text: undefined };
	if (
		typeof value.text !== "string" ||
		(value.zone !== "workspace" && value.zone !== "llm") ||
		(value.variants !== undefined &&
			(!Array.isArray(value.variants) ||
				!value.variants.every((variant) => typeof variant === "string"))) ||
		!optionalString(value.icon) ||
		(value.color !== undefined &&
			(typeof value.color !== "string" || !THEME_COLORS.has(value.color))) ||
		!optionalNumber(value.bar) ||
		!optionalString(value.suffix) ||
		!optionalString(value.group) ||
		!optionalString(value.separator) ||
		!optionalNumber(value.gap) ||
		!optionalNumber(value.order)
	)
		return undefined;
	return value as Segment;
}

function optionalString(value: unknown): boolean {
	return value === undefined || typeof value === "string";
}

function optionalNumber(value: unknown): boolean {
	return (
		value === undefined || (typeof value === "number" && Number.isFinite(value))
	);
}

function acknowledgeSegment(data: unknown): void {
	if (!data || typeof data !== "object") return;
	const accept = (data as { accept?: unknown }).accept;
	if (typeof accept !== "function") return;
	try {
		accept();
	} catch {
		// A producer callback cannot disrupt other footer segments.
	}
}

function canRenderFooter(
	ctx: Pick<ExtensionContext, "hasUI" | "mode">,
): boolean {
	return ctx.hasUI && ctx.mode === "tui";
}

/** Pad line with side margins, truncating to fit terminal width. */
function padLine(line: string, width: number): string {
	if (width <= 1) return truncateToWidth(line, width);
	const contentWidth = width - 2;
	const content =
		visibleWidth(line) <= contentWidth
			? line
			: truncateToWidth(line, contentWidth);
	return ` ${content} `;
}

/** Collapse whitespace and strip terminal/control characters for single-line display. */
function sanitizeDisplayText(text: string): string {
	return text
		.replace(
			/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g,
			"",
		)
		.replace(/[\x00-\x1F\x7F-\x9F]/g, " ")
		.replace(/ +/g, " ")
		.trim();
}

function compareSegments(a: Segment, b: Segment): number {
	const byOrder = (a.order ?? 50) - (b.order ?? 50);
	if (byOrder !== 0) return byOrder;
	return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
}

function groupSegments(segments: Segment[]): Segment[][] {
	const groups: Segment[][] = [];
	for (const segment of segments) {
		const previous = groups.at(-1);
		if (segment.group && previous?.[0]?.group === segment.group)
			previous.push(segment);
		else groups.push([segment]);
	}
	return groups;
}

function alignLeftAndRight(left: string, right: string, width: number): string {
	const safeLeft = truncateToWidth(left, width);
	const leftWidth = visibleWidth(safeLeft);
	if (!right || leftWidth >= width) return safeLeft;

	const gapWidth = leftWidth > 0 ? 1 : 0;
	const available = width - leftWidth - gapWidth;
	if (available <= 0) return safeLeft;

	const safeRight = truncateToWidth(right, available);
	const padding = width - leftWidth - visibleWidth(safeRight);
	return safeLeft + " ".repeat(padding) + safeRight;
}

function flowLines(
	rendered: string[],
	separator: string,
	width: number,
): [string, string] {
	let line1 = "";
	let line1Width = 0;
	let overflowIndex = -1;

	for (const [index, segment] of rendered.entries()) {
		const segmentWidth = visibleWidth(segment);
		const prefixWidth = line1Width > 0 ? visibleWidth(separator) : 0;
		if (line1Width + prefixWidth + segmentWidth <= width) {
			line1 += (line1Width > 0 ? separator : "") + segment;
			line1Width += prefixWidth + segmentWidth;
			continue;
		}
		if (line1Width === 0) {
			line1 = truncateToWidth(segment, width);
			overflowIndex = index + 1;
		} else overflowIndex = index;
		break;
	}

	if (overflowIndex < 0 || overflowIndex >= rendered.length) return [line1, ""];
	return [
		line1,
		truncateToWidth(rendered.slice(overflowIndex).join(separator), width),
	];
}

/** Zero-height footer stub for hidden mode. */
const HIDDEN_FOOTER = {
	dispose: () => {},
	invalidate() {},
	render(): string[] {
		return [];
	},
};

// ── Extension ─────────────────────────────────────────────────────

export default function footerExtension(pi: ExtensionAPI) {
	const segments = new Map<string, Segment>();
	let requestRender: (() => void) | undefined;
	let renderScheduled = false;
	let hidden = false;
	let acceptingSegments = true;
	let currentCtx: ExtensionContext | undefined;

	// ── Segment handling ──────────────────────────────────────────

	function handleSegment(data: unknown) {
		if (!acceptingSegments) {
			dbg?.("segment.update", { outcome: "disabled" });
			return;
		}
		const payload = segmentUpdate(data);
		if (!payload) {
			dbg?.("segment.update", { outcome: "invalid" });
			return;
		}

		if (payload.text === undefined) {
			dbg?.("segment.update", { outcome: "removed" });
			segments.delete(payload.id);
		} else {
			const { accept: _accept, ...segment } = payload as Segment & {
				accept?: unknown;
			};
			segments.set(payload.id, segment);
			dbg?.("segment.update", { outcome: "updated" });
		}
		acknowledgeSegment(data);
		scheduleRender();
	}

	pi.events.on("footer:segment", handleSegment);

	// ── Coalesced render scheduling ───────────────────────────────

	function scheduleRender() {
		if (hidden || renderScheduled || !requestRender) return;
		renderScheduled = true;
		queueMicrotask(() => {
			renderScheduled = false;
			requestRender?.();
		});
	}

	// ── Segment queries ───────────────────────────────────────────

	function getZoneSegments(zone: FooterZone): Segment[] {
		return Array.from(segments.values())
			.filter((s) => s.zone === zone)
			.sort(compareSegments);
	}

	// ── Segment rendering ─────────────────────────────────────────

	function renderSegmentText(seg: Segment, theme: Theme, text: string): string {
		const color = seg.color ?? DEFAULT_SEGMENT_COLOR;
		const safeIcon = seg.icon ? sanitizeDisplayText(seg.icon) : "";
		const safeText = sanitizeDisplayText(text);
		const safeSuffix = seg.suffix ? sanitizeDisplayText(seg.suffix) : "";
		const parts: string[] = [];

		if (safeIcon) parts.push(theme.fg(color, safeIcon));
		if (safeText) parts.push(theme.fg(color, safeText));

		if (seg.bar !== undefined) {
			const bar = renderBlocksBar(seg.bar, BAR_WIDTH, theme, color);
			const suffix = safeSuffix ? theme.fg(color, safeSuffix) : "";
			parts.push(bar + suffix);
		} else if (safeSuffix) {
			parts.push(theme.fg(color, safeSuffix));
		}

		return parts.join(" ");
	}

	function renderSegment(
		seg: Segment,
		theme: Theme,
		width = Number.POSITIVE_INFINITY,
	): string {
		const variants = [seg.text, ...(seg.variants ?? [])].filter(Boolean);
		for (const text of variants) {
			const rendered = renderSegmentText(seg, theme, text);
			if (visibleWidth(rendered) <= width) return rendered;
		}
		return renderSegmentText(seg, theme, variants.at(-1) ?? "");
	}

	function safeRenderSegment(
		segment: Segment,
		theme: Theme,
		width: number,
	): string | undefined {
		try {
			return renderSegment(segment, theme, width);
		} catch {
			return undefined;
		}
	}

	function joinSegments(
		segments: Segment[],
		separator: string,
		theme: Theme,
		width: number,
	): string {
		return segments
			.map((segment) => safeRenderSegment(segment, theme, width))
			.filter((rendered): rendered is string => rendered !== undefined)
			.join(separator);
	}

	function renderSegmentGroup(
		segments: Segment[],
		theme: Theme,
		width: number,
	): string {
		let result = "";
		for (const segment of segments) {
			const rendered = safeRenderSegment(segment, theme, width);
			if (rendered === undefined) continue;
			if (result.length === 0) {
				result = rendered;
				continue;
			}
			const separator = sanitizeDisplayText(segment.separator ?? "");
			const gap = Math.max(1, Math.min(width, Math.floor(segment.gap ?? 1)));
			result += separator
				? `${" ".repeat(gap)}${theme.fg(SEPARATOR_COLOR, separator)} ${rendered}`
				: `${" ".repeat(gap)}${rendered}`;
		}
		return result;
	}

	// ── Zone rendering ────────────────────────────────────────────

	function safeSessionName(
		ctx: ExtensionContext | undefined,
	): string | undefined {
		try {
			return ctx?.sessionManager.getSessionName();
		} catch {
			return undefined;
		}
	}

	function renderWorkspaceLine(
		theme: Theme,
		width: number,
		ctx: ExtensionContext | undefined,
	): string {
		const segs = getZoneSegments("workspace");
		const name = safeSessionName(ctx);
		if (name) {
			segs.push({
				id: "session-name",
				text: name,
				icon: "󰑕",
				color: "accent" as ThemeColor,
				zone: "workspace",
				order: 0.5,
			});
			segs.sort(compareSegments);
		}
		if (segs.length === 0) return "";
		const sep = theme.fg(SEPARATOR_COLOR, " | ");
		return truncateToWidth(joinSegments(segs, sep, theme, width), width);
	}

	/** Flow layout: fill line 1, overflow to line 2, truncate line 2. */
	function renderLlmLines(theme: Theme, width: number): [string, string] {
		const segs = getZoneSegments("llm");
		if (segs.length === 0) return ["", ""];

		const sep = theme.fg(SEPARATOR_COLOR, " | ");
		const rendered = groupSegments(segs)
			.map((group) => renderSegmentGroup(group, theme, width))
			.filter(Boolean);

		return flowLines(rendered, sep, width);
	}

	function renderStatusLine(
		theme: Theme,
		statuses: ReadonlyMap<string, string>,
	): string {
		if (statuses.size === 0) return "";
		const parts = Array.from(statuses.entries())
			.sort(([a], [b]) => a.localeCompare(b))
			.map(([, t]) => sanitizeDisplayText(t))
			.join(" · ");
		return theme.fg(STATUS_COLOR, parts);
	}

	function renderFooterLines(
		theme: Theme,
		width: number,
		ctx: ExtensionContext | undefined,
		statuses: ReadonlyMap<string, string>,
	): string[] {
		const contentWidth = Math.max(0, width - 2);
		const workspaceLine = renderWorkspaceLine(theme, contentWidth, ctx);
		const [llmLine, llmOverflowLine] = renderLlmLines(theme, contentWidth);
		const statusLine = renderStatusLine(theme, statuses);
		const topLine = alignLeftAndRight(workspaceLine, statusLine, contentWidth);
		const lines = [padLine(topLine, width), padLine(llmLine, width)];
		if (llmOverflowLine) lines.push(padLine(llmOverflowLine, width));
		return lines;
	}

	// ── Mounting ──────────────────────────────────────────────────

	function mountFooter() {
		if (!currentCtx) return;

		if (hidden) {
			requestRender = undefined;
			currentCtx.ui.setFooter((tui) => {
				tui.requestRender(true);
				return HIDDEN_FOOTER;
			});
			return;
		}

		currentCtx.ui.setFooter((tui, theme, footerData) => {
			requestRender = () => tui.requestRender();
			const unsubBranch = footerData.onBranchChange(() => scheduleRender());

			return {
				dispose: () => {
					unsubBranch();
					requestRender = undefined;
				},
				invalidate() {},
				render(width: number): string[] {
					try {
						return renderFooterLines(
							theme,
							width,
							currentCtx,
							footerData.getExtensionStatuses(),
						);
					} catch {
						return [];
					}
				},
			};
		});
	}

	// ── Toggle ────────────────────────────────────────────────────

	function toggleVisibility(ctx: ExtensionContext) {
		if (!canRenderFooter(ctx)) return;
		hidden = !hidden;
		currentCtx = ctx;
		ctx.ui.notify(hidden ? "Footer hidden" : "Footer visible", "info");
		mountFooter();
	}

	pi.registerCommand("footer", {
		description: "Toggle footer visibility",
		handler: async (_args, ctx) => toggleVisibility(ctx),
	});

	pi.registerShortcut(Key.ctrlAlt("f"), {
		description: "Toggle footer visibility",
		handler: async (ctx) => toggleVisibility(ctx),
	});

	// ── Lifecycle ─────────────────────────────────────────────────

	pi.on("session_start", async (_event, ctx) => {
		dbg?.("session.start", { mode: ctx.mode });
		const end = span?.("footer.mount", { mode: ctx.mode });
		try {
			acceptingSegments = canRenderFooter(ctx);
			if (!acceptingSegments) {
				end?.("finish", { status: "skipped" });
				return;
			}
			currentCtx = ctx;
			mountFooter();
			end?.();
		} catch (error) {
			end?.("error", { type: "mount" });
			throw error;
		}
	});

	pi.on("session_shutdown", async () => {
		dbg?.("session.shutdown");
		acceptingSegments = false;
		segments.clear();
		requestRender = undefined;
		currentCtx = undefined;
		closeDebug();
	});
}

export const __test = {
	acknowledgeSegment,
	alignLeftAndRight,
	canRenderFooter,
	compareSegments,
	segmentUpdate,
	flowLines,
	groupSegments,
};