/** * 自定义状态栏(footer)扩展 * * 用 ctx.ui.setFooter() 替换内置 footer,单行显示: * git 分支 │ ↑输入 ↓输出 ⚡缓存·命中率 $花费 ⏱首token 输出tok/s(生成中按字符估算,带 ~ 前缀) │ ▰▰▰▱▱ 上下文% │ 订阅额度 │ 模型 │ 其他扩展状态 * * 会话名不占 footer 宽度,只写入终端标题(会话名 · 目录名 · 模型) * * - 花费按实时单价计算:session 启动时从 models.dev/api.json 拉取各家官方单价(本地缓存 24h), * 失败时回落 models.json 的 cost 字段;/statusbar prices 强制刷新并显示当前模型单价来源与匹配方式 * 计价按「每条 assistant 消息自己的模型」逐条进行,会话中途切换模型不会把历史轮次 * 按新模型单价重算;阶梯定价也按单次请求判定(与 pi-ai 的 calculateCost 一致) * - 上下文占用 ≥75% 变黄,≥90% 变红 * - 订阅额度自动发现(按 provider baseUrl 匹配,只显示当前模型所属 provider 的额度): * Codex(chatgpt.com) → ChatGPT OAuth 额度窗口已用百分比 * GitHub Copilot(githubcopilot.com) → 高级请求/聊天/补全配额;不限量显示 ∞ * xAI OAuth(api.x.ai / grok.com) → 真实订阅 credits / 按量消费 / 预付余额 * Radius(radius.pi.dev) → 组织可用余额(扣除 reserved) * GLM Coding Plan(bigmodel.cn / z.ai) → 5h/周 token 窗口百分比 + 恢复倒计时 * Kimi(api.kimi.com) → 短窗口/周/月配额百分比 + 恢复倒计时 * DeepSeek 余额(deepseek.com) → 按量账户余额(无重置概念) * OpenRouter 额度(openrouter.ai) → 剩余 credits(无重置概念) * OpenCode Go(opencode.ai/zen/go) → 5h/周/月三窗口已用百分比 + 恢复倒计时 * MiniMax Coding Plan(minimaxi.com / minimax.io)→ 5h/周窗口已用百分比 + 恢复倒计时 * Command Code(commandcode.ai) → 5h/周窗口已用百分比(credits 口径)+ 剩余积分余额 * Moonshot 开放平台(api.moonshot.cn/.ai) → 按量账户余额(无重置概念) * SiliconFlow(siliconflow.cn/.com) → 按量账户余额(无重置概念) * StepFun(stepfun.com/.ai) → 按量账户余额(无重置概念) * Novita AI(novita.ai) → 按量账户余额(无重置概念) * magpie(本机网关 127.0.0.1:3425) → 本会话当前模型实际路由到的那个账号的窗口/余额(订阅/套餐/按量都行) * 每个窗口用量后括注 (恢复倒计时)(45s/13m/2h13m/6d4h;不足 24h 按 xhyym,超 24h 按 xdyyh,渲染时按重置时刻实时换算); * 底部单行各窗口用 · 连接,右侧面板逐窗口分行;带 TTL 缓存,失败静默;/statusbar quota 强制刷新并显示详情 * - /exit 为 /quit 的别名,优雅退出 pi * - 窄终端先按 扩展状态 → 额度/token → 模型 的顺序收起,仍放不下则整段换行成多行(分支与上下文永不丢弃) * - 布局可配置(layout):bottom 底部单行 / right 右侧浮层(浮在聊天上)/ auto 自动右侧浮层(默认) * / auto-split 自动右侧分栏 / split 右侧分栏 * 两种 auto 模式都按终端宽度自动选择(阈值 autoMinWidth,默认 120 列),resize 实时切换: * auto = ≥autoMinWidth 用右侧浮层,否则底部单行; * auto-split = ≥autoMinWidth 用右侧分栏,否则底部单行; * 浮层为非捕获 overlay(不抢键盘焦点),宽度由 rightWidth 配置(默认 32 列); * 注意:浮层浮在聊天内容之上,会遮住右缘内容(regular 模式没有布局树,pi 扩展 API 无法真分栏); * split = 右侧分栏(类 opencode:把核心布局根包进 HStack,聊天按剩余宽度重新换行,完全不遮挡), * 只要宽度 ≥ rightWidth+24 就用分栏(不套 auto 的阈值); * 右侧分栏只在 fullscreen 模式(alt-screen)下成立,regular 模式/极窄终端自动退回底部单行, * 且不再创建浮层(浮层会挡住 /settings 切换 TUI mode);auto-split 在 regular 模式下 * 会像 split 一样自动把 pi 的 tuiMode 补成 fullscreen(提示重启),本次会话仍走底部单行; * /statusbar 无参数打开交互式菜单(布局 ◀▶ 调值 / 面板边框 / 指标显隐 / 启停;Enter 确认,Esc 退出), * 或子命令快捷方式:/statusbar [on|off] | layout [right|bottom|auto|auto-split|split] | split [on|off] | metrics * - 新会话默认恢复自定义样式 * - 用户配置 ~/.pi/agent/statusbar.json(环境变量 PI_STATUSBAR_CONFIG 可覆盖路径): * priceMap 本地模型 → models.dev 单价映射,key 为 "provider:model" 或裸 "model" * hideExtStatuses 按文本包含隐藏的其他扩展状态(默认 ["LSP Inactive"]) * layout "bottom" | "right" | "auto" | "auto-split" | "split"(默认 "auto"); * auto = 自动右侧浮层,auto-split = 自动右侧分栏,split = 始终右侧分栏; * 旧配置的 split: true 会自动迁移为 layout: "split" * rightWidth 右侧面板宽度,默认 32,范围 [20, 60] * autoMinWidth 两种 auto 模式的宽度阈值(列),默认 120,范围 [40, 400]; * ≥ 该值才切到右侧(auto 用浮层 / auto-split 用分栏),否则底部单行 * (auto-split 另需满足 rightWidth+24,避免把聊天压成一条) * panelBorder 面板边框字符集 "auto"(默认,只跟随 PI_STATUSBAR_BORDER 环境变量) * | "unicode"(┌─┐│└┘)| "ascii"(+ - |);也可用 /statusbar border [auto|unicode|ascii] 切换 * panelFill 分栏面板是否把边框铺满整屏高度(默认 true);false = 高度贴内容。 * 只对 layout=split 有意义(浮层本来就贴合内容);也可用 /statusbar fill on|off * dockWidgetsInSplit split 分栏时把 pi「编辑器上方」的 widget 容器(subagent 的 agent 面板) * 搬进右栏:状态面板在上、agent 面板在下(默认 true)。点击折叠等鼠标交互 * 按布局位置命中,搬动后保留;panelFill 在搬入期间自动让位(面板贴内容)。 * 也可用 /statusbar dock on|off;仅 split 生效,其余布局/极窄终端自动回底部 * hiddenMetrics 隐藏的指标 key 数组,可选:branch/ctx/model/effort/usage/ttft/speed/quota/ext; * 也可用 /statusbar metrics 交互式配置(会写回此字段) * language 配置面板显示语言 "zh" | "en"(默认 "zh");/statusbar 菜单语言行 ←→ 切换并即时写回 * 未配置 priceMap 时按顺序匹配:models.dev 同名 provider 直配 → 模型注册表单价 * (models.json cost / pi 内置目录)→ models.dev 全量同名 id 自动匹配(官方 provider 优先、 * 排除 0 价的订阅/免费端点、其余取最便宜);自动匹配到 0 价或多候选时 /statusbar prices * 会提示并建议配置 priceMap 固定来源。零配置可用; * 配置在新会话时重读,改完开新会话即生效 */ import type { AssistantMessage, ModelCost, ModelCostRates, } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext, ReadonlyFooterDataProvider, Theme, } from "@earendil-works/pi-coding-agent"; // 同上:getSettingsPath 用于「开右侧分栏时自动把 pi 的 TUI mode 设为 fullscreen」, // 命名空间导入避免旧版没有该导出时在链接期抛错 import * as piAgentRuntime from "@earendil-works/pi-coding-agent"; // 命名空间导入:HStack 在旧版 pi-tui 中不存在,命名导入会在链接期直接抛错, // 命名空间导入只是取到 undefined,便于「有就用右侧分栏,没有就静默降级」 import * as piTuiRuntime from "@earendil-works/pi-tui"; import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type TUI, } from "@earendil-works/pi-tui"; import { readFileSync, writeFileSync } from "node:fs"; import { mkdirSync, renameSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; /** 会话累计用量 */ interface UsageStats { input: number; output: number; cacheRead: number; cacheWrite: number; /** cacheWrite 中用 1h 保留期写入的部分(Anthropic 按 2× input 计费) */ cacheWrite1h: number; cost: number; } /** 渲染段:pri 越大,窄终端下越先被丢弃;label 仅在右侧面板竖排展示时使用;key 用于指标显隐配置 */ interface Segment { label: string; text: string; pri: number; color?: string; key?: MetricKey; } /** 额度源单个窗口的渲染文本(面板逐窗口分行,底部用 · 连接成一行) */ interface QuotaRow { text: string; percent?: number; } /** 额度源渲染出的短文本与最大占用百分比(决定颜色);rows 有值时右侧面板逐窗口分行展示 */ interface QuotaSeg { text: string; rows?: QuotaRow[]; maxPercent?: number; } /** 交给额度源的会话上下文:magpie 靠它定位「本会话这一轮实际被服务的上游账号」 */ interface QuotaFetchInfo { /** 当前模型 id(provider 内的 id,如 magpie 的 `kimi-code-cn/k3`) */ modelId?: string; /** 当前会话 id(magpie 的 X-Magpie-Session 与之一致,由 sendSessionAffinityHeaders 发出) */ sessionId?: string; } /** 订阅额度源:按 provider baseUrl 匹配,从 provider origin 拉取 */ interface QuotaSource { id: string; match: RegExp; ttlMs: number; /** 额度随模型变化(magpie 按路由到的上游账号取数):切模型即视为过期,不等 TTL */ modelAware?: boolean; /** 少数额度接口需要不同于推理端的凭据(如 Copilot 的 GitHub OAuth token) */ resolveApiKey?(ctx: ExtensionContext, providerId: string): Promise; fetch( origin: string, apiKey: string, info?: QuotaFetchInfo, ): Promise<{ seg: QuotaSeg | null; detail: string }>; } // ---------- 用户配置(~/.pi/agent/statusbar.json,环境变量 PI_STATUSBAR_CONFIG 可覆盖路径) ---------- /** 状态栏布局: * bottom 底部单行 footer * right 右侧浮层(overlay,始终) * auto 终端列数 ≥ autoMinWidth 时用右侧浮层,否则底部单行(默认) * auto-split 终端列数 ≥ autoMinWidth 时用右侧分栏,否则底部单行 * split 右侧分栏(fullscreen 布局分栏,聊天按剩余宽度重新换行、完全不遮挡); * 宽度 ≥ rightWidth + 24 时生效,否则回退底部单行;regular 模式/缺 HStack 同样回退 */ type LayoutMode = "bottom" | "right" | "auto" | "auto-split" | "split"; /** 分栏族布局:显式 split 与自动分栏 auto-split —— 都需要 fullscreen + 分栏安装逻辑 */ function isSplitLike(m: LayoutMode): boolean { return m === "split" || m === "auto-split"; } interface StatusbarConfig { /** 本地模型 → models.dev 单价映射:key 为本地 "provider:model" 或裸 "model",value 为 [models.dev provider, 模型 id] */ priceMap: Record; /** 需要隐藏的其他扩展状态(文本包含即隐藏)。 * 默认隐藏 pi-lens 的 "LSP Inactive"(未编辑代码时的被动状态),保留 LSP Active / Failed 等有信息量的状态。 */ hideExtStatuses: string[]; /** 布局模式,默认 auto(终端 ≥120 列时右侧浮层,否则底部单行);见 LayoutMode */ layout: LayoutMode; /** 右侧面板宽度(列),默认 32,读取时 clamp 到 [20, 60] */ rightWidth: number; /** 两种 auto 模式的宽度阈值(列),默认 120,读取时 clamp 到 [40, 400]。 * auto / auto-split 都在终端列数 ≥ 该值时切到右侧,否则底部单行 */ autoMinWidth: number; /** 开启 layout=split 时自动改写 pi settings.json 的 tuiMode,这里记录改前的值以便切回时还原。 * 缺省 = 从未改过;"none" = 原本没有该字段(切回时删除);"regular"/"fullscreen" = 原值 */ tuiModeBackup?: string; /** 面板边框字符集,默认 auto(只跟随 PI_STATUSBAR_BORDER);见 PanelBorder */ panelBorder: PanelBorder; /** 分栏面板是否把边框铺满整屏高度(默认 true);false = 高度贴内容。 * 只对 layout=split 有效(浮层本来就贴合内容),/statusbar fill on|off 同效 */ panelFill: boolean; /** split 分栏时把 pi「编辑器上方」的 widget 容器(subagent 的 agent 面板)搬进右栏(默认 true); * /statusbar dock on|off 同效。仅 split 生效,其余布局自动留在底部 */ dockWidgetsInSplit: boolean; /** 隐藏的指标 key 列表(默认全部显示),/statusbar metrics 交互式配置 */ hiddenMetrics: MetricKey[]; /** 配置面板显示语言,默认 zh;/statusbar 菜单语言行 ←→ 切换(即时写回配置文件) */ language: Lang; } /** 配置面板语言 */ type Lang = "zh" | "en"; /** 面板边框字符集: * auto(默认)= 不主动改字形,只跟随 PI_STATUSBAR_BORDER 环境变量; * unicode = ┌─┐│└┘;ascii = + - | 。 * 为什么留 ascii:个别终端对 U+2500 段方框字符的处理确实会出问题,作为可手动切的后路。 * 注意:实测 ascii 并不能修复「行内排版漂移」那类残影(错位来自终端渲染器本身, * 与方框字形无关)。已验证的修法:VSCode / Cursor 内置终端把 * terminal.integrated.gpuAcceleration 从 "off"(DOM 渲染器)改回 "auto"/"on"(WebGL); * 详见 README 的「排障」一节。 */ type PanelBorder = "auto" | "unicode" | "ascii"; /** 可配置显隐的指标 */ type MetricKey = | "branch" | "ctx" | "model" | "effort" | "usage" | "ttft" | "speed" | "quota" | "ext"; const METRICS: { key: MetricKey; zh: string; en: string }[] = [ { key: "branch", zh: "分支 / 目录", en: "Branch / Dir" }, { key: "ctx", zh: "上下文占用", en: "Context usage" }, { key: "model", zh: "模型名", en: "Model" }, { key: "effort", zh: "思考强度 effort(仅右侧面板)", en: "Effort (right panel only)", }, { key: "usage", zh: "token 用量 / 缓存 / 花费", en: "Tokens / cache / cost" }, { key: "ttft", zh: "首 token 耗时 TTFT", en: "TTFT" }, { key: "speed", zh: "输出吞吐 tok/s", en: "Speed tok/s" }, { key: "quota", zh: "订阅额度", en: "Quota" }, { key: "ext", zh: "其他扩展状态", en: "Extension statuses" }, ]; const LAYOUT_ORDER: LayoutMode[] = [ "auto", "auto-split", "bottom", "right", "split", ]; const LAYOUT_LABELS: Record> = { zh: { auto: "自动浮层", "auto-split": "自动分栏", bottom: "底部单行", right: "右侧浮层", split: "右侧分栏", }, en: { auto: "Auto overlay", "auto-split": "Auto column", bottom: "Bottom", right: "Right overlay", split: "Right column", }, }; const LANG_LABELS: Record = { zh: "中文", en: "English" }; /** 配置面板(菜单/指标选择器)双语文案 */ const UI_TEXT = { zh: { menuTitle: "状态栏设置", rowLayout: "布局", rowBorder: "面板边框", rowFill: "面板填充", rowDock: "Agent 面板入栏", rowLang: "语言", rowMetrics: "指标显隐", rowDisable: "停用自定义状态栏", rowEnable: "启用自定义状态栏", fillOn: "铺满高度", fillOff: "贴内容", dockIn: "搬入右栏", dockOut: "留在底部", metricsCount: (n: number, total: number) => `${n}/${total} 显示`, menuHint: "↑↓ 选择 · ←→ 调整 · Enter 确认 · Esc 退出", pickerTitle: "指标显隐", pickerHint: "↑↓ 选择 · Space 切换 · Enter 保存 · Esc 取消", savedAll: "已保存:显示全部指标", savedHidden: (keys: string) => `已保存:隐藏 ${keys}`, saveFailed: (e: unknown) => `写入配置文件失败: ${e}`, canceled: "已取消,配置未保存", noTui: "当前模式不支持交互式配置,请直接改配置文件", splitNeedFullscreen: "需重启生效", splitEnabled: "右侧分栏已开启", splitDisabled: "右侧分栏已关闭", splitNeedsRestart: "已自动把 pi 的 TUI mode 设为 fullscreen:重启 pi 后生效(当前会话仍是 regular,已退回底部单行)", splitRestored: "右侧分栏已关闭,pi 的 TUI mode 已还原", splitSetFailed: "自动写入 pi settings.json 失败,请手动在 /settings → TUI mode 里切到 fullscreen", splitUnavailable: "分栏需要 fullscreen 模式(/settings → TUI mode)与支持 HStack 的 pi-tui", }, en: { menuTitle: "Statusbar Settings", rowLayout: "Layout", rowBorder: "Panel border", rowFill: "Panel fill", rowDock: "Agent dock", rowLang: "Language", rowMetrics: "Metrics", rowDisable: "Disable custom statusbar", rowEnable: "Enable custom statusbar", fillOn: "Full height", fillOff: "Fit content", dockIn: "Right column", dockOut: "Bottom", metricsCount: (n: number, total: number) => `${n}/${total} shown`, menuHint: "↑↓ select · ←→ adjust · Enter confirm · Esc exit", pickerTitle: "Metrics", pickerHint: "↑↓ select · Space toggle · Enter save · Esc cancel", savedAll: "Saved: all metrics shown", savedHidden: (keys: string) => `Saved: hidden ${keys}`, saveFailed: (e: unknown) => `Failed to write config: ${e}`, canceled: "Cancelled, not saved", noTui: "Interactive config requires TUI mode; edit the config file instead", splitNeedFullscreen: "restart to apply", splitEnabled: "Right column enabled", splitDisabled: "Right column disabled", splitNeedsRestart: "Set pi TUI mode to fullscreen for you: restart pi to apply (this session stays regular, using the bottom line)", splitRestored: "Right column disabled; pi TUI mode restored", splitSetFailed: "Could not write pi settings.json; switch manually via /settings → TUI mode → fullscreen", splitUnavailable: "Right column needs fullscreen mode (/settings → TUI mode) and a pi-tui build that exports HStack", }, } as const; /** 当前语言的配置面板文案。 * 返回类型写成 `(typeof UI_TEXT)[Lang]`(zh|en 的联合)而不是 zh 单支: * 两语言字面量不同,标注成 zh 会让 `UI_TEXT[config.language]` 这个联合体赋值失败 * (TS2322,本项目 2026-09 首次 `tsc --noEmit` 才发现)。联合体下调属性/调用签名一致,用起来无差别 */ const t = (): (typeof UI_TEXT)[Lang] => UI_TEXT[config.language]; /** 指标名(跟随当前语言) */ const metricName = (m: (typeof METRICS)[number]): string => m[config.language]; function toMetricKeys(v: unknown): MetricKey[] { if (!Array.isArray(v)) return []; const valid = new Set(METRICS.map((m) => m.key as string)); return v.filter((k): k is MetricKey => typeof k === "string" && valid.has(k)); } const CONFIG_FILE = process.env.PI_STATUSBAR_CONFIG || join(homedir(), ".pi/agent/statusbar.json"); const DEFAULT_HIDE_EXT_STATUSES = ["LSP Inactive"]; const DEFAULT_LAYOUT: LayoutMode = "auto"; const DEFAULT_RIGHT_WIDTH = 32; const DEFAULT_PANEL_BORDER: PanelBorder = "auto"; const DEFAULT_PANEL_FILL = true; /** 面板边框字符表(h 横线 / v 竖线 / 四角);auto 只是一种设定值,不参与查表 */ const BORDER_CHARS: Record< "unicode" | "ascii", { h: string; v: string; tl: string; tr: string; bl: string; br: string } > = { unicode: { h: "─", v: "│", tl: "┌", tr: "┐", bl: "└", br: "┘" }, ascii: { h: "-", v: "|", tl: "+", tr: "+", bl: "+", br: "+" }, }; /** 两种 auto 模式的默认宽度阈值:终端列数 ≥ 该值时切到右侧(auto 用浮层 / auto-split 用分栏) */ const DEFAULT_AUTO_MIN_WIDTH = 120; const MIN_AUTO_MIN_WIDTH = 40; const MAX_AUTO_MIN_WIDTH = 400; function toLayoutMode(v: unknown, legacySplit = false): LayoutMode { if ( v === "bottom" || v === "right" || v === "auto" || v === "auto-split" || v === "split" ) return v; // 旧配置的 split: true 迁移为 layout: "split" return legacySplit ? "split" : DEFAULT_LAYOUT; } function toAutoMinWidth(v: unknown): number { if (typeof v !== "number" || !Number.isFinite(v)) return DEFAULT_AUTO_MIN_WIDTH; return Math.min( MAX_AUTO_MIN_WIDTH, Math.max(MIN_AUTO_MIN_WIDTH, Math.round(v)), ); } function toRightWidth(v: unknown): number { if (typeof v !== "number" || !Number.isFinite(v)) return DEFAULT_RIGHT_WIDTH; return Math.min(60, Math.max(20, Math.round(v))); } function toLang(v: unknown): Lang { return v === "en" ? "en" : "zh"; } function toPanelBorder(v: unknown): PanelBorder { if (v === "unicode" || v === "ascii") return v; return DEFAULT_PANEL_BORDER; } /** * 边框字符集解析:显式设置优先;auto 只看 PI_STATUSBAR_BORDER 环境变量, * 否则用 unicode。曾经按 TERM_PROGRAM=vscode 猜 ascii,但实测那类残影与方框字形无关 * (是终端渲染器的逐行排版/重绘问题),猜错只会白让边框变丑,所以去掉了。 */ function resolvePanelBorder(setting: PanelBorder): "unicode" | "ascii" { if (setting === "unicode" || setting === "ascii") return setting; return process.env.PI_STATUSBAR_BORDER === "ascii" ? "ascii" : "unicode"; } const BORDER_ORDER: PanelBorder[] = ["auto", "unicode", "ascii"]; /** 循环切换:auto → unicode → ascii → auto */ function nextPanelBorder(cur: PanelBorder): PanelBorder { const i = BORDER_ORDER.indexOf(cur); return BORDER_ORDER[(i + 1) % BORDER_ORDER.length]; } /** 菜单/提示用的边框值文案:auto 时同时给出实际生效的字形集 */ function panelBorderLabel(v: PanelBorder): string { const glyphs = resolvePanelBorder(v) === "ascii" ? "ASCII (+-+)" : "Unicode (┌─┐)"; return v === "auto" ? `Auto · ${glyphs}` : glyphs; } /** 去除 jsonc 行注释(保留字符串内的 //,如 URL) */ function stripJsonComments(s: string): string { return s .split("\n") .map((line) => { let inStr = false; for (let i = 0; i < line.length; i++) { const c = line[i]; if (c === '"' && line[i - 1] !== "\\") inStr = !inStr; if (!inStr && c === "/" && line[i + 1] === "/") return line.slice(0, i); } return line; }) .join("\n"); } /** 读取配置(支持 jsonc 行注释);文件缺失或损坏时回落默认值(新会话时重读,改完配置开新会话即生效) */ function loadConfig(): StatusbarConfig { try { const raw = JSON.parse(stripJsonComments(readFileSync(CONFIG_FILE, "utf8"))); // 旧版的独立 split 开关:内存里已并进 layout,session_start 时再写回文件把它删掉 legacySplitKey = typeof raw?.split === "boolean"; return { priceMap: raw?.priceMap ?? {}, hideExtStatuses: Array.isArray(raw?.hideExtStatuses) ? raw.hideExtStatuses : [...DEFAULT_HIDE_EXT_STATUSES], layout: toLayoutMode(raw?.layout, raw?.split === true), rightWidth: toRightWidth(raw?.rightWidth), autoMinWidth: toAutoMinWidth(raw?.autoMinWidth), panelBorder: toPanelBorder(raw?.panelBorder), panelFill: raw?.panelFill !== false, dockWidgetsInSplit: raw?.dockWidgetsInSplit !== false, tuiModeBackup: typeof raw?.tuiModeBackup === "string" ? raw.tuiModeBackup : undefined, hiddenMetrics: toMetricKeys(raw?.hiddenMetrics), language: toLang(raw?.language), }; } catch { return { priceMap: {}, hideExtStatuses: [...DEFAULT_HIDE_EXT_STATUSES], layout: DEFAULT_LAYOUT, rightWidth: DEFAULT_RIGHT_WIDTH, autoMinWidth: DEFAULT_AUTO_MIN_WIDTH, panelBorder: DEFAULT_PANEL_BORDER, panelFill: DEFAULT_PANEL_FILL, dockWidgetsInSplit: true, tuiModeBackup: undefined, hiddenMetrics: [], language: "zh", }; } } /** 将字段补丁写回配置文件(保留其他字段;会去除 jsonc 注释)。 * 文件缺失或损坏时基于当前生效配置重建。调用方自行更新内存 config。 */ function saveConfigPatch(patch: Record): void { let raw: Record = {}; try { raw = JSON.parse(stripJsonComments(readFileSync(CONFIG_FILE, "utf8"))); } catch { raw = { priceMap: config.priceMap, hideExtStatuses: config.hideExtStatuses, layout: config.layout, rightWidth: config.rightWidth, autoMinWidth: config.autoMinWidth, panelBorder: config.panelBorder, panelFill: config.panelFill, dockWidgetsInSplit: config.dockWidgetsInSplit, tuiModeBackup: config.tuiModeBackup, hiddenMetrics: config.hiddenMetrics, language: config.language, }; } Object.assign(raw, patch); writeFileSync(CONFIG_FILE, JSON.stringify(raw, null, "\t") + "\n"); } /** 配置文件里是否还有旧版的 split 字段(一次性迁移用,见 session_start)。 * 注意必须声明在 loadConfig() 首次调用之前:loadConfig 成功解析后会写这个变量, * 若声明在其后,首次调用会撞 TDZ 抛 ReferenceError,被 catch 吞掉后整份配置静默回落默认值 */ let legacySplitKey = false; let config = loadConfig(); /** models.dev 模型单价 → 本地 ModelCost 结构 */ function toModelCost(c: any): ModelCost { const base = { input: c.input ?? 0, output: c.output ?? 0, cacheRead: c.cache_read ?? 0, cacheWrite: c.cache_write ?? 0, }; const tiers = (c.tiers ?? []).map((t: any) => ({ inputTokensAbove: t?.tier?.size ?? 0, input: t.input ?? 0, output: t.output ?? 0, cacheRead: t.cache_read ?? 0, cacheWrite: t.cache_write ?? 0, })); return tiers.length ? { ...base, tiers } : base; } const MODELS_DEV_URL = "https://models.dev/api.json"; /** OpenCode Go 用量端点(未文档化;与推理侧 /messages 只认 x-api-key 相反,这里只认 Bearer) */ const OPENCODE_GO_USAGE_URL = "https://opencode.ai/zen/go/v1/usage"; const PRICE_TTL_MS = 24 * 60 * 60_000; const PRICE_CACHE_FILE = join(homedir(), ".pi/agent/.models-dev-prices.json"); /** models.dev 上的模型原厂 provider:全量自动匹配多候选时优先于第三方中转/聚合站 */ const OFFICIAL_PROVIDERS = new Set([ "anthropic", "openai", "google", "deepseek", "moonshotai", "moonshotai-cn", "zhipuai", "xai", "minimax", "alibaba", "alibaba-cn", "mistral", ]); /** 单价是否为 0(input+output 均 ≤0:多为订阅制/免费端点,或未定价)。注意 pi 对缺省 cost 也补全 0 */ function isZeroCost(c: { input?: number; output?: number } | undefined | null): boolean { return !c || ((c.input ?? 0) <= 0 && (c.output ?? 0) <= 0); } /** 每个 provider:model 单价的匹配来源(/statusbar prices 展示与运行时叠加用) */ interface PriceMeta { /** priceMap=显式映射;provider=models.dev 同名 provider 直配;runtime=模型注册表(models.json/内置目录);auto=models.dev 全量自动匹配 */ source: "priceMap" | "provider" | "runtime" | "auto"; /** models.dev 上实际采用的 "provider/model"(runtime 来源无此字段) */ via?: string; /** auto 匹配时的同名候选总数 */ candidates?: number; /** auto 匹配时被丢弃的 0 价候选数(订阅/免费端点) */ droppedZero?: number; /** 所有同名候选都是 0 价(典型:订阅制端点),选中者也是 0 价 */ allZero?: boolean; } /** 数字缩写:999 → 999,12.3k,1.23m */ function fmtTokens(n: number): string { if (n < 1000) return `${n}`; if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`; return `${(n / 1_000_000).toFixed(2)}m`; } function fmtCost(n: number): string { return n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(3)}`; } /** 首 token 耗时:980ms → 980ms,1.2s → 1.2s */ function fmtMs(ms: number): string { return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`; } /** 输出吞吐(tok/s):低速保留 1 位小数,高速取整 */ function fmtTps(n: number): string { return n < 100 ? n.toFixed(1) : `${Math.round(n)}`; } /** * 按字符粗略估算 token 数(流式过程中 provider 不下发 usage,只能估算): * 英文/代码约 4 字符 1 token,CJK/全角约 1.5 字符 1 token。 */ function estimateTokens(text: string): number { let ascii = 0; let cjk = 0; for (const ch of text) { const code = ch.codePointAt(0) ?? 0; if ((code >= 0x2e80 && code <= 0x9fff) || (code >= 0xff00 && code <= 0xffef)) cjk++; else ascii++; } return ascii / 4 + cjk / 1.5; } /** 上下文占用条:▰▰▰▰▱▱▱▱▱▱ */ function contextBar(percent: number, cells = 10): string { const filled = Math.min( cells, Math.max(0, Math.round((percent / 100) * cells)), ); return "▰".repeat(filled) + "▱".repeat(cells - filled); } /** * 把接口返回的「重置时刻」统一成毫秒时间戳: * GLM 给毫秒数字(nextResetTime),Kimi 给 ISO 字符串(resetTime / reset_time)。 */ function toResetAt(v: unknown): number | undefined { if (typeof v === "number" && Number.isFinite(v) && v > 0) return v; if (typeof v === "string") { const t = Date.parse(v); if (!Number.isNaN(t)) return t; } return undefined; } /** 恢复倒计时:45s / 13m / 2h13m / 6d4h;时刻缺失或已过期时返回空串 */ function fmtCountdown(ms: number | undefined): string { if (ms == null || !Number.isFinite(ms)) return ""; const diff = ms - Date.now(); if (diff <= 0) return ""; if (diff < 60_000) return `${Math.max(1, Math.round(diff / 1000))}s`; if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`; if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h${Math.floor((diff % 3_600_000) / 60_000)}m`; return `${Math.floor(diff / 86_400_000)}d${Math.floor((diff % 86_400_000) / 3_600_000)}h`; } /** 重置时刻的本地钟点:`2026-09-15 13:47`(/quota 详情用) */ function fmtResetClock(ms: number | undefined): string { if (ms == null || !Number.isFinite(ms)) return ""; const d = new Date(ms); const p = (n: number) => `${n}`.padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; } /** 给窗口文本追加恢复倒计时:`5h 86%` → `5h 86%(2h13m)` */ function withReset(text: string, resetAt: number | undefined): string { const cd = fmtCountdown(resetAt); return cd ? `${text}(${cd})` : text; } /** /quota 详情里的重置说明:`,重置于 2026-09-15 13:47(2h13m)` */ function resetDetail(at: number | undefined): string { const clock = fmtResetClock(at); if (!clock) return ""; const cd = fmtCountdown(at); return `,重置于 ${clock}${cd ? `(${cd})` : ""}`; } /** GLM 窗口标签:unit=3 小时 / 6 周 / 5 月 / 4 天,其余原样输出 */ function glmWindowLabel(unit: number, num: number): string { if (unit === 3) return `${num}h`; if (unit === 6) return `${num}周`; if (unit === 5) return `${num}月`; if (unit === 4) return `${num}天`; return `u${unit}:${num}`; } /** Kimi usages 对象的窗口 key → 展示标签;不认识返回 null(月付套餐有 limit_month_total/limit_month_code) */ function kimiUsageLabel(key: string): string | null { if (key === "limit_month_total") return "月"; if (key === "limit_month_code") return "月编程"; const h = /^limit_(\d+)h$/.exec(key); if (h) return `${parseInt(h[1], 10)}h`; const d = /^limit_(\d+)d$/.exec(key); if (d) { const days = parseInt(d[1], 10); if (days % 7 !== 0) return `${days}天`; return days === 7 ? "周" : `${days / 7}周`; } return null; } const FETCH_TIMEOUT_MS = 8000; /** 新额度接口只读 GET;拒绝重定向,错误只保留状态码,不反射响应正文或凭据。 */ async function fetchQuotaJson(url: string, headers: Record): Promise { const res = await fetch(url, { headers: { Accept: "application/json", "User-Agent": "pi-statusbar", ...headers }, redirect: "error", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } function finiteNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } /** 只读 Pi 当前凭据;旧版没有公开 getter 时按 Pi agentDir 约定回退,不自行刷新或写 auth.json。 */ function storedOAuth(providerId: string): Record | undefined { try { const credential = typeof piAgentRuntime.readStoredCredential === "function" ? piAgentRuntime.readStoredCredential(providerId) : JSON.parse(readFileSync(join( typeof piAgentRuntime.getAgentDir === "function" ? piAgentRuntime.getAgentDir() : process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), "auth.json", ), "utf8"))[providerId]; return credential?.type === "oauth" ? credential : undefined; } catch { return undefined; } } /** Codex OAuth JWT 的账户 header;只接受 header-safe 字符,不输出或保存 JWT/账户 ID。 */ function codexAccountId(token: string): string | undefined { try { const claims = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8")); const id = claims?.["https://api.openai.com/auth"]?.chatgpt_account_id; return typeof id === "string" && /^[\x21-\x7e]{1,256}$/.test(id) ? id : undefined; } catch { return undefined; } } /** 内置额度源(按 provider baseUrl 发现;额度认证与推理认证不一定相同) */ const QUOTA_SOURCES: QuotaSource[] = [ { id: "Codex", match: /^https:\/\/chatgpt\.com(?:\/|$)/, ttlMs: 5 * 60_000, async fetch(_origin, apiKey) { const headers: Record = { Authorization: `Bearer ${apiKey}` }; const accountId = codexAccountId(apiKey); if (accountId) headers["ChatGPT-Account-Id"] = accountId; const json = await fetchQuotaJson("https://chatgpt.com/backend-api/wham/usage", headers); const rows: QuotaRow[] = []; const details: string[] = []; for (const [window, fallback] of [ [json?.rate_limit?.primary_window, "5h"], [json?.rate_limit?.secondary_window, "周"], ] as const) { const pct = finiteNumber(window?.used_percent); if (pct == null || pct < 0 || pct > 100) continue; const seconds = finiteNumber(window?.limit_window_seconds); const label = seconds === 604800 ? "周" : seconds && seconds > 0 ? seconds % 86400 === 0 ? `${seconds / 86400}天` : `${Math.round(seconds / 3600)}h` : fallback; const reset = finiteNumber(window?.reset_at); const after = finiteNumber(window?.reset_after_seconds); const at = reset && reset > 0 ? reset * 1000 : after != null && after >= 0 ? Date.now() + after * 1000 : undefined; rows.push({ text: withReset(`${label} ${Math.round(pct)}%`, at), percent: pct }); details.push(`${label} 窗口: 已用 ${Math.round(pct)}%${resetDetail(at)}`); } if (!rows.length) return { seg: null, detail: "Codex 响应中无可用额度窗口" }; return { seg: { text: `Codex ${rows.map((r) => r.text).join("·")}`, rows, maxPercent: Math.max(...rows.map((r) => r.percent!)) }, detail: `Codex\n ${details.join("\n ")}`, }; }, }, { id: "Copilot", match: /^https:\/\/(?:api(?:\.[a-z0-9-]+)?\.githubcopilot\.com|copilot-api\.github\.com)(?:\/|$)/, ttlMs: 5 * 60_000, async resolveApiKey(ctx, providerId) { // 先由 Pi 完成正常的 OAuth 刷新,再只读最新凭据。access 是推理 proxy token, // refresh 才是 GitHub access token;不能把前者发给 api.github.com 的额度接口。 const resolved = await ctx.modelRegistry.getApiKeyForProvider(providerId); if (!resolved) return undefined; const credential = storedOAuth(providerId); if (credential) { if (credential.enterpriseUrl && credential.enterpriseUrl !== "github.com") throw new Error("Copilot Enterprise 额度暂不支持,请在企业控制台查看"); return typeof credential.refresh === "string" ? credential.refresh : undefined; } if (resolved.includes("proxy-ep=")) throw new Error("无法读取 Copilot 的 GitHub OAuth 凭据,请重新登录"); return resolved; }, async fetch(_origin, apiKey) { const json = await fetchQuotaJson("https://api.github.com/copilot_internal/user", { Authorization: `Bearer ${apiKey}`, "Editor-Version": "vscode/1.107.0", "Editor-Plugin-Version": "copilot-chat/0.35.0", "X-GitHub-Api-Version": "2026-06-01", }); const at = toResetAt(json?.quota_reset_date); const rows: QuotaRow[] = []; for (const [key, label] of [["premium_interactions", "高级"], ["chat", "聊天"], ["completions", "补全"]]) { const quota = json?.quota_snapshots?.[key]; if (quota?.unlimited === true) { rows.push({ text: `${label} ∞` }); continue; } const remainingPct = finiteNumber(quota?.percent_remaining); const limit = finiteNumber(quota?.entitlement); const remaining = finiteNumber(quota?.remaining); const pct = remainingPct != null && remainingPct >= 0 && remainingPct <= 100 ? 100 - remainingPct : limit != null && limit > 0 && remaining != null && remaining >= 0 && remaining <= limit ? (1 - remaining / limit) * 100 : undefined; if (pct == null) continue; rows.push({ text: withReset(`${label} ${Math.round(pct)}%`, at), percent: pct }); } if (!rows.length) return { seg: null, detail: "Copilot 响应中无可用 quota_snapshots" }; const percentages = rows.flatMap((r) => r.percent == null ? [] : [r.percent]); return { seg: { text: `Copilot ${rows.map((r) => r.text).join("·")}`, rows, maxPercent: percentages.length ? Math.max(...percentages) : undefined }, detail: `Copilot\n ${rows.map((r) => r.text).join("\n ")}${resetDetail(at)}`, }; }, }, { id: "xAI", match: /^https:\/\/(?:api\.x\.ai|cli-chat-proxy\.grok\.com)(?:\/|$)/, ttlMs: 5 * 60_000, async resolveApiKey(ctx, providerId) { const registry = ctx.modelRegistry; if (typeof registry.getProviderAuth === "function") { const resolved = await registry.getProviderAuth(providerId); if (!resolved?.auth.apiKey) return undefined; if (resolved.source !== "OAuth") throw new Error("xAI 额度需要 Pi OAuth 登录;API key 余额暂不支持"); return resolved.auth.apiKey; } const apiKey = await registry.getApiKeyForProvider(providerId); if (!apiKey) return undefined; if (!storedOAuth(providerId)) throw new Error("xAI 额度需要 Pi OAuth 登录;API key 余额暂不支持"); return apiKey; }, async fetch(_origin, apiKey) { const headers: Record = { Authorization: `Bearer ${apiKey}`, "X-XAI-Token-Auth": "xai-grok-cli", "x-grok-client-identifier": "pi-statusbar", }; // 仅从本次认证 /user 的返回取 transient ID,不用 JWT、用户配置或持久化身份。 const user = await fetchQuotaJson("https://cli-chat-proxy.grok.com/v1/user", headers); if (typeof user?.userId !== "string" || !/^[\x21-\x7e]{1,256}$/.test(user.userId)) throw new Error("无法确认 xAI 账户身份,未查询账单"); const json = await fetchQuotaJson("https://cli-chat-proxy.grok.com/v1/billing?format=credits", { ...headers, "x-userid": user.userId, }); const config = json?.config ?? json; // 官方账单金额以 cents 返回,protobuf 包装值形如 { val: number }。 const cents = (value: any): number | undefined => { const n = finiteNumber(value?.val ?? value); return n != null && n >= 0 && n <= Number.MAX_SAFE_INTEGER ? n : undefined; }; const limit = cents(config?.monthlyLimit); const used = cents(config?.used); const creditPct = finiteNumber(config?.creditUsagePercent); const pct = creditPct != null && creditPct >= 0 && creditPct <= 100 ? creditPct : limit != null && limit > 0 && used != null ? Math.min(100, used / limit * 100) : undefined; const at = toResetAt(config?.currentPeriod?.end ?? config?.billingPeriodEnd); const rows: QuotaRow[] = []; const details: string[] = []; if (pct != null) { const label = config?.currentPeriod?.type === "WEEKLY" ? "周" : config?.currentPeriod?.type === "MONTHLY" ? "月" : "额度"; rows.push({ text: withReset(`${label} ${Math.round(pct)}%`, at), percent: pct }); details.push(`订阅已用 ${Math.round(pct)}%${resetDetail(at)}`); } const demandCap = cents(config?.onDemandCap); const demandUsed = cents(config?.onDemandUsed); if (demandUsed != null) { const demandPct = demandCap != null && demandCap > 0 ? Math.min(100, demandUsed / demandCap * 100) : undefined; rows.push({ text: demandPct == null ? `按量 $${(demandUsed / 100).toFixed(1)}` : `按量 ${Math.round(demandPct)}%`, percent: demandPct }); details.push(`按量已用 $${(demandUsed / 100).toFixed(2)}${demandCap != null ? ` / 上限 $${(demandCap / 100).toFixed(2)}` : ""}`); } const prepaid = cents(config?.prepaidBalance); if (prepaid != null) { rows.push({ text: `余$${(prepaid / 100).toFixed(1)}` }); details.push(`预付余额 $${(prepaid / 100).toFixed(2)}`); } if (!rows.length) return { seg: null, detail: "xAI 响应中无可用账单额度或余额" }; const percentages = rows.flatMap((r) => r.percent == null ? [] : [r.percent]); return { seg: { text: `xAI ${rows.map((r) => r.text).join("·")}`, rows, maxPercent: percentages.length ? Math.max(...percentages) : undefined }, detail: `xAI\n ${details.join("\n ")}`, }; }, }, { id: "Radius", match: /^https:\/\/radius\.pi\.dev(?:\/|$)/, ttlMs: 5 * 60_000, async fetch(_origin, apiKey) { // 官方 live accounting 接口,不把预算总额、reserved 或历史充值当作可用余额。 const json = await fetchQuotaJson("https://radius.pi.dev/v1/billing", { Authorization: `Bearer ${apiKey}` }); const available = finiteNumber(json?.balance?.available); if (json?.ok !== true || json?.currency !== "USD" || available == null) return { seg: null, detail: "Radius 响应中无可用 USD 余额(可能缺少账单权限)" }; const charged = finiteNumber(json?.current_period?.actual_charged); return { seg: { text: `Radius $${available.toFixed(1)}` }, detail: `Radius 组织可用余额 $${available.toFixed(2)}${charged != null ? `;本月实际扣费 $${charged.toFixed(2)}` : ""}`, }; }, }, { id: "Kimi", match: /api\.kimi\.com/, ttlMs: 5 * 60_000, async fetch(origin, apiKey) { // 官方 CLI 同款接口;缺 User-Agent 会 404 const res = await fetch(`${origin}/coding/v1/usages`, { headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": "KimiCLI/1.6" }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json: any = await res.json(); const usage = json?.usage ?? {}; const parts: string[] = []; const rows: QuotaRow[] = []; let maxPercent: number | undefined; const shownLabels = new Set(); // 短窗口(如 5 小时);detail 可能只有 remaining 没有 used const UNIT_SEC: Record = { TIME_UNIT_MINUTE: 60, TIME_UNIT_HOUR: 3600, TIME_UNIT_DAY: 86400, }; for (const lim of json?.limits ?? []) { const d = lim?.detail ?? {}; const limit = parseFloat(d.limit); if (!(limit > 0)) continue; const used = d.used == null ? limit - parseFloat(d.remaining ?? limit) : parseFloat(d.used); const hours = ((lim.window?.duration ?? 0) * (UNIT_SEC[lim.window?.timeUnit] ?? 60)) / 3600; const label = hours >= 1 ? `${Math.round(hours)}h` : `${Math.max(1, Math.round(hours * 60))}m`; const pct = (used / limit) * 100; const at = toResetAt(d.resetTime); const text = withReset(`${label} ${Math.round(pct)}%`, at); parts.push(text); rows.push({ text, percent: pct, }); maxPercent = Math.max(maxPercent ?? 0, pct); shownLabels.add(label); } // 周配额 const weekLimit = parseFloat(usage.limit); if (weekLimit > 0) { const pct = (parseFloat(usage.used) / weekLimit) * 100; const at = toResetAt(usage.resetTime); const text = withReset(`周 ${Math.round(pct)}%`, at); parts.push(text); rows.push({ text, percent: pct, }); maxPercent = Math.max(maxPercent ?? 0, pct); shownLabels.add("周"); } // usages 补充窗口:月付套餐没有 usage 周字段,额度在 usages.limit_* //(used_ratio 为 0~1 已用比例);与 limits[]/usage 已展示的窗口按标签去重 const detailAdds: { label: string; pct: number; at?: number }[] = []; for (const [key, w] of Object.entries(json?.usages ?? {})) { const label = kimiUsageLabel(key); if (!label || shownLabels.has(label)) continue; const u = w as { used_ratio?: unknown; reset_time?: unknown } | null; const ratio = parseFloat(String(u?.used_ratio)); if (!Number.isFinite(ratio)) continue; const pct = ratio * 100; const at = toResetAt(u?.reset_time); const text = withReset(`${label} ${Math.round(pct)}%`, at); parts.push(text); rows.push({ text, percent: pct, }); maxPercent = Math.max(maxPercent ?? 0, pct); shownLabels.add(label); detailAdds.push({ label, pct, at }); } if (parts.length === 0) return { seg: null, detail: "响应中无用量数据" }; const level = json?.user?.membership?.level; let detail = `Kimi${level ? `(${level})` : ""}`; for (const lim of json?.limits ?? []) { const d = lim?.detail ?? {}; if (d.limit) detail += `\n 窗口: 剩余 ${d.remaining}/${d.limit}${resetDetail(toResetAt(d.resetTime))}`; } if (weekLimit > 0) detail += `\n 周配额: ${usage.used}/${usage.limit}${resetDetail(toResetAt(usage.resetTime))}`; for (const a of detailAdds) detail += `\n ${a.label}窗口: 已用 ${Math.round(a.pct)}%${resetDetail(a.at)}`; if (json?.limited) detail += "\n ⚠ 当前限流中"; return { seg: { text: `Kimi ${parts.join("·")}`, rows, maxPercent }, detail, }; }, }, { id: "GLM", match: /bigmodel\.cn|z\.ai/, ttlMs: 5 * 60_000, async fetch(origin, apiKey) { // 智谱监控接口认证为裸 Authorization(非 Bearer) const res = await fetch(`${origin}/api/monitor/usage/quota/limit`, { headers: { Authorization: apiKey, "Content-Type": "application/json" }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json: any = await res.json(); const limits: any[] = json?.data?.limits ?? []; const windows = limits.filter( (l) => l.type === "TOKENS_LIMIT" && typeof l.percentage === "number", ); if (windows.length === 0) return { seg: null, detail: "响应中无 token 窗口" }; const parts: string[] = []; const rows: QuotaRow[] = []; for (const l of windows) { const label = glmWindowLabel(l.unit ?? 0, l.number ?? 1); const at = toResetAt(l.nextResetTime); const text = withReset(`${label} ${Math.round(l.percentage)}%`, at); parts.push(text); rows.push({ text, percent: l.percentage, }); } const maxPercent = Math.max(...windows.map((l) => l.percentage)); let detail = `GLM${json?.data?.level ? `(${json.data.level})` : ""}`; for (const l of windows) detail += `\n token ${glmWindowLabel(l.unit ?? 0, l.number ?? 1)} 窗口: ${l.percentage}%${resetDetail(toResetAt(l.nextResetTime))}`; for (const l of limits) { if (l.type === "TIME_LIMIT") detail += `\n MCP 调用: ${l.currentValue}/${l.usage}`; } return { seg: { text: `GLM ${parts.join("·")}`, rows, maxPercent }, detail, }; }, }, { id: "DeepSeek", match: /deepseek\.com/, ttlMs: 10 * 60_000, async fetch(origin, apiKey) { const res = await fetch(`${origin}/user/balance`, { headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json: any = await res.json(); const info = json?.balance_infos?.[0]; if (!info) return { seg: null, detail: "响应中无余额信息" }; let symbol = `${info.currency} `; if (info.currency === "CNY") symbol = "¥"; else if (info.currency === "USD") symbol = "$"; const total = parseFloat(info.total_balance); let detail = `DeepSeek 余额 ${symbol}${info.total_balance}`; if (parseFloat(info.granted_balance) > 0) detail += `(赠送 ${symbol}${info.granted_balance})`; return { seg: { text: `DS ${symbol}${total.toFixed(1)}` }, detail }; }, }, { id: "OpenRouter", match: /openrouter\.ai/, ttlMs: 10 * 60_000, async fetch(origin, apiKey) { const res = await fetch(`${origin}/api/v1/credits`, { headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json: any = await res.json(); const total = json?.data?.total_credits; const used = json?.data?.total_usage; if (typeof total !== "number" || typeof used !== "number") return { seg: null, detail: "响应中无 credits 信息" }; const remain = total - used; return { seg: { text: `OR $${remain.toFixed(1)}`, maxPercent: total > 0 ? (used / total) * 100 : undefined, }, detail: `OpenRouter 已用 $${used.toFixed(2)} / $${total.toFixed(2)}`, }; }, }, { id: "OpenCode Go", // Go 订阅($10/月,$12/5h、$30/周、$60/月三窗口)。Zen 按量版(/zen/v1) // 没有用量 API(实测 404),故只匹配 /zen/go,刻意不含纯 opencode.ai。 match: /opencode\.ai\/zen\/go/, ttlMs: 5 * 60_000, async fetch(_origin, apiKey) { // 用量端点固定在 /zen/go/v1/usage,且只认 Bearer——与推理侧 /messages // 只认 x-api-key 正好相反,不能互换(参照 cc-switch coding_plan.rs 实测结论)。 const res = await fetch(OPENCODE_GO_USAGE_URL, { headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json", }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); // 403:key 有效(Zen 与 Go 共用同一把 workspace key)但该账号无 Go 订阅 if (res.status === 403) return { seg: null, detail: "API key 有效,但该账号未订阅 OpenCode Go(HTTP 403)", }; if (!res.ok) throw new Error(`HTTP ${res.status}`); const json: any = await res.json(); const usage = json?.usage; if (!usage) return { seg: null, detail: "响应中无 usage 数据" }; // 未文档化端点(上线后改过形态),逐窗口防御解析:percent 为已用整数百分比, // status=="rate-limited" 时上游已把 percent 钉在 100; // percent=0 时 resetsAt 是「now+窗口」占位值,丢弃不展示倒计时。 const WINDOWS: [string, string][] = [ ["rolling", "5h"], ["weekly", "周"], ["monthly", "月"], ]; const parts: string[] = []; const rows: QuotaRow[] = []; let maxPercent: number | undefined; for (const [key, label] of WINDOWS) { const pct = parseFloat(usage[key]?.percent); if (!Number.isFinite(pct)) continue; const at = pct > 0 ? toResetAt(usage[key].resetsAt) : undefined; const text = withReset(`${label} ${Math.round(pct)}%`, at); parts.push(text); rows.push({ text, percent: pct, }); maxPercent = Math.max(maxPercent ?? 0, pct); } if (parts.length === 0) return { seg: null, detail: "响应形态不认识(端点可能再次变更)" }; let detail = "OpenCode Go"; for (const [key, label] of WINDOWS) { const w = usage[key]; const pct = parseFloat(w?.percent); if (!Number.isFinite(pct)) continue; detail += `\n ${label} 窗口: 已用 ${Math.round(pct)}%${w?.status === "rate-limited" ? "(限流中)" : ""}${pct > 0 ? resetDetail(toResetAt(w?.resetsAt)) : ""}`; } return { seg: { text: `OC ${parts.join("·")}`, rows, maxPercent }, detail, }; }, }, { id: "MiniMax", match: /minimaxi\.com|minimax\.io/, ttlMs: 5 * 60_000, async fetch(origin, apiKey) { // 新路径 /v1/token_plan/remains;旧的 /v1/api/openplatform/coding_plan/remains // 已废弃(会落到要 cookie 的页面报错),国内 api.minimaxi.com / 国际 api.minimax.io 同构。 const res = await fetch(`${origin}/v1/token_plan/remains`, { headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json: any = await res.json(); const base = json?.base_resp; if (base && base.status_code !== 0) return { seg: null, detail: `API error (${base.status_code}): ${base.status_msg ?? "Unknown"}`, }; const remains: any[] = json?.model_remains ?? []; // 编程套餐条目:国际站 model_name 为 "general",国内站为具体模型名(minimax-m*); // video 等非编程模型跳过 const item = remains.find((r) => r?.model_name === "general") ?? remains.find( (r) => typeof r?.model_name === "string" && !r.model_name.includes("video"), ); if (!item) return { seg: null, detail: "响应中无编程套餐额度(可能未订阅 Coding Plan)", }; // 接口给的是「剩余百分比」,与其他源统一成已用百分比展示 const parts: string[] = []; const rows: QuotaRow[] = []; let maxPercent: number | undefined; let detail = "MiniMax Coding Plan"; const push = (label: string, remainPct: unknown, endMs: unknown) => { const remain = parseFloat(remainPct as string); if (!Number.isFinite(remain)) return; const pct = 100 - remain; const at = toResetAt(endMs); const text = withReset(`${label} ${Math.round(pct)}%`, at); parts.push(text); rows.push({ text, percent: pct, }); maxPercent = Math.max(maxPercent ?? 0, pct); detail += `\n ${label} 窗口: 剩余 ${Math.round(remain)}%${resetDetail(at)}`; }; push("5h", item.current_interval_remaining_percent, item.end_time); // 周桶仅当 current_weekly_status===1 时激活(无周限额的套餐该字段为 3,剩余恒 100,不应展示) if (item.current_weekly_status === 1) push("周", item.current_weekly_remaining_percent, item.weekly_end_time); if (parts.length === 0) return { seg: null, detail: "响应中无可解析的额度窗口" }; return { seg: { text: `MM ${parts.join("·")}`, rows, maxPercent }, detail, }; }, }, { id: "Moonshot", // 开放平台按量余额(api.moonshot.cn / .ai),与 Kimi Coding(api.kimi.com)是两套 match: /api\.moonshot\.(cn|ai)/, ttlMs: 10 * 60_000, async fetch(origin, apiKey) { const res = await fetch(`${origin}/v1/users/me/balance`, { headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json: any = await res.json(); const data = json?.data; const avail = parseFloat(data?.available_balance); if (!Number.isFinite(avail)) return { seg: null, detail: "响应中无余额信息" }; const symbol = origin.includes(".cn") ? "¥" : "$"; let detail = `Moonshot 余额 ${symbol}${avail.toFixed(2)}`; if (parseFloat(data?.voucher_balance) > 0) detail += `(代金券 ${symbol}${data.voucher_balance})`; return { seg: { text: `MS ${symbol}${avail.toFixed(1)}` }, detail }; }, }, { id: "SiliconFlow", match: /siliconflow\.(cn|com)/, ttlMs: 10 * 60_000, async fetch(origin, apiKey) { const res = await fetch(`${origin}/v1/user/info`, { headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json: any = await res.json(); const data = json?.data; const total = parseFloat(data?.totalBalance); if (!Number.isFinite(total)) return { seg: null, detail: "响应中无余额信息" }; // .cn 人民币 / .com 美元;chargeBalance 充值、balance 赠送 const symbol = origin.includes(".cn") ? "¥" : "$"; let detail = `SiliconFlow 余额 ${symbol}${total.toFixed(2)}`; if (parseFloat(data?.balance) > 0) detail += `(赠送 ${symbol}${data.balance})`; return { seg: { text: `SF ${symbol}${total.toFixed(1)}` }, detail }; }, }, { id: "StepFun", match: /stepfun\.(com|ai)/, ttlMs: 10 * 60_000, async fetch(origin, apiKey) { // 响应:{ object, type, balance, total_cash_balance, total_voucher_balance } const res = await fetch(`${origin}/v1/accounts`, { headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json: any = await res.json(); const balance = parseFloat(json?.balance); if (!Number.isFinite(balance)) return { seg: null, detail: "响应中无余额信息" }; let detail = `StepFun 余额 ¥${balance.toFixed(2)}`; if (parseFloat(json?.total_voucher_balance) > 0) detail += `(代金券 ¥${json.total_voucher_balance})`; return { seg: { text: `Step ¥${balance.toFixed(1)}` }, detail }; }, }, { id: "Novita", match: /novita\.ai/, ttlMs: 10 * 60_000, async fetch(origin, apiKey) { const res = await fetch(`${origin}/v3/user/balance`, { headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json: any = await res.json(); // 金额单位为 0.0001 USD,需除以 10000 const raw = parseFloat(json?.availableBalance); if (!Number.isFinite(raw)) return { seg: null, detail: "响应中无余额信息" }; const avail = raw / 10000; return { seg: { text: `NV $${avail.toFixed(1)}` }, detail: `Novita 余额 $${avail.toFixed(2)}`, }; }, }, { id: "CommandCode", // Command Code(commandcode.ai)订阅:alpha 计费接口(与官方 pi-commandcode-provider // 的 src/quota.ts 同组端点):whoami 拿 orgId → credits(5h/周窗口 + 积分余额) // + subscriptions(套餐/账期)+ usage/summary(本账期用量)。窗口用量单位是 // credits(≈美元),resetAt 为秒级时间戳(毫秒值也能兼容识别)。 match: /commandcode\.ai/, ttlMs: 5 * 60_000, async fetch(origin, apiKey) { const headers = { Accept: "application/json", Authorization: `Bearer ${apiKey}`, }; const getJson = async (path: string): Promise => { const res = await fetch(`${origin}${path}`, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }; /** resetAt 兼容秒/毫秒/ISO,统一成毫秒 */ const toResetMs = (v: unknown): number | undefined => { let ts: number | undefined; if (typeof v === "number" && Number.isFinite(v) && v > 0) ts = v; else if (typeof v === "string" && v.trim()) { const s = v.trim(); ts = /^\d+$/.test(s) ? Number(s) : Date.parse(s); } if (ts === undefined || !Number.isFinite(ts) || ts <= 0) return undefined; return ts >= 1e12 ? ts : ts * 1000; }; const who = await getJson("/alpha/whoami"); const orgId = typeof who?.org?.id === "string" ? who.org.id : undefined; const q = orgId ? `?orgId=${encodeURIComponent(orgId)}` : ""; // credits/subscriptions/summary 任一失败不拖垮整体(无套餐的账号 subscriptions 可能 404) const [creditsRaw, subRaw, summaryRaw] = await Promise.all([ getJson(`/alpha/billing/credits${q}`).catch(() => null), getJson(`/alpha/billing/subscriptions${q}`).catch(() => null), getJson(`/alpha/usage/summary${q}`).catch(() => null), ]); const parts: string[] = []; const rows: QuotaRow[] = []; let maxPercent: number | undefined; const detailAdds: string[] = []; // 5h/周窗口:used/cap 为 credits,占比 = used/cap const WINDOWS: [string, string][] = [ ["fiveHour", "5h"], ["weekly", "周"], ]; for (const [key, label] of WINDOWS) { const w = creditsRaw?.windowLimits?.[key]; const used = typeof w?.used === "number" ? w.used : undefined; const cap = typeof w?.cap === "number" ? w.cap : undefined; if (used === undefined || cap === undefined || (used === 0 && cap === 0)) continue; const pct = cap > 0 ? (used / cap) * 100 : 0; const at = toResetMs(w?.resetAt); const text = withReset(`${label} ${Math.round(pct)}%`, at); parts.push(text); rows.push({ text, percent: pct }); maxPercent = Math.max(maxPercent ?? 0, pct); detailAdds.push( ` ${label} 窗口: ${used.toFixed(2)}/${cap.toFixed(2)} credits(${Math.round(pct)}%)${resetDetail(at)}`, ); } // 积分余额:monthly + purchased + free(均为**剩余**值,随用量递减;剩余+本周期已用≈月付总额) const c = creditsRaw?.credits; const monthly = typeof c?.monthlyCredits === "number" ? c.monthlyCredits : 0; const purchased = typeof c?.purchasedCredits === "number" ? c.purchasedCredits : 0; const free = typeof c?.freeCredits === "number" ? c.freeCredits : 0; const remaining = monthly + purchased + free; // 「余$」前缀:紧跟在窗口百分比后面,不加前缀会被读成「已花」 if (c) parts.push(`余$${remaining.toFixed(1)}`); // 右侧浮层(panel 变体)只渲染 rows 逐行输出,seg.text 整段被丢弃: // 余额必须自己占一行,否则面板只会显示窗口、看不到余额。 if (c) rows.push({ text: `余$${remaining.toFixed(1)}` }); const sub = subRaw?.data; const plan = typeof sub?.planId === "string" ? sub.planId : ""; const status = typeof sub?.status === "string" ? sub.status : ""; let login = ""; if (typeof who?.org?.login === "string") login = who.org.login; else if (typeof who?.user?.userName === "string") login = who.user.userName; if (parts.length === 0) return { seg: null, detail: "响应中无窗口/积分数据" }; let planTag = ""; if (plan) planTag = `(${plan}${status ? ` ${status}` : ""})`; let detail = `CommandCode${planTag}${login ? ` · ${login}` : ""}`; for (const line of detailAdds) detail += `\n${line}`; if (c) { const sources = [ `月付 $${monthly.toFixed(2)}`, `购买 $${purchased.toFixed(2)}`, ]; if (free > 0) sources.push(`免费 $${free.toFixed(2)}`); detail += `\n 剩余积分: $${remaining.toFixed(2)}(${sources.join(" + ")})`; } if (typeof sub?.currentPeriodEnd === "string") detail += `\n 账期截止: ${sub.currentPeriodEnd}`; const totalCost = typeof summaryRaw?.totalCost === "number" ? summaryRaw.totalCost : undefined; if (totalCost !== undefined) { const cnt = typeof summaryRaw?.totalCount === "number" ? ` · ${summaryRaw.totalCount} 次` : ""; // 月度总额 = 剩余 + 本周期已用(与官方 /usage 面板同口径) const totalPool = c ? `;月度总额 $${(remaining + totalCost).toFixed(2)}` : ""; detail += `\n 本周期已用: $${totalCost.toFixed(2)}${cnt}${totalPool}`; } return { seg: { text: `CC ${parts.join("·")}`, rows, maxPercent }, detail, }; }, }, { id: "Magpie", // magpie(usemagpie.ai)在本机跑一个 LLM 网关:pi 指向它时,上游账号的额度只有它知道 // (magpie quota --json 与 GET /v1/magpie/quotas 是同一份数据)。 // 只匹配回环地址、不限定端口(端口见 ~/.config/magpie/settings.json 的 port), // 因此先问 /api/hello 认身份:本机别的服务(vLLM、Ollama…)不会被当成额度源。 // 非回环的 magpie(局域网共享)不匹配:那条路要网关 key,暂不猜。 match: /^https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?(?:\/|$)/, ttlMs: 60_000, // footer 显示哪个账号取决于本会话当前模型(route 定位),切模型就作废缓存,不等 TTL modelAware: true, async fetch(origin, apiKey, info) { const headers = { Authorization: `Bearer ${apiKey}` }; // 文案里只用 host(origin 已在 resolveQuotaBindings 里验过,这里不重复 new URL) const host = origin.replace(/^https?:\/\//, "").replace(/\/$/, ""); const hello = await fetchQuotaJson(`${origin}/api/hello`, headers); if (hello?.name !== "magpie") return { seg: null, detail: `本机 ${host} 不是 magpie(/api/hello 未回 name=magpie)`, }; // route 说本会话这一轮实际被服务的是哪个上游模型(magpie 按 X-Magpie-Session 记的), // 拿不到就退回全局 last;与额度并行取,失败不影响额度 const routeUrl = info?.sessionId ? `${origin}/v1/magpie/route?session=${encodeURIComponent(info.sessionId)}` : ""; const [json, route] = await Promise.all([ fetchQuotaJson(`${origin}/v1/magpie/quotas`, headers), routeUrl ? fetchQuotaJson(routeUrl, headers).catch(() => null) : Promise.resolve(null), ]); const items: any[] = Array.isArray(json?.data) ? json.data : []; if (!items.length) return { seg: null, detail: "magpie 未读到任何订阅/套餐/余额" }; /** magpie 的窗口名是英文(5 hours / 7 days / Month / Cursor Models…):通用时间窗归一化,其余原样 */ const windowLabel = (name: unknown): string => { const raw = typeof name === "string" ? name.trim() : ""; switch (raw.toLowerCase()) { case "5 hours": case "5 hour": case "5h": return "5h"; case "7 days": case "7d": case "weekly": case "week": return "周"; case "month": case "monthly": return "月"; default: return raw || "额度"; } }; /** 账号名优先用用户自己的命名(alias),否则 provider 给的名字 */ const accountName = (q: any): string => { for (const key of ["alias", "name", "provider"]) { const v = q?.[key]; if (typeof v === "string" && v.trim()) return v.trim(); } return "magpie"; }; /** 一个账号的窗口/余额:footer 片段 + 面板行 + 详情行。used/remaining 已是 0~100 百分比 */ const read = (q: any) => { const parts: string[] = []; const rows: QuotaRow[] = []; const details: string[] = []; let maxPercent: number | undefined; for (const w of Array.isArray(q?.windows) ? q.windows : []) { const label = windowLabel(w?.name); if (w?.unlimited === true) { parts.push(`${label} ∞`); rows.push({ text: `${label} ∞` }); details.push(`${label} 窗口: 不限量`); continue; } const usedPct = finiteNumber(w?.used); const remainPct = finiteNumber(w?.remaining); // used 缺失时用 remaining 反推;两者都没有就不展示该窗口,不猜数字 let used: number | undefined; if (usedPct != null && usedPct >= 0 && usedPct <= 100) used = usedPct; else if (remainPct != null && remainPct >= 0 && remainPct <= 100) used = 100 - remainPct; if (used == null) continue; const at = toResetAt(w?.resetsAt); const text = withReset(`${label} ${Math.round(used)}%`, at); parts.push(text); rows.push({ text, percent: used }); maxPercent = Math.max(maxPercent ?? 0, used); const own = typeof w?.display === "string" && w.display.trim() ? `(${w.display.trim()})` : ""; details.push(`${label} 窗口: 已用 ${Math.round(used)}%${own}${resetDetail(at)}`); } const balance = typeof q?.balance === "string" ? q.balance.trim() : ""; if (balance) { // 余额自己占一行:右侧面板只渲染 rows,混进 parts 会被丢掉(同 CommandCode) parts.push(balance); rows.push({ text: balance }); details.push(`余额: ${balance}`); } return { parts, rows, details, maxPercent }; }; /** magpie 的模型 id 是 `provider/model`;`group` 是路由组,不是上游 provider */ const providerOf = (modelId: unknown): string => { const raw = typeof modelId === "string" ? modelId : ""; const slash = raw.indexOf("/"); const p = slash > 0 ? raw.slice(0, slash) : ""; return p === "group" ? "" : p; }; const servedModel = typeof route?.route?.model === "string" ? route.route.model : ""; const askedModel = info?.modelId ?? ""; // 本会话实际走的上游:优先 route 的服务模型,其次当前模型自己的前缀 const wanted = providerOf(servedModel) || providerOf(askedModel); const all = items.map((q) => ({ q, r: read(q) })); const newest = (list: typeof all) => [...list].sort( (a, b) => (toResetAt(b.q?.lastServedAt) ?? 0) - (toResetAt(a.q?.lastServedAt) ?? 0), ); const mine = wanted ? all.filter((e) => e.q?.provider === wanted) : []; // magpie 是路由器,footer 只能显示一个账号,所以按「本会话实际走的上游」选: // 1) 知道走哪个上游 → 只看它的账号(多账号取 last / 最近服务过的) // 2) 定位不到(还没 route、或模型不是 magpie 的上游)→ 退回全局 last // 选中的账号没有窗口/余额时不拿别的 provider 的数字冒充(见下) let picked = mine.find((e) => e.q?.last === true) ?? newest(mine)[0]; // 说清这个账号是怎么定位到的:route 的服务模型 > 当前模型自己的前缀 let basis = ""; if (picked) basis = servedModel ? `本会话 route: ${servedModel}` : `当前模型 ${askedModel}`; if (!picked && !wanted) { picked = all.find((e) => e.q?.last === true) ?? newest(all.filter((e) => e.r.parts.length > 0))[0] ?? all[0]; if (picked) basis = "未定位到本会话所用账号,按 magpie 最近服务过的显示"; } const name = picked ? accountName(picked.q) : ""; // 其余账号只报它用得最多的那个窗口:一行能扫出「谁快用完了」,不把每个窗口都铺开 const others: string[] = []; for (const e of all) { if (e === picked) continue; const hot = e.r.rows.reduce( (a, b) => (b.percent != null && (a == null || b.percent > (a.percent ?? -1)) ? b : a), undefined, ); const body = hot ? hot.text : e.r.parts.join("·"); others.push(`${accountName(e.q)} ${body || "—"}`); } const othersLine = others.length ? `\n 其余 ${others.length} 个账号: ${others.join(";")}` : ""; let detail = `Magpie${typeof hello.version === "string" ? ` v${hello.version}` : ""}(${host})`; if (!picked || !picked.r.parts.length) { // 不显示假额度:说清是哪一步断了,也不把别的账号的数字借过来 if (askedModel || servedModel) detail += `\n 当前模型: ${servedModel || askedModel}`; if (wanted && !mine.length) detail += `\n magpie 里没有 ${wanted} 的额度卡`; else if (picked) detail += `\n ${name} 无可用窗口或余额`; else detail += "\n magpie 没有可展示的窗口或余额"; if (picked && typeof picked.q?.error === "string" && picked.q.error) detail += `\n ⚠ ${picked.q.error}`; return { seg: null, detail: `${detail}${othersLine}` }; } const current = picked.q; const cur = picked.r; const KIND_LABELS: Record = { subscription: "订阅", plan: "套餐", balance: "余额" }; const kindText = [ KIND_LABELS[String(current?.kind ?? "")] ?? "", typeof current?.plan === "string" && current.plan.trim() ? current.plan.trim() : "", ] .filter(Boolean) .join(" · "); const user = typeof current?.user === "string" && current.user.trim() ? current.user.trim() : ""; const readAt = fmtResetClock(toResetAt(current?.readAt)); detail += `\n 当前账号: ${name}${kindText ? `(${kindText})` : ""}${user ? ` · ${user}` : ""}${basis ? ` · ${basis}` : ""}`; for (const line of cur.details) detail += `\n ${line}`; // 额度是 magpie 的缓存读数,标出它是什么时候读的,别把旧数字当实时 if (readAt) detail += `\n 读数: ${readAt}`; if (typeof current?.error === "string" && current.error) detail += `\n ⚠ ${current.error}`; detail += othersLine; return { seg: { text: `${name} ${cur.parts.join("·")}`, rows: cur.rows, maxPercent: cur.maxPercent }, detail, }; }, }, ]; /** JSON 存储的原子写缝:先写临时文件再 rename,崩溃不会留半个 JSON。 * 用于单价缓存;payload 走模板串,统一末尾换行 */ function writeJsonAtomic(path: string, data: unknown): void { mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.tmp-${process.pid}`; const payload = `${JSON.stringify(data)}\n`; writeFileSync(tmp, payload); renameSync(tmp, path); } export default function (pi: ExtensionAPI) { // 用户开关:/statusbar 切换;新会话按此恢复 let userWants = true; // 当前布局模式:session_start 重读配置时重置,/statusbar layout 运行时切换 let layoutMode: LayoutMode = config.layout; // 当前会话的扩展上下文,setFooter 的 render 闭包通过它读取会话数据 let currentCtx: ExtensionContext | null = null; // 当前 footer 绑定的 TUI,异步数据到位后请求重绘 let activeTui: TUI | null = null; // footer factory 提供的数据源与主题,右侧面板复用(仅 setFooter factory 可拿到 footerData) let activeFooterData: ReadonlyFooterDataProvider | null = null; // 用量缓存:仅在分支条目数、末条目或单价表变化时重算,避免流式输出期间每帧全量遍历 let cacheKey = ""; let cachedUsage: UsageStats = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0, cost: 0, }; // 首 token 耗时(TTFT):before_provider_request 到首个 assistant message_start let reqStartTs = 0; let lastTtftMs: number | undefined; // 输出吞吐(tok/s):assistant 首 token 到 message_end 的输出 token 数 / 生成耗时 let firstTokTs = 0; let lastTps: number | undefined; // 生成中按 delta 字符估算的实时吞吐;liveTs 为最近一次 delta 时刻,长时间无增量则不再显示 let liveTokens = 0; let liveTs = 0; // 实时单价表(models.dev),key 为 "provider:modelId" let priceTable: Record = {}; // 每个键的匹配来源(与 priceTable 同步维护,随缓存持久化) let priceMeta: Record = {}; // 最近一次联网拉取建立的 models.dev 同名索引(/statusbar prices 提示用,仅内存) let lastById = new Map(); let priceStamp = "init"; let priceSource = "models.json"; let priceInflight = false; // ---------- 订阅额度 ---------- interface QuotaBinding { source: QuotaSource; providerId: string; origin: string; } interface QuotaState { seg: QuotaSeg | null; detail: string; fetchedAt: number; inflight: boolean; /** 本次结果对应的模型 id:modelAware 的源靠它判断切模型后要不要立刻重拉 */ modelKey?: string; /** 进行中的请求本身,供并发调用方等待同一份结果 */ inflightTask?: Promise; } const bindings: QuotaBinding[] = []; // 以 providerId 为 key:同一额度源(如 api.kimi.com)可能挂多个 provider, // 每个 provider 各绑一份、各自拉取与缓存,渲染时只取当前 provider 那份 const quotaStates = new Map(); /** 从可用模型反查 provider baseUrl 匹配额度源(models.json 的自定义 provider 不在 getRegisteredProviderIds 里) */ function resolveQuotaBindings(ctx: ExtensionContext): void { bindings.length = 0; quotaStates.clear(); for (const model of ctx.modelRegistry.getAvailable()) { if (!model.baseUrl || bindings.some((b) => b.providerId === model.provider)) continue; for (const source of QUOTA_SOURCES) { if (source.match.test(model.baseUrl)) { // baseUrl 非法时 new URL 会抛 TypeError,跳过该 provider 的额度绑定 try { bindings.push({ source, providerId: model.provider, origin: new URL(model.baseUrl).origin, }); } catch { // 忽略无法解析的 baseUrl } break; } } } } /** 额度是否该重拉:过了 TTL,或 modelAware 的源发现模型换了(不等 TTL) */ function quotaExpired( b: QuotaBinding, state: QuotaState | undefined, ctx: ExtensionContext, ): boolean { if (!state) return true; if (Date.now() - state.fetchedAt >= b.source.ttlMs) return true; return b.source.modelAware === true && state.modelKey !== ctx.model?.id; } /** 当前会话 id:magpie 靠它问 /v1/magpie/route 定位本会话实际走的账号 */ function currentSessionId(ctx: ExtensionContext): string | undefined { try { return ctx.sessionManager.getSessionId(); } catch { return undefined; } } /** 带 TTL 与 inflight 去重的额度拉取;失败静默(保留旧值/空段) */ async function refreshQuota( b: QuotaBinding, ctx: ExtensionContext, force: boolean, ): Promise { let state = quotaStates.get(b.providerId); if (!state) { // 首次拉取:fetchedAt=0 视为已过期,立即请求 state = { seg: null, detail: "", fetchedAt: 0, inflight: false }; quotaStates.set(b.providerId, state); } if (state.inflight) return state.inflightTask ?? Promise.resolve(); if (!force && !quotaExpired(b, state, ctx)) return; // 先记下本次结果的模型:失败时不会因 modelChanged 反复重试 state.modelKey = ctx.model?.id; state.inflight = true; state.inflightTask = (async () => { try { const apiKey = b.source.resolveApiKey ? await b.source.resolveApiKey(ctx, b.providerId) : await ctx.modelRegistry.getApiKeyForProvider(b.providerId); if (!apiKey) throw new Error("provider 未配置 API key"); const r = await b.source.fetch(b.origin, apiKey, { modelId: ctx.model?.id, sessionId: currentSessionId(ctx), }); state.seg = r.seg; state.detail = r.detail; state.fetchedAt = Date.now(); } catch (err) { // 拉取失败:保留旧 seg,1 分钟后允许重试 state.detail = `查询失败: ${err instanceof Error ? err.message : String(err)}`; state.fetchedAt = Date.now() - b.source.ttlMs + 60_000; } finally { state.inflight = false; state.inflightTask = undefined; activeTui?.requestRender(); } })(); return state.inflightTask; } // ---------- 实时单价(models.dev) ---------- /** 读取磁盘缓存的价格表(24h 内有效) */ function loadPriceCache(): { fetchedAt: number; prices: Record; meta?: Record; } | null { try { const raw = JSON.parse(readFileSync(PRICE_CACHE_FILE, "utf8")); if (raw?.fetchedAt && raw?.prices) return raw; } catch { // 首次使用或缓存损坏,忽略 } return null; } /** * 把模型注册表里的非零单价(models.json 手写 cost / pi 内置目录)叠加到价格表。 * 只覆盖 auto 猜测或未匹配的条目,不碰 priceMap 显式映射与 provider 直配; * 同时清掉缓存里已失效的 runtime 条目(用户从 models.json 删了 cost)。 * 该数据本地可得、不随 24h 缓存过期,任何路径(含缓存命中与网络失败)都执行。 */ function applyRuntimePrices(ctx: ExtensionContext): void { const runtime = new Map(); for (const m of ctx.modelRegistry.getAvailable()) { if (!m?.provider || !m?.id) continue; const c = m.cost; if (!isZeroCost(c)) runtime.set(`${m.provider}:${m.id}`, c); } for (const [key, meta] of Object.entries(priceMeta)) { if (meta.source === "runtime" && !runtime.has(key)) { delete priceTable[key]; delete priceMeta[key]; } } for (const [key, c] of runtime) { const src = priceMeta[key]?.source; if (src === "priceMap" || src === "provider") continue; priceTable[key] = c; priceMeta[key] = { source: "runtime" }; } } /** 拉取 models.dev 全量单价并投影到本地 provider:模型 键;失败静默保留旧值 */ async function refreshPrices( ctx: ExtensionContext, force: boolean, ): Promise { if (priceInflight) return; const cached = loadPriceCache(); if (cached) { priceTable = cached.prices; priceMeta = cached.meta ?? {}; priceStamp = `cache:${cached.fetchedAt}`; priceSource = "models.dev(缓存)"; } // 注册表单价本地可得,任何路径都先叠加(含缓存命中与后续网络失败) applyRuntimePrices(ctx); if (!force && cached && Date.now() - cached.fetchedAt < PRICE_TTL_MS) return; priceInflight = true; try { const res = await fetch(MODELS_DEV_URL, { signal: AbortSignal.timeout(20_000), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json: any = await res.json(); const providers = json?.providers ?? json; const next: Record = {}; const nextMeta: Record = {}; // 1) 显式 priceMap 优先:key 为本地 "provider:model" 或裸 "model" for (const [key, [pname, mid]] of Object.entries(config.priceMap)) { const c = providers?.[pname]?.models?.[mid]?.cost; if (c) { next[key] = toModelCost(c); nextMeta[key] = { source: "priceMap", via: `${pname}/${mid}` }; } } // 2) models.dev 全量按模型 id 建索引(同 id 可能有多家 provider 提供,记录来源) const byId = new Map(); for (const [pid, p] of Object.entries(providers ?? {})) { for (const [id, m] of Object.entries(p?.models ?? {})) { if (!m?.cost) continue; const k = id.toLowerCase(); const entry = { pid, id, cost: m.cost }; const list = byId.get(k); if (list) list.push(entry); else byId.set(k, [entry]); } } lastById = byId; // 3) 本地可用模型自动投影,按顺序取第一个命中: // priceMap 裸 model 映射 → models.dev 同名 provider 直配 → 注册表单价(models.json/内置目录) // → models.dev 全量同名 id 自动匹配(官方 provider 优先,排除 0 价订阅/免费端点,其余取最便宜) for (const m of ctx.modelRegistry.getAvailable()) { if (!m?.provider || !m?.id) continue; const key = `${m.provider}:${m.id}`; if (next[key]) continue; const bare = config.priceMap[m.id]; const mapped = bare ? providers?.[bare[0]]?.models?.[bare[1]]?.cost : undefined; if (mapped) { next[key] = toModelCost(mapped); nextMeta[key] = { source: "priceMap", via: `${bare[0]}/${bare[1]}` }; continue; } const direct = providers?.[m.provider]?.models?.[m.id]?.cost; if (direct) { next[key] = toModelCost(direct); nextMeta[key] = { source: "provider", via: `${m.provider}/${m.id}` }; continue; } const runtime = m.cost; if (!isZeroCost(runtime)) { next[key] = runtime; nextMeta[key] = { source: "runtime" }; continue; } const candidates = byId.get(m.id.toLowerCase()); if (!candidates?.length) continue; const nonZero = candidates.filter((e) => !isZeroCost(e.cost)); // 全部 0 价时仍取其一(订阅制端点确实免费),由 meta.allZero 标记供 /statusbar prices 提示 const pool = nonZero.length ? nonZero : candidates; const official = pool.filter((e) => OFFICIAL_PROVIDERS.has(e.pid)); const finalPool = official.length ? official : pool; const pick = finalPool.reduce((a, b) => (a.cost.input ?? 0) + (a.cost.output ?? 0) <= (b.cost.input ?? 0) + (b.cost.output ?? 0) ? a : b, ); next[key] = toModelCost(pick.cost); nextMeta[key] = { source: "auto", via: `${pick.pid}/${pick.id}`, candidates: candidates.length, droppedZero: candidates.length - pool.length || undefined, allZero: nonZero.length === 0 || undefined, }; } if (Object.keys(next).length === 0) throw new Error("未匹配到任何模型单价"); priceTable = next; priceMeta = nextMeta; priceStamp = `net:${Date.now()}`; priceSource = "models.dev(实时)"; try { writeJsonAtomic(PRICE_CACHE_FILE, { fetchedAt: Date.now(), prices: next, meta: nextMeta, }); } catch { // 写缓存失败不影响使用 } } catch { // 拉取失败:保留缓存/注册表/models.json 兜底 if (!cached) priceSource = "models.json"; } finally { priceInflight = false; activeTui?.requestRender(); } } /** 当前模型生效单价:models.dev 实时优先,回落 models.json 的 cost 字段 */ function ratesFor(ctx: ExtensionContext): ModelCost | undefined { const model = ctx.model; if (!model) return undefined; return priceTable[`${model.provider}:${model.id}`] ?? model.cost; } /** 单条消息的用量切片:阶梯定价按单次请求判定,所以计价的最小单位是「一条消息」 */ type UsageSlice = Pick< UsageStats, "input" | "output" | "cacheRead" | "cacheWrite" > & { cacheWrite1h?: number }; /** * 按单价计算花费(含阶梯定价与 1h 缓存写,逻辑与 pi-ai 的 calculateCost 一致)。 * rates 为空时返回 fallback(pi 自己算好的 usage.cost.total)。 */ function calcCost( rates: ModelCost | undefined, u: UsageSlice, fallback: number, ): number { if (!rates) return fallback; let r: ModelCostRates = rates; // 阶梯定价按「本次请求」的输入总量判定,不是会话累计量 const inputTokens = u.input + u.cacheRead + u.cacheWrite; let matched = -1; for (const t of rates.tiers ?? []) { if (inputTokens > t.inputTokensAbove && t.inputTokensAbove > matched) { r = t; matched = t.inputTokensAbove; } } // Anthropic 的 1h 缓存写按 2× input 计费,其余按 cacheWrite 单价 const longWrite = Math.min(u.cacheWrite1h ?? 0, u.cacheWrite); const shortWrite = u.cacheWrite - longWrite; return ( (r.input / 1_000_000) * u.input + (r.output / 1_000_000) * u.output + (r.cacheRead / 1_000_000) * u.cacheRead + ((r.cacheWrite ?? 0) * shortWrite + (r.input ?? 0) * 2 * longWrite) / 1_000_000 ); } // ---------- 会话用量 ---------- /** * 汇总当前分支上全部 assistant 消息的用量(含工具内部调用)。 * 花费逐条计价:每条消息用它自己的 provider:model 对应单价重算(查不到则用 pi 算好的 * usage.cost.total),因此会话中途切换模型时历史轮次不会被新模型单价重算, * 长短上下文混用的会话也不会被会话级累计量错误抬价。 */ function computeUsage(ctx: ExtensionContext): UsageStats { const branch = ctx.sessionManager.getBranch(); const key = `${ctx.sessionManager.getSessionId()}:${branch.length}:${branch.at(-1)?.id ?? ""}:${priceStamp}`; if (key === cacheKey) return cachedUsage; const stats: UsageStats = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0, cost: 0, }; for (const e of branch) { if (e.type !== "message" || e.message.role !== "assistant") continue; const msg = e.message as AssistantMessage; const usage = msg.usage; if (!usage) continue; stats.input += usage.input ?? 0; stats.output += usage.output ?? 0; stats.cacheRead += usage.cacheRead ?? 0; stats.cacheWrite += usage.cacheWrite ?? 0; stats.cacheWrite1h += usage.cacheWrite1h ?? 0; // 产生这条消息的模型:消息自带 provider/model,单价表按同一个键取 const rates = msg.provider && msg.model ? priceTable[`${msg.provider}:${msg.model}`] : undefined; stats.cost += calcCost(rates, usage, usage.cost?.total ?? 0); } cacheKey = key; cachedUsage = stats; return cachedUsage; } /** * 终端标题:pi · 会话名(未命名时只显示 pi),与 opencode 等其他 agent 区分 * Cursor/VS Code 默认 `terminal.integrated.tabs.title: "${process}"`,看不到这里设的标题; * 已在该设置里改成 `"${sequence}"`,改后集成终端显示的就是这里的标题。 */ function updateTitle(ctx: ExtensionContext): void { if (!ctx.hasUI) return; const name = ctx.sessionManager.getSessionName(); ctx.ui.setTitle(name ? `pi · ${name}` : "pi"); } // ---------- 布局:底部单行 / 右侧悬浮面板 ---------- /** 右侧面板生命周期:done 即关闭销毁(overlay 不可复用),alive 表示已创建/创建中 */ let panelDone: (() => void) | null = null; let panelAlive = false; let panelPending = false; /** 当前宽度下是否应由右侧浮层接管展示(仅 auto / right;分栏族走 splitActive) */ function panelActive(width: number): boolean { return ( layoutMode === "right" || (layoutMode === "auto" && width >= config.autoMinWidth) ); } /** * 当前宽度下是否应由右侧分栏接管展示。 * split 是显式选择:只看 splitWidthOk(不套 auto 阈值); * auto-split 还要求 ≥ autoMinWidth,并同样保证聊天区至少 24 列。 */ function splitActive(width: number): boolean { if (layoutMode === "split") return splitWidthOk(width); if (layoutMode === "auto-split") return width >= config.autoMinWidth && splitWidthOk(width); return false; } /** * 分栏过渡帧:分栏族布局下、分栏马上要装上但还没装好。 * 此时让浮层先顶住,避免切换过程中出现“既无分栏也无浮层”的空帧; * 反过来,regular 模式/缺 HStack 时永远为 false —— 那种情况下分栏根本装不上, * 浮层必须隐掉(存活的可视浮层会让 pi 拒绝切换 TUI mode,用户就没法去 /settings 改 fullscreen)。 */ function splitTransitionFrame(width: number): boolean { if (!isSplitLike(layoutMode) || !splitActive(width)) return false; const tui = activeTui; if (!tui || !splitCapable(tui)) return false; return !splitInstalled(tui); } /** * 构建 footer 与右侧面板共用的状态段(逻辑与原单行 footer 一致)。 * variant=footer:用量/花费合为一段,TTFT 带 ⏱ 前缀,ctx 未知时显示 "ctx —"; * variant=panel:Usage 与 Cost 分行,label 由面板渲染器展示。 */ function buildSegments( ctx: ExtensionContext, theme: Theme, footerData: ReadonlyFooterDataProvider, variant: "footer" | "panel", ): Segment[] { const segs: Segment[] = []; // git 分支;非 git 目录(如多仓工作区根)时回退显示当前目录名 const branch = footerData.getGitBranch(); if (branch) { segs.push({ label: "Branch", text: theme.fg("accent", branch), pri: 0, key: "branch", }); } else { segs.push({ label: "Dir", text: theme.fg("muted", basename(ctx.cwd) || ctx.cwd), pri: 0, key: "branch", }); } // 上下文占用 const cu = ctx.getContextUsage(); if (cu) { if (cu.percent == null) { // 压缩后或下次响应前 tokens 未知 segs.push({ label: "Ctx", text: theme.fg("dim", variant === "footer" ? "ctx —" : "—"), pri: 0, key: "ctx", }); } else { let color: "error" | "warning" | "success" = "success"; if (cu.percent >= 90) color = "error"; else if (cu.percent >= 75) color = "warning"; segs.push({ label: "Ctx", text: theme.fg( color, `${contextBar(cu.percent)} ${Math.round(cu.percent)}%`, ), pri: 0, key: "ctx", }); } } // 当前模型 + 思考强度(off 时不显示强度) if (ctx.model?.id) { const level = pi.getThinkingLevel(); const showEffort = Boolean(level) && level !== "off" && !config.hiddenMetrics.includes("effort"); if (variant === "panel") { // 面板竖排:模型与思考强度分行,避免长模型名被截断 segs.push({ label: "Model", text: theme.fg("muted", ctx.model.id), pri: 1, key: "model", }); if (showEffort) segs.push({ label: "Effort", text: theme.fg("muted", level), pri: 1, key: "effort", }); } else { const label = showEffort ? `${ctx.model.id}·${level}` : ctx.model.id; segs.push({ label: "Model", text: theme.fg("muted", label), pri: 1, key: "model", }); } } // token 用量、缓存命中率与花费 const u = computeUsage(ctx); const inText = `↑${fmtTokens(u.input)}`; const outText = `↓${fmtTokens(u.output)}`; let cacheText = ""; if (u.cacheRead > 0) { // 命中率 = 缓存读 / 总输入(未缓存输入 + 缓存读 + 缓存写) const totalIn = u.input + u.cacheRead + u.cacheWrite; const rate = totalIn > 0 ? Math.round((u.cacheRead / totalIn) * 100) : 0; cacheText = `⚡${fmtTokens(u.cacheRead)}·${rate}%`; } const costText = fmtCost(u.cost); if (variant === "panel") { // 竖排值列较窄,输入/输出/缓存各占一行,避免长值被截断 segs.push({ label: "In", text: theme.fg("muted", inText), pri: 2, key: "usage", }); segs.push({ label: "Out", text: theme.fg("muted", outText), pri: 2, key: "usage", }); if (cacheText) segs.push({ label: "Cache", text: theme.fg("muted", cacheText), pri: 2, key: "usage", }); segs.push({ label: "Cost", text: theme.fg("muted", costText), pri: 2, key: "usage", }); } else { const parts = [inText, outText]; if (cacheText) parts.push(cacheText); parts.push(costText); segs.push({ label: "Usage", text: theme.fg("muted", parts.join(" ")), pri: 2, key: "usage", }); } // 首 token 耗时(最近一次请求,未完成时不显示) if (lastTtftMs != null) segs.push({ label: "TTFT", text: theme.fg( "muted", variant === "footer" ? `⏱${fmtMs(lastTtftMs)}` : fmtMs(lastTtftMs), ), pri: 2, key: "ttft", }); // 输出吞吐:生成中显示按字符估算的实时值(~ 前缀),否则显示最近一次响应的精确值 const liveSecs = firstTokTs ? (Date.now() - firstTokTs) / 1000 : 0; if ( firstTokTs && liveTokens > 0 && liveSecs >= 0.3 && Date.now() - liveTs < 5000 ) { segs.push({ label: "Speed", text: theme.fg("muted", `~${fmtTps(liveTokens / liveSecs)} tok/s`), pri: 2, key: "speed", }); } else if (lastTps != null) { segs.push({ label: "Speed", text: theme.fg("muted", `${fmtTps(lastTps)} tok/s`), pri: 2, key: "speed", }); } // 订阅额度:只显示当前模型所属 provider 的源,切模型即切换;无缓存或过期则异步刷新 const curProvider = ctx.model?.provider; const active = curProvider == null ? undefined : bindings.find((b) => b.providerId === curProvider); if (active) { const state = quotaStates.get(active.providerId); if (quotaExpired(active, state, ctx)) void refreshQuota(active, ctx, false); if (state?.seg) { const colorOf = (p: number | undefined) => { if (p == null) return "muted"; if (p >= 85) return "error"; if (p >= 60) return "warning"; return "success"; }; // 面板值列窄:逐窗口分行(每行自带恢复倒计时),底部单行各窗口用 · 连接 if (variant === "panel" && state.seg.rows?.length) { for (const r of state.seg.rows) segs.push({ label: "Quota", text: theme.fg(colorOf(r.percent), r.text), pri: 2, key: "quota", }); } else { segs.push({ label: "Quota", text: theme.fg(colorOf(state.seg.maxPercent), state.seg.text), pri: 2, key: "quota", }); } } } // 保留其他扩展通过 setStatus 输出的状态,命中隐藏规则的除外 for (const s of footerData.getExtensionStatuses().values()) { if (!s) continue; if (config.hideExtStatuses.some((h) => s.includes(h))) continue; segs.push({ label: "", text: s, pri: 3, key: "ext" }); } // 应用指标显隐配置(/statusbar metrics) return segs.filter((s) => !s.key || !config.hiddenMetrics.includes(s.key)); } /** 右侧信息面板:竖排 label/value 行 + 边框。 * overlay 模式下(无 heightOf)高度贴合内容; * 右侧分栏模式下传入 heightOf,把卡片铺满整个视口高度,避免右侧留下一条空白列。 */ const PANEL_LABEL_W = 7; class StatusPanel implements Component { constructor( private theme: Theme, private heightOf?: () => number, ) {} invalidate(): void {} render(width: number): string[] { // 渲染期异常绝不允许冒泡:pi 会把它当 uncaughtException 直接退出进程。 // 旧实例的组件在 /reload 后可能仍挂在布局树上(currentCtx 已失效)。 try { return this.renderPanel(width); } catch { return []; } } private renderPanel(width: number): string[] { const ctx = currentCtx; const fd = activeFooterData; if (!ctx || !fd) return []; const th = this.theme; const innerW = Math.max(PANEL_LABEL_W + 4, width - 2); const g = BORDER_CHARS[resolvePanelBorder(config.panelBorder)]; const border = (l: string, r: string) => th.fg("dim", l + g.h.repeat(innerW) + r); const body: string[] = []; const row = (content: string) => { const padded = content + " ".repeat(Math.max(0, innerW - visibleWidth(content))); body.push(th.fg("dim", g.v) + padded + th.fg("dim", g.v)); }; for (const s of buildSegments(ctx, th, fd, "panel")) { if (s.label) { const valueW = Math.max(1, innerW - 2 - PANEL_LABEL_W - 1); const valueLines = wrapTextWithAnsi(s.text, valueW); valueLines.forEach((line, i) => { const labelCol = i === 0 ? th.fg("dim", s.label.padEnd(PANEL_LABEL_W)) : " ".repeat(PANEL_LABEL_W); row(" " + labelCol + " " + line); }); } else { for (const line of wrapTextWithAnsi(s.text, innerW - 2)) { row(" " + line); } } } // 分栏模式:panelFill 时补满到视口高度(上下边框各占 1 行),超出部分由布局层裁切; // agent 面板入栏(dockedWidget)时让位给内容高度,否则填满的面板会把 agent 面板挤出屏 const target = this.heightOf && config.panelFill && !dockedWidget ? Math.max(1, this.heightOf() - 2) : 0; while (target > 0 && body.length < target) row(""); return [border(g.tl, g.tr), ...body, border(g.bl, g.br)]; } } /** 创建右侧面板 overlay(微任务延迟,避免在 render 周期内同步变更 overlay 栈) */ function ensurePanel(ctx: ExtensionContext): void { if (panelAlive || panelPending || !ctx.hasUI || !activeFooterData) return; panelPending = true; queueMicrotask(() => { panelPending = false; if (panelAlive || !userWants) return; panelAlive = true; const reset = () => { panelAlive = false; panelDone = null; }; try { void ctx.ui .custom( (tui, theme, _kb, done) => { panelDone = done; if (!activeTui) activeTui = tui; return new StatusPanel(theme); }, { overlay: true, // 函数形式:每次渲染重读配置/布局,resize 时自动重判可见性 overlayOptions: () => ({ anchor: "right-center", width: config.rightWidth, maxHeight: "80%", // right:0:与右侧分栏面板同一列。浮层是 right:1(面板左移一格), // 切换 split 开关时面板会左右跳一格,终端只会重绘变化过的行, // 于是留下半旧半新的错位残影(用户反馈的“不对齐”) margin: { right: 0 }, nonCapturing: true, visible: (w: number) => userWants && panelAlive && // 进分栏的过渡帧:分栏还没装好就先把浮层顶住,避免面板闪掉一帧; // 装好了(或本来就未在切分栏)就按普通规则:只在该显示浮层时可见 (isSplitLike(layoutMode) ? splitTransitionFrame(w) : panelActive(w)), }), }, ) .then(reset) .catch(reset); } catch { // custom() 同步抛错(如 UI 已销毁)时复位,下一帧 footer 兜底继续渲染 reset(); } }); } /** 关闭并销毁面板(overlay 销毁后不可复用,再次启用时由 ensurePanel 重建) */ function closePanel(): void { const done = panelDone; panelDone = null; panelAlive = false; panelPending = false; try { done?.(); } catch { // 已关闭时重复调用 done 可能报错,忽略 } } // ---------- 右侧分栏(split):仅 fullscreen/alt-screen 模式具备布局树 ---------- /** 我们安装的 HStack(幂等判据 + 卸载依据);splitCore 是它包住的核心布局根(transcript + dock) */ let splitWrapper: Component | null = null; let splitCore: Component | null = null; let splitBasis = 0; let splitPending = false; let splitPanel: StatusPanel | null = null; let splitPanelTheme: Theme | null = null; /** visible 回调每帧记录整个视口宽度:分栏下 footer 收到的宽度已扣掉侧栏,不能再当归属判据 */ let splitViewportW = 0; /** split 下被搬进右栏的 widget 容器(pi「编辑器上方」的 widget dock,subagent 的 agent 面板就挂在这里) */ let dockedWidget: { container: Component; /** 当前所在的父容器(右栏 VStack),重建/还原时先摘下 */ curParent: { removeChild(c: Component): void } | null; } | null = null; /** 布局树里的父容器(pi 的 dock 是 VStack,entries 与 children 同序对应) */ type ContainerParent = { children: Component[]; removeChild(c: Component): void; invalidate?(): void; entries?: unknown[]; }; /** 在组件树里找 target 的直接父容器(按 children 数组递归,深度有限) */ function findContainerParent( root: unknown, target: unknown, depth = 0, ): ContainerParent | null { if (depth > 10 || !root || typeof root !== "object") return null; const children = (root as { children?: unknown }).children; if (!Array.isArray(children)) return null; if (children.includes(target)) return root as ContainerParent; for (const c of children) { const p = findContainerParent(c, target, depth + 1); if (p) return p; } return null; } /** * pi 的「编辑器上方」widget 容器(subagent 的 agent 面板挂在这里)。 * 识别依据:InteractiveMode 按固定顺序把 7 个容器 addChild 到 TUI,children[3] 即它; * 再要求它确实出现在当前布局树里、且是编辑器容器(children[4])的前一个兄弟。 * 结构对不上就放弃搬运(dock 留在底部),绝不猜。 */ function findWidgetsAbove(tui: TUI, core: Component): Component | null { const candidate = tui.children?.[3]; const editor = tui.children?.[4]; if ( !candidate || typeof candidate.render !== "function" || !Array.isArray((candidate as { children?: unknown }).children) ) return null; if (!findContainerParent(core, candidate)) return null; // 结构校验:候选必须是编辑器容器的直接前兄弟(pi dock 的固定顺序) if (editor) { const dock = findContainerParent(core, editor); const dc = dock?.children; if ( !dc || dc.indexOf(editor) < 1 || dc[dc.indexOf(editor) - 1] !== candidate ) return null; } return candidate; } /** * 把搬入右栏的 widget 容器插回底部 dock 的原位置(编辑器容器之前)。 * 自愈式还原:不依赖「当时是从哪个父容器摘的」,而是按 pi 的固定结构重新找家, * reload / 模式切换后旧布局树被丢弃也能正确归位。 */ function undockWidgets(core: Component | null): void { const d = dockedWidget; dockedWidget = null; if (!d) return; try { d.curParent?.removeChild(d.container); } catch { // 旧父容器可能已废弃,忽略 } try { const tui = activeTui; const editor = tui?.children?.[4]; const root = core ?? (tui ? asLayoutRootHost(tui).layoutRoot : undefined); const dock = editor && root ? findContainerParent(root, editor) : null; if (!dock || !editor) return; const idx = Math.max(0, dock.children.indexOf(editor)); dock.children.splice(idx, 0, d.container); // Stack 的排版走 entries,必须同步插回(pi 给 widgetsAbove 的原始参数是 shrink:1, minSize:0) if (Array.isArray(dock.entries)) dock.entries.splice(idx, 0, { component: d.container, shrink: 1, minSize: 0, }); dock.invalidate?.(); } catch { // 结构变了就让它留在原处,最多少一块显示,不崩 } } /** * 访问 pi-tui alt-screen renderer 的布局根。 * SAFETY: pi-tui 只公开了 `ViewportTUI.setLayoutRoot()`,真正的读写目标是 TS 里标为 private 的 * `layoutRoot` 字段;调用方必须先过 splitCapable()(已确认 mode==="fullscreen" 且 setLayoutRoot 可调用)。 * 另外扩展拿到的 `tui` 是 pi 的 live proxy,每次读属性都会转发到当前 renderer,切模式后依然有效。 */ interface LayoutRootHost { layoutRoot?: Component; setLayoutRoot(root: Component | undefined): void; } function asLayoutRootHost(tui: TUI): LayoutRootHost { // SAFETY: 仅当 splitCapable() 通过(mode==="fullscreen" 且 setLayoutRoot 可调用)时才会调用本函数; // 断言的对象是 pi 的 live TUI proxy,属性读取会转发到当前 alt-screen renderer。 return tui as unknown as LayoutRootHost; } /** pi-tui 是否提供 HStack(旧版本没有;命名导入会在链接期直接抛错,所以用命名空间导入 + 一次性特性检测) */ const HAS_PI_TUI_HSTACK = typeof piTuiRuntime.HStack === "function"; /** 当前 TUI 是否具备右侧分栏能力:fullscreen 布局树 + pi-tui 提供 HStack */ function splitCapable(tui: TUI | null): boolean { if (!tui || tui.mode !== "fullscreen") return false; if (!HAS_PI_TUI_HSTACK) return false; const t = asLayoutRootHost(tui); return typeof t.setLayoutRoot === "function" && "layoutRoot" in tui; } /** 分栏可用宽度下限:聊天内容区至少留 24 列(否则右侧竖卡会把聊天压成一条) */ function splitWidthOk(width: number): boolean { return width >= config.rightWidth + 24; } /** 分栏是否已装在当前 renderer 上(核心切模式时会重设 layoutRoot,需要重包) */ function splitInstalled(tui: TUI): boolean { if (!splitWrapper) return false; return asLayoutRootHost(tui).layoutRoot === splitWrapper; } /** * 安装右侧分栏:把核心布局根(transcript + dock 的 VStack)包进 HStack,右侧挂状态面板。 * 聊天按剩余宽度重新换行,不遮挡任何内容 —— 与 opencode 的 flexDirection="row" 同构。 * 幂等;微任务延迟,不在 render 周期内同步改写布局树。 */ function ensureSplit(tui: TUI, theme: Theme): void { if (!isSplitLike(layoutMode) || splitPending || !splitCapable(tui)) return; const t = asLayoutRootHost(tui); const cur = t.layoutRoot; if (!cur) return; if (splitInstalled(tui) && splitBasis === config.rightWidth) return; // 已就绪 splitPending = true; queueMicrotask(() => { splitPending = false; if (!isSplitLike(layoutMode) || !userWants || !splitCapable(tui)) return; const root = t.layoutRoot; if (!root) return; const basis = config.rightWidth; if (root === splitWrapper && splitBasis === basis) return; // 核心重设过 layoutRoot 时 root 就是新的核心根,否则 root 是我们的 wrapper const core = root === splitWrapper && splitCore ? splitCore : root; try { if (!splitPanel || splitPanelTheme !== theme) { splitPanel = new StatusPanel(theme, () => tui.terminal.rows); splitPanelTheme = theme; } const panel = splitPanel; // 把 pi「编辑器上方」的 widget 容器(subagent 的 agent 面板)搬入右栏: // 状态面板在上、agent 面板在下。fullscreen 下鼠标按布局位置命中, // 点击折叠等交互不受影响;结构对不上时静默放弃(留在底部) const dockAllowed = config.dockWidgetsInSplit; if (!dockAllowed && dockedWidget) undockWidgets(core); let rightSide: Component = panel; if ( dockAllowed && typeof piTuiRuntime.VStack === "function" ) { // 重建路径(改宽度 / reload 后重包):先从旧的右栏摘下 if (dockedWidget?.curParent) { try { dockedWidget.curParent.removeChild(dockedWidget.container); } catch { // 旧父容器已废弃,忽略 } dockedWidget.curParent = null; } if (!dockedWidget) { const w = findWidgetsAbove(tui, core); const home = w ? findContainerParent(core, w) : null; if (w && home) { home.removeChild(w); dockedWidget = { container: w, curParent: null }; } } if (dockedWidget) { const col = new piTuiRuntime.VStack([ // panel 取自然高度(docked 时 panelFill 自动让位,见 StatusPanel) { component: panel, basis: "auto", grow: 0, shrink: 1, minSize: 0 }, // agent 面板吃掉剩余高度:内容少时底部留白,内容多时由布局层裁切 { component: dockedWidget.container, basis: "auto", grow: 1, shrink: 1, minSize: 0, }, ]); dockedWidget.curParent = col; rightSide = col; } } const wrapper = new piTuiRuntime.HStack( [ // basis: 0 + grow: 1 与核心自身的 transcript 写法一致: // 避免布局引擎为测量固有宽度而多渲染一遍整个核心布局(每帧一次全量重排) { component: core, basis: 0, grow: 1, shrink: 1, minSize: 24 }, { component: rightSide, basis, grow: 0, shrink: 0, visible: (vp: { width: number; height: number }) => { splitViewportW = vp.width; return userWants && splitWidthOk(vp.width); }, }, ], { gap: 1 }, ); splitWrapper = wrapper; splitCore = core; splitBasis = basis; t.setLayoutRoot(wrapper); // 分栏已装好,这时才关浇浮层:切换过程中面板不会“消失一帧” closePanel(); // 普通重绘即可(布局变了,受影响的行字符串都变了); // 不要用 requestRender(true) 整屏清屏,那会闪一下,切换很不丝滑 tui.requestRender(); } catch { // 布局根不可用(pi 升级改了结构 / 缺 HStack):静默回退浮层或底部单行; // 若 widget 容器已摘下但包装失败,顺手放回底部 dock,避免 agent 面板凭空消失 splitWrapper = null; splitCore = null; splitBasis = 0; undockWidgets(core); } }); } /** 卸载右侧分栏,把核心布局根还回去 */ function closeSplit(): void { const wrapper = splitWrapper; const core = splitCore; splitWrapper = null; splitCore = null; splitBasis = 0; splitViewportW = 0; // 先把搬入右栏的 widget 容器插回底部 dock:布局树手术与渲染模式无关, // 不能用下面的 fullscreen 判据提前返回,否则切 regular 后容器会被困在废弃的右栏里 undockWidgets(core); if (!wrapper) return; // 只判 mode:这里不需要 HStack(卸载不依赖它),也不能用 splitCapable // 提前返回,否则 wrapper 会永久留在布局树上(reload 后渲染陈旧 ctx → pi 退出) const tui = activeTui; if (!tui || tui.mode !== "fullscreen") return; const t = asLayoutRootHost(tui); try { if (t.layoutRoot === wrapper) { t.setLayoutRoot(core ?? undefined); // 同理:普通重绘即可,不做整屏清屏(避免切换时闪一下) queueMicrotask(() => { try { tui.requestRender(); } catch { // TUI 已销毁,忽略 } }); } } catch { // TUI 已销毁或核心已接管布局根,忽略 } } // ---------- footer ---------- function enable(ctx: ExtensionContext): void { if (!ctx.hasUI) return; currentCtx = ctx; userWants = true; cacheKey = ""; resolveQuotaBindings(ctx); ctx.ui.setFooter((tui, theme, footerData) => { activeTui = tui; activeFooterData = footerData; // 分支切换(含 worktree 切换)时请求重绘 const unsub = footerData.onBranchChange(() => tui.requestRender()); return { dispose: unsub, invalidate() {}, render(width: number): string[] { const ctx = currentCtx; if (!ctx) return []; // 分栏 / 浮层 / 底部单行 三态切换:尽量保证每一帧都有面板,且不做整屏清屏(会闪) // 分栏下 footer 收到的宽度已被侧栏扣掉,归属判定用整个视口宽度 const fullW = splitViewportW || tui.terminal.columns; if (isSplitLike(layoutMode)) { // split / auto-split:显式 split 只要装得下(≥ rightWidth+24)就分栏, // 不套 auto 阈值;auto-split 另需 ≥ autoMinWidth const capable = splitCapable(tui); // 分栏族不需要浮层;装不上时更要确保没有存活浮层, // 否则 pi 会以 “Close active overlays before changing TUI mode” 拒绝切到 fullscreen。 // 微任务延迟:不在 render 周期内同步变更 overlay 栈(与 ensurePanel 同理) if (!capable && (panelAlive || panelPending)) { queueMicrotask(() => { if (isSplitLike(layoutMode) && !splitCapable(activeTui)) closePanel(); }); } if (splitActive(fullW)) { ensureSplit(tui, theme); // 本帧就会装上(pending)或已装好:让位。 // 只有“确实装不上”(非 pending)时才回退渲染底部单行 if (splitPending || splitInstalled(tui)) return []; } else if (splitWrapper) { closeSplit(); // 宽度不够:拆掉退回底部单行 } } else if (panelActive(fullW)) { // 该显示浮层:先把浮层起起来,就绪后再拆分栏,避免中间出现无面板的帧 ensurePanel(ctx); if (panelAlive || panelPending) { if (splitWrapper) closeSplit(); return []; } } else if (splitWrapper) { closeSplit(); // 目标是底部单行:直接拆 } const sep = theme.fg("dim", " │ "); const segs = buildSegments(ctx, theme, footerData, "footer"); // 超宽时先按 pri 从大到小丢弃可牺牲段(保留 pri=0 的分支/上下文), // 仍放不下则换行成多行,段保持完整不拆 const join = (list: Segment[]) => list.map((s) => s.text).join(sep); const dropOrder = segs .map((_, i) => i) .sort((a, b) => segs[b].pri - segs[a].pri); let out = segs; let di = 0; while ( di < dropOrder.length && segs[dropOrder[di]].pri > 0 && visibleWidth(truncateToWidth(join(out), width)) > width ) { const victim = segs[dropOrder[di++]]; out = out.filter((s) => s !== victim); } const rows: string[] = []; let rowSegs: Segment[] = []; for (const s of out) { const cand = rowSegs.length ? [...rowSegs, s] : [s]; const line = join(cand); if (rowSegs.length && visibleWidth(line) > width) { rows.push(truncateToWidth(join(rowSegs), width)); rowSegs = [s]; } else { rowSegs = cand; } } if (rowSegs.length) rows.push(truncateToWidth(join(rowSegs), width)); return rows; }, }; }); } function disable(ctx: ExtensionContext): void { userWants = false; closePanel(); closeSplit(); if (!ctx.hasUI) return; ctx.ui.setFooter(undefined); } // ---------- 命令与事件 ---------- type MenuAction = "metrics" | "toggle"; /** 将一块输入拆分为独立按键序列(终端快速按键可能合并为单个 data 块送达) */ function splitKeySeqs(data: string): string[] { return ( data.match( /\x1b\[[0-9;]*[a-zA-Z]|\x1bO[A-Za-z]|\x1b[A-Za-z]|\x1b|\r|\n|[\s\S]/g, ) ?? [data] ); } /** 菜单头部:─── 标题 ───… */ function menuHeader(theme: Theme, title: string, width: number): string { const bar = theme.fg("borderMuted", "─"); const tt = ` ${theme.fg("accent", title)} `; const rest = bar.repeat(Math.max(0, width - 6 - visibleWidth(tt))); return truncateToWidth(` ${bar.repeat(3)}${tt}${rest}`, width); } /** 菜单行:光标 + 标签(固定列宽,值紧跟其后) + 值 */ function menuRow( theme: Theme, sel: boolean, left: string, right: string, labelCol: number, width: number, ): string { const mark = sel ? theme.fg("accent", "❯") : " "; const l = sel ? theme.fg("text", left) : theme.fg("muted", left); const pad = Math.max(2, labelCol - visibleWidth(left)); return truncateToWidth(` ${mark} ${l}${" ".repeat(pad)}${right}`, width); } /** /statusbar 交互式菜单:↑↓ 选择,布局/语言行 ←→ 调值,Enter 确认,Esc 退出 */ class StatusbarMenuComponent { private sel = 0; private cachedW?: number; private cachedLines?: string[]; private static readonly ROWS = 7; constructor( private theme: Theme, private close: (action: MenuAction | null) => void, private notify: (msg: string, type: "info" | "warning") => void = () => {}, ) {} private cycleLayout(dir: 1 | -1): void { const i = LAYOUT_ORDER.indexOf(layoutMode); setLayout( LAYOUT_ORDER[(i + dir + LAYOUT_ORDER.length) % LAYOUT_ORDER.length], this.notify, ); this.invalidate(); } /** 切换中英文(即时写回配置文件,菜单文案实时切换) */ private cycleLang(): void { config.language = config.language === "zh" ? "en" : "zh"; try { saveConfigPatch({ language: config.language }); } catch { // 写入失败不影响本次会话内的显示 } this.invalidate(); } /** 切换面板边框字符集:auto → unicode → ascii 循环(与 /statusbar border 共用) */ private cycleBorder(): void { setPanelBorder(nextPanelBorder(config.panelBorder)); this.invalidate(); } /** 切换面板是否铺满高度(写回配置,与 /statusbar fill 共用) */ private toggleFill(): void { setPanelFill(!config.panelFill); this.invalidate(); } /** 切换 agent 面板是否搬进右栏(写回配置,与 /statusbar dock 共用) */ private toggleDock(): void { setDockWidgets(!config.dockWidgetsInSplit); this.invalidate(); } handleInput(data: string): void { for (const seq of splitKeySeqs(data)) this.handleKey(seq); } private handleKey(data: string): void { const N = StatusbarMenuComponent.ROWS; if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) return this.close(null); if (matchesKey(data, "up")) { this.sel = (this.sel + N - 1) % N; this.invalidate(); return; } if (matchesKey(data, "down")) { this.sel = (this.sel + 1) % N; this.invalidate(); return; } if (matchesKey(data, "left")) { if (this.sel === 0) return this.cycleLayout(-1); if (this.sel === 1) return this.cycleBorder(); if (this.sel === 2) return this.toggleFill(); if (this.sel === 3) return this.toggleDock(); if (this.sel === 4) return this.cycleLang(); } if (matchesKey(data, "right")) { if (this.sel === 0) return this.cycleLayout(1); if (this.sel === 1) return this.cycleBorder(); if (this.sel === 2) return this.toggleFill(); if (this.sel === 3) return this.toggleDock(); if (this.sel === 4) return this.cycleLang(); } if (matchesKey(data, "return")) { if (this.sel === 5) return this.close("metrics"); if (this.sel === 6) return this.close("toggle"); return this.close(null); // 布局/边框/填充/入栏/语言行:已实时生效,Enter 即完成退出 } } render(width: number): string[] { if (this.cachedLines && this.cachedW === width) return this.cachedLines; const th = this.theme; const T = t(); const lines: string[] = ["", menuHeader(th, T.menuTitle, width), ""]; const labels = [ T.rowLayout, T.rowBorder, T.rowFill, T.rowDock, T.rowLang, T.rowMetrics, userWants ? T.rowDisable : T.rowEnable, ]; // 值列紧跟标签:固定列宽 = 最长标签 + 3,不再贴右边缘 const labelCol = Math.max(...labels.map((l) => visibleWidth(l))) + 3; // 可调值:两侧 ◀ ▶,选中行高亮 const adjustable = (on: boolean, text: string) => on ? `${th.fg("accent", "◀")} ${th.fg("accent", text)} ${th.fg("accent", "▶")}` : `${th.fg("dim", "◀")} ${th.fg("muted", text)} ${th.fg("dim", "▶")}`; // 行 0:布局(auto / auto-split / bottom / right / split;进出分栏族会同步 pi 的 tuiMode 并写回配置) const layoutValue = isSplitLike(layoutMode) && !splitCapable(activeTui) ? `${LAYOUT_LABELS[config.language][layoutMode]} · ${T.splitNeedFullscreen}` : LAYOUT_LABELS[config.language][layoutMode]; lines.push( menuRow( th, this.sel === 0, labels[0], adjustable(this.sel === 0, layoutValue), labelCol, width, ), ); // 行 1:面板边框字符集(值显示实际生效的字形集) lines.push( menuRow( th, this.sel === 1, labels[1], adjustable(this.sel === 1, panelBorderLabel(config.panelBorder)), labelCol, width, ), ); // 行 2:面板填充(只影响 layout=split;浮层本来就贴合内容) lines.push( menuRow( th, this.sel === 2, labels[2], adjustable(this.sel === 2, config.panelFill ? T.fillOn : T.fillOff), labelCol, width, ), ); // 行 3:Agent 面板入栏(只影响 layout=split;其余布局 dock 本来就在底部) lines.push( menuRow( th, this.sel === 3, labels[3], adjustable( this.sel === 3, config.dockWidgetsInSplit ? T.dockIn : T.dockOut, ), labelCol, width, ), ); // 行 4:语言 lines.push( menuRow( th, this.sel === 4, labels[4], adjustable(this.sel === 4, LANG_LABELS[config.language]), labelCol, width, ), ); // 行 5:指标显隐(显示计数) const hiddenCount = config.hiddenMetrics.length; lines.push( menuRow( th, this.sel === 5, labels[5], th.fg( hiddenCount ? "warning" : "muted", T.metricsCount(METRICS.length - hiddenCount, METRICS.length), ), labelCol, width, ), ); // 行 6:启用/停用 lines.push(menuRow(th, this.sel === 6, labels[6], "", labelCol, width)); lines.push(""); lines.push(truncateToWidth(` ${th.fg("dim", T.menuHint)}`, width)); lines.push(""); this.cachedW = width; this.cachedLines = lines; return lines; } invalidate(): void { this.cachedW = undefined; this.cachedLines = undefined; } } /** 指标显隐选择器:↑↓ 选择、Space 切换、Enter 保存、Esc 取消 */ class MetricsPickerComponent { private sel = 0; private hidden: Set; private cachedW?: number; private cachedLines?: string[]; constructor( private theme: Theme, initHidden: MetricKey[], private onPreview: (keys: MetricKey[]) => void, private close: (saved: boolean) => void, ) { this.hidden = new Set(initHidden); } private keys(): MetricKey[] { return METRICS.flatMap((m) => (this.hidden.has(m.key) ? [m.key] : [])); } handleInput(data: string): void { for (const seq of splitKeySeqs(data)) this.handleKey(seq); } private handleKey(data: string): void { if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) return this.close(false); if (matchesKey(data, "up")) { this.sel = (this.sel + METRICS.length - 1) % METRICS.length; this.invalidate(); return; } if (matchesKey(data, "down")) { this.sel = (this.sel + 1) % METRICS.length; this.invalidate(); return; } if (matchesKey(data, "space")) { const k = METRICS[this.sel].key; if (this.hidden.has(k)) this.hidden.delete(k); else this.hidden.add(k); this.onPreview(this.keys()); // 即时预览,Esc 可回退 this.invalidate(); return; } if (matchesKey(data, "return")) return this.close(true); } render(width: number): string[] { if (this.cachedLines && this.cachedW === width) return this.cachedLines; const th = this.theme; const T = t(); const lines: string[] = ["", menuHeader(th, T.pickerTitle, width), ""]; METRICS.forEach((m, i) => { const cur = i === this.sel; const shown = !this.hidden.has(m.key); const name = metricName(m); const mark = cur ? th.fg("accent", "❯") : " "; const icon = shown ? th.fg("success", "●") : th.fg("dim", "○"); let text = th.fg("dim", name); if (shown) text = cur ? th.fg("text", name) : th.fg("muted", name); lines.push(truncateToWidth(` ${mark} ${icon} ${text}`, width)); }); lines.push(""); lines.push(truncateToWidth(` ${th.fg("dim", T.pickerHint)}`, width)); lines.push(""); this.cachedW = width; this.cachedLines = lines; return lines; } invalidate(): void { this.cachedW = undefined; this.cachedLines = undefined; } } /** /statusbar layout 子命令入口:省略 mode 时按 LAYOUT_ORDER 循环 */ function applyLayout(ctx: ExtensionContext, mode?: LayoutMode): void { const notify = (msg: string, type: "info" | "warning") => ctx.ui.notify(msg, type); if (mode) { setLayout(mode, notify); return; } const i = LAYOUT_ORDER.indexOf(layoutMode); setLayout(LAYOUT_ORDER[(i + 1) % LAYOUT_ORDER.length], notify); } /** * 开关右侧分栏并提示(写回配置文件,与 language 一样持久化)。 * 需 fullscreen 模式才真正生效;regular 模式/缺 HStack 时退回底部单行(不建浮层)。 */ /** pi 全局设置文件路径(split 自动同步 TUI mode 用;尊重 PI_CODING_AGENT_DIR) */ function piSettingsPath(): string { const fn = (piAgentRuntime as { getSettingsPath?: () => string }) .getSettingsPath; if (typeof fn === "function") return fn.call(piAgentRuntime); const envDir = process.env.PI_CODING_AGENT_DIR; const dir = envDir ? envDir.replace(/^~(?=\/|$)/, homedir()) : join(homedir(), ".pi", "agent"); return join(dir, "settings.json"); } interface TuiModeSync { /** 是否真的写入了 pi settings.json */ wrote: boolean; /** 当前会话仍不是 fullscreen,需要重启 pi 才生效 */ needsRestart: boolean; failed: boolean; } /** * 开启右侧分栏时自动把 pi 的 tuiMode 设为 fullscreen(避免用户要改两处配置),关闭时还原。 * 安全性:pi 自己的 settings 保存只合并「已修改字段」(settings-manager.persistScopedSettings * 先读文件再合并),所以这里的外部写入不会被 pi 后续保存抹掉。 */ function syncPiTuiMode(on: boolean): TuiModeSync { const none: TuiModeSync = { wrote: false, needsRestart: false, failed: false, }; try { const file = piSettingsPath(); const parsed: unknown = JSON.parse(readFileSync(file, "utf8")); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { ...none, failed: true }; const settings = parsed as Record; const current = typeof settings.tuiMode === "string" ? settings.tuiMode : ""; if (on) { if (current === "fullscreen") return none; if (config.tuiModeBackup === undefined) { config.tuiModeBackup = current === "" ? "none" : current; saveConfigPatch({ tuiModeBackup: config.tuiModeBackup }); } settings.tuiMode = "fullscreen"; writeFileSync(file, JSON.stringify(settings, null, 2) + "\n"); return { wrote: true, needsRestart: !splitCapable(activeTui), failed: false, }; } // 关闭:只有确实由我们改过(有备份)才还原 if (config.tuiModeBackup === undefined) return none; if (config.tuiModeBackup === "none") settings.tuiMode = undefined; else settings.tuiMode = config.tuiModeBackup; config.tuiModeBackup = undefined; saveConfigPatch({ tuiModeBackup: config.tuiModeBackup }); writeFileSync(file, JSON.stringify(settings, null, 2) + "\n"); return { wrote: true, needsRestart: false, failed: false }; } catch { return { ...none, failed: true }; } } /** * 统一的 split 开关:写 statusbar.json + 自动同步 pi 的 TUI mode + 报结果。 * /statusbar split 子命令与交互式菜单共用,保证两条路径行为一致。 */ function setLayout( next: LayoutMode, notify?: (msg: string, type: "info" | "warning") => void, ): void { const T = t(); const prev = layoutMode; if (prev === next) return; layoutMode = next; config.layout = next; try { // split 是旧的独立开关:写回时顺手置 undefined 删掉,避免两份配置打架 saveConfigPatch({ layout: next, split: undefined }); } catch { // 写入失败不影响本次会话内的显示 } // 进分栏:不在这里关浮层 —— 浮层的 visible 会一直顶到分栏装好(见 overlayOptions) // 出分栏:也不在这里拆 —— 等浮层就绪后由 footer 下一帧拆(见 render 里的分支), // 这样两个方向都不会出现“既没有分栏也没有浮层”的中间帧 if (!userWants) closeSplit(); // 面板位置/高度变了:普通重绘即可,不做整屏清屏(否则切换时会闪一下) if (userWants) activeTui?.requestRender(); if (!isSplitLike(next) && !isSplitLike(prev)) { if (!notify) return; notify(`状态栏布局: ${layoutModeLabel(next)}`, "info"); return; } const sync = syncPiTuiMode(isSplitLike(next)); if (!isSplitLike(next)) { let msg: string = T.splitDisabled; if (sync.failed) msg = T.splitSetFailed; else if (sync.wrote) msg = T.splitRestored; notify?.(msg, sync.failed ? "warning" : "info"); return; } if (!HAS_PI_TUI_HSTACK) { notify?.(T.splitUnavailable, "warning"); return; } if (splitCapable(activeTui)) { notify?.( next === "auto-split" ? `状态栏布局: ${layoutModeLabel("auto-split")}` : T.splitEnabled, "info", ); return; } // 当前会话不是 fullscreen:配置已写好,重启 pi 后生效 notify?.( sync.failed ? T.splitSetFailed : T.splitNeedsRestart, sync.failed ? "warning" : "info", ); } /** /statusbar split on|off 兼容别名:on = layout split,off = 回到默认布局 */ function applySplit(ctx: ExtensionContext, on: boolean): void { setLayout(on ? "split" : DEFAULT_LAYOUT, (msg, type) => ctx.ui.notify(msg, type), ); } /** 布局的中文提示文案:auto / auto-split 带上当前阈值,其余用标签表 */ function layoutModeLabel(m: LayoutMode): string { const n = config.autoMinWidth; if (m === "auto") return `自动浮层(终端 ≥${n} 列时右侧浮层,否则底部单行)`; if (m === "auto-split") return `自动分栏(终端 ≥${n} 列时右侧分栏,否则底部单行)`; return LAYOUT_LABELS[config.language][m]; } /** * 切换面板边框字符集(写回配置文件,立即生效)。 * 换字符会改掉整块边框:强制整屏重绘,避开个别终端对旧方框字符的残影。 */ function setPanelBorder( next: PanelBorder, notify?: (msg: string, type: "info" | "warning") => void, ): void { if (config.panelBorder === next) { notify?.(`面板边框已是 ${panelBorderLabel(next)}`, "info"); return; } config.panelBorder = next; try { saveConfigPatch({ panelBorder: next }); } catch { // 写入失败不影响本次会话内的显示 } activeTui?.requestRender(); notify?.( `面板边框: ${panelBorderLabel(next)}` + (resolvePanelBorder(next) === "ascii" ? "(与文字同层,可绕开方框字符残影)" : ""), "info", ); } /** /statusbar border 子命令入口(无参数 = 按 auto → unicode → ascii 循环) */ function applyPanelBorder( ctx: ExtensionContext, arg: string | undefined, ): void { let next: PanelBorder; if (arg === "auto" || arg === "unicode" || arg === "ascii") next = arg; else next = nextPanelBorder(config.panelBorder); setPanelBorder(next, (msg, type) => ctx.ui.notify(msg, type)); } /** * 切换分栏面板是否铺满整屏高度(写回配置 + 整屏重绘,边框长度会变)。 * 只对 layout=split 有效;浮层本来就贴合内容。 */ function setPanelFill( on: boolean, notify?: (msg: string, type: "info" | "warning") => void, ): void { const T = t(); if (config.panelFill === on) { notify?.(`面板填充已是 ${on ? T.fillOn : T.fillOff}`, "info"); return; } config.panelFill = on; try { saveConfigPatch({ panelFill: on }); } catch { // 写入失败不影响本次会话内的显示 } activeTui?.requestRender(); notify?.( `面板填充: ${on ? T.fillOn : T.fillOff}` + (isSplitLike(layoutMode) ? "" : "(仅分栏布局生效)"), "info", ); } /** /statusbar fill 子命令入口(无参数 = 取反) */ function applyPanelFill(ctx: ExtensionContext, arg: string | undefined): void { const on = arg ? arg === "on" : !config.panelFill; setPanelFill(on, (msg, type) => ctx.ui.notify(msg, type)); } /** * 切换 split 下是否把 agent 面板(pi「编辑器上方」widget 容器)搬进右栏。 * 写回配置;分栏已装好时会拆掉重装(搬入/还原立即生效),其余布局记住设置即可。 */ function setDockWidgets( on: boolean, notify?: (msg: string, type: "info" | "warning") => void, ): void { const T = t(); if (config.dockWidgetsInSplit === on) { notify?.(`Agent 面板入栏已是「${on ? T.dockIn : T.dockOut}」`, "info"); return; } config.dockWidgetsInSplit = on; try { saveConfigPatch({ dockWidgetsInSplit: on }); } catch { // 写入失败不影响本次会话内的显示 } if (isSplitLike(layoutMode) && activeTui && splitWrapper) { closeSplit(); // 关掉时 undock 回底部;开启时由下一帧 ensureSplit 重新搬入 try { activeTui.requestRender(); } catch { // TUI 已销毁,忽略 } } notify?.( `Agent 面板: ${on ? T.dockIn : T.dockOut}` + (isSplitLike(layoutMode) ? "" : "(仅分栏布局生效)"), "info", ); } /** /statusbar dock 子命令入口(无参数 = 取反) */ function applyDock(ctx: ExtensionContext, arg: string | undefined): void { const on = arg ? arg === "on" : !config.dockWidgetsInSplit; setDockWidgets(on, (msg, type) => ctx.ui.notify(msg, type)); } /** 切换自定义状态栏 / 内置 footer */ function toggleStatusbar(ctx: ExtensionContext): void { if (userWants) { disable(ctx); ctx.ui.notify("已恢复内置 footer", "info"); } else { enable(ctx); ctx.ui.notify("已启用自定义状态栏", "info"); } } /** 交互式指标显隐选择器;保存返回 true,取消返回 false */ async function runMetricsPicker(ctx: ExtensionContext): Promise { if (ctx.mode !== "tui") { ctx.ui.notify(t().noTui, "warning"); return false; } const orig = config.hiddenMetrics; const saved = await ctx.ui.custom( (_tui, theme, _kb, done) => new MetricsPickerComponent( theme, orig, (keys) => { config.hiddenMetrics = keys; // 即时预览 if (userWants) activeTui?.requestRender(); }, done, ), ); if (!saved) { // 取消:回退未保存的预览改动 config.hiddenMetrics = orig; if (userWants) activeTui?.requestRender(); ctx.ui.notify(t().canceled, "info"); return false; } const keys = config.hiddenMetrics; try { saveConfigPatch({ hiddenMetrics: keys }); const T = t(); const sep = config.language === "zh" ? "、" : ", "; ctx.ui.notify( keys.length === 0 ? T.savedAll : T.savedHidden(keys.join(sep)), "info", ); } catch (e) { ctx.ui.notify(t().saveFailed(e), "error"); return false; } if (userWants) activeTui?.requestRender(); return true; } /** 强制刷新订阅额度并显示详情(/statusbar quota 子命令) */ async function refreshQuotaNow(ctx: ExtensionContext): Promise { if (bindings.length === 0) { ctx.ui.notify("未发现可查询额度的订阅 provider", "warning"); return; } await Promise.all(bindings.map((b) => refreshQuota(b, ctx, true))); const line = bindings .map( (b) => `${b.source.id}(${b.providerId}): ${quotaStates.get(b.providerId)?.detail ?? "—"}`, ) .join(";") .replace(/\n/g, " "); ctx.ui.notify(line, "info"); } pi.registerCommand("statusbar", { description: "状态栏设置:无参数打开交互菜单;子命令 on/off | layout [right|bottom|auto|split] | split [on|off] | border [auto|unicode|ascii] | fill [on|off] | dock [on|off] | metrics | quota | prices", handler: async (args, ctx) => { const parts = args.trim().toLowerCase().split(/\s+/).filter(Boolean); const sub = parts[0]; // 子命令快捷方式(脚本/RPC 友好,跳过交互菜单) if (sub === "on" || sub === "off") { if ((sub === "on") === userWants) ctx.ui.notify( sub === "on" ? "自定义状态栏已启用" : "已是内置 footer", "info", ); else toggleStatusbar(ctx); return; } if (sub === "layout") { const a = parts[1]; if ( a && a !== "right" && a !== "bottom" && a !== "auto" && a !== "auto-split" && a !== "split" ) { ctx.ui.notify( `无效布局: ${a}(可选 right / bottom / auto / auto-split / split)`, "warning", ); return; } applyLayout(ctx, a as LayoutMode | undefined); return; } if (sub === "split") { const a = parts[1]; if (a && a !== "on" && a !== "off") { ctx.ui.notify(`无效参数: ${a}(可选 on / off)`, "warning"); return; } applySplit(ctx, a ? a === "on" : !isSplitLike(layoutMode)); return; } if (sub === "border") { const a = parts[1]; if (a && a !== "auto" && a !== "unicode" && a !== "ascii") { ctx.ui.notify(`无效参数: ${a}(可选 auto / unicode / ascii)`, "warning"); return; } applyPanelBorder(ctx, a); return; } if (sub === "fill") { const a = parts[1]; if (a && a !== "on" && a !== "off") { ctx.ui.notify(`无效参数: ${a}(可选 on / off)`, "warning"); return; } applyPanelFill(ctx, a); return; } if (sub === "dock") { const a = parts[1]; if (a && a !== "on" && a !== "off") { ctx.ui.notify(`无效参数: ${a}(可选 on / off)`, "warning"); return; } applyDock(ctx, a); return; } if (sub === "metrics") { await runMetricsPicker(ctx); return; } if (sub === "quota") { await refreshQuotaNow(ctx); return; } if (sub === "prices") { await refreshPricesNow(ctx); return; } if (sub) { ctx.ui.notify( `未知子命令: ${sub}。用法: /statusbar [on|off|layout [right|bottom|auto|split]|split [on|off]|border [auto|unicode|ascii]|fill [on|off]|dock [on|off]|metrics|quota|prices],或无参数打开交互菜单`, "warning", ); return; } // 无参数:非 TUI 环境保持旧行为(切换开关),TUI 打开交互式菜单 if (ctx.mode !== "tui") { toggleStatusbar(ctx); return; } for (;;) { const action = await ctx.ui.custom( (_tui, theme, _kb, done) => new StatusbarMenuComponent(theme, done, (msg, type) => ctx.ui.notify(msg, type), ), ); if (action == null) return; if (action === "metrics") { await runMetricsPicker(ctx); continue; // 回到菜单可继续操作 } toggleStatusbar(ctx); return; } }, }); /** 刷新 models.dev 实时单价并显示当前模型单价来源与匹配方式(/statusbar prices 子命令) */ async function refreshPricesNow(ctx: ExtensionContext): Promise { await refreshPrices(ctx, true); const model = ctx.model; const r = ratesFor(ctx); const meta = model ? priceMeta[`${model.provider}:${model.id}`] : undefined; // meta 缺失且单价为 0 = 只有 pi 默认补的 0 价 cost,等于无可用单价 if (!r || (!meta && isZeroCost(r))) { // 附带 models.dev 同名候选(若有),方便直接抄进 priceMap const cands = model ? (lastById.get(model.id.toLowerCase()) ?? []) : []; let hint = ""; if (cands.length) { const shown = cands .slice(0, 3) .map( (e) => `${e.pid}/${e.id} $${e.cost?.input ?? 0}/$${e.cost?.output ?? 0}`, ) .join(","); const more = cands.length > 3 ? ` 等 ${cands.length} 个` : ""; hint = `;同名候选: ${shown}${more}`; } ctx.ui.notify( `${model?.id ?? "?"}: 无可用单价(来源 ${priceSource})。可在 statusbar.json 的 priceMap 加 "${model?.provider ?? "provider"}:${model?.id ?? "model"}": ["models.dev的provider", "模型id"]${hint}`, "warning", ); return; } const SRC_LABEL: Record = { priceMap: "priceMap 显式映射", provider: "models.dev 同名直配", runtime: "models.json/内置目录", auto: "models.dev 自动匹配", }; let line = `${model?.id ?? "?"}: $${r.input}/$${r.output} 每百万(缓存读 $${r.cacheRead ?? 0})来源 ${priceSource}`; if (meta) line += ` · ${SRC_LABEL[meta.source]}${meta.via ? ` ${meta.via}` : ""}`; ctx.ui.notify(line, "info"); // 自动匹配结果需要用户留意时给出提示与 priceMap 建议 if (meta?.source === "auto" && meta.allZero) { ctx.ui.notify( `⚠ 同名 ${meta.candidates ?? 1} 个条目全部 0 价(多为订阅/免费端点),花费将显示 $0。若该模型实际按量计费,请在 priceMap 指向官方条目(候选见 https://models.dev)`, "warning", ); } else if ( meta?.source === "auto" && ((meta.candidates ?? 0) > 1 || (meta.droppedZero ?? 0) > 0) ) { ctx.ui.notify( `自动匹配到 ${meta.via}(${meta.candidates} 个同名候选${meta.droppedZero ? `,已跳过 ${meta.droppedZero} 个 0 价` : ""},取官方/最低价)。配置 priceMap 可固定来源`, "info", ); } } pi.registerCommand("exit", { description: "退出 pi(/quit 的别名)", handler: async (_args, ctx) => { ctx.shutdown(); }, }); // 会话被替换/重载/退出前:卸掉右侧分栏并清空缓存引用。 // 关键:右侧分栏挂在 pi 的布局树上,不随 reload 一起销毁;旧实例的 StatusPanel // 若留着,reload 后仍会渲染并读陈旧 ctx —— pi 的陈旧 ctx 守卫会抛错, // 而布局渲染异常 = uncaughtException,pi 直接退出。 pi.on("session_shutdown", async () => { closeSplit(); closePanel(); currentCtx = null; activeFooterData = null; activeTui = null; }); // 新会话/续接时按用户偏好恢复,并后台刷新一次实时单价 pi.on("session_start", async (_event, ctx) => { config = loadConfig(); // 新会话重读配置,改配置文件无需重启 layoutMode = config.layout; // 旧配置一次性迁移:split: true 已并进 layout,顺手把过时字段从文件里删掉 if (legacySplitKey) { try { saveConfigPatch({ layout: config.layout, split: undefined }); legacySplitKey = false; } catch { // 写失败不影响使用,下次会话再试 } } // 右侧分栏需要 fullscreen:自动补齐 pi 的 TUI mode,用户只需配 layout 一处;写成功才提示(避免每次 /new 都刷) if (isSplitLike(layoutMode) && HAS_PI_TUI_HSTACK && !splitCapable(activeTui)) { const sync = syncPiTuiMode(true); if (sync.wrote) ctx.ui.notify(t().splitNeedsRestart, "info"); else if (sync.failed) ctx.ui.notify(t().splitSetFailed, "warning"); } if (userWants) enable(ctx); updateTitle(ctx); void refreshPrices(ctx, false); }); // 切换模型后重绘:触发新 provider 额度段显示与首次拉取;用量缓存作废后按逐条消息单价重算 pi.on("model_select", async (_event, ctx) => { currentCtx = ctx; cacheKey = ""; updateTitle(ctx); if (userWants) activeTui?.requestRender(); }); // 会话名变更(/name、自动命名等)时刷新终端标题 pi.on("session_info_changed", async (_event, ctx) => { updateTitle(ctx); }); // 思考强度变化后重绘(footer 里的 模型·强度 段) pi.on("thinking_level_select", async (_event, ctx) => { currentCtx = ctx; if (userWants) activeTui?.requestRender(); }); // turn 结束后触发一次重绘刷新数值 pi.on("turn_end", async (_event, ctx) => { currentCtx = ctx; updateTitle(ctx); if (userWants) activeTui?.requestRender(); }); // 首 token 耗时:请求发出前记起点,首个 assistant 消息开始时记终点 pi.on("before_provider_request", async () => { reqStartTs = Date.now(); }); pi.on("message_start", async (event) => { if (event.message.role !== "assistant") return; if (reqStartTs) { lastTtftMs = Date.now() - reqStartTs; reqStartTs = 0; } firstTokTs = Date.now(); liveTokens = 0; liveTs = 0; if (userWants) activeTui?.requestRender(); }); // 实时吞吐估算:累计每个 delta 的估算 token 数(含思考与工具调用参数) pi.on("message_update", async (event) => { if (event.message.role !== "assistant" || !firstTokTs) return; const delta = (event.assistantMessageEvent as { delta?: unknown }).delta; if (typeof delta !== "string" || !delta) return; liveTokens += estimateTokens(delta); liveTs = Date.now(); }); // 输出吞吐:assistant 消息结束时用输出 token 数除以生成耗时(不含首 token 等待) pi.on("message_end", async (event) => { if (event.message.role !== "assistant") return; const output = (event.message as AssistantMessage).usage?.output ?? 0; const secs = firstTokTs ? (Date.now() - firstTokTs) / 1000 : 0; firstTokTs = 0; liveTokens = 0; liveTs = 0; if (output > 0 && secs > 0) lastTps = output / secs; if (userWants) activeTui?.requestRender(); }); }