// 状态栏段数据收集 // 将 pi-agent 的数据源映射到 CCometixLine 的各 segment: // model → ctx.model (id/name/contextWindow/provider/reasoning) + ModelConfig 解析 // directory → ctx.cwd // git → footerData.getGitBranch() + GitCache(状态/ahead/behind/sha) // session → ctx.sessionManager.getSessionName() // contextWindow→ ctx.getContextUsage() (tokens/contextWindow/percent) // tokens → 汇总 session entries 的 assistant/tool/summary 用量 (↑input ↓output) // cache → 汇总 cacheRead/cacheWrite + 最近一次缓存命中率 CH(对齐 pi footer 的 R/W/CH) // cost → 汇总 usage.cost.total // thinking → ctx.thinkingLevel(默认常驻,off 时显示 off) import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { Config, ModelConfig, SegmentConfig, SegmentData, SegmentId } from "./types.ts"; import type { GitCache } from "./git.ts"; /** 运行时状态(由事件更新,footer 渲染时读取) */ export interface CclineState { model: | { id: string; name: string; contextWindow: number; provider: string; reasoning: boolean } | undefined; thinkingLevel: string | undefined; } /** footerData 最小接口(pi 的 ReadonlyFooterDataProvider 子集) */ export interface FooterDataLike { getGitBranch(): string | null; getAvailableProviderCount?(): number; onBranchChange(cb: () => void): () => void; } export interface SegmentInput { ctx: ExtensionContext; footerData: FooterDataLike; gitCache: GitCache; state: CclineState; modelConfig: ModelConfig; } // ─── 模型显示名解析(移植 CCometixLine ModelConfig.resolve,简化) ────────── /** 内置 Claude 模型家族:sonnet/opus/haiku + 版本号自动提取 */ const FAMILIES: { prefix: string; limit: number; re: RegExp }[] = [ { prefix: "Sonnet", limit: 200_000, re: /(?:(\d{1,2})(?:-(\d{1,2}))?-sonnet|sonnet-(\d{1,2})(?:-(\d{1,2}))?)(?:-\d{3,}|-[a-z]|\[|$)/i, }, { prefix: "Opus", limit: 200_000, re: /(?:(\d{1,2})(?:-(\d{1,2}))?-opus|opus-(\d{1,2})(?:-(\d{1,2}))?)(?:-\d{3,}|-[a-z]|\[|$)/i, }, { prefix: "Haiku", limit: 200_000, re: /(?:(\d{1,2})(?:-(\d{1,2}))?-haiku|haiku-(\d{1,2})(?:-(\d{1,2}))?)(?:-\d{3,}|-[a-z]|\[|$)/i, }, ]; function matchBuiltinFamily(lower: string): { name: string; limit: number } | null { for (const f of FAMILIES) { const m = lower.match(f.re); if (m) { const major = m[1] ?? m[3]; if (!major) continue; const minor = m[2] ?? m[4]; const version = minor ? `${major}.${minor}` : major; return { name: `${f.prefix} ${version}`, limit: f.limit }; } } return null; } /** 解析模型 ID → { displayName, contextLimit, suffix }(对齐 CCometixLine resolve) */ export function resolveModel( modelId: string, modelConfig: ModelConfig, ): { displayName?: string; contextLimit?: number; suffix?: string } { const lower = modelId.toLowerCase(); // 1. 用户配置 entries(子串匹配,优先级最高) const entry = modelConfig.models.find((e) => lower.includes(e.pattern.toLowerCase())); let baseName: string | undefined = entry?.displayName; let baseLimit: number | undefined = entry?.contextLimit; // 2. 内置 Claude 家族(regex 版本提取) if (!baseName) { const fam = matchBuiltinFamily(lower); if (fam) { baseName = fam.name; baseLimit = fam.limit; } } // 3. 上下文修饰符(独立匹配,可组合) const mod = modelConfig.contextModifiers.find((m) => lower.includes(m.pattern.toLowerCase())); const displayName = baseName ? (mod ? baseName + mod.displaySuffix : baseName) : undefined; const contextLimit = mod?.contextLimit ?? baseLimit; return { displayName, contextLimit, suffix: mod?.displaySuffix }; } // ─── 工具函数 ────────────────────────────────────────────────────────────── /** token 数格式化(对齐 pi footer formatTokens:1.2k / 12k / 1.2M) */ function formatTokens(count: number): string { if (count < 1000) return count.toString(); if (count < 10000) return `${(count / 1000).toFixed(1)}k`; if (count < 1000000) return `${Math.round(count / 1000)}k`; if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`; return `${Math.round(count / 1000000)}M`; } /** 取路径最后一段(跨平台:兼容 Windows 盘符/UNC、末尾分隔符与 Unix 路径) */ function extractDirName(path: string): string { const trimmed = path.replace(/[\\/]+$/, ""); // 去掉末尾分隔符("E:\a\" -> "E:\a") if (!trimmed) return path; // "/"、"\" 等根目录原样显示 const parts = trimmed.split(/[\\/]+/); return parts[parts.length - 1] || path; } /** 宽松的 usage / session entry 结构(兼容不同 pi 版本的字段可选性) */ interface LooseUsage { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; cost?: { total?: number }; } interface LooseEntry { type: string; message?: { role?: string; usage?: LooseUsage }; usage?: LooseUsage; } /** 会话用量汇总(对齐 pi footer:assistant 消息 + toolResult + branch_summary/compaction) */ interface UsageTotals { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; /** 最近一次 assistant 请求的缓存命中率(百分比),无缓存时 undefined */ lastCacheHitRate?: number; } function collectUsage(ctx: ExtensionContext): UsageTotals { const totals: UsageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }; const entries = ctx.sessionManager.getEntries() as LooseEntry[]; for (const e of entries) { const isAssistant = e.type === "message" && e.message?.role === "assistant"; let usage: LooseUsage | undefined; if (isAssistant) { usage = e.message?.usage; } else if (e.type === "message" && e.message?.role === "toolResult") { usage = e.message.usage; } else if (e.type === "branch_summary" || e.type === "compaction") { usage = e.usage; } if (!usage) continue; totals.input += usage.input ?? 0; totals.output += usage.output ?? 0; totals.cacheRead += usage.cacheRead ?? 0; totals.cacheWrite += usage.cacheWrite ?? 0; totals.cost += usage.cost?.total ?? 0; // pi footer 语义:CH 取最近一次带 usage 的 assistant 请求 if (isAssistant) { const promptTokens = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); totals.lastCacheHitRate = promptTokens > 0 ? ((usage.cacheRead ?? 0) / promptTokens) * 100 : undefined; } } return totals; } // ─── 各 segment 收集 ──────────────────────────────────────────────────────── function collectModel(input: SegmentInput, seg: SegmentConfig): SegmentData | null { const m = input.state.model; if (!m) return null; const resolved = resolveModel(m.id, input.modelConfig); let primary = resolved.displayName; if (!primary) { // 回退:优先 model.name,其次 model.id,附加修饰符后缀 const base = m.name || m.id; primary = resolved.suffix ? base + resolved.suffix : base; } // 多个 provider 可用时跟随 pi footer 显示 (provider) 前缀 const providers = input.footerData.getAvailableProviderCount?.() ?? 0; if (seg.options["show_provider"] !== false && m.provider && providers > 1) { primary = `(${m.provider}) ${primary}`; } return { primary, secondary: "", metadata: { model_id: m.id, display_name: primary, provider: m.provider }, }; } function collectDirectory(input: SegmentInput): SegmentData | null { const cwd = input.ctx.cwd; return { primary: extractDirName(cwd), secondary: "", metadata: { full_path: cwd } }; } function collectGit(input: SegmentInput): SegmentData | null { const branch = input.footerData.getGitBranch(); if (!branch) return null; // 非 git 仓库 const extra = input.gitCache.get(input.ctx.cwd); const metadata: Record = { branch }; const parts: string[] = []; if (extra) { parts.push(extra.status === "clean" ? "✓" : extra.status === "dirty" ? "●" : "⚠"); if (extra.ahead > 0) parts.push(`↑${extra.ahead}`); if (extra.behind > 0) parts.push(`↓${extra.behind}`); if (extra.sha) { parts.push(extra.sha); metadata["sha"] = extra.sha; } metadata["status"] = extra.status; metadata["ahead"] = String(extra.ahead); metadata["behind"] = String(extra.behind); } return { primary: branch, secondary: parts.join(" "), metadata }; } function collectSession(input: SegmentInput): SegmentData | null { // getSessionName 在旧版 pi 上可能不存在,运行时可选用 const name = (input.ctx.sessionManager as { getSessionName?: () => string | undefined }) .getSessionName?.(); if (!name) return null; return { primary: name, secondary: "", metadata: { session_name: name } }; } function collectContextWindow(input: SegmentInput, seg: SegmentConfig): SegmentData | null { const usage = input.ctx.getContextUsage(); const m = input.state.model; const limit = m?.contextWindow ?? usage?.contextWindow ?? 0; const tokens = usage?.tokens; // number | null // 对齐 pi footer:显示 `百分比 + 已用/总量`,未知时用 ? const showTotal = seg.options["show_total"] !== false; if (tokens == null || limit <= 0) { const primary = tokens == null && limit > 0 ? (showTotal ? `?/${formatTokens(limit)}` : "?") : "-"; return { primary, secondary: "", metadata: { limit: String(limit), tokens: "-", percentage: "-" }, }; } const pct = (tokens / limit) * 100; const percentage = Number.isInteger(pct) ? `${pct}%` : `${pct.toFixed(1)}%`; const used = showTotal ? `${formatTokens(tokens)}/${formatTokens(limit)}` : formatTokens(tokens); return { primary: `${percentage} · ${used}`, secondary: "", metadata: { tokens: String(tokens), percentage: String(pct), limit: String(limit) }, }; } function collectTokens(usage: UsageTotals): SegmentData | null { const { input: i, output: o } = usage; if (i === 0 && o === 0) return null; return { primary: `↑${formatTokens(i)} ↓${formatTokens(o)}`, secondary: "", metadata: { input: String(i), output: String(o) }, }; } function collectCache(usage: UsageTotals, seg: SegmentConfig): SegmentData | null { const { cacheRead, cacheWrite, lastCacheHitRate } = usage; if (cacheRead === 0 && cacheWrite === 0) return null; const parts: string[] = []; if (cacheRead > 0) parts.push(`R${formatTokens(cacheRead)}`); if (cacheWrite > 0) parts.push(`W${formatTokens(cacheWrite)}`); // 与 pi footer 一致:仅在有缓存读写且命中率已知时显示 CH if (seg.options["show_hit_rate"] !== false && lastCacheHitRate !== undefined) { parts.push(`CH${lastCacheHitRate.toFixed(1)}%`); } return { primary: parts.join(" "), secondary: "", metadata: { cache_read: String(cacheRead), cache_write: String(cacheWrite), cache_hit_rate: lastCacheHitRate !== undefined ? lastCacheHitRate.toFixed(1) : "-", }, }; } function collectCost(usage: UsageTotals, seg: SegmentConfig): SegmentData | null { const { cost } = usage; if (cost <= 0) return null; const decimals = typeof seg.options["decimals"] === "number" ? (seg.options["decimals"] as number) : 3; return { primary: `$${cost.toFixed(Math.max(0, Math.min(6, decimals)))}`, secondary: "", metadata: { cost: String(cost) }, }; } function collectThinking(input: SegmentInput, seg: SegmentConfig): SegmentData | null { const lvl = input.state.thinkingLevel; // 常驻语义:level 缺失或关闭时显示 off(可用 hide_when_off 恢复隐藏) const normalized = !lvl || lvl === "off" || lvl === "none" ? "off" : lvl; if (normalized === "off" && seg.options["hide_when_off"] === true) return null; return { primary: normalized, secondary: "", metadata: { thinking: normalized, reasoning: String(input.state.model?.reasoning ?? false), }, }; } // ─── 主收集入口 ───────────────────────────────────────────────────────────── /** 收集所有启用的段数据 */ export function collectSegments( input: SegmentInput, config: Config, ): { seg: SegmentConfig; data: SegmentData }[] { const usage = collectUsage(input.ctx); const out: { seg: SegmentConfig; data: SegmentData }[] = []; for (const seg of config.segments) { if (!seg.enabled) continue; let data: SegmentData | null = null; switch (seg.id as SegmentId) { case "model": data = collectModel(input, seg); break; case "directory": data = collectDirectory(input); break; case "git": data = collectGit(input); break; case "session": data = collectSession(input); break; case "contextWindow": data = collectContextWindow(input, seg); break; case "tokens": data = collectTokens(usage); break; case "cache": data = collectCache(usage, seg); break; case "cost": data = collectCost(usage, seg); break; case "thinking": data = collectThinking(input, seg); break; default: continue; } if (data) out.push({ seg, data }); } return out; }