From 5c4d6c6a872cdc762369c9f50ce6fffdbb212d88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E6=9E=97=E7=AB=8B?= Date: Wed, 19 Aug 2026 22:24:37 +0800 Subject: [PATCH] feat: show fast mode in minimal footer --- AGENTS.md | 7 +- README.md | 2 +- package-lock.json | 19 +- package.json | 5 +- pi-minimal-footer/LICENSE | 21 + pi-minimal-footer/README.md | 75 ++ pi-minimal-footer/UPSTREAM.md | 8 + pi-minimal-footer/assets/claude.png | Bin 0 -> 10914 bytes pi-minimal-footer/assets/codex.png | Bin 0 -> 11891 bytes pi-minimal-footer/index.ts | 1107 +++++++++++++++++++++++++ pi-minimal-footer/package.json | 46 + pi-minimal-footer/status.ts | 28 + pi-minimal-footer/test/status.test.ts | 29 + tests/extension-load.test.ts | 5 + tests/mcp.test.ts | 2 +- 15 files changed, 1340 insertions(+), 14 deletions(-) create mode 100644 pi-minimal-footer/LICENSE create mode 100644 pi-minimal-footer/README.md create mode 100644 pi-minimal-footer/UPSTREAM.md create mode 100644 pi-minimal-footer/assets/claude.png create mode 100644 pi-minimal-footer/assets/codex.png create mode 100644 pi-minimal-footer/index.ts create mode 100644 pi-minimal-footer/package.json create mode 100644 pi-minimal-footer/status.ts create mode 100644 pi-minimal-footer/test/status.test.ts 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 0000000000000000000000000000000000000000..402a2c5ac5e4d4c05de5388bbe7f25036ccba4d6 GIT binary patch literal 10914 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πFUeI!GPPq5RfC-`tG=r7aG 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 literal 0 HcmV?d00001 diff --git a/pi-minimal-footer/assets/codex.png b/pi-minimal-footer/assets/codex.png new file mode 100644 index 0000000000000000000000000000000000000000..b64eb4ae91d8200be0b1906f490ed2ab0df8c5d0 GIT binary patch literal 11891 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 Wfr=xNh3|fF7+FariE=SR|NjT-5=cV; literal 0 HcmV?d00001 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");