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,
};