mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-19 08:01:19 +00:00
278 lines
8.8 KiB
TypeScript
278 lines
8.8 KiB
TypeScript
import type { AgentState } from "@mariozechner/pi-agent-core";
|
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
|
import { type Component, visibleWidth } from "@mariozechner/pi-tui";
|
|
import { existsSync, type FSWatcher, readFileSync, watch } from "fs";
|
|
import { dirname, join } from "path";
|
|
import { isModelUsingOAuth } from "../../../core/model-config.js";
|
|
import { theme } from "../theme/theme.js";
|
|
|
|
/**
|
|
* Find the git root directory by walking up from cwd.
|
|
* Returns the path to .git/HEAD if found, null otherwise.
|
|
*/
|
|
function findGitHeadPath(): string | null {
|
|
let dir = process.cwd();
|
|
while (true) {
|
|
const gitHeadPath = join(dir, ".git", "HEAD");
|
|
if (existsSync(gitHeadPath)) {
|
|
return gitHeadPath;
|
|
}
|
|
const parent = dirname(dir);
|
|
if (parent === dir) {
|
|
// Reached filesystem root
|
|
return null;
|
|
}
|
|
dir = parent;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Footer component that shows pwd, token stats, and context usage
|
|
*/
|
|
export class FooterComponent implements Component {
|
|
private state: AgentState;
|
|
private cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name
|
|
private gitWatcher: FSWatcher | null = null;
|
|
private onBranchChange: (() => void) | null = null;
|
|
private autoCompactEnabled: boolean = true;
|
|
|
|
constructor(state: AgentState) {
|
|
this.state = state;
|
|
}
|
|
|
|
setAutoCompactEnabled(enabled: boolean): void {
|
|
this.autoCompactEnabled = enabled;
|
|
}
|
|
|
|
/**
|
|
* Set up a file watcher on .git/HEAD to detect branch changes.
|
|
* Call the provided callback when branch changes.
|
|
*/
|
|
watchBranch(onBranchChange: () => void): void {
|
|
this.onBranchChange = onBranchChange;
|
|
this.setupGitWatcher();
|
|
}
|
|
|
|
private setupGitWatcher(): void {
|
|
// Clean up existing watcher
|
|
if (this.gitWatcher) {
|
|
this.gitWatcher.close();
|
|
this.gitWatcher = null;
|
|
}
|
|
|
|
const gitHeadPath = findGitHeadPath();
|
|
if (!gitHeadPath) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.gitWatcher = watch(gitHeadPath, () => {
|
|
this.cachedBranch = undefined; // Invalidate cache
|
|
if (this.onBranchChange) {
|
|
this.onBranchChange();
|
|
}
|
|
});
|
|
} catch {
|
|
// Silently fail if we can't watch
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean up the file watcher
|
|
*/
|
|
dispose(): void {
|
|
if (this.gitWatcher) {
|
|
this.gitWatcher.close();
|
|
this.gitWatcher = null;
|
|
}
|
|
}
|
|
|
|
updateState(state: AgentState): void {
|
|
this.state = state;
|
|
}
|
|
|
|
invalidate(): void {
|
|
// Invalidate cached branch so it gets re-read on next render
|
|
this.cachedBranch = undefined;
|
|
}
|
|
|
|
/**
|
|
* Get current git branch by reading .git/HEAD directly.
|
|
* Returns null if not in a git repo, branch name otherwise.
|
|
*/
|
|
private getCurrentBranch(): string | null {
|
|
// Return cached value if available
|
|
if (this.cachedBranch !== undefined) {
|
|
return this.cachedBranch;
|
|
}
|
|
|
|
try {
|
|
const gitHeadPath = findGitHeadPath();
|
|
if (!gitHeadPath) {
|
|
this.cachedBranch = null;
|
|
return null;
|
|
}
|
|
const content = readFileSync(gitHeadPath, "utf8").trim();
|
|
|
|
if (content.startsWith("ref: refs/heads/")) {
|
|
// Normal branch: extract branch name
|
|
this.cachedBranch = content.slice(16);
|
|
} else {
|
|
// Detached HEAD state
|
|
this.cachedBranch = "detached";
|
|
}
|
|
} catch {
|
|
// Not in a git repo or error reading file
|
|
this.cachedBranch = null;
|
|
}
|
|
|
|
return this.cachedBranch;
|
|
}
|
|
|
|
render(width: number): string[] {
|
|
// Calculate cumulative usage from all assistant messages
|
|
let totalInput = 0;
|
|
let totalOutput = 0;
|
|
let totalCacheRead = 0;
|
|
let totalCacheWrite = 0;
|
|
let totalCost = 0;
|
|
|
|
for (const message of this.state.messages) {
|
|
if (message.role === "assistant") {
|
|
const assistantMsg = message as AssistantMessage;
|
|
totalInput += assistantMsg.usage.input;
|
|
totalOutput += assistantMsg.usage.output;
|
|
totalCacheRead += assistantMsg.usage.cacheRead;
|
|
totalCacheWrite += assistantMsg.usage.cacheWrite;
|
|
totalCost += assistantMsg.usage.cost.total;
|
|
}
|
|
}
|
|
|
|
// Get last assistant message for context percentage calculation (skip aborted messages)
|
|
const lastAssistantMessage = this.state.messages
|
|
.slice()
|
|
.reverse()
|
|
.find((m) => m.role === "assistant" && m.stopReason !== "aborted") as AssistantMessage | undefined;
|
|
|
|
// Calculate context percentage from last message (input + output + cacheRead + cacheWrite)
|
|
const contextTokens = lastAssistantMessage
|
|
? lastAssistantMessage.usage.input +
|
|
lastAssistantMessage.usage.output +
|
|
lastAssistantMessage.usage.cacheRead +
|
|
lastAssistantMessage.usage.cacheWrite
|
|
: 0;
|
|
const contextWindow = this.state.model?.contextWindow || 0;
|
|
const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
|
|
const contextPercent = contextPercentValue.toFixed(1);
|
|
|
|
// Format token counts (similar to web-ui)
|
|
const 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";
|
|
};
|
|
|
|
// Replace home directory with ~
|
|
let pwd = process.cwd();
|
|
const home = process.env.HOME || process.env.USERPROFILE;
|
|
if (home && pwd.startsWith(home)) {
|
|
pwd = "~" + pwd.slice(home.length);
|
|
}
|
|
|
|
// Add git branch if available
|
|
const branch = this.getCurrentBranch();
|
|
if (branch) {
|
|
pwd = `${pwd} (${branch})`;
|
|
}
|
|
|
|
// Truncate path if too long to fit width
|
|
const maxPathLength = Math.max(20, width - 10); // Leave some margin
|
|
if (pwd.length > maxPathLength) {
|
|
const start = pwd.slice(0, Math.floor(maxPathLength / 2) - 2);
|
|
const end = pwd.slice(-(Math.floor(maxPathLength / 2) - 1));
|
|
pwd = `${start}...${end}`;
|
|
}
|
|
|
|
// Build stats line
|
|
const statsParts = [];
|
|
if (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`);
|
|
if (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`);
|
|
if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`);
|
|
if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`);
|
|
|
|
// Show cost with "(sub)" indicator if using OAuth subscription
|
|
const usingSubscription = this.state.model ? isModelUsingOAuth(this.state.model) : false;
|
|
if (totalCost || usingSubscription) {
|
|
const costStr = `$${totalCost.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}`;
|
|
if (contextPercentValue > 90) {
|
|
contextPercentStr = theme.fg("error", contextPercentDisplay);
|
|
} else if (contextPercentValue > 70) {
|
|
contextPercentStr = theme.fg("warning", contextPercentDisplay);
|
|
} else {
|
|
contextPercentStr = contextPercentDisplay;
|
|
}
|
|
statsParts.push(contextPercentStr);
|
|
|
|
let statsLeft = statsParts.join(" ");
|
|
|
|
// Add model name on the right side, plus thinking level if model supports it
|
|
const modelName = this.state.model?.id || "no-model";
|
|
|
|
// Add thinking level hint if model supports reasoning and thinking is enabled
|
|
let rightSide = modelName;
|
|
if (this.state.model?.reasoning) {
|
|
const thinkingLevel = this.state.thinkingLevel || "off";
|
|
if (thinkingLevel !== "off") {
|
|
rightSide = `${modelName} • ${thinkingLevel}`;
|
|
}
|
|
}
|
|
|
|
let statsLeftWidth = visibleWidth(statsLeft);
|
|
const rightSideWidth = visibleWidth(rightSide);
|
|
|
|
// If statsLeft is too wide, truncate it
|
|
if (statsLeftWidth > width) {
|
|
// Truncate statsLeft to fit width (no room for right side)
|
|
const plainStatsLeft = statsLeft.replace(/\x1b\[[0-9;]*m/g, "");
|
|
statsLeft = plainStatsLeft.substring(0, width - 3) + "...";
|
|
statsLeftWidth = visibleWidth(statsLeft);
|
|
}
|
|
|
|
// Calculate available space for padding (minimum 2 spaces between stats and model)
|
|
const minPadding = 2;
|
|
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 > 3) {
|
|
// Truncate to fit (strip ANSI codes for length calculation, then truncate raw string)
|
|
const plainRightSide = rightSide.replace(/\x1b\[[0-9;]*m/g, "");
|
|
const truncatedPlain = plainRightSide.substring(0, availableForRight);
|
|
// For simplicity, just use plain truncated version (loses color, but fits)
|
|
const padding = " ".repeat(width - statsLeftWidth - truncatedPlain.length);
|
|
statsLine = statsLeft + padding + truncatedPlain;
|
|
} else {
|
|
// Not enough space for right side at all
|
|
statsLine = statsLeft;
|
|
}
|
|
}
|
|
|
|
// Return two lines: pwd and stats
|
|
return [theme.fg("dim", pwd), theme.fg("dim", statsLine)];
|
|
}
|
|
}
|