diff --git a/AGENTS.md b/AGENTS.md index 37573a2..f5e556e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,9 +36,12 @@ - 初始导入快照:`e928179a3b35e8fe5837878aed071d6025ced45c`(`v1.33.0`)。 - 官方 npm tarball 不包含 Pi Extension;仓库保留官方 README、LICENSE 和来源说明,匹配 CLI 版本由 `config/hippo-memory-version` 声明。 - `@ogulcancelik/pi-codex-compaction@0.1.3`:为 `openai-codex` 提供原生远程 compaction,默认阈值为 90%。 -- `@ogulcancelik/pi-minimal-footer@0.1.10`:用紧凑的上下文仪表和订阅用量条替换默认 footer。 +- `pi-minimal-footer/`:基于上游紧凑 footer,保留上下文仪表、订阅用量条和扩展状态,并在 Codex Fast 模式开启时显示 `Fast on`。 +- 上游来源: +- 初始导入快照:`77bd1e175003cd08e6d05d9e7fed695f86ae87b7`(`@ogulcancelik/pi-minimal-footer@0.1.10`)。 +- 该目录从明确 commit 的上游源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。 - `pi-condense@2.9.1`:总结已完成的工具调用批次,以短 stub 替换历史原始输出,并通过 `context_tree_query` 按需恢复;组合包在用户尚未配置 `contextPrune.enabled` 时默认开启。 -- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0`、`typescript@6.0.3`、`pi-context-view` 和 `@firstpick/pi-extension-codex-fast-mode`;`@gotgenes/pi-permission-system` 与 `pi-tool-search` 分别指向仓库内对应目录的本地 `file:` 依赖。 +- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0`、`typescript@6.0.3`、`pi-context-view` 和 `@firstpick/pi-extension-codex-fast-mode`;`@gotgenes/pi-permission-system`、`pi-minimal-footer` 与 `pi-tool-search` 分别指向仓库内对应目录的本地 `file:` 依赖。 - `install.sh`:先安装根组合包,再交互检查 Hippo Memory CLI、Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Hippo 项目目录与 `hippo init` 始终由用户自行决定。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。 - `update.sh`:先通过 `pi update` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。 - `uninstall.sh`:先移除根组合包;若检测到 Hippo Memory CLI,再明确询问是否卸载确认属于 npm 全局安装的 `hippo-memory`,默认保留且始终不删除 `.hippo/` 或用户记忆数据。其他共享终端环境和机器工具不卸载。 diff --git a/README.md b/README.md index 29dfb7e..b9d4ae3 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ - `pi-lsp@0.1.7`:为 TypeScript/JavaScript、Kotlin 和 Java 提供 LSP 诊断、跳转与符号工具;TypeScript 后端由组合包内置。 - `hippo-memory-pi/`:从官方 `hippo-memory v1.33.0` 源码导入的 Pi Extension,提供项目记忆注入、错误捕获和 sleep consolidation。 - `@ogulcancelik/pi-codex-compaction@0.1.3`:为 `openai-codex` 提供原生远程压缩。 -- `@ogulcancelik/pi-minimal-footer@0.1.10`:用紧凑的上下文仪表和订阅用量条替换默认 footer。 +- 本仓库维护的 `pi-minimal-footer`:基于 `@ogulcancelik/pi-minimal-footer@0.1.10`,用紧凑的上下文仪表和订阅用量条替换默认 footer,并在 Codex Fast 模式开启时显示 `Fast on`。 - `pi-condense@2.9.1`:把已完成的工具调用批次总结为可恢复的短摘要,并通过 `context_tree_query` 按需取回原始输出;组合包首次加载时默认开启。 - 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,以最多 3 个动态组的 LRU 策略按组加载原始完整 schema。 - 本仓库维护的 `pi-permission-auto-review`:作为 `pi-permission-system` authorizer,使用 Codex Guardian 风格策略自动复核 `ask` 请求。 diff --git a/package-lock.json b/package-lock.json index f293ea1..94404e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@gotgenes/pi-permission-system": "file:./pi-permission-system", "@keenable/pi-search": "0.1.2", "@ogulcancelik/pi-codex-compaction": "0.1.3", - "@ogulcancelik/pi-minimal-footer": "0.1.10", + "@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer", "@tavily/pi-extension": "0.1.2", "context-mode": "1.0.169", "pi-condense": "2.9.1", @@ -2690,13 +2690,8 @@ } }, "node_modules/@ogulcancelik/pi-minimal-footer": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@ogulcancelik/pi-minimal-footer/-/pi-minimal-footer-0.1.10.tgz", - "integrity": "sha512-JXR2drsf7Igx12tcSFneERnCD2f1Cfz8GwWGJx9T4//sMc5Uj4lu66wzQfb+8hd24T9c8XmQFWHuAd997E041Q==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } + "resolved": "pi-minimal-footer", + "link": true }, "node_modules/@oxc-project/types": { "version": "0.144.0", @@ -7030,6 +7025,14 @@ "zod": "^3.25.28 || ^4" } }, + "pi-minimal-footer": { + "name": "@ogulcancelik/pi-minimal-footer", + "version": "0.1.10-my-pi.1", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "pi-permission-system": { "name": "@gotgenes/pi-permission-system", "version": "26.2.1", diff --git a/package.json b/package.json index 18242f4..b426f69 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "extensions", "hippo-memory-pi", "pi-rtk-optimizer", + "pi-minimal-footer", "pi-tool-search/extensions", "pi-tool-search/docs", "pi-tool-search/package.json", @@ -51,7 +52,7 @@ "./node_modules/@firstpick/pi-extension-codex-fast-mode/index.ts", "./hippo-memory-pi/index.ts", "./node_modules/@ogulcancelik/pi-codex-compaction/index.ts", - "./node_modules/@ogulcancelik/pi-minimal-footer/index.ts", + "./pi-minimal-footer/index.ts", "./extensions/condense.ts", "./pi-permission-auto-review/index.ts", "./extensions/permission-system.ts", @@ -69,7 +70,7 @@ "@gotgenes/pi-permission-system": "file:./pi-permission-system", "@keenable/pi-search": "0.1.2", "@ogulcancelik/pi-codex-compaction": "0.1.3", - "@ogulcancelik/pi-minimal-footer": "0.1.10", + "@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer", "@tavily/pi-extension": "0.1.2", "context-mode": "1.0.169", "pi-condense": "2.9.1", diff --git a/pi-minimal-footer/LICENSE b/pi-minimal-footer/LICENSE new file mode 100644 index 0000000..e538821 --- /dev/null +++ b/pi-minimal-footer/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Can Celik + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pi-minimal-footer/README.md b/pi-minimal-footer/README.md new file mode 100644 index 0000000..c64dc85 --- /dev/null +++ b/pi-minimal-footer/README.md @@ -0,0 +1,75 @@ +# pi-minimal-footer + +Minimal footer for [pi](https://github.com/earendil-works/pi) that replaces the default footer with a compact two-line display: context gauge on top, subscription usage bars below. + +![Claude Max](assets/claude.png) + +![OpenAI Codex](assets/codex.png) + +## Features + +- **Context gauge** — optional working directory and git branch, model, thinking level, and context window usage with token counts +- **Subscription usage bars** — rolling window quotas with reset timers for supported providers +- **Auto-refresh** — fetches usage on startup and model switch, then every 5 minutes +- **Git integration** — branch name, dirty state, ahead/behind counts +- **Extension statuses** — preserves statuses published through `ctx.ui.setStatus()` and shows `Fast on` while Codex Fast mode is enabled + +## Supported providers + +| Provider | What it shows | +| -------------- | ------------------------------------------------------ | +| Claude Max | 5h + weekly rolling windows | +| OpenAI Codex | Primary + secondary rolling windows | +| GitHub Copilot | Premium interactions + chat quotas | +| Google Gemini | Pro + Flash remaining quotas | +| MiniMax | 5h + weekly rolling windows (Token Plan, credit-based) | +| MiniMax CN | Same as MiniMax, China endpoint | +| Kimi Coding | 5h + weekly rolling windows (Plan) | + +## Install + +```bash +pi install npm:@ogulcancelik/pi-minimal-footer +``` + +## Configuration + +Environment variables (all optional): + +| Variable | Description | Default | +| ------------------------------- | -------------------------------------------------------- | ------- | +| `PI_MINIMAL_FOOTER_SHOW_CWD` | Show current working directory in footer status line | `1` | +| `PI_MINIMAL_FOOTER_SHOW_BRANCH` | Show git branch/dirty/ahead/behind in footer status line | `1` | + +Accepted false values: `0`, `false`, `no`, `off` (case-insensitive). + +## How it works + +The footer reads context usage from the last assistant message's token counts (free — comes with every LLM response). Subscription usage is fetched from each provider's dedicated quota API using your existing auth tokens from `~/.pi/agent/auth.json` or environment variables. + +Usage is fetched: + +- Once on startup +- Immediately on model switch (Ctrl+P) +- Every 5 minutes after that + +Git state is refreshed: + +- Once on startup +- When pi reports a branch change +- At the end of each turn + +The footer adapts to narrow terminals by stacking lines vertically instead of the single-line wide layout. + +## Known issues + +### Claude Max usage bar not showing + +Anthropic's OAuth usage endpoint (`/api/oauth/usage`) has been returning persistent 429 (rate limit) errors since late March 2026, affecting all third-party tools that display Claude usage data (CodexBar, oh-my-claudecode, claude-pulse, etc.). This is an Anthropic-side issue — tracked in [claude-code#30930](https://github.com/anthropics/claude-code/issues/30930) and [claude-code#31021](https://github.com/anthropics/claude-code/issues/31021). The usage bar will start working again once Anthropic fixes the endpoint. + +## Notes + +- Replaces the default pi footer entirely via `ctx.ui.setFooter()` +- The local `my-pi` version reads `footerData.getExtensionStatuses()`; `codex-fast-mode=on` is rendered as an accented `Fast on` segment, while the disabled state is hidden. +- Auth tokens are read from `~/.pi/agent/auth.json` (populated by `/login`) or standard env vars (`ANTHROPIC_API_KEY`, `MINIMAX_API_KEY`, etc.) +- Providers without auth simply don't show a usage bar — no errors diff --git a/pi-minimal-footer/UPSTREAM.md b/pi-minimal-footer/UPSTREAM.md new file mode 100644 index 0000000..11923b0 --- /dev/null +++ b/pi-minimal-footer/UPSTREAM.md @@ -0,0 +1,8 @@ +# Upstream source + +- Repository: +- Imported commit: `77bd1e175003cd08e6d05d9e7fed695f86ae87b7` +- Imported package version: `@ogulcancelik/pi-minimal-footer@0.1.10` +- Imported on: 2026-08-19 + +This directory is maintained directly by `my-pi`; it is not a submodule and does not retain upstream Git metadata. Local changes add rendering for Pi extension statuses, including an explicit `Fast on` indicator for `@firstpick/pi-extension-codex-fast-mode`. diff --git a/pi-minimal-footer/assets/claude.png b/pi-minimal-footer/assets/claude.png new file mode 100644 index 0000000..402a2c5 Binary files /dev/null and b/pi-minimal-footer/assets/claude.png differ diff --git a/pi-minimal-footer/assets/codex.png b/pi-minimal-footer/assets/codex.png new file mode 100644 index 0000000..b64eb4a Binary files /dev/null and b/pi-minimal-footer/assets/codex.png differ diff --git a/pi-minimal-footer/index.ts b/pi-minimal-footer/index.ts new file mode 100644 index 0000000..0749786 --- /dev/null +++ b/pi-minimal-footer/index.ts @@ -0,0 +1,1107 @@ +/** + * Context Gauge Extension + * + * Custom footer with context gauge + subscription usage bars. + * Auto-detects provider from current model and shows relevant usage. + * + * Supports: Claude Max, Codex, Copilot, Gemini, MiniMax Token Plan, Kimi Coding + */ + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { buildSessionContext } from "@earendil-works/pi-coding-agent"; +import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui"; +import { execSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { collectFooterStatusItems } from "./status.ts"; + +// ============ Types ============ + +interface RateWindow { + label: string; + usedPercent: number; + resetsIn?: string; // human readable "2h38m" +} + +interface UsageSnapshot { + provider: string; + windows: RateWindow[]; + error?: string; + fetchedAt: number; +} + +interface GitCache { + branch: string | null; + dirty: boolean; + ahead: number; + behind: number; +} + +// ============ Usage Cache ============ + +const USAGE_REFRESH_INTERVAL = 5 * 60_000; // 5 minutes +const usageCache = new Map(); // keyed by provider + +// ============ Env Flags ============ + +function parseBooleanEnv(value: string | undefined, fallback: boolean): boolean { + if (value === undefined) return fallback; + + const normalized = value.trim().toLowerCase(); + if (!normalized) return fallback; + + if (["1", "true", "yes", "on"].includes(normalized)) return true; + if (["0", "false", "no", "off"].includes(normalized)) return false; + return fallback; +} + +// ============ Identity Cache ============ + +let codexEmailPrefix: string | null | undefined; // undefined = not yet resolved + +// ============ Git Cache ============ + +let gitCache: GitCache | null = null; + +function parseGitStatus(output: string): GitCache { + let branch: string | null = null; + let dirty = false; + let ahead = 0; + let behind = 0; + + for (const line of output.split("\n")) { + if (!line) continue; + + if (line.startsWith("# branch.head ")) { + const head = line.slice("# branch.head ".length).trim(); + branch = head && head !== "(detached)" ? head : null; + continue; + } + + if (line.startsWith("# branch.ab ")) { + const match = line.match(/^# branch\.ab \+(\d+) -(\d+)$/); + if (match) { + ahead = parseInt(match[1], 10) || 0; + behind = parseInt(match[2], 10) || 0; + } + continue; + } + + if (!line.startsWith("# ")) dirty = true; + } + + return { branch, dirty, ahead, behind }; +} + +function sameGitCache(a: GitCache | null, b: GitCache | null): boolean { + if (a === b) return true; + if (!a || !b) return false; + return a.branch === b.branch && a.dirty === b.dirty && a.ahead === b.ahead && a.behind === b.behind; +} + +function refreshGitCache(): boolean { + let next: GitCache | null = null; + + try { + const status = execSync("git status --porcelain=v2 --branch 2>/dev/null", { + encoding: "utf8", + timeout: 1000, + }); + next = parseGitStatus(status.trimEnd()); + } catch { + next = null; + } + + const changed = !sameGitCache(gitCache, next); + gitCache = next; + return changed; +} + +// ============ JWT Helpers ============ + +function decodeJwtPayload(token: string): Record | null { + try { + const parts = token.split("."); + if (parts.length < 2) return null; + let payload = parts[1]; + // Add base64 padding + payload += "=".repeat((4 - (payload.length % 4)) % 4); + const decoded = Buffer.from(payload, "base64url").toString("utf-8"); + return JSON.parse(decoded); + } catch { + return null; + } +} + +function getEmailPrefixFromJwt(token: string): string | null { + const payload = decodeJwtPayload(token); + const email = payload?.["https://api.openai.com/profile"]?.email; + if (!email || typeof email !== "string") return null; + const prefix = email.split("@")[0]; + return prefix || null; +} + +// ============ Auth Loading ============ + +function loadAuthJson(): Record { + const authPath = join(homedir(), ".pi", "agent", "auth.json"); + try { + if (existsSync(authPath)) { + return JSON.parse(readFileSync(authPath, "utf-8")); + } + } catch {} + return {}; +} + +function resolveAuthValue(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + + if (trimmed.startsWith("!")) { + try { + const output = execSync(trimmed.slice(1), { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + timeout: 2000, + }).trim(); + return output || undefined; + } catch { + return undefined; + } + } + + if (/^[A-Z][A-Z0-9_]*$/.test(trimmed) && process.env[trimmed]) { + return process.env[trimmed]; + } + + return trimmed; +} + +function getApiKey(providerKey: string, envVar: string): string | undefined { + if (process.env[envVar]) return process.env[envVar]; + + const auth = loadAuthJson(); + const entry = auth[providerKey]; + if (!entry) return undefined; + + if (typeof entry === "string") { + return resolveAuthValue(entry); + } + + return resolveAuthValue(entry.key ?? entry.access ?? entry.refresh); +} + +function getClaudeToken(): string | undefined { + const auth = loadAuthJson(); + if (auth.anthropic?.access) return auth.anthropic.access; + + // Fallback: Claude CLI keychain (macOS) + try { + const keychainData = execSync( + 'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', + { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] } + ).trim(); + if (keychainData) { + const parsed = JSON.parse(keychainData); + if (parsed.claudeAiOauth?.accessToken) { + return parsed.claudeAiOauth.accessToken; + } + } + } catch {} + + return undefined; +} + +function getCopilotToken(): string | undefined { + const auth = loadAuthJson(); + return auth["github-copilot"]?.refresh; +} + +function getCodexToken(): { token: string; accountId?: string } | undefined { + const auth = loadAuthJson(); + if (auth["openai-codex"]?.access) { + return { token: auth["openai-codex"].access, accountId: auth["openai-codex"]?.accountId }; + } + + // Fallback: ~/.codex/auth.json + const codexPath = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "auth.json"); + try { + if (existsSync(codexPath)) { + const data = JSON.parse(readFileSync(codexPath, "utf-8")); + if (data.OPENAI_API_KEY) { + return { token: data.OPENAI_API_KEY }; + } + if (data.tokens?.access_token) { + return { token: data.tokens.access_token, accountId: data.tokens.account_id }; + } + } + } catch {} + + return undefined; +} + +function getGeminiToken(): string | undefined { + const auth = loadAuthJson(); + if (auth["google-gemini-cli"]?.access) return auth["google-gemini-cli"].access; + + // Fallback: ~/.gemini/oauth_creds.json + const geminiPath = join(homedir(), ".gemini", "oauth_creds.json"); + try { + if (existsSync(geminiPath)) { + const data = JSON.parse(readFileSync(geminiPath, "utf-8")); + return data.access_token; + } + } catch {} + + return undefined; +} + +function getMinimaxToken(provider: "minimax" | "minimax-cn"): string | undefined { + return provider === "minimax" + ? getApiKey("minimax", "MINIMAX_API_KEY") + : getApiKey("minimax-cn", "MINIMAX_CN_API_KEY"); +} + +function getKimiToken(): string | undefined { + return getApiKey("kimi-coding", "KIMI_API_KEY"); +} + +// ============ Time Formatting ============ + +function formatResetTime(date: Date): string { + const diffMs = date.getTime() - Date.now(); + if (diffMs < 0) return "now"; + + const diffMins = Math.floor(diffMs / 60000); + if (diffMins < 60) return `${diffMins}m`; + + const hours = Math.floor(diffMins / 60); + const mins = diffMins % 60; + if (hours < 24) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`; + + const days = Math.floor(hours / 24); + const remainingHours = hours % 24; + return remainingHours > 0 ? `${days}d${remainingHours}h` : `${days}d`; +} + +/** Clamp a percentage to [0, 100]. Does NOT auto-normalize 0-1 fractions. */ +function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(100, value)); +} + +/** Normalize a value that might be 0-1 fraction OR 0-100 percent, then clamp. */ +function normalizePercent(value: number): number { + if (!Number.isFinite(value)) return 0; + const normalized = value <= 1 && value >= 0 ? value * 100 : value; + return Math.max(0, Math.min(100, normalized)); +} + +function getWindowLabel(durationMs: number | undefined, fallback: string): string { + if (!durationMs || !Number.isFinite(durationMs) || durationMs <= 0) return fallback; + + const hourMs = 60 * 60 * 1000; + const dayMs = 24 * hourMs; + const weekMs = 7 * dayMs; + + // Check if duration is close to a standard window and use the fallback label. + // This preserves "5h" / "Week" / "Day" labels even when the actual + // rolling window start/end times don't align perfectly. + const isCloseToWeek = Math.abs(durationMs - weekMs) <= hourMs * 2; + const isCloseToDay = Math.abs(durationMs - dayMs) <= hourMs * 2; + const isCloseTo5h = Math.abs(durationMs - 5 * hourMs) <= hourMs * 2; + + if (isCloseToWeek || fallback === "Week") return "Week"; + if (isCloseToDay || fallback === "Day") return "Day"; + if (isCloseTo5h || fallback === "5h") return fallback; + + const hours = Math.round(durationMs / hourMs); + if (hours >= 1 && hours < 48) return `${hours}h`; + + const days = Math.round(durationMs / dayMs); + if (days >= 1) return `${days}d`; + + const mins = Math.max(1, Math.round(durationMs / 60000)); + return `${mins}m`; +} + +async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs = 5000): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +} + +// ============ Usage Fetchers ============ + +async function fetchClaudeUsage(): Promise { + const token = getClaudeToken(); + if (!token) { + return { provider: "Claude", windows: [], error: "no-auth", fetchedAt: Date.now() }; + } + + try { + const res = await fetchWithTimeout("https://api.anthropic.com/api/oauth/usage", { + headers: { + Authorization: `Bearer ${token}`, + "anthropic-beta": "oauth-2025-04-20", + }, + }); + + if (!res.ok) { + return { provider: "Claude", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() }; + } + + const data = (await res.json()) as any; + const windows: RateWindow[] = []; + + if (data.five_hour?.utilization !== undefined) { + windows.push({ + label: "5h", + usedPercent: normalizePercent(data.five_hour.utilization), + resetsIn: data.five_hour.resets_at ? formatResetTime(new Date(data.five_hour.resets_at)) : undefined, + }); + } + + if (data.seven_day?.utilization !== undefined) { + windows.push({ + label: "Week", + usedPercent: normalizePercent(data.seven_day.utilization), + resetsIn: data.seven_day.resets_at ? formatResetTime(new Date(data.seven_day.resets_at)) : undefined, + }); + } + + return { provider: "Claude", windows, fetchedAt: Date.now() }; + } catch (e) { + return { provider: "Claude", windows: [], error: String(e), fetchedAt: Date.now() }; + } +} + +async function fetchCopilotUsage(): Promise { + const token = getCopilotToken(); + if (!token) { + return { provider: "Copilot", windows: [], error: "no-auth", fetchedAt: Date.now() }; + } + + try { + const res = await fetchWithTimeout("https://api.github.com/copilot_internal/user", { + headers: { + "Editor-Version": "vscode/1.96.2", + "User-Agent": "GitHubCopilotChat/0.26.7", + "X-Github-Api-Version": "2025-04-01", + Accept: "application/json", + Authorization: `token ${token}`, + }, + }); + + if (!res.ok) { + return { provider: "Copilot", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() }; + } + + const data = (await res.json()) as any; + const windows: RateWindow[] = []; + + const resetDate = data.quota_reset_date_utc ? new Date(data.quota_reset_date_utc) : undefined; + const resetsIn = resetDate ? formatResetTime(resetDate) : undefined; + + if (data.quota_snapshots?.premium_interactions) { + const pi = data.quota_snapshots.premium_interactions; + const usedPercent = clampPercent(100 - (pi.percent_remaining || 0)); + windows.push({ label: "Premium", usedPercent, resetsIn }); + } + + if (data.quota_snapshots?.chat && !data.quota_snapshots.chat.unlimited) { + const chat = data.quota_snapshots.chat; + windows.push({ + label: "Chat", + usedPercent: clampPercent(100 - (chat.percent_remaining || 0)), + resetsIn, + }); + } + + return { provider: "Copilot", windows, fetchedAt: Date.now() }; + } catch (e) { + return { provider: "Copilot", windows: [], error: String(e), fetchedAt: Date.now() }; + } +} + +async function fetchCodexUsage(): Promise { + const creds = getCodexToken(); + if (!creds) { + return { provider: "Codex", windows: [], error: "no-auth", fetchedAt: Date.now() }; + } + + const providerLabel = "Codex"; + + try { + const headers: Record = { + Authorization: `Bearer ${creds.token}`, + "User-Agent": "pi-agent", + Accept: "application/json", + }; + + if (creds.accountId) { + headers["ChatGPT-Account-Id"] = creds.accountId; + } + + const res = await fetchWithTimeout("https://chatgpt.com/backend-api/wham/usage", { + method: "GET", + headers, + }); + + if (!res.ok) { + return { provider: providerLabel, windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() }; + } + + const data = (await res.json()) as any; + const windows: RateWindow[] = []; + + if (data.rate_limit?.primary_window) { + const pw = data.rate_limit.primary_window; + const resetDate = pw.reset_at ? new Date(pw.reset_at * 1000) : undefined; + const durationMs = typeof pw.limit_window_seconds === "number" ? pw.limit_window_seconds * 1000 : undefined; + windows.push({ + label: getWindowLabel(durationMs, "5h"), + usedPercent: clampPercent(pw.used_percent || 0), + resetsIn: resetDate ? formatResetTime(resetDate) : undefined, + }); + } + + if (data.rate_limit?.secondary_window) { + const sw = data.rate_limit.secondary_window; + const resetDate = sw.reset_at ? new Date(sw.reset_at * 1000) : undefined; + const durationMs = typeof sw.limit_window_seconds === "number" ? sw.limit_window_seconds * 1000 : undefined; + windows.push({ + label: getWindowLabel(durationMs, "Week"), + usedPercent: clampPercent(sw.used_percent || 0), + resetsIn: resetDate ? formatResetTime(resetDate) : undefined, + }); + } + + return { provider: providerLabel, windows, fetchedAt: Date.now() }; + } catch (e) { + return { provider: providerLabel, windows: [], error: String(e), fetchedAt: Date.now() }; + } +} + +async function fetchGeminiUsage(): Promise { + const token = getGeminiToken(); + if (!token) { + return { provider: "Gemini", windows: [], error: "no-auth", fetchedAt: Date.now() }; + } + + try { + const res = await fetchWithTimeout("https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: "{}", + }); + + if (!res.ok) { + return { provider: "Gemini", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() }; + } + + const data = (await res.json()) as any; + const quotas: Record = {}; + + for (const bucket of data.buckets || []) { + const model = bucket.modelId || "unknown"; + const frac = bucket.remainingFraction ?? 1; + if (!quotas[model] || frac < quotas[model]) quotas[model] = frac; + } + + const windows: RateWindow[] = []; + let proMin = 1, + flashMin = 1; + let hasProModel = false, + hasFlashModel = false; + + for (const [model, frac] of Object.entries(quotas)) { + if (model.toLowerCase().includes("pro")) { + hasProModel = true; + if (frac < proMin) proMin = frac; + } + if (model.toLowerCase().includes("flash")) { + hasFlashModel = true; + if (frac < flashMin) flashMin = frac; + } + } + + if (hasProModel) windows.push({ label: "Pro", usedPercent: clampPercent((1 - proMin) * 100) }); + if (hasFlashModel) windows.push({ label: "Flash", usedPercent: clampPercent((1 - flashMin) * 100) }); + + return { provider: "Gemini", windows, fetchedAt: Date.now() }; + } catch (e) { + return { provider: "Gemini", windows: [], error: String(e), fetchedAt: Date.now() }; + } +} + +async function fetchMinimaxUsage(provider: "minimax" | "minimax-cn"): Promise { + const token = getMinimaxToken(provider); + const providerLabel = provider === "minimax-cn" ? "MiniMax CN" : "MiniMax"; + // Docs-recommended Token Plan endpoint. The legacy /coding_plan/remains path + // still works but the response field names differ from the current UI. + const endpoint = + provider === "minimax-cn" + ? "https://api.minimaxi.com/v1/token_plan/remains" + : "https://api.minimax.io/v1/token_plan/remains"; + + if (!token) { + return { provider: providerLabel, windows: [], error: "no-auth", fetchedAt: Date.now() }; + } + + try { + const res = await fetchWithTimeout(endpoint, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + if (!res.ok) { + return { provider: providerLabel, windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() }; + } + + const data = (await res.json()) as any; + const baseResp = data?.base_resp; + if (baseResp?.status_code && baseResp.status_code !== 0) { + return { + provider: providerLabel, + windows: [], + error: baseResp.status_msg || `API ${baseResp.status_code}`, + fetchedAt: Date.now(), + }; + } + + const remains = Array.isArray(data?.model_remains) ? data.model_remains : []; + // Token Plan returns one bucket per capability (general = text/code, video, etc.). + // Prefer the active "general" bucket since M-series chat models land there. + // status === 1 = window is active/limiting, 3 = inactive (no usage). Fall back + // to the first active bucket, then the first bucket of any kind. + const textBucket = + remains.find( + (entry: any) => entry?.model_name === "general" && Number(entry?.current_interval_status) === 1 + ) || + remains.find((entry: any) => entry?.model_name === "general") || + remains.find((entry: any) => Number(entry?.current_interval_status) === 1) || + remains[0]; + + if (!textBucket) { + return { provider: providerLabel, windows: [], error: "no-usage-data", fetchedAt: Date.now() }; + } + + const windows: RateWindow[] = []; + + // Source of truth: *_remaining_percent (the *_total_count / *_usage_count + // fields are zeroed in the credit-based model and cannot be used to compute + // a fraction). The UI usage bar maps 100 - remainingPercent -> used. + const intervalRemaining = Number(textBucket.current_interval_remaining_percent); + if (Number.isFinite(intervalRemaining)) { + const usedPercent = clampPercent(100 - intervalRemaining); + const resetDate = textBucket.end_time ? new Date(Number(textBucket.end_time)) : undefined; + const durationMs = + textBucket.start_time && textBucket.end_time + ? Number(textBucket.end_time) - Number(textBucket.start_time) + : undefined; + windows.push({ + label: getWindowLabel(durationMs, "5h"), + usedPercent, + resetsIn: resetDate ? formatResetTime(resetDate) : undefined, + }); + } + + const weeklyRemaining = Number(textBucket.current_weekly_remaining_percent); + if (Number.isFinite(weeklyRemaining)) { + const usedPercent = clampPercent(100 - weeklyRemaining); + const resetDate = textBucket.weekly_end_time + ? new Date(Number(textBucket.weekly_end_time)) + : undefined; + const durationMs = + textBucket.weekly_start_time && textBucket.weekly_end_time + ? Number(textBucket.weekly_end_time) - Number(textBucket.weekly_start_time) + : undefined; + windows.push({ + label: getWindowLabel(durationMs, "Week"), + usedPercent, + resetsIn: resetDate ? formatResetTime(resetDate) : undefined, + }); + } + + if (windows.length === 0) { + return { provider: providerLabel, windows: [], error: "no-usage-data", fetchedAt: Date.now() }; + } + + return { provider: providerLabel, windows, fetchedAt: Date.now() }; + } catch (e) { + return { provider: providerLabel, windows: [], error: String(e), fetchedAt: Date.now() }; + } +} + +async function fetchKimiUsage(): Promise { + const token = getKimiToken(); + const endpoint = "https://api.kimi.com/coding/v1/usages"; + if (!token) { + return { provider: "Kimi Coding", windows: [], error: "no-auth", fetchedAt: Date.now() }; + } + + try { + const res = await fetchWithTimeout(endpoint, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + if (!res.ok) { + return { provider: "Kimi Coding", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() }; + } + + const data = (await res.json()) as any; + const windows: RateWindow[] = []; + + for (const limit of data.limits || []) { + const windowLimit = Number(limit.detail?.limit) || 0; + const windowRemaining = Number(limit.detail?.remaining) || 0; + if (windowLimit > 0) { + const used = windowLimit - windowRemaining; + const usedPercent = clampPercent((used / windowLimit) * 100); + const resetDate = limit.detail?.resetTime ? new Date(limit.detail.resetTime) : undefined; + const durationMs = + limit.window?.duration && limit.window?.timeUnit === "TIME_UNIT_MINUTE" + ? limit.window.duration * 60 * 1000 + : undefined; + + windows.push({ + label: getWindowLabel(durationMs, "5h"), + usedPercent, + resetsIn: resetDate ? formatResetTime(resetDate) : undefined, + }); + } + } + + const weeklyLimit = Number(data.usage?.limit) || 0; + const weeklyRemaining = Number(data.usage?.remaining) || 0; + const weeklyResetTime = data.usage?.resetTime; + + if (weeklyLimit > 0) { + const used = weeklyLimit - weeklyRemaining; + const usedPercent = clampPercent((used / weeklyLimit) * 100); + windows.push({ + label: "Weekly", + usedPercent, + resetsIn: weeklyResetTime ? formatResetTime(new Date(weeklyResetTime)) : undefined, + }); + } + + return { provider: "Kimi Coding", windows, fetchedAt: Date.now() }; + } catch (e) { + return { provider: "Kimi Coding", windows: [], error: String(e), fetchedAt: Date.now() }; + } +} + +// ============ Provider Detection ============ + +// Map pi provider names to our internal usage provider keys +const PROVIDER_MAP: Record = { + anthropic: "claude", // Claude Max subscription + "openai-codex": "codex", // Codex subscription + "github-copilot": "copilot", // Copilot subscription + "google-gemini-cli": "gemini", // Gemini CLI subscription + minimax: "minimax", // MiniMax Token Plan / Coding Plan + "minimax-cn": "minimax-cn", // MiniMax China plan + "kimi-coding": "kimi-coding", // Kimi plan +}; + +function detectProvider(modelProvider: string): string | null { + return PROVIDER_MAP[modelProvider] || null; +} + +async function fetchUsageForProvider(provider: string): Promise { + switch (provider) { + case "claude": + return fetchClaudeUsage(); + case "codex": + return fetchCodexUsage(); + case "copilot": + return fetchCopilotUsage(); + case "gemini": + return fetchGeminiUsage(); + case "minimax": + return fetchMinimaxUsage("minimax"); + case "minimax-cn": + return fetchMinimaxUsage("minimax-cn"); + case "kimi-coding": + return fetchKimiUsage(); + default: + return { provider: "Unknown", windows: [], error: "unknown-provider", fetchedAt: Date.now() }; + } +} + +// ============ Extension ============ + +export default function (pi: ExtensionAPI) { + const CTX_GAUGE_WIDTH = 12; + + // Thin bar characters (same style for both context and usage) + const BAR_FILLED = "━"; + const BAR_EMPTY = "─"; + + // Optional visibility toggles (default: enabled) + const showCwd = parseBooleanEnv(process.env.PI_MINIMAL_FOOTER_SHOW_CWD, true); + const showBranch = parseBooleanEnv(process.env.PI_MINIMAL_FOOTER_SHOW_BRANCH, true); + + function formatTokenCount(tokens: number): string { + if (tokens >= 1_000_000) { + const m = tokens / 1_000_000; + return m % 1 === 0 ? `${m}M` : `${m.toFixed(1).replace(/\.0$/, "")}M`; + } + if (tokens >= 1_000) { + return `${Math.round(tokens / 1_000)}k`; + } + return `${tokens}`; + } + + function fitFooterSegment(width: number, variants: string[]): string { + const safeWidth = Math.max(1, width); + + for (const variant of variants) { + if (visibleWidth(variant) <= safeWidth) return variant; + } + + return truncateToWidth(variants[variants.length - 1] || "", safeWidth); + } + + function wrapFooterSegments(segments: string[], width: number, sep: string): string[] { + const safeWidth = Math.max(1, width); + const lines: string[] = []; + let current = ""; + + for (const segment of segments.filter(Boolean)) { + const fitted = truncateToWidth(segment, safeWidth); + + if (!current) { + current = fitted; + continue; + } + + const candidate = current + sep + fitted; + if (visibleWidth(candidate) <= safeWidth) { + current = candidate; + continue; + } + + lines.push(truncateToWidth(current, safeWidth)); + current = fitted; + } + + if (current) lines.push(truncateToWidth(current, safeWidth)); + return lines; + } + + function renderContextGauge( + percentage: number, + theme: any, + used?: number, + total?: number, + options?: { barWidth?: number; includeCounts?: boolean } + ): string { + const barWidth = Math.max(4, options?.barWidth ?? CTX_GAUGE_WIDTH); + const clamped = Math.max(0, Math.min(100, percentage)); + const filled = Math.round((clamped / 100) * barWidth); + const empty = barWidth - filled; + + let color: string; + if (clamped >= 90) color = "error"; + else if (clamped >= 70) color = "warning"; + else if (clamped >= 50) color = "accent"; + else color = "success"; + + const bar = theme.fg(color, BAR_FILLED.repeat(filled)) + theme.fg("dim", BAR_EMPTY.repeat(empty)); + const pct = `${Math.round(clamped)}%`; + const counts = + options?.includeCounts === false || used === undefined || !total + ? "" + : ` ${formatTokenCount(used)}/${formatTokenCount(total)}`; + + return theme.fg("dim", "ctx ") + bar + " " + theme.fg("dim", pct + counts); + } + + function renderUsageBar(usedPercent: number, barWidth: number, theme: any): string { + const clamped = Math.max(0, Math.min(100, usedPercent)); + const filled = Math.round((clamped / 100) * barWidth); + const empty = barWidth - filled; + + let color: string; + if (clamped >= 92) color = "error"; + else if (clamped >= 85) color = "warning"; + else color = "success"; + + return theme.fg(color, BAR_FILLED.repeat(filled)) + theme.fg("dim", BAR_EMPTY.repeat(empty)); + } + + function renderUsageWindow( + window: RateWindow, + theme: any, + options?: { barWidth?: number; includeReset?: boolean } + ): string { + const dim = (s: string) => theme.fg("dim", s); + const bar = renderUsageBar(window.usedPercent, Math.max(4, options?.barWidth ?? 10), theme); + const pct = dim(`${Math.round(window.usedPercent)}%`); + const timeStr = options?.includeReset === false || !window.resetsIn ? "" : " " + dim(window.resetsIn); + return `${dim(window.label)} ${bar} ${pct}${timeStr}`; + } + + function renderUsageLine(usage: UsageSnapshot, width: number, theme: any): string[] { + if (!usage.windows.length) return []; + + const dim = (s: string) => theme.fg("dim", s); + const sep = " " + dim(">") + " "; + const segments: string[] = [theme.fg("accent", usage.provider)]; + + for (const w of usage.windows) { + segments.push( + fitFooterSegment(width, [ + renderUsageWindow(w, theme, { barWidth: 10, includeReset: true }), + renderUsageWindow(w, theme, { barWidth: 8, includeReset: true }), + renderUsageWindow(w, theme, { barWidth: 8, includeReset: false }), + renderUsageWindow(w, theme, { barWidth: 6, includeReset: false }), + renderUsageWindow(w, theme, { barWidth: 4, includeReset: false }), + ]) + ); + } + + return wrapFooterSegments(segments, width, sep); + } + + function getThinkingLevel(ctx: any): string { + const entries = ctx.sessionManager.getEntries(); + const leafId = ctx.sessionManager.getLeafId(); + const context = buildSessionContext(entries, leafId); + return context.thinkingLevel || "off"; + } + + function getContextInfo(ctx: any): { percentage: number; used: number; total: number } { + const model = ctx.model; + const contextWindow = model?.contextWindow ?? 0; + if (contextWindow === 0) return { percentage: 0, used: 0, total: 0 }; + + const entries = ctx.sessionManager.getEntries(); + const leafId = ctx.sessionManager.getLeafId(); + const context = buildSessionContext(entries, leafId); + const messages = context.messages; + + const lastAssistant = messages + .slice() + .reverse() + .find((m: any) => m.role === "assistant" && m.stopReason !== "aborted") as any; + + const usage = lastAssistant?.usage; + if (!usage) return { percentage: 0, used: 0, total: contextWindow }; + const contextTokens = (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); + + return { percentage: (contextTokens / contextWindow) * 100, used: contextTokens, total: contextWindow }; + } + + // Track usage state for rendering + let latestUsage: UsageSnapshot | null = null; + let activeProvider: string | null = null; // internal provider key for the current model + let refreshTimer: ReturnType | null = null; + + // Store tui reference for triggering re-renders from event handlers + let tuiRef: { requestRender: () => void } | null = null; + + function refreshGitFooter(): void { + if (refreshGitCache()) tuiRef?.requestRender(); + } + + /** Fetch usage for the active provider. Shows cached data immediately, + * then fetches fresh in the background. Discards results if provider + * changed while the fetch was in flight. */ + function fetchUsage(modelProvider: string): void { + const provider = detectProvider(modelProvider); + if (!provider) { + activeProvider = null; + latestUsage = null; + stopRefreshTimer(); + tuiRef?.requestRender(); + return; + } + + activeProvider = provider; + + // Show cached data immediately if available + const cached = usageCache.get(provider); + if (cached && cached.windows.length > 0) { + latestUsage = cached; + tuiRef?.requestRender(); + } + + // Fetch fresh in background — keep cached data on transient errors + fetchUsageForProvider(provider) + .then((u) => { + if (!u || activeProvider !== provider) return; + if (u.windows.length === 0 && u.error && cached?.windows.length) return; + usageCache.set(provider, u); + latestUsage = u; + tuiRef?.requestRender(); + }) + .catch(() => {}); + } + + /** Start (or restart) the periodic refresh timer. */ + function startRefreshTimer(): void { + if (refreshTimer) clearInterval(refreshTimer); + refreshTimer = setInterval(() => { + if (activeProvider) { + const provider = activeProvider; + const cached = usageCache.get(provider); + fetchUsageForProvider(provider) + .then((u) => { + if (!u || activeProvider !== provider) return; + if (u.windows.length === 0 && u.error && cached?.windows.length) return; + usageCache.set(provider, u); + latestUsage = u; + tuiRef?.requestRender(); + }) + .catch(() => {}); + } + }, USAGE_REFRESH_INTERVAL); + } + + function stopRefreshTimer(): void { + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + } + + pi.on("session_start", async (_event, ctx) => { + refreshGitCache(); + + if (!ctx.hasUI) return; + + ctx.ui.setFooter((tui: any, theme: any, footerData: any) => { + tuiRef = tui; + + const unsub = footerData.onBranchChange(() => { + refreshGitFooter(); + }); + + // Initial fetch inside factory — tui is guaranteed available here, + // so requestRender() will work when the async fetch completes. + if (ctx.model?.provider) { + fetchUsage(ctx.model.provider); + startRefreshTimer(); + } + + return { + dispose: () => { + unsub(); + tuiRef = null; + stopRefreshTimer(); + }, + invalidate() {}, + render(width: number): string[] { + const { percentage, used: ctxUsed, total: ctxTotal } = getContextInfo(ctx); + + // Build parts for status line + let pwd = ctx.cwd; + const home = process.env.HOME || process.env.USERPROFILE; + if (home && pwd.startsWith(home)) { + pwd = `~${pwd.slice(home.length)}`; + } + + let branchStr = ""; + if (showBranch && gitCache?.branch) { + const branchColor = gitCache.dirty ? "warning" : "success"; + branchStr = theme.fg(branchColor, gitCache.branch); + if (gitCache.dirty) branchStr += theme.fg("warning", " *"); + if (gitCache.ahead) branchStr += theme.fg("success", ` ↑${gitCache.ahead}`); + if (gitCache.behind) branchStr += theme.fg("error", ` ↓${gitCache.behind}`); + } + + // Model + thinking + const modelName = ctx.model?.id?.split("/").pop() || "no-model"; + const plainModelStr = theme.fg("muted", modelName); + let modelStr = plainModelStr; + if (ctx.model?.reasoning) { + const thinkingLevel = getThinkingLevel(ctx); + if (thinkingLevel !== "off") { + modelStr += " " + theme.fg("dim", ">") + " " + theme.fg("accent", thinkingLevel); + } + } + + const sep = " " + theme.fg("dim", ">") + " "; + const lines: string[] = []; + + const pwdStr = showCwd ? theme.fg("accent", pwd) : ""; + const locationVariants: string[] = []; + if (pwdStr && branchStr) locationVariants.push(pwdStr + sep + branchStr); + if (pwdStr) locationVariants.push(pwdStr); + if (branchStr) locationVariants.push(branchStr); + const locationBlock = locationVariants.length > 0 ? fitFooterSegment(width, locationVariants) : ""; + + const extensionStatusBlocks = collectFooterStatusItems(footerData.getExtensionStatuses()).map((status) => + status.accent ? theme.fg("accent", status.text) : status.text + ); + + const statusBlocks = [ + locationBlock, + ...extensionStatusBlocks, + fitFooterSegment(width, modelStr === plainModelStr ? [plainModelStr] : [modelStr, plainModelStr]), + fitFooterSegment(width, [ + renderContextGauge(percentage, theme, ctxUsed, ctxTotal, { + barWidth: CTX_GAUGE_WIDTH, + includeCounts: true, + }), + renderContextGauge(percentage, theme, ctxUsed, ctxTotal, { + barWidth: 10, + includeCounts: false, + }), + renderContextGauge(percentage, theme, ctxUsed, ctxTotal, { + barWidth: 8, + includeCounts: false, + }), + renderContextGauge(percentage, theme, ctxUsed, ctxTotal, { + barWidth: 6, + includeCounts: false, + }), + renderContextGauge(percentage, theme, ctxUsed, ctxTotal, { + barWidth: 4, + includeCounts: false, + }), + ]), + ]; + + lines.push(...wrapFooterSegments(statusBlocks, width, sep)); + + if (latestUsage && latestUsage.windows.length > 0) { + lines.push(...renderUsageLine(latestUsage, width, theme)); + } + + return lines.map((line) => truncateToWidth(line, width)); + }, + }; + }); + + }); + + pi.on("turn_end", async () => { + refreshGitFooter(); + }); + + // Refresh when model changes — fetch immediately, restart timer + pi.on("model_select", (event, _ctx) => { + if (!event.model?.provider) return; + fetchUsage(event.model.provider); + startRefreshTimer(); // reset the 5min countdown since we just fetched + }); +} diff --git a/pi-minimal-footer/package.json b/pi-minimal-footer/package.json new file mode 100644 index 0000000..34339bd --- /dev/null +++ b/pi-minimal-footer/package.json @@ -0,0 +1,46 @@ +{ + "name": "@ogulcancelik/pi-minimal-footer", + "version": "0.1.10-my-pi.1", + "description": "Minimal footer for pi with context gauge and subscription usage bars", + "type": "module", + "keywords": [ + "pi-package", + "pi-extension", + "footer", + "context", + "usage", + "coding-agent" + ], + "author": "Can Celik", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/ogulcancelik/pi-extensions.git", + "directory": "packages/pi-minimal-footer" + }, + "bugs": { + "url": "https://github.com/ogulcancelik/pi-extensions/issues" + }, + "homepage": "https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-minimal-footer#readme", + "engines": { + "node": ">=18.0.0" + }, + "pi": { + "extensions": [ + "./index.ts" + ] + }, + "files": [ + "index.ts", + "status.ts", + "assets/claude.png", + "assets/codex.png", + "README.md", + "UPSTREAM.md", + "LICENSE" + ], + "scripts": { + "test": "node --experimental-strip-types --test test/*.test.ts", + "check": "node --experimental-strip-types --check index.ts" + } +} diff --git a/pi-minimal-footer/status.ts b/pi-minimal-footer/status.ts new file mode 100644 index 0000000..7a3ebf0 --- /dev/null +++ b/pi-minimal-footer/status.ts @@ -0,0 +1,28 @@ +export const FAST_MODE_STATUS_KEY = "codex-fast-mode"; + +export interface FooterStatusItem { + text: string; + accent: boolean; +} + +/** Convert Pi extension statuses into compact footer segments. */ +export function collectFooterStatusItems( + statuses: ReadonlyMap, +): FooterStatusItem[] { + const items: FooterStatusItem[] = []; + + for (const [key, value] of statuses) { + if (key === FAST_MODE_STATUS_KEY) { + if (value.trim().toLowerCase() === "on") { + items.push({ text: "Fast on", accent: true }); + } + continue; + } + + if (value.trim()) { + items.push({ text: value, accent: false }); + } + } + + return items; +} diff --git a/pi-minimal-footer/test/status.test.ts b/pi-minimal-footer/test/status.test.ts new file mode 100644 index 0000000..16e2f8b --- /dev/null +++ b/pi-minimal-footer/test/status.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { collectFooterStatusItems, FAST_MODE_STATUS_KEY } from "../status.ts"; + +test("renders enabled Codex Fast mode as an explicit accented segment", () => { + const items = collectFooterStatusItems(new Map([[FAST_MODE_STATUS_KEY, "on"]])); + + assert.deepEqual(items, [{ text: "Fast on", accent: true }]); +}); + +test("hides disabled Codex Fast mode", () => { + const items = collectFooterStatusItems(new Map([[FAST_MODE_STATUS_KEY, "off"]])); + + assert.deepEqual(items, []); +}); + +test("preserves other non-empty extension statuses", () => { + const items = collectFooterStatusItems(new Map([ + ["one", "Ready"], + ["empty", " "], + ["two", "2 tasks"], + ])); + + assert.deepEqual(items, [ + { text: "Ready", accent: false }, + { text: "2 tasks", accent: false }, + ]); +}); diff --git a/tests/extension-load.test.ts b/tests/extension-load.test.ts index cf11cf0..311f278 100644 --- a/tests/extension-load.test.ts +++ b/tests/extension-load.test.ts @@ -18,6 +18,11 @@ test("all package extensions load together without global registration conflicts packageJson.pi.extensions.includes("./hippo-memory-pi/index.ts"), "the bundle must load the vendored official Hippo Memory extension", ); + assert.ok( + packageJson.pi.extensions.includes("./pi-minimal-footer/index.ts"), + "the bundle must load the locally maintained minimal footer", + ); + assert.equal(packageJson.dependencies["@ogulcancelik/pi-minimal-footer"], "file:./pi-minimal-footer"); assert.equal( packageJson.pi.extensions.some((entry) => entry.includes("pi-hermes-memory") || entry.includes("the-forge-flow")), false, diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index effd402..0614acc 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -46,7 +46,7 @@ test("the package has exactly one local MCP adapter owner", async () => { const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) as { pi: { extensions: string[] }; }; - const localEntries = packageJson.pi.extensions.filter((entry) => entry.startsWith("./extensions/")); + const localEntries = packageJson.pi.extensions.filter((entry) => !entry.startsWith("./node_modules/")); const adapterOwners: string[] = []; for (const entry of localEntries) { const source = await readFile(new URL(`../${entry.slice(2)}`, import.meta.url), "utf8");