/** * 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 = new Set([ "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; 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, ): 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(); 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 { 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[] { 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, };