125 lines
4.5 KiB
TypeScript
125 lines
4.5 KiB
TypeScript
// ABOUTME: Footer widget displaying model name, context percentage + window size, and working directory.
|
||
// ABOUTME: Shows context usage warnings; core pi framework handles actual auto-compaction.
|
||
/**
|
||
* Footer — Dark status bar with model · context % / window · directory.
|
||
*
|
||
* Context compaction is handled by pi's core _runAutoCompaction which properly
|
||
* emits auto_compaction_start/end events. The interactive-mode handles these
|
||
* events by calling rebuildChatFromMessages() to clear and re-render the UI.
|
||
*
|
||
* Previously, this extension called ctx.compact() directly which bypassed
|
||
* the auto_compaction events, leaving stale UI components that caused
|
||
* doubled/artifact rendering after compaction.
|
||
*/
|
||
|
||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||
import { basename, dirname } from "node:path";
|
||
import { applyExtensionDefaults } from "./lib/themeMap.ts";
|
||
import { shouldWarnForCompaction, getProactiveCompactionPhase } from "./lib/context-gate.ts";
|
||
|
||
/** Turn a model name like "Claude 4 Opus" into "opus 4" */
|
||
function shortModelName(name: string | undefined): string {
|
||
if (!name) return "no model";
|
||
const cleaned = name.replace(/^claude\s*/i, "").trim();
|
||
const tokens = cleaned.split(/\s+/);
|
||
const versions: string[] = [];
|
||
const words: string[] = [];
|
||
for (const token of tokens) {
|
||
if (/^[\d.]+$/.test(token)) versions.push(token);
|
||
else words.push(token.toLowerCase());
|
||
}
|
||
const parts = [...words, ...versions];
|
||
return parts.join(" ") || name.toLowerCase();
|
||
}
|
||
|
||
/** Format a token count into compact K/M notation: 200K, 1.2M */
|
||
export function formatTokens(n: number): string {
|
||
if (n < 1000) return String(Math.round(n));
|
||
if (n < 1_000_000) {
|
||
const k = n / 1000;
|
||
return k % 1 === 0 ? `${k}K` : `${parseFloat(k.toFixed(1))}K`;
|
||
}
|
||
const m = n / 1_000_000;
|
||
return m % 1 === 0 ? `${m}M` : `${parseFloat(m.toFixed(1))}M`;
|
||
}
|
||
|
||
/** Thinking level → labeled indicator */
|
||
function thinkingIndicator(level: string | undefined, theme: any): string {
|
||
const label = level || "off";
|
||
const color = label === "off" ? "dim" : label === "high" || label === "xhigh" ? "warning" : "accent";
|
||
return theme.fg("dim", "thinking: ") + theme.fg(color, theme.bold(label));
|
||
}
|
||
|
||
/** Last two path components: "Github-Work/pi-vs-claude-code" */
|
||
function shortDir(cwd: string): string {
|
||
const child = basename(cwd);
|
||
const parent = basename(dirname(cwd));
|
||
return parent ? `${parent}/${child}` : child;
|
||
}
|
||
|
||
function setupFooter(pi: ExtensionAPI, ctx: any, onUnsub: (unsub: () => void) => void) {
|
||
ctx.ui.setFooter((tui: any, theme: any, footerData: any) => {
|
||
const unsub = footerData.onBranchChange(() => tui.requestRender());
|
||
onUnsub(unsub);
|
||
return {
|
||
dispose: unsub,
|
||
invalidate() {},
|
||
render(width: number): string[] {
|
||
const model = shortModelName(ctx.model?.name);
|
||
const usage = ctx.getContextUsage();
|
||
const contextWindow = ctx.model?.contextWindow || 0;
|
||
|
||
let usageStr = "–";
|
||
if (usage?.percent != null) {
|
||
const pct = `${Math.round(usage.percent)}%`;
|
||
if (contextWindow > 0) {
|
||
usageStr = `${pct} / ${formatTokens(contextWindow)}`;
|
||
} else {
|
||
usageStr = pct;
|
||
}
|
||
}
|
||
|
||
const dir = shortDir(ctx.cwd);
|
||
const thinking = thinkingIndicator(pi.getThinkingLevel?.(), theme);
|
||
const sep = theme.fg("dim", " | ");
|
||
const modelStr = theme.fg("accent", theme.bold(model));
|
||
const leftContent = ` ` + modelStr + sep + theme.fg("dim", usageStr) + sep + theme.fg("dim", dir);
|
||
const rightContent = thinking + ` `;
|
||
|
||
const leftWidth = visibleWidth(leftContent);
|
||
const rightWidth = visibleWidth(rightContent);
|
||
const gap = Math.max(1, width - leftWidth - rightWidth);
|
||
const line = leftContent + " ".repeat(gap) + rightContent;
|
||
|
||
return [truncateToWidth(line, width, "")];
|
||
},
|
||
};
|
||
});
|
||
}
|
||
|
||
export default function (pi: ExtensionAPI) {
|
||
let branchUnsub: (() => void) | null = null;
|
||
|
||
pi.on("session_start", async (_event, ctx) => {
|
||
applyExtensionDefaults(import.meta.url, ctx);
|
||
setupFooter(pi, ctx, (unsub) => {
|
||
branchUnsub = unsub;
|
||
});
|
||
});
|
||
|
||
// No tool_call blocking — core auto-compaction handles compaction properly
|
||
// via auto_compaction_start/end events which trigger UI rebuild.
|
||
|
||
// Footer no longer shows context warnings — memory-cycle.ts handles
|
||
// proactive compaction with two-phase inject (70% prep, 80% hard stop).
|
||
// The footer just renders the percentage in the status bar.
|
||
|
||
pi.on("session_shutdown", async () => {
|
||
if (branchUnsub) {
|
||
branchUnsub();
|
||
branchUnsub = null;
|
||
}
|
||
});
|
||
}
|