Vectrum
Sign In
153 lines · 5.1 KB · TypeScript
/**
 * Clean UI: big pixel "PI" header + two-line footer.
 * All colors are derived from the active theme (no theme changes).
 */
import type { AssistantMessage } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
import { mixColors, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";

// ── Logo ────────────────────────────────────────────────────────────────
// Each cell is drawn 2 chars wide. "#" = filled.
const LOGO = [
	"#####  ##",
	"##  ## ##",
	"##  ## ##",
	"#####  ##",
	"##     ##",
	"##     ##",
];

function cwdLabel(cwd: string): string {
	const home = process.env.HOME;
	return home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
}

function center(line: string, width: number): string {
	const pad = Math.max(0, Math.floor((width - visibleWidth(line)) / 2));
	return truncateToWidth(" ".repeat(pad) + line, width);
}

function renderLogo(theme: Theme): string[] {
	const rows = LOGO.length;
	const cols = LOGO[0].length * 2; // char width
	const filled = (r: number, c: number) =>
		r >= 0 && r < rows && c >= 0 && c < cols && LOGO[r][Math.floor(c / 2)] === "#";

	// Gradient from the theme's accent: lighter top-left → deeper bottom-right.
	let paint: (t: number, s: string) => string;
	let shadow: (s: string) => string;
	try {
		const { accent, text, background } = theme.colors as any;
		const light = mixColors(accent, text, 0.45);
		const dark = mixColors(accent, background, 0.35);
		const shade = mixColors(accent, background, 0.6);
		paint = (t, s) => theme.style(s, { fg: mixColors(light, dark, Math.min(1, Math.max(0, t))) } as any);
		shadow = (s) => theme.style(s, { fg: shade } as any);
	} catch {
		paint = (_t, s) => theme.fg("accent", s);
		shadow = (s) => theme.fg("borderAccent", s);
	}

	const out: string[] = [];
	// +1 row / +1 col for the drop shadow
	for (let r = 0; r <= rows; r++) {
		let line = "";
		for (let c = 0; c <= cols; c++) {
			if (filled(r, c)) {
				line += paint((c / cols) * 0.6 + (r / rows) * 0.4, "█");
			} else if (filled(r - 1, c - 1)) {
				line += shadow("░");
			} else {
				line += " ";
			}
		}
		out.push(line);
	}
	return out;
}

// ── Footer helpers ──────────────────────────────────────────────────────
function row(left: string, right: string, width: number): string {
	const gap = width - visibleWidth(left) - visibleWidth(right);
	if (gap < 1) return truncateToWidth(`${left} ${right}`, width);
	return left + " ".repeat(gap) + right;
}

function fmtK(n: number): string {
	return n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`;
}

export default function (pi: ExtensionAPI) {
	let changedFiles = 0;
	let requestRender: (() => void) | undefined;

	const refreshGit = async (ctx: ExtensionContext) => {
		const res = await pi.exec("git", ["status", "--porcelain"], { cwd: ctx.cwd }).catch(() => undefined);
		changedFiles = res && res.code === 0 ? res.stdout.split("\n").filter((l) => l.trim()).length : 0;
		requestRender?.();
	};

	pi.on("message_end", () => requestRender?.());

	pi.on("agent_settled", (_e, ctx) => void refreshGit(ctx));

	pi.on("session_start", (_event, ctx) => {
		if (ctx.mode !== "tui") return;

		// Header: logo + cwd, centered
		ctx.ui.setHeader((_tui, theme) => ({
			render(width: number): string[] {
				return [
					"",
					...renderLogo(theme).map((l) => center(l, width)),
					center(theme.fg("accent", cwdLabel(ctx.cwd)), width),
					"",
				];
			},
			invalidate() {},
		}));

		// Footer: two lines, left/right aligned
		ctx.ui.setFooter((tui, theme, footerData) => {
			requestRender = () => tui.requestRender();
			const unsub = footerData.onBranchChange(() => {
				void refreshGit(ctx);
				tui.requestRender();
			});
			void refreshGit(ctx);

			return {
				dispose() {
					unsub();
					requestRender = undefined;
				},
				invalidate() {},
				render(width: number): string[] {
					let cost = 0;
					for (const e of ctx.sessionManager.getBranch()) {
						if (e.type === "message" && e.message.role === "assistant") {
							cost += (e.message as AssistantMessage).usage?.cost?.total ?? 0;
						}
					}

					const usage = ctx.getContextUsage();
					const window = usage?.contextWindow ?? ctx.model?.contextWindow;
					const pct = usage?.percent != null ? Math.round(usage.percent) : 0;
					const ctxStr = window ? `${pct}%/${fmtK(window)}` : "ctx ?";

					const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "no model";
					const thinking = pi.getThinkingLevel();
					const branch = footerData.getGitBranch();
					const git = branch
						? `${branch}${changedFiles ? ` · ${changedFiles} file${changedFiles === 1 ? "" : "s"} changed` : ""}`
						: "";

					return [
						row(theme.fg("text", cwdLabel(ctx.cwd)), theme.fg("muted", `${model} · ${thinking}`), width),
						row(theme.fg("dim", `${ctxStr} · $${cost.toFixed(2)}`), theme.fg("dim", git), width),
					];
				},
			};
		});
	});
}