diff --git a/pi/files.linux/agent/settings.json b/pi/files.linux/agent/settings.json index b97d588..0149dd4 100644 --- a/pi/files.linux/agent/settings.json +++ b/pi/files.linux/agent/settings.json @@ -1,7 +1,7 @@ { "lastChangelogVersion": "0.57.1", - "defaultProvider": "openrouter", - "defaultModel": "openai/gpt-5.3-codex", + "defaultProvider": "opencode-go", + "defaultModel": "minimax-m2.5", "defaultThinkingLevel": "medium", "theme": "matugen", "lsp": { diff --git a/pi/files/agent/extensions/session-name.ts b/pi/files/agent/extensions/session-name.ts index ebece8c..954f12f 100644 --- a/pi/files/agent/extensions/session-name.ts +++ b/pi/files/agent/extensions/session-name.ts @@ -14,9 +14,9 @@ import { complete, type Message } from "@mariozechner/pi-ai"; import { getModel } from "@mariozechner/pi-ai"; import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent"; import { - BorderedLoader, - convertToLlm, - serializeConversation, + BorderedLoader, + convertToLlm, + serializeConversation, } from "@mariozechner/pi-coding-agent"; import * as fs from "node:fs"; import * as path from "node:path"; @@ -43,306 +43,306 @@ Output ONLY the session name, nothing else.`; const AUTO_NAME_MODEL = getModel("opencode-go", "minimax-m2.5"); // Number of messages before auto-naming kicks in -const AUTO_NAME_THRESHOLD = 2; +const AUTO_NAME_THRESHOLD = 1; // Debug log file const LOG_FILE = path.join(os.homedir(), ".pi", "session-name-debug.log"); function log(message: string) { - const timestamp = new Date().toISOString(); - const entry = `[${timestamp}] ${message}\n`; - try { - fs.appendFileSync(LOG_FILE, entry); - } catch (e) { - // ignore write errors - } + const timestamp = new Date().toISOString(); + const entry = `[${timestamp}] ${message}\n`; + try { + fs.appendFileSync(LOG_FILE, entry); + } catch (e) { + // ignore write errors + } } -export default function (pi: ExtensionAPI) { - // Track if we've already attempted auto-naming for this session - let autoNamedAttempted = false; +export default function(pi: ExtensionAPI) { + // Track if we've already attempted auto-naming for this session + let autoNamedAttempted = false; - // Listen for agent_end to auto-name sessions (non-blocking) - pi.on("agent_end", (_event, ctx) => { - log("=== agent_end triggered ==="); - log(`hasUI: ${ctx.hasUI}`); - log(`current session name: ${pi.getSessionName()}`); - log(`autoNamedAttempted: ${autoNamedAttempted}`); + // Listen for agent_end to auto-name sessions (non-blocking) + pi.on("agent_end", (_event, ctx) => { + log("=== agent_end triggered ==="); + log(`hasUI: ${ctx.hasUI}`); + log(`current session name: ${pi.getSessionName()}`); + log(`autoNamedAttempted: ${autoNamedAttempted}`); - // Skip if already has a name or already attempted - if (pi.getSessionName() || autoNamedAttempted) { - log("Skipping: already has name or already attempted"); - return; - } + // Skip if already has a name or already attempted + if (pi.getSessionName() || autoNamedAttempted) { + log("Skipping: already has name or already attempted"); + return; + } - // Count user messages in the branch - const branch = ctx.sessionManager.getBranch(); - const userMessages = branch.filter( - (entry): entry is SessionEntry & { type: "message" } => - entry.type === "message" && entry.message.role === "user", - ); + // Count user messages in the branch + const branch = ctx.sessionManager.getBranch(); + const userMessages = branch.filter( + (entry): entry is SessionEntry & { type: "message" } => + entry.type === "message" && entry.message.role === "user", + ); - log(`Total entries in branch: ${branch.length}`); - log(`User messages: ${userMessages.length}`); - log(`Threshold: ${AUTO_NAME_THRESHOLD}`); + log(`Total entries in branch: ${branch.length}`); + log(`User messages: ${userMessages.length}`); + log(`Threshold: ${AUTO_NAME_THRESHOLD}`); - // Only auto-name after threshold is reached - if (userMessages.length < AUTO_NAME_THRESHOLD) { - log("Skipping: below threshold"); - return; - } + // Only auto-name after threshold is reached + if (userMessages.length < AUTO_NAME_THRESHOLD) { + log("Skipping: below threshold"); + return; + } - // Mark as attempted so we don't try again - autoNamedAttempted = true; - log("Threshold reached, attempting auto-name"); + // Mark as attempted so we don't try again + autoNamedAttempted = true; + log("Threshold reached, attempting auto-name"); - // Only auto-name in interactive mode - if (!ctx.hasUI) { - log("Skipping: no UI (non-interactive mode)"); - return; - } + // Only auto-name in interactive mode + if (!ctx.hasUI) { + log("Skipping: no UI (non-interactive mode)"); + return; + } - // Gather conversation context - const messages = branch - .filter( - (entry): entry is SessionEntry & { type: "message" } => - entry.type === "message", - ) - .map((entry) => entry.message); + // Gather conversation context + const messages = branch + .filter( + (entry): entry is SessionEntry & { type: "message" } => + entry.type === "message", + ) + .map((entry) => entry.message); - log(`Total messages to analyze: ${messages.length}`); + log(`Total messages to analyze: ${messages.length}`); - if (messages.length === 0) { - log("No messages found, aborting"); - return; - } + if (messages.length === 0) { + log("No messages found, aborting"); + return; + } - // Convert to LLM format and serialize - const llmMessages = convertToLlm(messages); - const conversationText = serializeConversation(llmMessages); + // Convert to LLM format and serialize + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); - log(`Conversation text length: ${conversationText.length}`); + log(`Conversation text length: ${conversationText.length}`); - // Truncate if too long (keep costs low) - const maxChars = 4000; - const truncatedText = - conversationText.length > maxChars - ? conversationText.slice(0, maxChars) + "\n..." - : conversationText; + // Truncate if too long (keep costs low) + const maxChars = 4000; + const truncatedText = + conversationText.length > maxChars + ? conversationText.slice(0, maxChars) + "\n..." + : conversationText; - log(`Truncated text length: ${truncatedText.length}`); - log("Starting background auto-name..."); + log(`Truncated text length: ${truncatedText.length}`); + log("Starting background auto-name..."); - // Fire-and-forget: run auto-naming in background without blocking - const doAutoName = async () => { - const apiKey = await ctx.modelRegistry.getApiKey(AUTO_NAME_MODEL); - log(`Got API key: ${apiKey ? "yes" : "no"}`); + // Fire-and-forget: run auto-naming in background without blocking + const doAutoName = async () => { + const apiKey = await ctx.modelRegistry.getApiKey(AUTO_NAME_MODEL); + log(`Got API key: ${apiKey ? "yes" : "no"}`); - if (!apiKey) { - log("No API key available, aborting"); - return; - } + if (!apiKey) { + log("No API key available, aborting"); + return; + } - const userMessage: Message = { - role: "user", - content: [ - { - type: "text", - text: `## Conversation History\n\n${truncatedText}\n\nGenerate a concise session name for this conversation.`, - }, - ], - timestamp: Date.now(), - }; + const userMessage: Message = { + role: "user", + content: [ + { + type: "text", + text: `## Conversation History\n\n${truncatedText}\n\nGenerate a concise session name for this conversation.`, + }, + ], + timestamp: Date.now(), + }; - const response = await complete( - AUTO_NAME_MODEL, - { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, - { apiKey }, - ); + const response = await complete( + AUTO_NAME_MODEL, + { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, + { apiKey }, + ); - log(`Response received, stopReason: ${response.stopReason}`); + log(`Response received, stopReason: ${response.stopReason}`); - if (response.stopReason === "aborted") { - log("Request was aborted"); - return; - } + if (response.stopReason === "aborted") { + log("Request was aborted"); + return; + } - const name = response.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text.trim()) - .join(" ") - .replace(/^[\"']|[\"']$/g, ""); // Remove surrounding quotes + const name = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text.trim()) + .join(" ") + .replace(/^[\"']|[\"']$/g, ""); // Remove surrounding quotes - log(`Generated name: "${name}"`); + log(`Generated name: "${name}"`); - // Clean up the generated name - const cleanName = name - .replace(/\n/g, " ") - .replace(/\s+/g, " ") - .trim() - .slice(0, 50); // Max 50 chars + // Clean up the generated name + const cleanName = name + .replace(/\n/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 50); // Max 50 chars - log(`Cleaned name: "${cleanName}"`); + log(`Cleaned name: "${cleanName}"`); - if (cleanName) { - pi.setSessionName(cleanName); - ctx.ui.notify(`Auto-named: ${cleanName}`, "info"); - log(`Successfully set session name to: ${cleanName}`); - } else { - log("Cleaned name was empty, not setting"); - } - }; + if (cleanName) { + pi.setSessionName(cleanName); + ctx.ui.notify(`Auto-named: ${cleanName}`, "info"); + log(`Successfully set session name to: ${cleanName}`); + } else { + log("Cleaned name was empty, not setting"); + } + }; - // Run in background without awaiting - don't block the agent - doAutoName().catch((err) => { - log(`ERROR: ${err}`); - console.error("Auto-naming failed:", err); - }); - }); + // Run in background without awaiting - don't block the agent + doAutoName().catch((err) => { + log(`ERROR: ${err}`); + console.error("Auto-naming failed:", err); + }); + }); - // Reset flag on new session - pi.on("session_start", () => { - log("=== session_start ==="); - autoNamedAttempted = false; - log("Reset autoNamedAttempted to false"); - }); + // Reset flag on new session + pi.on("session_start", () => { + log("=== session_start ==="); + autoNamedAttempted = false; + log("Reset autoNamedAttempted to false"); + }); - pi.on("session_switch", () => { - log("=== session_switch ==="); - autoNamedAttempted = false; - log("Reset autoNamedAttempted to false"); - }); + pi.on("session_switch", () => { + log("=== session_switch ==="); + autoNamedAttempted = false; + log("Reset autoNamedAttempted to false"); + }); - // Manual command for setting/getting session name - pi.registerCommand("session-name", { - description: - "Set, show, or auto-generate session name (usage: /session-name [name] or /session-name --auto)", - handler: async (args, ctx) => { - const trimmedArgs = args.trim(); + // Manual command for setting/getting session name + pi.registerCommand("session-name", { + description: + "Set, show, or auto-generate session name (usage: /session-name [name] or /session-name --auto)", + handler: async (args, ctx) => { + const trimmedArgs = args.trim(); - // Show current name if no args - if (!trimmedArgs) { - const current = pi.getSessionName(); - ctx.ui.notify( - current ? `Session: ${current}` : "No session name set", - "info", - ); - return; - } + // Show current name if no args + if (!trimmedArgs) { + const current = pi.getSessionName(); + ctx.ui.notify( + current ? `Session: ${current}` : "No session name set", + "info", + ); + return; + } - // Auto-generate name using AI - if (trimmedArgs === "--auto" || trimmedArgs === "-a") { - if (!ctx.hasUI) { - ctx.ui.notify("Auto-naming requires interactive mode", "error"); - return; - } + // Auto-generate name using AI + if (trimmedArgs === "--auto" || trimmedArgs === "-a") { + if (!ctx.hasUI) { + ctx.ui.notify("Auto-naming requires interactive mode", "error"); + return; + } - // Gather conversation context - const branch = ctx.sessionManager.getBranch(); - const messages = branch - .filter( - (entry): entry is SessionEntry & { type: "message" } => - entry.type === "message", - ) - .map((entry) => entry.message); + // Gather conversation context + const branch = ctx.sessionManager.getBranch(); + const messages = branch + .filter( + (entry): entry is SessionEntry & { type: "message" } => + entry.type === "message", + ) + .map((entry) => entry.message); - if (messages.length === 0) { - ctx.ui.notify("No conversation to analyze", "error"); - return; - } + if (messages.length === 0) { + ctx.ui.notify("No conversation to analyze", "error"); + return; + } - // Convert to LLM format and serialize - const llmMessages = convertToLlm(messages); - const conversationText = serializeConversation(llmMessages); + // Convert to LLM format and serialize + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); - // Truncate if too long (keep costs low) - const maxChars = 4000; - const truncatedText = - conversationText.length > maxChars - ? conversationText.slice(0, maxChars) + "\n..." - : conversationText; + // Truncate if too long (keep costs low) + const maxChars = 4000; + const truncatedText = + conversationText.length > maxChars + ? conversationText.slice(0, maxChars) + "\n..." + : conversationText; - // Generate name with loader UI - const result = await ctx.ui.custom( - (tui, theme, _kb, done) => { - const loader = new BorderedLoader( - tui, - theme, - "Generating session name...", - ); - loader.onAbort = () => done(null); + // Generate name with loader UI + const result = await ctx.ui.custom( + (tui, theme, _kb, done) => { + const loader = new BorderedLoader( + tui, + theme, + "Generating session name...", + ); + loader.onAbort = () => done(null); - const doGenerate = async () => { - const apiKey = await ctx.modelRegistry.getApiKey(AUTO_NAME_MODEL); + const doGenerate = async () => { + const apiKey = await ctx.modelRegistry.getApiKey(AUTO_NAME_MODEL); - const userMessage: Message = { - role: "user", - content: [ - { - type: "text", - text: `## Conversation History\n\n${truncatedText}\n\nGenerate a concise session name for this conversation.`, - }, - ], - timestamp: Date.now(), - }; + const userMessage: Message = { + role: "user", + content: [ + { + type: "text", + text: `## Conversation History\n\n${truncatedText}\n\nGenerate a concise session name for this conversation.`, + }, + ], + timestamp: Date.now(), + }; - const response = await complete( - AUTO_NAME_MODEL, - { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, - { apiKey, signal: loader.signal }, - ); + const response = await complete( + AUTO_NAME_MODEL, + { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, + { apiKey, signal: loader.signal }, + ); - if (response.stopReason === "aborted") { - return null; - } + if (response.stopReason === "aborted") { + return null; + } - const name = response.content - .filter( - (c): c is { type: "text"; text: string } => c.type === "text", - ) - .map((c) => c.text.trim()) - .join(" ") - .replace(/^[\"']|[\"']$/g, ""); // Remove surrounding quotes + const name = response.content + .filter( + (c): c is { type: "text"; text: string } => c.type === "text", + ) + .map((c) => c.text.trim()) + .join(" ") + .replace(/^[\"']|[\"']$/g, ""); // Remove surrounding quotes - return name; - }; + return name; + }; - doGenerate() - .then(done) - .catch((err) => { - console.error("Auto-naming failed:", err); - done(null); - }); + doGenerate() + .then(done) + .catch((err) => { + console.error("Auto-naming failed:", err); + done(null); + }); - return loader; - }, - ); + return loader; + }, + ); - if (result === null) { - ctx.ui.notify("Auto-naming cancelled", "info"); - return; - } + if (result === null) { + ctx.ui.notify("Auto-naming cancelled", "info"); + return; + } - // Clean up the generated name - const cleanName = result - .replace(/\n/g, " ") - .replace(/\s+/g, " ") - .trim() - .slice(0, 50); // Max 50 chars + // Clean up the generated name + const cleanName = result + .replace(/\n/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 50); // Max 50 chars - if (!cleanName) { - ctx.ui.notify("Failed to generate name", "error"); - return; - } + if (!cleanName) { + ctx.ui.notify("Failed to generate name", "error"); + return; + } - pi.setSessionName(cleanName); - ctx.ui.notify(`Session auto-named: ${cleanName}`, "info"); - return; - } + pi.setSessionName(cleanName); + ctx.ui.notify(`Session auto-named: ${cleanName}`, "info"); + return; + } - // Manual naming - pi.setSessionName(trimmedArgs); - ctx.ui.notify(`Session named: ${trimmedArgs}`, "info"); - }, - }); + // Manual naming + pi.setSessionName(trimmedArgs); + ctx.ui.notify(`Session named: ${trimmedArgs}`, "info"); + }, + }); } diff --git a/pi/files/agent/extensions/sub-bar-local.ts b/pi/files/agent/extensions/sub-bar-local.ts new file mode 100644 index 0000000..a16c394 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar-local.ts @@ -0,0 +1,622 @@ +import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent"; +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import * as fs from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +type ProviderName = "anthropic" | "codex" | "gemini" | "opencode-go"; + +interface RateWindow { + label: string; + usedPercent: number; + resetDescription?: string; + resetAt?: string; +} + +interface UsageSnapshot { + provider: ProviderName; + displayName: string; + windows: RateWindow[]; + error?: string; + fetchedAt: number; + lastSuccessAt?: number; + fromCache?: boolean; +} + +interface ProviderCache { + usage?: UsageSnapshot; + lastSuccessAt?: number; +} + +interface CodexRateWindow { + reset_at?: number; + limit_window_seconds?: number; + used_percent?: number; +} + +interface CodexRateLimit { + primary_window?: CodexRateWindow; + secondary_window?: CodexRateWindow; +} + +interface PiAuthShape { + anthropic?: { access?: string }; + "google-gemini-cli"?: { access?: string }; + "openai-codex"?: { access?: string; accountId?: string }; +} + +const CACHE_TTL_MS = 5 * 60 * 1000; +const REFRESH_MS = 2 * 60 * 1000; +const PROVIDER_ORDER: ProviderName[] = ["anthropic", "codex", "gemini", "opencode-go"]; +const SHORTCUT_TOGGLE = "ctrl+alt+b"; +const showToggleState = async (ctx: ExtensionContext, next: boolean, refresh: () => Promise) => { + if (!next) { + ctx.ui.setWidget("sub-bar-local", undefined); + ctx.ui.notify("sub bar hidden", "info"); + return; + } + ctx.ui.notify("sub bar shown", "info"); + await refresh(); +}; + +const OPENCODE_CONFIG_FILE = join(homedir(), ".config", "opencode", "opencode-go-usage.json"); +const PI_AUTH_FILE = join(homedir(), ".pi", "agent", "auth.json"); + +function readJsonFile(path: string): T | undefined { + try { + if (!fs.existsSync(path)) return undefined; + const content = fs.readFileSync(path, "utf-8"); + return JSON.parse(content) as T; + } catch { + return undefined; + } +} + +function clampPercent(value: number | undefined): number { + if (typeof value !== "number" || Number.isNaN(value)) return 0; + return Math.max(0, Math.min(100, Math.round(value))); +} + +function formatDuration(seconds?: number): string | undefined { + if (typeof seconds !== "number" || seconds <= 0) return undefined; + if (seconds < 60) return `${Math.max(1, Math.round(seconds))}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + if (seconds < 86400) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return m > 0 ? `${h}h ${m}m` : `${h}h`; + } + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + return h > 0 ? `${d}d ${h}h` : `${d}d`; +} + +function formatResetDate(resetIso?: string): string | undefined { + if (!resetIso) return undefined; + const resetDate = new Date(resetIso); + if (Number.isNaN(resetDate.getTime())) return undefined; + const diffSec = Math.max(0, Math.floor((resetDate.getTime() - Date.now()) / 1000)); + return formatDuration(diffSec); +} + +function getErrorAge(lastSuccessAt?: number): string | undefined { + if (!lastSuccessAt) return undefined; + const diffSec = Math.floor((Date.now() - lastSuccessAt) / 1000); + return formatDuration(diffSec); +} + +function getPiAuth(): PiAuthShape { + return readJsonFile(PI_AUTH_FILE) ?? {}; +} + +function loadAnthropicToken(): string | undefined { + const env = process.env.ANTHROPIC_OAUTH_TOKEN?.trim(); + if (env) return env; + return getPiAuth().anthropic?.access; +} + +function loadGeminiToken(): string | undefined { + const env = ( + process.env.GOOGLE_GEMINI_CLI_OAUTH_TOKEN || + process.env.GOOGLE_GEMINI_CLI_ACCESS_TOKEN || + process.env.GEMINI_OAUTH_TOKEN + )?.trim(); + if (env) return env; + return getPiAuth()["google-gemini-cli"]?.access; +} + +function loadCodexCredentials(): { accessToken?: string; accountId?: string } { + const envToken = ( + process.env.OPENAI_CODEX_OAUTH_TOKEN || + process.env.OPENAI_CODEX_ACCESS_TOKEN || + process.env.CODEX_OAUTH_TOKEN || + process.env.CODEX_ACCESS_TOKEN + )?.trim(); + const envAccountId = (process.env.OPENAI_CODEX_ACCOUNT_ID || process.env.CHATGPT_ACCOUNT_ID)?.trim(); + if (envToken) { + return { accessToken: envToken, accountId: envAccountId || undefined }; + } + const auth = getPiAuth()["openai-codex"]; + if (!auth?.access) return {}; + return { accessToken: auth.access, accountId: auth.accountId }; +} + +function loadOpenCodeGoCredentials(): { workspaceId?: string; authCookie?: string } { + const envWorkspaceId = process.env.OPENCODE_GO_WORKSPACE_ID?.trim(); + const envAuthCookie = process.env.OPENCODE_GO_AUTH_COOKIE?.trim(); + if (envWorkspaceId && envAuthCookie) { + return { workspaceId: envWorkspaceId, authCookie: envAuthCookie }; + } + const config = readJsonFile<{ workspaceId?: string; authCookie?: string }>(OPENCODE_CONFIG_FILE); + return { + workspaceId: config?.workspaceId?.trim(), + authCookie: config?.authCookie?.trim(), + }; +} + +function modelToProvider(modelProvider?: string): ProviderName | undefined { + const id = (modelProvider ?? "").toLowerCase(); + if (id.includes("opencode-go")) return "opencode-go"; + if (id.includes("anthropic")) return "anthropic"; + if (id.includes("gemini") || id.includes("google")) return "gemini"; + if (id.includes("codex") || id.includes("openai")) return "codex"; + return undefined; +} + +function pushCodexWindow(windows: RateWindow[], label: string, window?: CodexRateWindow): void { + if (!window) return; + const resetIso = typeof window.reset_at === "number" ? new Date(window.reset_at * 1000).toISOString() : undefined; + windows.push({ + label, + usedPercent: clampPercent(window.used_percent), + resetAt: resetIso, + }); +} + +function barForPercent(theme: Theme, usedPercent: number, width = 8): string { + const safeWidth = Math.max(1, width); + const filled = Math.round((clampPercent(usedPercent) / 100) * safeWidth); + const empty = Math.max(0, safeWidth - filled); + const color = usedPercent >= 85 ? "error" : usedPercent >= 60 ? "warning" : "success"; + return `${theme.fg(color, "─".repeat(filled))}${theme.fg("dim", "─".repeat(empty))}`; +} + +function padToWidth(text: string, width: number): string { + const w = visibleWidth(text); + if (w >= width) return truncateToWidth(text, width); + return text + " ".repeat(width - w); +} + +function buildColumnWidths(totalWidth: number, columns: number): number[] { + if (columns <= 0) return []; + const base = Math.max(1, Math.floor(totalWidth / columns)); + let remainder = Math.max(0, totalWidth - base * columns); + const widths: number[] = []; + for (let i = 0; i < columns; i++) { + const extra = remainder > 0 ? 1 : 0; + widths.push(base + extra); + if (remainder > 0) remainder--; + } + return widths; +} + +function formatUsageTwoLines(theme: Theme, usage: UsageSnapshot, width: number): { top: string; bottom?: string } { + const provider = theme.bold(theme.fg("accent", usage.displayName)); + if (usage.error && usage.windows.length === 0) { + const age = getErrorAge(usage.lastSuccessAt); + const stale = age ? ` (stale ${age})` : ""; + return { top: truncateToWidth(`${provider} ${theme.fg("warning", `⚠ ${usage.error}${stale}`)}`, width) }; + } + if (usage.windows.length === 0) { + return { top: truncateToWidth(provider, width) }; + } + + const prefix = `${provider} ${theme.fg("dim", "•")} `; + const prefixWidth = Math.min(visibleWidth(prefix), Math.max(0, width - 1)); + const contentWidth = Math.max(1, width - prefixWidth); + const gap = " "; + const gapWidth = visibleWidth(gap); + const windowsCount = usage.windows.length; + const totalGapWidth = Math.max(0, (windowsCount - 1) * gapWidth); + const columnsArea = Math.max(1, contentWidth - totalGapWidth); + const colWidths = buildColumnWidths(columnsArea, windowsCount); + + const topCols = usage.windows.map((window, index) => { + const colWidth = colWidths[index] ?? 1; + const pct = clampPercent(window.usedPercent); + const resetRaw = formatResetDate(window.resetAt) ?? window.resetDescription ?? ""; + const left = `${window.label} ${pct}%`; + const right = resetRaw; + if (!right) return padToWidth(theme.fg("text", left), colWidth); + const leftWidth = visibleWidth(left); + const rightWidth = visibleWidth(right); + if (leftWidth + 1 + rightWidth <= colWidth) { + const spaces = " ".repeat(colWidth - leftWidth - rightWidth); + return `${theme.fg("text", left)}${spaces}${theme.fg("dim", right)}`; + } + const compact = `${left} ${right}`; + return padToWidth(theme.fg("text", truncateToWidth(compact, colWidth)), colWidth); + }); + + const bottomCols = usage.windows.map((window, index) => { + const colWidth = colWidths[index] ?? 1; + const pct = clampPercent(window.usedPercent); + return barForPercent(theme, pct, colWidth); + }); + + const top = prefix + topCols.join(gap); + const bottomPrefix = " ".repeat(prefixWidth); + const bottom = bottomPrefix + bottomCols.join(gap); + + return { + top: padToWidth(top, width), + bottom: padToWidth(bottom, width), + }; +} + +async function fetchAnthropicUsage(): Promise { + const token = loadAnthropicToken(); + if (!token) throw new Error("missing anthropic oauth token"); + + const response = await fetch("https://api.anthropic.com/api/oauth/usage", { + headers: { + Authorization: `Bearer ${token}`, + "anthropic-beta": "oauth-2025-04-20", + }, + }); + if (!response.ok) throw new Error(`anthropic http ${response.status}`); + + const data = (await response.json()) as { + five_hour?: { utilization?: number; resets_at?: string }; + seven_day?: { utilization?: number; resets_at?: string }; + extra_usage?: { utilization?: number; used_credits?: number; monthly_limit?: number; is_enabled?: boolean }; + }; + + const windows: RateWindow[] = []; + if (data.five_hour?.utilization !== undefined) { + windows.push({ label: "5h", usedPercent: clampPercent(data.five_hour.utilization), resetAt: data.five_hour.resets_at }); + } + if (data.seven_day?.utilization !== undefined) { + windows.push({ label: "Week", usedPercent: clampPercent(data.seven_day.utilization), resetAt: data.seven_day.resets_at }); + } + if (data.extra_usage?.is_enabled && data.extra_usage.utilization !== undefined) { + const used = data.extra_usage.used_credits ?? 0; + const limit = data.extra_usage.monthly_limit; + const label = limit && limit > 0 ? `Extra ${used}/${limit}` : `Extra ${used}`; + windows.push({ label, usedPercent: clampPercent(data.extra_usage.utilization) }); + } + if (windows.length === 0) throw new Error("no anthropic usage windows"); + + const now = Date.now(); + return { + provider: "anthropic", + displayName: "Claude Plan", + windows, + fetchedAt: now, + lastSuccessAt: now, + }; +} + +async function fetchCodexUsage(): Promise { + const { accessToken, accountId } = loadCodexCredentials(); + if (!accessToken) throw new Error("missing codex oauth token"); + + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }; + if (accountId) headers["ChatGPT-Account-Id"] = accountId; + + const response = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers }); + if (!response.ok) throw new Error(`codex http ${response.status}`); + + const data = (await response.json()) as { + rate_limit?: CodexRateLimit; + }; + + const windows: RateWindow[] = []; + pushCodexWindow(windows, "3h", data.rate_limit?.primary_window); + pushCodexWindow(windows, "Day", data.rate_limit?.secondary_window); + if (windows.length === 0) throw new Error("no codex usage windows"); + + const now = Date.now(); + return { + provider: "codex", + displayName: "Codex Plan", + windows, + fetchedAt: now, + lastSuccessAt: now, + }; +} + +async function fetchGeminiUsage(): Promise { + const token = loadGeminiToken(); + if (!token) throw new Error("missing gemini oauth token"); + + const response = await fetch("https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: "{}", + }); + if (!response.ok) throw new Error(`gemini http ${response.status}`); + + const data = (await response.json()) as { + buckets?: Array<{ modelId?: string; remainingFraction?: number }>; + }; + + let minPro = 1; + let minFlash = 1; + let hasPro = false; + let hasFlash = false; + for (const bucket of data.buckets ?? []) { + const id = bucket.modelId?.toLowerCase() ?? ""; + const remaining = typeof bucket.remainingFraction === "number" ? bucket.remainingFraction : 1; + if (id.includes("pro")) { + hasPro = true; + minPro = Math.min(minPro, remaining); + } + if (id.includes("flash")) { + hasFlash = true; + minFlash = Math.min(minFlash, remaining); + } + } + + const windows: RateWindow[] = []; + if (hasPro) windows.push({ label: "Pro", usedPercent: clampPercent((1 - minPro) * 100) }); + if (hasFlash) windows.push({ label: "Flash", usedPercent: clampPercent((1 - minFlash) * 100) }); + if (windows.length === 0) throw new Error("no gemini usage windows"); + + const now = Date.now(); + return { + provider: "gemini", + displayName: "Gemini Plan", + windows, + fetchedAt: now, + lastSuccessAt: now, + }; +} + +function parseInlineObject(raw: string): Record { + const normalized = raw.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)(\s*:)/g, "$1\"$2\"$3"); + return JSON.parse(normalized) as Record; +} + +async function fetchOpenCodeGoUsage(): Promise { + const { workspaceId, authCookie } = loadOpenCodeGoCredentials(); + if (!workspaceId || !authCookie) throw new Error(`missing ${OPENCODE_CONFIG_FILE} credentials`); + if (!/^wrk_[a-zA-Z0-9]+$/.test(workspaceId)) throw new Error("invalid workspace id format"); + + const url = `https://opencode.ai/workspace/${encodeURIComponent(workspaceId)}/go`; + const response = await fetch(url, { + headers: { + "User-Agent": "Mozilla/5.0", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + Cookie: `auth=${authCookie}`, + }, + }); + if (!response.ok) throw new Error(`opencode-go http ${response.status}`); + + const html = await response.text(); + const patterns: Record = { + Rolling: /rollingUsage:\$R\[\d+\]=(\{[^}]+\})/, + Weekly: /weeklyUsage:\$R\[\d+\]=(\{[^}]+\})/, + Monthly: /monthlyUsage:\$R\[\d+\]=(\{[^}]+\})/, + }; + + const windows: RateWindow[] = []; + for (const [label, pattern] of Object.entries(patterns)) { + const match = html.match(pattern); + if (!match?.[1]) continue; + try { + const parsed = parseInlineObject(match[1]); + const usagePercent = clampPercent(Number(parsed.usagePercent)); + const resetSec = Number(parsed.resetInSec); + const resetDescription = formatDuration(Number.isFinite(resetSec) ? resetSec : undefined); + const resetAt = Number.isFinite(resetSec) && resetSec > 0 + ? new Date(Date.now() + resetSec * 1000).toISOString() + : undefined; + windows.push({ + label, + usedPercent: usagePercent, + resetDescription, + resetAt, + }); + } catch { + // Ignore malformed chunks and keep parsing others + } + } + + if (windows.length === 0) throw new Error("could not parse opencode-go usage from page"); + + const now = Date.now(); + return { + provider: "opencode-go", + displayName: "OpenCode Go", + windows, + fetchedAt: now, + lastSuccessAt: now, + }; +} + +async function fetchUsage(provider: ProviderName): Promise { + switch (provider) { + case "anthropic": + return fetchAnthropicUsage(); + case "codex": + return fetchCodexUsage(); + case "gemini": + return fetchGeminiUsage(); + case "opencode-go": + return fetchOpenCodeGoUsage(); + } +} + +export default function createSubBarLocal(pi: ExtensionAPI) { + let lastCtx: ExtensionContext | undefined; + let activeProvider: ProviderName | "auto" = "auto"; + let widgetEnabled = true; + let refreshTimer: NodeJS.Timeout | undefined; + const cache: Partial> = {}; + + function getSelectedProvider(ctx?: ExtensionContext): ProviderName | undefined { + if (activeProvider !== "auto") return activeProvider; + return modelToProvider(ctx?.model?.provider); + } + + function render(ctx: ExtensionContext): void { + if (!ctx.hasUI) return; + if (!widgetEnabled) { + ctx.ui.setWidget("sub-bar-local", undefined); + return; + } + const provider = getSelectedProvider(ctx); + if (!provider) { + ctx.ui.setWidget("sub-bar-local", undefined); + return; + } + const snapshot = cache[provider]?.usage; + + const setWidgetWithPlacement = (ctx.ui as unknown as { setWidget: (...args: unknown[]) => void }).setWidget; + setWidgetWithPlacement( + "sub-bar-local", + (_tui: unknown, theme: Theme) => ({ + render(width: number) { + const safeWidth = Math.max(1, width); + const topDivider = theme.fg("dim", "─".repeat(safeWidth)); + if (!snapshot) { + const loading = truncateToWidth(theme.fg("dim", `sub bar loading ${provider} usage...`), safeWidth); + return [topDivider, padToWidth(loading, safeWidth)]; + } + const lines = formatUsageTwoLines(theme, snapshot, safeWidth); + const output = [topDivider, lines.top]; + if (lines.bottom) output.push(lines.bottom); + return output; + }, + invalidate() {}, + }), + { placement: "aboveEditor" }, + ); + } + + async function refreshCurrent(ctx: ExtensionContext, force = false): Promise { + const provider = getSelectedProvider(ctx); + if (!provider) { + ctx.ui.setWidget("sub-bar-local", undefined); + return; + } + const cached = cache[provider]?.usage; + if (!force && cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + render(ctx); + return; + } + + try { + const fresh = await fetchUsage(provider); + cache[provider] = { + usage: { + ...fresh, + fromCache: false, + lastSuccessAt: Date.now(), + }, + lastSuccessAt: Date.now(), + }; + } catch (error) { + const message = error instanceof Error ? error.message : "fetch failed"; + const fallback = cache[provider]?.usage; + cache[provider] = { + usage: { + provider, + displayName: fallback?.displayName ?? provider, + windows: fallback?.windows ?? [], + error: message, + fetchedAt: Date.now(), + lastSuccessAt: cache[provider]?.lastSuccessAt, + fromCache: Boolean(fallback), + }, + lastSuccessAt: cache[provider]?.lastSuccessAt, + }; + } + + render(ctx); + } + + function startRefreshLoop(ctx: ExtensionContext): void { + if (refreshTimer) clearInterval(refreshTimer); + refreshTimer = setInterval(() => { + if (!lastCtx) return; + void refreshCurrent(lastCtx); + }, REFRESH_MS); + refreshTimer.unref?.(); + void refreshCurrent(ctx, true); + } + + pi.registerCommand("sub:refresh", { + description: "Refresh sub bar usage now", + handler: async (_args, ctx) => { + await refreshCurrent(ctx, true); + }, + }); + + pi.registerCommand("sub:provider", { + description: "Set sub bar provider (auto|anthropic|codex|gemini|opencode-go)", + handler: async (args, ctx) => { + const raw = String(args ?? "").trim().toLowerCase(); + if (!raw || raw === "auto") { + activeProvider = "auto"; + ctx.ui.notify("sub bar provider: auto", "info"); + await refreshCurrent(ctx, true); + return; + } + if (PROVIDER_ORDER.includes(raw as ProviderName)) { + activeProvider = raw as ProviderName; + ctx.ui.notify(`sub bar provider: ${activeProvider}`, "info"); + await refreshCurrent(ctx, true); + return; + } + ctx.ui.notify("invalid provider. use: auto|anthropic|codex|gemini|opencode-go", "warning"); + }, + }); + + pi.registerCommand("sub:toggle", { + description: "Toggle sub bar on/off", + handler: async (_args, ctx) => { + widgetEnabled = !widgetEnabled; + await showToggleState(ctx, widgetEnabled, async () => refreshCurrent(ctx, true)); + }, + }); + + pi.registerShortcut(SHORTCUT_TOGGLE as import("@mariozechner/pi-tui").KeyId, { + description: "Toggle sub bar on/off", + handler: async (ctx) => { + widgetEnabled = !widgetEnabled; + await showToggleState(ctx, widgetEnabled, async () => refreshCurrent(ctx, true)); + }, + }); + + pi.on("session_start", async (_event, ctx) => { + lastCtx = ctx; + if (!ctx.hasUI) return; + render(ctx); + startRefreshLoop(ctx); + }); + + pi.on("model_select", async (_event, ctx) => { + lastCtx = ctx; + if (!ctx.hasUI) return; + render(ctx); + if (activeProvider === "auto") { + await refreshCurrent(ctx, true); + } + }); + + pi.on("session_shutdown", async () => { + lastCtx = undefined; + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = undefined; + } + }); +}