// PiCometixLine — pi-agent 状态栏扩展入口 // 移植自 CCometixLine:通过 ctx.ui.setFooter() 自定义 pi 的 footer 状态栏, // 显示 Model | Directory | Git | Context Window | Cost | Tokens | Thinking 等段。 import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Container, SelectList, type SelectItem, Text } from "@earendil-works/pi-tui"; import { DynamicBorder } from "@earendil-works/pi-coding-agent"; import { loadConfig, saveConfig, initConfig } from "./config.ts"; import { getTheme, THEME_NAMES } from "./themes.ts"; import { renderFooterLine } from "./render.ts"; import { collectSegments, type CclineState, type FooterDataLike } from "./segments.ts"; import { GitCache } from "./git.ts"; /** footerData 的完整结构(对齐 pi 的 ReadonlyFooterDataProvider) */ interface FooterData { getGitBranch(): string | null; getExtensionStatuses(): ReadonlyMap; getAvailableProviderCount(): number; onBranchChange(cb: () => void): () => void; } /** 主题描述(命令选择列表用) */ const THEME_DESC: Record = { cometix: "NerdFont · 16色(默认)", default: "Plain emoji · 16色", minimal: "Plain · 低饱和", nord: "NerdFont · Nord 配色", gruvbox: "NerdFont · Gruvbox 配色", "powerline-dark": "Powerline · 深色", "powerline-light": "Powerline · 浅色", "powerline-rose-pine": "Powerline · Rose Pine", "powerline-tokyo-night": "Powerline · Tokyo Night", }; /** 运行时状态 */ interface RuntimeState { config: ReturnType; modelConfig: ReturnType["modelConfig"]; state: CclineState; gitCache: GitCache; enabled: boolean; /** 由 footer factory 注入的重新渲染回调(事件中调用以刷新 footer) */ requestRender?: () => void; } export default function (pi: ExtensionAPI) { const rt: RuntimeState = { config: getTheme("cometix"), modelConfig: { models: [], contextModifiers: [] }, state: { model: undefined, thinkingLevel: undefined }, gitCache: new GitCache(false), enabled: true, }; /** 重新加载配置文件(config.json + models.json) */ function reload() { const { config, modelConfig, enabled } = loadConfig(); rt.config = config; rt.modelConfig = modelConfig; rt.enabled = enabled; const gitSeg = config.segments.find((s) => s.id === "git"); rt.gitCache.setShowSha(Boolean(gitSeg?.options?.["show_sha"])); } /** Model → 精简 state.model */ function toModelState(m: { id: string; name: string; contextWindow: number; provider: string; reasoning: boolean; }) { return { id: m.id, name: m.name, contextWindow: m.contextWindow, provider: m.provider, reasoning: m.reasoning, }; } /** 安装自定义 footer */ function installFooter(ctx: ExtensionContext) { ctx.ui.setFooter((tui, _theme, footerData: FooterData) => { // 注入重新渲染回调,供事件触发 footer 刷新 rt.requestRender = () => tui.requestRender(); // git 分支变化 → 重新渲染 const unsub = footerData.onBranchChange(() => rt.requestRender?.()); return { dispose: unsub, invalidate() {}, render(width: number): string[] { if (!rt.enabled) return []; // 触发异步 git 状态刷新(节流,不阻塞渲染),刷新后重新渲染 rt.gitCache.refresh(ctx.cwd, () => rt.requestRender?.()).catch(() => {}); // 兜底:state.model 未初始化时用闭包 ctx.model const model = rt.state.model ?? (ctx.model ? toModelState(ctx.model) : undefined); const thinking = rt.state.thinkingLevel ?? (ctx.thinkingLevel as string | undefined); const items = collectSegments( { ctx, footerData: footerData as FooterDataLike, gitCache: rt.gitCache, state: { model, thinkingLevel: thinking }, modelConfig: rt.modelConfig, }, rt.config, ); return renderFooterLine(rt.config, items, width); }, }; }); } /** 卸载自定义 footer,恢复 pi 默认 */ function uninstallFooter(ctx: ExtensionContext) { ctx.ui.setFooter(undefined); } // ─── 会话生命周期 ──────────────────────────────────────────────────────── pi.on("session_start", async (_event, ctx) => { reload(); // 初始化运行时状态:当前 model + thinking level rt.state.model = ctx.model ? toModelState(ctx.model) : undefined; rt.state.thinkingLevel = ctx.thinkingLevel as string | undefined; if (rt.enabled) installFooter(ctx); // 首次强制刷新 git 状态 rt.gitCache.refresh(ctx.cwd, () => rt.requestRender?.(), true).catch(() => {}); }); pi.on("model_select", async (event, _ctx) => { const m = event.model; rt.state.model = m ? toModelState(m) : undefined; rt.requestRender?.(); }); pi.on("thinking_level_select", async (event, _ctx) => { rt.state.thinkingLevel = event.level as string; rt.requestRender?.(); }); pi.on("session_info_changed", async (_event, _ctx) => { // 会话名变化 → session 段刷新 rt.requestRender?.(); }); pi.on("turn_end", async (_event, ctx) => { // 每轮结束强制刷新 git 状态(status/ahead/behind 可能变化) rt.gitCache.refresh(ctx.cwd, () => rt.requestRender?.(), true).catch(() => {}); // token/cost 由 pi 自动重绘 footer,此处不重复 requestRender }); // ─── 命令:/ccline ───────────────────────────────────────────────────────── pi.registerCommand("ccline", { description: "PiCometixLine 状态栏:主题选择 / 开关 / 初始化", handler: async (args, ctx) => { const parts = args.trim().split(/\s+/).filter(Boolean); const sub = parts[0]?.toLowerCase(); if (sub === "off") { rt.enabled = false; uninstallFooter(ctx); saveConfig(rt.config, false); ctx.ui.notify("PiCometixLine 已关闭(恢复默认 footer)", "info"); return; } if (sub === "on") { rt.enabled = true; reload(); installFooter(ctx); saveConfig(rt.config, true); ctx.ui.notify("PiCometixLine 已开启", "info"); return; } if (sub === "init") { initConfig(); reload(); ctx.ui.notify("已初始化配置:~/.pi/agent/ccline/", "info"); return; } if (sub === "theme") { const name = parts[1]; if (name && THEME_NAMES.includes(name as (typeof THEME_NAMES)[number])) { rt.config = getTheme(name); saveConfig(rt.config, rt.enabled); reload(); rt.requestRender?.(); ctx.ui.notify(`已切换主题:${name}`, "info"); } else { ctx.ui.notify(`未知主题。可用:${THEME_NAMES.join(", ")}`, "warning"); } return; } if (sub === "status") { ctx.ui.notify( `ccline: ${rt.enabled ? "on" : "off"} | theme=${rt.config.theme} | model=${rt.state.model?.name ?? "-"}`, "info", ); return; } // 默认:打开主题选择对话框 await pickTheme(ctx); }, }); /** 主题选择对话框 */ async function pickTheme(ctx: ExtensionCommandContext) { const items: SelectItem[] = THEME_NAMES.map((n) => ({ value: n, label: n, description: n === rt.config.theme ? `${THEME_DESC[n] ?? ""} ·(当前)` : (THEME_DESC[n] ?? ""), })); const result = await ctx.ui.custom((tui, theme, _kb, done) => { const container = new Container(); container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); container.addChild(new Text(theme.fg("accent", theme.bold("PiCometixLine 主题")), 1, 0)); container.addChild(new Text(theme.fg("muted", "为 pi-agent footer 选择状态栏主题"), 1, 0)); const list = new SelectList(items, Math.min(items.length, 12), { selectedPrefix: (t) => theme.fg("accent", t), selectedText: (t) => theme.fg("accent", t), description: (t) => theme.fg("muted", t), scrollInfo: (t) => theme.fg("dim", t), noMatch: (t) => theme.fg("warning", t), }); list.onSelect = (item) => done(item.value); list.onCancel = () => done(null); container.addChild(list); container.addChild(new Text(theme.fg("dim", "↑↓ 选择 • enter 确认 • esc 取消"), 1, 0)); container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); return { render: (w: number) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data: string) => { list.handleInput(data); tui.requestRender(); }, }; }); if (result) { rt.config = getTheme(result); saveConfig(rt.config, rt.enabled); reload(); rt.requestRender?.(); ctx.ui.notify(`已切换主题:${result}`, "info"); } } }