diff --git a/.changeset/pi-footer-atlas-status.md b/.changeset/pi-footer-atlas-status.md new file mode 100644 index 0000000..f528076 --- /dev/null +++ b/.changeset/pi-footer-atlas-status.md @@ -0,0 +1,5 @@ +--- +"@edheltzel/pi-bits": patch +--- + +Rework the Pi footer (re-exported by `pi-bits`) to the Atlas status-line information design: `π` + Pi version, provider/model with thinking level, working directory shortened to `…//`, git branch with the Nerd Font branch glyph, a responsive 24-cell context meter, and cache hit percent from the latest successful assistant prompt with the Nerd Font cache glyph. Extension statuses keep their own ANSI styling; `codex-status` and `mcp` are suppressed and the background-process status keeps its `/proc` special case. diff --git a/AGENTS.md b/AGENTS.md index a25d155..d96f729 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ Develop independently loadable Pi extensions and supporting packages in one pnpm - Keep TypeScript source under `src/` and tests under `tests/` with `.unit.test.ts`, `.integration.test.ts`, or `.e2e.test.ts` suffixes. - Pi loads TypeScript through jiti; extension workspaces normally need no build step. - Keep each extension independently loadable through its own `package.json` `pi` manifest. -- The root `pi` manifest is the Git collection at `git:github.com/edheltzel/pi-extensions`. It loads every `extensions/*/src/index.ts` entry plus Atlas skill folders. Do not also install `npm:@richardgill/*` copies of the same extensions. +- The root `pi` manifest is the Git collection at `git:github.com/edheltzel/pi-extensions`. Only resources listed in the root `package.json` belong to that collection; see the root README for installation paths for other workspaces. Do not also install `npm:@richardgill/*` copies of the same extensions. - Atlas-owned workspaces remain private until an explicit npm publishing decision removes that safeguard and adds a release changeset. - Preserve upstream attribution, copyright, and split-license notices in package-level license files. diff --git a/PI_SETUP.md b/PI_SETUP.md index eefaed8..62d13fe 100644 --- a/PI_SETUP.md +++ b/PI_SETUP.md @@ -120,7 +120,7 @@ pi install ./extensions/context-commands ## `pi-bits`: the rest of my personal setup -- A compact footer showing the model, thinking level, context usage, and extension statuses. +- The [`pi-footer`](./extensions/footer/README.md) Atlas-style status line. - Automatically trust every new folder - bypassing Pi's project trust prompt. ```sh diff --git a/README.md b/README.md index cf71932..ed0138b 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ Below are the resources used to shape this Pi setup. These workspaces are not distributed individually. Footer and trust-all-projects are bundled by [`pi-bits`](./packages/pi-bits/README.md); thinking-toggle remains local-only. -- [`pi-footer`](./extensions/footer/README.md) - Replaces the footer with model, thinking, context, and extension status information. +- [`pi-footer`](./extensions/footer/README.md) - Atlas-style Pi status line. - [`pi-trust-all-projects`](./extensions/trust-all-projects/README.md) - Automatically trusts every project. - [`pi-thinking-toggle`](./extensions/thinking-toggle/README.md) - Cycles medium, high, and xhigh thinking levels. diff --git a/extensions/footer/README.md b/extensions/footer/README.md index f2ee33e..b543166 100644 --- a/extensions/footer/README.md +++ b/extensions/footer/README.md @@ -1,3 +1,17 @@ # pi-footer -Private extension that replaces Pi's footer with model, thinking, context usage, and extension status information. +Private extension that replaces Pi's footer with an Atlas-style status line. + +The footer displays these items in order: + +1. `π` and the running Pi version +2. Provider/model and thinking level +3. The working directory shortened to `…//`, followed by the Git branch +4. Context usage as a colored `⛁` meter and a whole-number percentage +5. Cache usage for the latest non-aborted, non-error assistant prompt + +The context meter uses 24 cells at widths of 80 columns or more, then 16, 8, or 6 cells below 80, 55, or 35 columns. Cache usage is `cacheRead / (input + cacheRead + cacheWrite)`, rounded to a whole percent; it is hidden when the prompt has no input or cache tokens. + +The footer keeps one space of side padding and uses Nerd Font glyphs for the Git branch and cache indicators. Other extensions' statuses keep their ANSI styling on a separate line, except `mcp` and `codex-status`, which are hidden. Background command status retains its `/proc` label. + +Light and dark colors are selected from the active Pi theme name without reading machine-specific theme configuration. diff --git a/extensions/footer/package.json b/extensions/footer/package.json index dbc88db..c93e685 100644 --- a/extensions/footer/package.json +++ b/extensions/footer/package.json @@ -20,8 +20,8 @@ }, "scripts": { "tsc": "tsc -p tsconfig.json --noEmit", - "test": "vitest --run", - "test:watch": "vitest" + "test": "vitest --run --root ../.. extensions/footer/tests", + "test:watch": "vitest --root ../.. extensions/footer/tests" }, "peerDependencies": { "@earendil-works/pi-coding-agent": "*", diff --git a/extensions/footer/src/index.ts b/extensions/footer/src/index.ts index c94b270..a131b41 100644 --- a/extensions/footer/src/index.ts +++ b/extensions/footer/src/index.ts @@ -1,64 +1,424 @@ -import type { ContextUsage, ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent"; +import { parse, sep } from "node:path"; +import { VERSION, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; -const horizontalPadding = 1; -const bottomPaddingLines = 1; +const SYMBOLS = { + thinking: "⚡︎", // U+FE0E keeps text presentation instead of emoji + pi: "π", + contextCell: "⛁", +} as const; -const padFooterLine = (line: string, width: number) => { - const contentWidth = Math.max(0, width - horizontalPadding * 2); - return `${" ".repeat(horizontalPadding)}${truncateToWidth(line, contentWidth)}${" ".repeat(horizontalPadding)}`; +export const NERD_FONT_ICONS = { + branch: "\uF418", // nf-oct-git_branch + cache: "\uF49B", // nf-oct-cache +} as const; + +type Theme = ExtensionContext["ui"]["theme"]; +type Rgb = readonly [number, number, number]; + +type CacheUsage = { + input: number; + cacheRead: number; + cacheWrite: number; +}; + +type StatusPalette = { + brand: Rgb; + text: Rgb; + muted: Rgb; + separator: Rgb; + path: Rgb; + branch: Rgb; + pi: Rgb; + contextEmpty: Rgb; + cacheIcon: Rgb; + gradient: readonly [Rgb, Rgb, Rgb, Rgb]; + usage: readonly [Rgb, Rgb, Rgb, Rgb]; + cache: readonly [Rgb, Rgb, Rgb, Rgb, Rgb]; +}; + +const DARK_PALETTE: StatusPalette = { + brand: [164, 140, 242], + text: [235, 250, 250], + muted: [112, 129, 208], + separator: [50, 52, 73], + path: [4, 209, 249], + branch: [242, 101, 181], + pi: [232, 97, 32], + contextEmpty: [50, 52, 73], + cacheIcon: [164, 140, 242], + gradient: [ + [55, 244, 153], + [241, 252, 121], + [247, 198, 127], + [241, 108, 117], + ], + usage: [ + [55, 244, 153], + [251, 191, 36], + [251, 146, 60], + [241, 108, 117], + ], + cache: [ + [55, 244, 153], + [4, 209, 249], + [241, 252, 121], + [247, 198, 127], + [241, 108, 117], + ], +}; + +const LIGHT_PALETTE: StatusPalette = { + brand: [144, 122, 169], + text: [87, 82, 121], + muted: [121, 117, 147], + separator: [223, 218, 217], + path: [86, 148, 159], + branch: [215, 130, 126], + pi: [234, 157, 52], + contextEmpty: [223, 218, 217], + cacheIcon: [144, 122, 169], + gradient: [ + [40, 105, 131], + [234, 157, 52], + [215, 130, 126], + [180, 99, 122], + ], + usage: [ + [40, 105, 131], + [234, 157, 52], + [215, 130, 126], + [180, 99, 122], + ], + cache: [ + [40, 105, 131], + [86, 148, 159], + [234, 157, 52], + [215, 130, 126], + [180, 99, 122], + ], +}; + +// Extension statuses may already carry their own ANSI colors, so labels drop +// control characters while status text keeps them (only whitespace is folded). +const sanitizeLabel = (text: string) => + text + .replace(/[\r\n\t]/g, " ") + .replace(/\p{Cc}/gu, "") + .replace(/ +/g, " ") + .trim(); + +const sanitizeStatusText = (text: string) => + text + .replace(/[\r\n\t]/g, " ") + .replace(/ +/g, " ") + .trim(); + +const color = (rgb: Rgb, text: string) => `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m${text}\x1b[39m`; + +const mix = (from: Rgb, to: Rgb, amount: number): Rgb => { + const clamped = Math.max(0, Math.min(1, amount)); + return [ + Math.round(from[0] + (to[0] - from[0]) * clamped), + Math.round(from[1] + (to[1] - from[1]) * clamped), + Math.round(from[2] + (to[2] - from[2]) * clamped), + ]; }; -const joinFooter = (left: string, right: string, width: number) => { - const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right))); - return truncateToWidth(left + pad + right, width); +// Detect a light theme from its name; the canonical dark palette is the default. +const paletteFor = (theme: Theme): StatusPalette => { + const themeName = theme.name?.toLowerCase() || ""; + return /light|dawn|latte/.test(themeName) ? LIGHT_PALETTE : DARK_PALETTE; }; -const formatTokenCount = (tokens: number) => - tokens < 1000 ? `${tokens}` : `${(tokens / 1000).toFixed(1)}k`; +// Shorten the working directory to `…//` (or less when shallow). +export const formatCurrentDirectory = (cwd: string): string => { + const safeCwd = sanitizeLabel(cwd); + const { root } = parse(safeCwd); + const parts = safeCwd.slice(root.length).split(sep).filter(Boolean); + + if (parts.length === 0) return root || sep; -const formatContextUsage = (usage: ContextUsage | undefined) => { - if (!usage) return "ctx n/a"; + const tail = parts.slice(-2).join(sep); + if (root || parts.length > 2) return `…${sep}${tail}`; + return tail; +}; - const window = formatTokenCount(usage.contextWindow); - if (usage.tokens === null || usage.percent === null) return `ctx ?/${window}`; +// Cache hit rate for the latest assistant prompt: cacheRead / (input + cacheRead + cacheWrite). +export const calculateCacheHitPercent = (usage: CacheUsage): number | null => { + const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite; + if (!Number.isFinite(promptTokens) || promptTokens <= 0) return null; + + const percent = (usage.cacheRead / promptTokens) * 100; + return Math.max(0, Math.min(100, Math.round(percent))); +}; - return `ctx ${formatTokenCount(usage.tokens)}/${window} ${usage.percent.toFixed(1)}%`; +// Responsive context-meter width: 24 cells at normal widths, smaller when narrow. +export const contextBarWidth = (terminalWidth: number): number => { + if (terminalWidth < 35) return 6; + if (terminalWidth < 55) return 8; + if (terminalWidth < 80) return 16; + return 24; }; -const hiddenStatusKeys = new Set(["codex-status"]); -const backgroundBashStatusKey = "backgroundBashProcesses"; +export const contextFilledCells = (percent: number, width: number): number => { + const clamped = Math.max(0, Math.min(100, Math.floor(percent))); + return Math.max(0, Math.min(width, Math.floor((clamped * width) / 100))); +}; + +// Pack status items onto as few lines as fit the width, in the given order. +export const layoutStatusItems = ( + items: string[], + width: number, + separator = " · ", + ellipsis = "…", +): string[] => { + if (width <= 0 || items.length === 0) return []; + + const lines: string[] = []; + let line = ""; + + for (const item of items) { + const fitted = truncateToWidth(item, width, ellipsis); + const candidate = line ? `${line}${separator}${fitted}` : fitted; + if (!line || visibleWidth(candidate) <= width) { + line = candidate; + } else { + lines.push(line); + line = fitted; + } + } + + if (line) lines.push(line); + return lines; +}; + +const bucketColor = (position: number, width: number, palette: StatusPalette): Rgb => { + const percent = (position * 100) / width; + const [low, middle, warning, high] = palette.gradient; + if (percent <= 33) return mix(low, middle, percent / 33); + if (percent <= 66) return mix(middle, warning, (percent - 33) / 33); + return mix(warning, high, (percent - 66) / 34); +}; + +const renderContextBar = ( + percent: number | null, + width: number, + palette: StatusPalette, +): string => { + const filled = percent === null ? 0 : contextFilledCells(percent, width); + let output = ""; + for (let position = 1; position <= width; position += 1) { + const bucket = + position <= filled ? bucketColor(position, width, palette) : palette.contextEmpty; + output += color(bucket, SYMBOLS.contextCell); + } + return output; +}; + +const usageColor = (percent: number, palette: StatusPalette): Rgb => { + if (percent >= 80) return palette.usage[3]; + if (percent >= 60) return palette.usage[2]; + if (percent >= 40) return palette.usage[1]; + return palette.usage[0]; +}; + +const cacheColor = (percent: number, palette: StatusPalette): Rgb => { + if (percent >= 80) return palette.cache[0]; + if (percent >= 60) return palette.cache[1]; + if (percent >= 40) return palette.cache[2]; + if (percent >= 20) return palette.cache[3]; + return palette.cache[4]; +}; + +// Cache percent from the most recent successful assistant prompt. +const latestCacheHitPercent = (ctx: ExtensionContext): number | null => { + const entries = ctx.sessionManager.getBranch(); + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (entry.type !== "message" || entry.message.role !== "assistant") continue; + if (entry.message.stopReason === "aborted" || entry.message.stopReason === "error") continue; + + return calculateCacheHitPercent(entry.message.usage); + } + return null; +}; + +const createCacheHitPercentReader = (ctx: ExtensionContext) => { + let cachedLeafId: string | null | undefined; + let cachedPercent: number | null = null; + + return { + read() { + const leafId = ctx.sessionManager.getLeafId(); + if (leafId !== cachedLeafId) { + cachedPercent = latestCacheHitPercent(ctx); + cachedLeafId = leafId; + } + return cachedPercent; + }, + }; +}; + +// Build the ordered footer items: π + version, provider/model + thinking, +// working directory, git branch, context meter, and cache hit rate. +const buildStatusItems = ( + pi: ExtensionAPI, + ctx: ExtensionContext, + palette: StatusPalette, + branch: string | null, + terminalWidth: number, + layoutWidth: number, + cachePercent: number | null, +): string[] => { + const model = ctx.model; + const provider = sanitizeLabel(model?.provider || "no-provider"); + const modelId = sanitizeLabel(model?.id || "no-model"); + const thinking = sanitizeLabel(pi.getThinkingLevel()); + const cwd = formatCurrentDirectory(ctx.sessionManager.getCwd()); + const gitBranch = sanitizeLabel(branch || "no-git"); + const usage = ctx.getContextUsage(); + const contextPercent = + typeof usage?.percent === "number" && Number.isFinite(usage.percent) + ? Math.max(0, Math.min(100, Math.floor(usage.percent))) + : null; + const separator = color(palette.separator, "·"); + + const providerModel = `${color(palette.muted, `${provider}/`)}${color(palette.brand, modelId)}`; + const modelText = [ + providerModel, + color(palette.path, SYMBOLS.thinking), + color(palette.brand, thinking), + ].join(" "); + const contextLabel = + contextPercent === null + ? color(palette.muted, "n/a") + : color(usageColor(contextPercent, palette), `${contextPercent}%`); + const contextText = `${renderContextBar( + contextPercent, + contextBarWidth(terminalWidth), + palette, + )} ${contextLabel}`; + + const items = [ + `${color(palette.pi, SYMBOLS.pi)} ${color(palette.text, VERSION)}`, + modelText, + color(palette.path, cwd), + `${color(palette.branch, NERD_FONT_ICONS.branch)} ${color(palette.branch, gitBranch)}`, + contextText, + ]; + + if (cachePercent !== null) { + items.push( + `${color(palette.cacheIcon, NERD_FONT_ICONS.cache)} ${color( + cacheColor(cachePercent, palette), + `${cachePercent}%`, + )}`, + ); + } + + return layoutStatusItems(items, layoutWidth, ` ${separator} `, color(palette.muted, "…")); +}; + +// `codex-status` and `mcp` are suppressed entirely; every other extension status +// keeps its own ANSI styling. +const hiddenStatusKeys = new Set(["codex-status", "mcp"]); +const backgroundBashStatusKey = "backgroundBashTmuxCommands"; const formatStatus = (key: string, value: string, theme: Theme) => { - if (key !== backgroundBashStatusKey) return theme.fg("dim", value); + if (key !== backgroundBashStatusKey) return sanitizeStatusText(value); const backgroundCount = value.replace(/ procs?$/, ""); return `${theme.fg("dim", `${backgroundCount} `)}${theme.fg("accent", theme.bold("/proc"))}`; }; -const getStatuses = (statuses: ReadonlyMap, theme: Theme) => - Array.from(statuses.entries()) +const getStatusLine = ( + statuses: ReadonlyMap, + theme: Theme, + palette: StatusPalette, + width: number, +): string | null => { + const rendered = Array.from(statuses.entries()) .filter(([key, value]) => Boolean(value) && !hiddenStatusKeys.has(key)) - .map(([key, value]) => formatStatus(key, value, theme)) - .join(" "); + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => formatStatus(key, value, theme)); + + if (rendered.length === 0) return null; + return truncateToWidth(rendered.join(" "), width, color(palette.muted, "…")); +}; + +const horizontalPadding = 1; +const bottomPaddingLines = 1; + +const padFooterLine = (line: string, width: number) => { + const contentWidth = Math.max(0, width - horizontalPadding * 2); + return `${" ".repeat(horizontalPadding)}${truncateToWidth(line, contentWidth)}${" ".repeat(horizontalPadding)}`; +}; + +export default function footer(pi: ExtensionAPI) { + let requestRender: (() => void) | undefined; + const refresh = () => requestRender?.(); + + pi.on("turn_start", refresh); + pi.on("message_end", refresh); + pi.on("agent_settled", refresh); + pi.on("session_compact", refresh); + pi.on("session_tree", refresh); + pi.on("model_select", refresh); + pi.on("thinking_level_select", refresh); -export default function (pi: ExtensionAPI) { pi.on("session_start", (_event, ctx) => { - ctx.ui.setFooter((_tui, theme, footerData) => ({ - invalidate() {}, - render(width: number): string[] { - const model = ctx.model?.id ?? "no-model"; - const usage = formatContextUsage(ctx.getContextUsage()); - const thinkingLevel = pi.getThinkingLevel(); - const statuses = getStatuses(footerData.getExtensionStatuses(), theme); - const thinking = theme.getThinkingBorderColor(thinkingLevel)(thinkingLevel); - const left = `${theme.fg("dim", `${model} · `)}${thinking}${theme.fg("dim", ` · ${usage}`)}`; - const right = statuses; - const line = padFooterLine(joinFooter(left, right, width - horizontalPadding * 2), width); - const bottomPadding = Array.from({ length: bottomPaddingLines }, () => ""); - - return [line, ...bottomPadding]; - }, - })); + if (ctx.mode !== "tui") return; + + ctx.ui.setFooter((tui, theme, footerData) => { + let active = true; + let palette = paletteFor(theme); + const cacheReader = createCacheHitPercentReader(ctx); + const renderNow = () => { + if (active) tui.requestRender(); + }; + requestRender = renderNow; + const unsubscribeBranch = footerData.onBranchChange(renderNow); + + return { + dispose() { + active = false; + unsubscribeBranch(); + if (requestRender === renderNow) requestRender = undefined; + }, + invalidate() { + palette = paletteFor(theme); + }, + render(width: number): string[] { + const contentWidth = Math.max(0, width - horizontalPadding * 2); + // The meter sizes off the raw terminal width (spec breakpoints), while + // item packing/truncation respects the padded content width. + const lines = buildStatusItems( + pi, + ctx, + palette, + footerData.getGitBranch(), + width, + contentWidth, + cacheReader.read(), + ); + + const statusLine = getStatusLine( + footerData.getExtensionStatuses(), + theme, + palette, + contentWidth, + ); + if (statusLine) lines.push(statusLine); + + const bottomPadding = Array.from({ length: bottomPaddingLines }, () => ""); + return [...lines.map((line) => padFooterLine(line, width)), ...bottomPadding]; + }, + }; + }); + }); + + pi.on("session_shutdown", () => { + requestRender = undefined; }); } diff --git a/extensions/footer/tests/footer.unit.test.ts b/extensions/footer/tests/footer.unit.test.ts new file mode 100644 index 0000000..376871e --- /dev/null +++ b/extensions/footer/tests/footer.unit.test.ts @@ -0,0 +1,282 @@ +import { sep } from "node:path"; +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { VERSION } from "@earendil-works/pi-coding-agent"; +import { describe, expect, it, vi } from "vitest"; + +import footer, { + calculateCacheHitPercent, + contextBarWidth, + contextFilledCells, + formatCurrentDirectory, + layoutStatusItems, + NERD_FONT_ICONS, +} from "../src/index"; + +const ESC = String.fromCharCode(27); +const ansiPattern = new RegExp(`${ESC}\\[[0-9;]*m`, "g"); +const stripAnsi = (text: string) => text.replace(ansiPattern, ""); +const p = (...segments: string[]) => segments.join(sep); + +describe("formatCurrentDirectory", () => { + it("shortens a deep path to …//", () => { + expect(formatCurrentDirectory(p("", "home", "user", "code", "myproj"))).toBe( + `…${sep}code${sep}myproj`, + ); + }); + + it("keeps the leading ellipsis for a two-segment absolute path", () => { + expect(formatCurrentDirectory(p("", "home", "user"))).toBe(`…${sep}home${sep}user`); + }); + + it("returns the tail without ellipsis for a shallow relative path", () => { + expect(formatCurrentDirectory(p("parent", "current"))).toBe(p("parent", "current")); + }); +}); + +describe("calculateCacheHitPercent", () => { + it("computes cacheRead / (input + cacheRead + cacheWrite)", () => { + expect(calculateCacheHitPercent({ input: 100, cacheRead: 300, cacheWrite: 100 })).toBe(60); + }); + + it("rounds to a whole percent", () => { + expect(calculateCacheHitPercent({ input: 0, cacheRead: 1, cacheWrite: 2 })).toBe(33); + expect(calculateCacheHitPercent({ input: 0, cacheRead: 2, cacheWrite: 1 })).toBe(67); + }); + + it("returns null when there are no prompt tokens", () => { + expect(calculateCacheHitPercent({ input: 0, cacheRead: 0, cacheWrite: 0 })).toBeNull(); + }); +}); + +describe("contextBarWidth", () => { + it("uses 24 cells at normal widths and smaller responsive meters when narrow", () => { + expect(contextBarWidth(34)).toBe(6); + expect(contextBarWidth(54)).toBe(8); + expect(contextBarWidth(79)).toBe(16); + expect(contextBarWidth(80)).toBe(24); + expect(contextBarWidth(200)).toBe(24); + }); +}); + +describe("contextFilledCells", () => { + it("fills whole cells proportional to the percent", () => { + expect(contextFilledCells(50, 24)).toBe(12); + expect(contextFilledCells(100, 24)).toBe(24); + expect(contextFilledCells(0, 24)).toBe(0); + }); + + it("clamps out-of-range percents", () => { + expect(contextFilledCells(150, 24)).toBe(24); + expect(contextFilledCells(-10, 24)).toBe(0); + }); +}); + +describe("layoutStatusItems", () => { + it("preserves item order and wraps onto extra lines when needed", () => { + const lines = layoutStatusItems(["alpha", "beta", "gamma"], 12); + expect(lines).toEqual(["alpha · beta", "gamma"]); + }); +}); + +type RenderHarness = { + render: (width: number) => string[]; + rawRender: (width: number) => string; + statuses: Map; + branchReadCount: () => number; + updateBranch: (entries: unknown[], event?: "message_end" | "session_tree") => void; +}; + +type AssistantUsage = { input: number; cacheRead: number; cacheWrite: number }; + +const assistantEntry = (usage: AssistantUsage, stopReason = "stop") => ({ + type: "message", + message: { role: "assistant", stopReason, usage }, +}); + +const defaultBranch = [assistantEntry({ input: 100, cacheRead: 300, cacheWrite: 100 })]; + +const mountFooter = (options?: { + statuses?: Map; + contextPercent?: number | null; + branchEntries?: unknown[]; +}): RenderHarness => { + const statuses = options?.statuses ?? new Map(); + let branchEntries = options?.branchEntries ?? defaultBranch; + let leafSequence = 0; + let leafId = `leaf-${leafSequence}`; + const handlers = new Map void>(); + const getBranch = vi.fn(() => branchEntries); + + const pi = { + on: (event: string, handler: (event: unknown, ctx: ExtensionContext) => void) => { + handlers.set(event, handler); + }, + getThinkingLevel: () => "high", + } as unknown as ExtensionAPI; + + const ctx = { + mode: "tui", + model: { provider: "anthropic", id: "claude-opus-4" }, + getContextUsage: () => ({ + tokens: 50_000, + contextWindow: 200_000, + percent: options?.contextPercent === undefined ? 25 : options.contextPercent, + }), + sessionManager: { + getCwd: () => p("", "home", "user", "code", "myproj"), + getBranch, + getLeafId: () => leafId, + }, + ui: {}, + } as unknown as ExtensionContext; + + let component: (Component & { render(width: number): string[] }) | undefined; + type Component = { render(width: number): string[] }; + + const setFooter = vi.fn( + (factory: (tui: unknown, theme: unknown, footerData: unknown) => Component) => { + const tui = { requestRender: vi.fn() }; + const theme = { + name: "eldritch", + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + }; + const footerData = { + getGitBranch: () => "main", + getExtensionStatuses: () => statuses, + onBranchChange: () => () => {}, + }; + component = factory(tui, theme, footerData); + }, + ); + (ctx.ui as unknown as { setFooter: typeof setFooter }).setFooter = setFooter; + + footer(pi); + handlers.get("session_start")?.({}, ctx); + if (!component) throw new Error("footer did not register a component"); + const active = component; + + return { + render: (width: number) => active.render(width), + rawRender: (width: number) => active.render(width).join("\n"), + statuses, + branchReadCount: () => getBranch.mock.calls.length, + updateBranch: (entries: unknown[], event = "message_end") => { + branchEntries = entries; + leafSequence += 1; + leafId = `leaf-${leafSequence}`; + handlers.get(event)?.({}, ctx); + }, + }; +}; + +describe("footer render", () => { + it("renders the Atlas items in order: π + version, provider/model + thinking, cwd, branch, meter, cache", () => { + const { render } = mountFooter(); + const text = stripAnsi(render(200).join("\n")); + + const order = [ + "π", + VERSION, + "anthropic/", + "claude-opus-4", + "high", + `code${sep}myproj`, + NERD_FONT_ICONS.branch, + "main", + "25%", + NERD_FONT_ICONS.cache, + "60%", + ]; + let previous = -1; + for (const token of order) { + const index = text.indexOf(token); + expect(index, `expected to find ${JSON.stringify(token)}`).toBeGreaterThan(previous); + previous = index; + } + }); + + const countCells = (lines: string[]) => (stripAnsi(lines.join("\n")).match(/⛁/g) ?? []).length; + + it("uses a 24-cell context meter at normal widths (including the 80-column boundary)", () => { + const { render } = mountFooter(); + expect(countCells(render(200))).toBe(24); + expect(countCells(render(80))).toBe(24); + }); + + it("uses smaller responsive meters at narrow widths", () => { + const { render } = mountFooter(); + expect(countCells(render(79))).toBe(16); + expect(countCells(render(54))).toBe(8); + expect(countCells(render(34))).toBe(6); + }); + + it("renders unavailable context usage without inventing zero percent", () => { + const text = stripAnsi(mountFooter({ contextPercent: null }).rawRender(200)); + + expect(text).toContain("n/a"); + expect(text).not.toContain(" 0%"); + }); + + it("suppresses mcp and codex-status while preserving another extension's ANSI styling", () => { + const styled = `${ESC}[32mbuild-ok${ESC}[39m`; + const statuses = new Map([ + ["mcp", "MCP_SHOULD_BE_HIDDEN"], + ["codex-status", "CODEX_SHOULD_BE_HIDDEN"], + ["other-ext", styled], + ]); + const raw = mountFooter({ statuses }).rawRender(200); + + expect(raw).not.toContain("MCP_SHOULD_BE_HIDDEN"); + expect(raw).not.toContain("CODEX_SHOULD_BE_HIDDEN"); + // The status keeps its own SGR color rather than being re-styled or stripped. + expect(raw).toContain(styled); + }); + + it("keeps the background process special case", () => { + const statuses = new Map([ + ["backgroundBashTmuxCommands", "3 background procs"], + ]); + const text = stripAnsi(mountFooter({ statuses }).render(200).join("\n")); + expect(text).toContain("3 background /proc"); + }); + + it.each(["aborted", "error"])( + "reads cache from the latest successful assistant prompt, skipping %s prompts", + (stopReason) => { + const branchEntries = [ + assistantEntry({ input: 100, cacheRead: 100, cacheWrite: 0 }), + assistantEntry({ input: 0, cacheRead: 300, cacheWrite: 0 }, stopReason), + ]; + const text = stripAnsi(mountFooter({ branchEntries }).render(200).join("\n")); + + expect(text).toContain("50%"); + expect(text).not.toContain("100%"); + }, + ); + + it("hides cache usage when the latest successful prompt has no prompt tokens", () => { + const branchEntries = [ + assistantEntry({ input: 100, cacheRead: 100, cacheWrite: 0 }), + assistantEntry({ input: 0, cacheRead: 0, cacheWrite: 0 }), + ]; + const text = stripAnsi(mountFooter({ branchEntries }).rawRender(200)); + + expect(text).not.toContain(NERD_FONT_ICONS.cache); + }); + + it("caches branch-derived usage until the session leaf changes", () => { + const harness = mountFooter(); + + expect(stripAnsi(harness.rawRender(200))).toContain("60%"); + harness.render(120); + expect(harness.branchReadCount()).toBe(1); + + harness.updateBranch( + [assistantEntry({ input: 0, cacheRead: 1, cacheWrite: 0 })], + "session_tree", + ); + expect(stripAnsi(harness.rawRender(200))).toContain("100%"); + expect(harness.branchReadCount()).toBe(2); + }); +}); diff --git a/extensions/tmux-bash/README.md b/extensions/tmux-bash/README.md index 2267139..bb9f7f5 100644 --- a/extensions/tmux-bash/README.md +++ b/extensions/tmux-bash/README.md @@ -347,28 +347,23 @@ const windows = context ? listBashWindows(context) : []; ### Read the active background count from footer status -Tmux-bash publishes the active background count with Pi's status API. Footer extensions can read it from `footerData.getExtensionStatuses()` and handle their own string formatting. +Tmux-bash publishes the active background count with Pi's status API. Footer extensions can read the formatted value from `footerData.getExtensionStatuses()` and apply their own display styling. ```ts const backgroundBashStatusKey = "backgroundBashTmuxCommands"; -const formatBackgroundBashStatus = (value: string) => - `${value} background proc${value === "1" ? "" : "s"}`; - ctx.ui.setFooter((_tui, theme, footerData) => ({ invalidate() {}, render(width: number): string[] { const status = footerData.getExtensionStatuses().get(backgroundBashStatusKey); - const backgroundBashStatus = status ? formatBackgroundBashStatus(status) : ""; - return [theme.fg("dim", backgroundBashStatus)]; + return [theme.fg("dim", status ?? "")]; }, })); ``` -The status key is `backgroundBashTmuxCommands`. Status values are strings; tmux-bash clears the status when there are no active background windows. +The status key is `backgroundBashTmuxCommands`. Its value already includes the count and `background proc` or `background procs`; tmux-bash clears the status when there are no active background windows. ## Credits This extension was inspired by [`indigoviolet/pi-tmux`](https://github.com/indigoviolet/pi-tmux). - diff --git a/packages/pi-bits/README.md b/packages/pi-bits/README.md index b7300ab..9f641fc 100644 --- a/packages/pi-bits/README.md +++ b/packages/pi-bits/README.md @@ -2,7 +2,7 @@ A Pi package containing two independent extensions: -- `footer` - Replaces Pi's footer with model, thinking, context, and extension status information. +- [`footer`](../../extensions/footer/README.md) - Atlas-style Pi status line. - `trust-all-projects` - Automatically trusts and remembers every project. > [!WARNING]