import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { PolishedTuiConfig } from "./config"; import { type ExtensionStatusSegment, collectExtensionStatusSegments } from "./extension-status"; import { formatCwdLabel, formatRuntimeSegment } from "./format"; import type { FooterState } from "./state"; import { renderStyleForSource } from "./style"; function joinStatusTexts(statusTexts: string[], separator: string): string { return statusTexts.filter(Boolean).join(separator); } function fitStatusTexts(statusTexts: string[], maxWidth: number, separator: string): string { if (maxWidth <= 0) return ""; const fitted: string[] = []; for (const text of statusTexts) { const candidate = joinStatusTexts([...fitted, text], separator); if (visibleWidth(candidate) <= maxWidth) { fitted.push(text); continue; } if (fitted.length === 0) { return maxWidth > 1 ? truncateToWidth(text, maxWidth, "…") : ""; } break; } return joinStatusTexts(fitted, separator); } function appendStatusArea(base: string, statusText: string, separator: string): string { if (!base) return statusText; if (!statusText) return base; return `${base}${separator}${statusText}`; } function prependStatusArea(base: string, statusText: string, separator: string): string { if (!base) return statusText; if (!statusText) return base; return `${statusText}${separator}${base}`; } function composeBuiltInFooterContent(left: string, right: string, innerWidth: number): string { const leftWidth = visibleWidth(left); const rightWidth = visibleWidth(right); return leftWidth >= innerWidth ? truncateToWidth(left, innerWidth, "") : leftWidth + 1 + rightWidth <= innerWidth ? `${left}${" ".repeat(innerWidth - leftWidth - rightWidth)}${right}` : truncateToWidth(left, innerWidth, ""); } function composeFooterContent( builtInLeft: string, builtInRight: string, extensionLeft: string[], extensionMiddle: string[], extensionRight: string[], separator: string, innerWidth: number, ): string { const builtInLeftWidth = visibleWidth(builtInLeft); const builtInRightWidth = visibleWidth(builtInRight); const minimumGap = builtInLeft && builtInRight ? 1 : 0; if (builtInLeftWidth + minimumGap + builtInRightWidth > innerWidth) { return composeBuiltInFooterContent(builtInLeft, builtInRight, innerWidth); } const available = Math.max(0, innerWidth - builtInLeftWidth - builtInRightWidth - minimumGap); let remaining = available; const leftConnectorWidth = builtInLeft && extensionLeft.length > 0 ? visibleWidth(separator) : 0; const rightConnectorWidth = builtInRight && extensionRight.length > 0 ? visibleWidth(separator) : 0; let leftStatus = ""; let rightStatus = ""; if (extensionLeft.length > 0 && extensionRight.length > 0) { const leftBudget = Math.max(0, Math.floor(available / 2) - leftConnectorWidth); leftStatus = fitStatusTexts(extensionLeft, leftBudget, separator); remaining -= leftStatus ? leftConnectorWidth + visibleWidth(leftStatus) : 0; const rightBudget = Math.max(0, remaining - rightConnectorWidth); rightStatus = fitStatusTexts(extensionRight, rightBudget, separator); remaining -= rightStatus ? rightConnectorWidth + visibleWidth(rightStatus) : 0; const expandedLeftBudget = Math.max(0, remaining + visibleWidth(leftStatus)); const expandedLeftStatus = fitStatusTexts(extensionLeft, expandedLeftBudget, separator); if (visibleWidth(expandedLeftStatus) > visibleWidth(leftStatus)) { remaining += leftStatus ? leftConnectorWidth + visibleWidth(leftStatus) : 0; leftStatus = expandedLeftStatus; remaining -= leftStatus ? leftConnectorWidth + visibleWidth(leftStatus) : 0; } } else if (extensionLeft.length > 0) { leftStatus = fitStatusTexts( extensionLeft, Math.max(0, available - leftConnectorWidth), separator, ); remaining -= leftStatus ? leftConnectorWidth + visibleWidth(leftStatus) : 0; } else if (extensionRight.length > 0) { rightStatus = fitStatusTexts( extensionRight, Math.max(0, available - rightConnectorWidth), separator, ); remaining -= rightStatus ? rightConnectorWidth + visibleWidth(rightStatus) : 0; } const left = appendStatusArea(builtInLeft, leftStatus, separator); const right = prependStatusArea(builtInRight, rightStatus, separator); const gapWidth = Math.max(0, innerWidth - visibleWidth(left) - visibleWidth(right)); const middle = fitStatusTexts(extensionMiddle, gapWidth, separator); const middleWidth = visibleWidth(middle); if (!middle || middleWidth <= 0) { return `${left}${" ".repeat(gapWidth)}${right}`; } const leftPadding = Math.floor((gapWidth - middleWidth) / 2); const rightPadding = gapWidth - middleWidth - leftPadding; return `${left}${" ".repeat(leftPadding)}${middle}${" ".repeat(rightPadding)}${right}`; } export function installFooter( ctx: ExtensionContext, state: FooterState, getConfig: () => PolishedTuiConfig, hooks: { setRequestRender: (fn: (() => void) | undefined) => void; scheduleProjectRefresh: (ctx: ExtensionContext) => void; setExtensionStatusesGetter?: (fn: (() => ReadonlyMap) | undefined) => void; }, ): void { ctx.ui.setFooter((tui, theme, footerData) => { hooks.setRequestRender(() => tui.requestRender()); hooks.setExtensionStatusesGetter?.(() => footerData.getExtensionStatuses()); const unsubscribeBranch = footerData.onBranchChange(() => { hooks.scheduleProjectRefresh(ctx); tui.requestRender(); }); return { dispose: () => { unsubscribeBranch(); hooks.setRequestRender(undefined); hooks.setExtensionStatusesGetter?.(undefined); }, invalidate() {}, render(width: number): string[] { if (width <= 0) return [""]; const config = getConfig(); const colorSource = config.colorSources.starship; const separator = renderStyleForSource(theme, colorSource, config.colors.separator, " | "); const innerWidth = Math.max(1, width - 2); const cwdLabel = renderStyleForSource( theme, colorSource, config.colors.cwd, formatCwdLabel(ctx.cwd, config.icons.cwd), ); const branch = state.branch; const contextUsage = ctx.getContextUsage(); const contextColor = contextUsage?.percent !== null && contextUsage?.percent !== undefined ? contextUsage.percent >= 90 ? config.colors.contextError : contextUsage.percent >= 70 ? config.colors.contextWarning : config.colors.contextNormal : config.colors.contextNormal; const gitColor = (text: string) => renderStyleForSource(theme, colorSource, config.colors.gitBranch, text); const gitStatusColor = (text: string) => renderStyleForSource(theme, colorSource, config.colors.gitStatus, text); const gitIcon = config.icons.git ? gitColor(config.icons.git) : ""; const allStatus = [ state.conflicted > 0 ? config.icons.conflicted : "", state.stashed ? config.icons.stashed : "", state.deleted > 0 ? config.icons.deleted : "", state.renamed > 0 ? config.icons.renamed : "", state.modified > 0 ? config.icons.modified : "", state.typechanged > 0 ? config.icons.typechanged : "", state.staged > 0 ? config.icons.staged : "", state.untracked > 0 ? config.icons.untracked : "", ].join(""); const aheadBehind = state.ahead > 0 && state.behind > 0 ? config.icons.diverged : state.ahead > 0 ? config.icons.ahead : state.behind > 0 ? config.icons.behind : ""; const statusBlock = allStatus || aheadBehind ? gitStatusColor(`[${allStatus}${aheadBehind}]`) : ""; const branchParts = config.footerSegments.gitBranch && branch ? ["on", gitIcon, gitColor(branch)].filter(Boolean) : []; const gitStatusParts = config.footerSegments.gitStatus && statusBlock ? [statusBlock] : []; const branchLabel = [...branchParts, ...gitStatusParts].filter(Boolean).join(" "); const runtimeLabel = config.footerSegments.runtime ? formatRuntimeSegment(theme, state.runtime, config.colors.runtimePrefix, colorSource) : ""; const left = [config.footerSegments.cwd ? cwdLabel : "", branchLabel, runtimeLabel] .filter(Boolean) .join(" "); const right = [ config.footerSegments.context ? renderStyleForSource(theme, colorSource, contextColor, state.contextLabel) : "", config.footerSegments.tokens ? renderStyleForSource(theme, colorSource, config.colors.tokens, state.tokenLabel) : "", config.footerSegments.cost ? renderStyleForSource(theme, colorSource, config.colors.cost, state.costLabel) : "", ] .filter(Boolean) .join(separator); const extensionStatuses = collectExtensionStatusSegments( footerData.getExtensionStatuses(), config, ); const renderExtensionStatus = (segment: ExtensionStatusSegment) => segment.colorMode === "original" ? segment.text : renderStyleForSource(theme, colorSource, config.colors.extensionStatus, segment.text); const content = composeFooterContent( left, right, extensionStatuses.left.map(renderExtensionStatus), extensionStatuses.middle.map(renderExtensionStatus), extensionStatuses.right.map(renderExtensionStatus), separator, innerWidth, ); const framed = width > 2 ? ` ${truncateToWidth(content, width - 2, "")} ` : content; return [truncateToWidth(framed, width, "")]; }, }; }); }