Download packages/coding-agent/src/modes/interactive/components/footer.ts from SaylorTwift/pi: direct link, hf CLI and curl.
- Browser
- Download file 9.73 kB
-
https://huggingface.co/SaylorTwift/pi/resolve/main/packages/coding-agent/src/modes/interactive/components/footer.ts
- Command line
-
hf download hf://SaylorTwift/pi/packages/coding-agent/src/modes/interactive/components/footer.ts
-
curl -L -o footer.ts https://huggingface.co/SaylorTwift/pi/resolve/main/packages/coding-agent/src/modes/interactive/components/footer.ts
9.73 kB
| import { isAbsolute, relative, resolve, sep } from "node:path"; | |
| import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; | |
| import type { AgentSession } from "../../../core/agent-session.ts"; | |
| import { areExperimentalFeaturesEnabled } from "../../../core/experimental.ts"; | |
| import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.ts"; | |
| import { addUsageToTotals, createUsageTotals } from "../../../core/usage-totals.ts"; | |
| import { theme } from "../theme/theme.ts"; | |
| /** | |
| * Sanitize text for display in a single-line status. | |
| * Removes newlines, tabs, carriage returns, and other control characters. | |
| */ | |
| function sanitizeStatusText(text: string): string { | |
| // Replace newlines, tabs, carriage returns with space, then collapse multiple spaces | |
| return text | |
| .replace(/[\r\n\t]/g, " ") | |
| .replace(/ +/g, " ") | |
| .trim(); | |
| } | |
| /** | |
| * Format token counts for compact footer display. | |
| */ | |
| export function formatTokens(count: number): string { | |
| if (count < 1000) return count.toString(); | |
| if (count < 10000) return `${(count / 1000).toFixed(1)}k`; | |
| if (count < 1000000) return `${Math.round(count / 1000)}k`; | |
| if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`; | |
| return `${Math.round(count / 1000000)}M`; | |
| } | |
| export function formatCwdForFooter(cwd: string, home: string | undefined): string { | |
| if (!home) return cwd; | |
| const resolvedCwd = resolve(cwd); | |
| const resolvedHome = resolve(home); | |
| const relativeToHome = relative(resolvedHome, resolvedCwd); | |
| const isInsideHome = | |
| relativeToHome === "" || | |
| (relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome)); | |
| if (!isInsideHome) return cwd; | |
| return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`; | |
| } | |
| /** | |
| * Footer component that shows pwd, token stats, and context usage. | |
| * Computes token/context stats from session, gets git branch and extension statuses from provider. | |
| */ | |
| export class FooterComponent implements Component { | |
| private autoCompactEnabled = true; | |
| private session: AgentSession; | |
| private footerData: ReadonlyFooterDataProvider; | |
| constructor(session: AgentSession, footerData: ReadonlyFooterDataProvider) { | |
| this.session = session; | |
| this.footerData = footerData; | |
| } | |
| setSession(session: AgentSession): void { | |
| this.session = session; | |
| } | |
| setAutoCompactEnabled(enabled: boolean): void { | |
| this.autoCompactEnabled = enabled; | |
| } | |
| /** | |
| * No-op: git branch caching now handled by provider. | |
| * Kept for compatibility with existing call sites in interactive-mode. | |
| */ | |
| invalidate(): void { | |
| // No-op: git branch is cached/invalidated by provider | |
| } | |
| /** | |
| * Clean up resources. | |
| * Git watcher cleanup now handled by provider. | |
| */ | |
| dispose(): void { | |
| // Git watcher cleanup handled by provider | |
| } | |
| render(width: number): string[] { | |
| const state = this.session.state; | |
| // Calculate cumulative usage from ALL session entries (not just post-compaction messages) | |
| const usageTotals = createUsageTotals(); | |
| let latestCacheHitRate: number | undefined; | |
| for (const entry of this.session.sessionManager.getEntries()) { | |
| if (entry.type === "message" && entry.message.role === "assistant") { | |
| addUsageToTotals(usageTotals, entry.message.usage); | |
| const latestPromptTokens = | |
| entry.message.usage.input + entry.message.usage.cacheRead + entry.message.usage.cacheWrite; | |
| latestCacheHitRate = | |
| latestPromptTokens > 0 ? (entry.message.usage.cacheRead / latestPromptTokens) * 100 : undefined; | |
| } else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) { | |
| addUsageToTotals(usageTotals, entry.message.usage); | |
| } else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) { | |
| addUsageToTotals(usageTotals, entry.usage); | |
| } | |
| } | |
| // Calculate context usage from session (handles compaction correctly). | |
| // After compaction, tokens are unknown until the next LLM response. | |
| const contextUsage = this.session.getContextUsage(); | |
| const contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0; | |
| const contextPercentValue = contextUsage?.percent ?? 0; | |
| const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : "?"; | |
| // Replace home directory with ~ | |
| let pwd = formatCwdForFooter(this.session.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE); | |
| // Add git branch if available | |
| const branch = this.footerData.getGitBranch(); | |
| if (branch) { | |
| pwd = `${pwd} (${branch})`; | |
| } | |
| // Add session name if set | |
| const sessionName = this.session.sessionManager.getSessionName(); | |
| if (sessionName) { | |
| pwd = `${pwd} • ${sessionName}`; | |
| } | |
| // Build stats line | |
| const statsParts = []; | |
| if (usageTotals.input) statsParts.push(`↑${formatTokens(usageTotals.input)}`); | |
| if (usageTotals.output) statsParts.push(`↓${formatTokens(usageTotals.output)}`); | |
| if (usageTotals.cacheRead) statsParts.push(`R${formatTokens(usageTotals.cacheRead)}`); | |
| if (usageTotals.cacheWrite) statsParts.push(`W${formatTokens(usageTotals.cacheWrite)}`); | |
| if ((usageTotals.cacheRead > 0 || usageTotals.cacheWrite > 0) && latestCacheHitRate !== undefined) { | |
| statsParts.push(`CH${latestCacheHitRate.toFixed(1)}%`); | |
| } | |
| // Kimi Coding is subscription-backed despite using API-key authentication. | |
| const usingSubscription = state.model | |
| ? state.model.provider === "kimi-coding" || this.session.modelRuntime.isUsingSubscription(state.model.provider) | |
| : false; | |
| if (usageTotals.cost || usingSubscription) { | |
| const costStr = `$${usageTotals.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`; | |
| statsParts.push(costStr); | |
| } | |
| // Colorize context percentage based on usage | |
| let contextPercentStr: string; | |
| const autoIndicator = this.autoCompactEnabled ? " (auto)" : ""; | |
| const contextPercentDisplay = | |
| contextPercent === "?" | |
| ? `?/${formatTokens(contextWindow)}${autoIndicator}` | |
| : `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`; | |
| if (contextPercentValue > 90) { | |
| contextPercentStr = theme.fg("error", contextPercentDisplay); | |
| } else if (contextPercentValue > 70) { | |
| contextPercentStr = theme.fg("warning", contextPercentDisplay); | |
| } else { | |
| contextPercentStr = contextPercentDisplay; | |
| } | |
| statsParts.push(contextPercentStr); | |
| if (areExperimentalFeaturesEnabled()) { | |
| statsParts.push(`${theme.fg("dim", "•")} ${theme.bold(theme.fg("warning", "xp"))}`); | |
| } | |
| let statsLeft = statsParts.join(" "); | |
| // Add model name on the right side, plus thinking level if model supports it | |
| const modelName = state.model?.id || "no-model"; | |
| let statsLeftWidth = visibleWidth(statsLeft); | |
| // If statsLeft is too wide, truncate it | |
| if (statsLeftWidth > width) { | |
| statsLeft = truncateToWidth(statsLeft, width, "..."); | |
| statsLeftWidth = visibleWidth(statsLeft); | |
| } | |
| // Calculate available space for padding (minimum 2 spaces between stats and model) | |
| const minPadding = 2; | |
| // Add thinking level indicator if model supports reasoning | |
| let rightSideWithoutProvider = modelName; | |
| if (state.model?.reasoning) { | |
| const thinkingLevel = state.thinkingLevel || "off"; | |
| rightSideWithoutProvider = | |
| thinkingLevel === "off" ? `${modelName} • thinking off` : `${modelName} • ${thinkingLevel}`; | |
| } | |
| // Prepend the provider in parentheses if there are multiple providers and there's enough room | |
| let rightSide = rightSideWithoutProvider; | |
| if (this.footerData.getAvailableProviderCount() > 1 && state.model) { | |
| rightSide = `(${state.model!.provider}) ${rightSideWithoutProvider}`; | |
| if (statsLeftWidth + minPadding + visibleWidth(rightSide) > width) { | |
| // Too wide, fall back | |
| rightSide = rightSideWithoutProvider; | |
| } | |
| } | |
| const rightSideWidth = visibleWidth(rightSide); | |
| const totalNeeded = statsLeftWidth + minPadding + rightSideWidth; | |
| let statsLine: string; | |
| if (totalNeeded <= width) { | |
| // Both fit - add padding to right-align model | |
| const padding = " ".repeat(width - statsLeftWidth - rightSideWidth); | |
| statsLine = statsLeft + padding + rightSide; | |
| } else { | |
| // Need to truncate right side | |
| const availableForRight = width - statsLeftWidth - minPadding; | |
| if (availableForRight > 0) { | |
| const truncatedRight = truncateToWidth(rightSide, availableForRight, ""); | |
| const truncatedRightWidth = visibleWidth(truncatedRight); | |
| const padding = " ".repeat(Math.max(0, width - statsLeftWidth - truncatedRightWidth)); | |
| statsLine = statsLeft + padding + truncatedRight; | |
| } else { | |
| // Not enough space for right side at all | |
| statsLine = statsLeft; | |
| } | |
| } | |
| // Apply dim to each part separately. statsLeft may contain color codes (for context %) | |
| // that end with a reset, which would clear an outer dim wrapper. So we dim the parts | |
| // before and after the colored section independently. | |
| const dimStatsLeft = theme.fg("dim", statsLeft); | |
| const remainder = statsLine.slice(statsLeft.length); // padding + rightSide | |
| const dimRemainder = theme.fg("dim", remainder); | |
| const pwdLine = truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "...")); | |
| const lines = [pwdLine, dimStatsLeft + dimRemainder]; | |
| // Add extension statuses on a single line, sorted by key alphabetically | |
| const extensionStatuses = this.footerData.getExtensionStatuses(); | |
| if (extensionStatuses.size > 0) { | |
| const sortedStatuses = Array.from(extensionStatuses.entries()) | |
| .sort(([a], [b]) => a.localeCompare(b)) | |
| .map(([, text]) => sanitizeStatusText(text)); | |
| const statusLine = sortedStatuses.join(" "); | |
| // Truncate to terminal width with dim ellipsis for consistency with footer style | |
| lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "..."))); | |
| } | |
| return lines; | |
| } | |
| } | |