2fd3868401
* add usage info to branch summary entries * add usage to compaction entries * allow custom tools to report llm usage in tool results * allow observing and patching usage in tool_result hooks * agent-harness: save usage in entries for compaction, branch summaries and tool results
246 lines
9.5 KiB
TypeScript
246 lines
9.5 KiB
TypeScript
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.isUsingOAuth(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;
|
|
}
|
|
}
|