From 403650a437ba6a58dceec05ff7704b24f7f38e4e Mon Sep 17 00:00:00 2001 From: "Thomas G. Lopes" Date: Thu, 12 Mar 2026 12:30:09 +0000 Subject: [PATCH] add sub-bar usage widget extension with opencode-go support --- pi/files.linux/agent/settings.json | 4 +- pi/files/agent/extensions/sub-bar/index.ts | 1018 ++++++ .../extensions/sub-bar/package-lock.json | 2718 +++++++++++++++++ .../agent/extensions/sub-bar/package.json | 16 + pi/files/agent/extensions/sub-bar/shared.ts | 6 + .../extensions/sub-bar/src/core-settings.ts | 25 + .../agent/extensions/sub-bar/src/dividers.ts | 48 + .../agent/extensions/sub-bar/src/errors.ts | 71 + .../extensions/sub-bar/src/formatting.ts | 937 ++++++ .../agent/extensions/sub-bar/src/paths.ts | 21 + .../sub-bar/src/providers/extras.ts | 21 + .../sub-bar/src/providers/metadata.ts | 202 ++ .../sub-bar/src/providers/settings.ts | 359 +++ .../sub-bar/src/providers/windows.ts | 23 + .../extensions/sub-bar/src/settings-types.ts | 611 ++++ .../extensions/sub-bar/src/settings-ui.ts | 5 + .../agent/extensions/sub-bar/src/settings.ts | 176 ++ .../sub-bar/src/settings/display.ts | 718 +++++ .../extensions/sub-bar/src/settings/menu.ts | 183 ++ .../extensions/sub-bar/src/settings/themes.ts | 349 +++ .../extensions/sub-bar/src/settings/ui.ts | 1378 +++++++++ .../agent/extensions/sub-bar/src/share.ts | 75 + .../agent/extensions/sub-bar/src/shared.ts | 229 ++ .../agent/extensions/sub-bar/src/status.ts | 103 + .../agent/extensions/sub-bar/src/storage.ts | 61 + .../agent/extensions/sub-bar/src/types.ts | 25 + .../sub-bar/src/ui/settings-list.ts | 304 ++ .../extensions/sub-bar/src/usage/types.ts | 5 + .../agent/extensions/sub-bar/src/utils.ts | 42 + .../extensions/sub-bar/sub-core/index.ts | 535 ++++ .../extensions/sub-bar/sub-core/package.json | 35 + .../extensions/sub-bar/sub-core/src/cache.ts | 489 +++ .../extensions/sub-bar/sub-core/src/config.ts | 35 + .../sub-bar/sub-core/src/dependencies.ts | 37 + .../extensions/sub-bar/sub-core/src/errors.ts | 71 + .../extensions/sub-bar/sub-core/src/paths.ts | 55 + .../sub-bar/sub-core/src/provider.ts | 66 + .../sub-core/src/providers/detection.ts | 51 + .../sub-core/src/providers/impl/anthropic.ts | 174 ++ .../src/providers/impl/antigravity.ts | 226 ++ .../sub-core/src/providers/impl/codex.ts | 186 ++ .../sub-core/src/providers/impl/copilot.ts | 176 ++ .../sub-core/src/providers/impl/gemini.ts | 130 + .../sub-core/src/providers/impl/kiro.ts | 92 + .../src/providers/impl/opencode-go.ts | 191 ++ .../sub-core/src/providers/impl/zai.ts | 120 + .../sub-bar/sub-core/src/providers/index.ts | 5 + .../sub-core/src/providers/metadata.ts | 16 + .../sub-core/src/providers/registry.ts | 57 + .../sub-core/src/providers/settings.ts | 109 + .../sub-bar/sub-core/src/providers/status.ts | 25 + .../sub-bar/sub-core/src/settings-types.ts | 95 + .../sub-bar/sub-core/src/settings-ui.ts | 1 + .../sub-bar/sub-core/src/settings.ts | 137 + .../sub-bar/sub-core/src/settings/behavior.ts | 58 + .../sub-bar/sub-core/src/settings/menu.ts | 83 + .../sub-bar/sub-core/src/settings/tools.ts | 38 + .../sub-bar/sub-core/src/settings/ui.ts | 450 +++ .../extensions/sub-bar/sub-core/src/status.ts | 245 ++ .../sub-bar/sub-core/src/storage.ts | 61 + .../sub-bar/sub-core/src/storage/lock.ts | 60 + .../extensions/sub-bar/sub-core/src/types.ts | 33 + .../sub-bar/sub-core/src/ui/settings-list.ts | 290 ++ .../sub-bar/sub-core/src/usage/controller.ts | 250 ++ .../sub-bar/sub-core/src/usage/fetch.ts | 215 ++ .../sub-bar/sub-core/src/usage/types.ts | 5 + .../extensions/sub-bar/sub-core/src/utils.ts | 122 + .../extensions/sub-bar/sub-core/tsconfig.json | 5 + .../agent/extensions/sub-bar/tsconfig.json | 5 + .../skills/pi-skills/browser-tools/SKILL.md | 14 +- 70 files changed, 14775 insertions(+), 6 deletions(-) create mode 100644 pi/files/agent/extensions/sub-bar/index.ts create mode 100644 pi/files/agent/extensions/sub-bar/package-lock.json create mode 100644 pi/files/agent/extensions/sub-bar/package.json create mode 100644 pi/files/agent/extensions/sub-bar/shared.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/core-settings.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/dividers.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/errors.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/formatting.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/paths.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/providers/extras.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/providers/metadata.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/providers/settings.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/providers/windows.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/settings-types.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/settings-ui.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/settings.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/settings/display.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/settings/menu.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/settings/themes.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/settings/ui.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/share.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/shared.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/status.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/storage.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/types.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/ui/settings-list.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/usage/types.ts create mode 100644 pi/files/agent/extensions/sub-bar/src/utils.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/index.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/package.json create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/cache.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/config.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/dependencies.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/errors.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/paths.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/provider.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/providers/detection.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/anthropic.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/antigravity.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/codex.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/copilot.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/gemini.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/kiro.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/opencode-go.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/zai.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/providers/index.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/providers/metadata.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/providers/registry.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/providers/settings.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/providers/status.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/settings-types.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/settings-ui.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/settings.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/settings/behavior.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/settings/menu.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/settings/tools.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/settings/ui.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/status.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/storage.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/storage/lock.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/types.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/ui/settings-list.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/usage/controller.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/usage/fetch.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/usage/types.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/src/utils.ts create mode 100644 pi/files/agent/extensions/sub-bar/sub-core/tsconfig.json create mode 100644 pi/files/agent/extensions/sub-bar/tsconfig.json diff --git a/pi/files.linux/agent/settings.json b/pi/files.linux/agent/settings.json index 0345261..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": "anthropic", - "defaultModel": "claude-opus-4-6", + "defaultProvider": "opencode-go", + "defaultModel": "minimax-m2.5", "defaultThinkingLevel": "medium", "theme": "matugen", "lsp": { diff --git a/pi/files/agent/extensions/sub-bar/index.ts b/pi/files/agent/extensions/sub-bar/index.ts new file mode 100644 index 0000000..4dee28c --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/index.ts @@ -0,0 +1,1018 @@ +/** + * sub-bar - Usage Widget Extension + * Shows current provider's usage in a widget above the editor. + * Only shows stats for the currently selected provider. + */ + +import type { ExtensionAPI, ExtensionContext, Theme, ThemeColor } from "@mariozechner/pi-coding-agent"; +import { Container, Input, SelectList, Spacer, Text, truncateToWidth, wrapTextWithAnsi, visibleWidth } from "@mariozechner/pi-tui"; +import * as fs from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ProviderName, ProviderUsageEntry, SubCoreAllState, SubCoreState, UsageSnapshot } from "./src/types.js"; +import { type Settings, type BaseTextColor } from "./src/settings-types.js"; +import { isBackgroundColor, resolveBaseTextColor, resolveDividerColor } from "./src/settings-types.js"; +import { buildDividerLine } from "./src/dividers.js"; +import type { CoreSettings } from "./src/shared.js"; +import type { KeyId } from "@mariozechner/pi-tui"; +import { formatUsageStatus, formatUsageStatusWithWidth } from "./src/formatting.js"; +import type { ContextInfo } from "./src/formatting.js"; +import { clearSettingsCache, loadSettings, saveSettings, SETTINGS_PATH } from "./src/settings.js"; +import { showSettingsUI } from "./src/settings-ui.js"; +import { decodeDisplayShareString } from "./src/share.js"; +import { upsertDisplayTheme } from "./src/settings/themes.js"; +import { getFallbackCoreSettings } from "./src/core-settings.js"; + +type SubCoreRequest = + | { + type?: "current"; + includeSettings?: boolean; + reply: (payload: { state: SubCoreState; settings?: CoreSettings }) => void; + } + | { + type: "entries"; + force?: boolean; + reply: (payload: { entries: ProviderUsageEntry[] }) => void; + }; + +type SubCoreAction = { + type: "refresh" | "cycleProvider"; + force?: boolean; +}; + +function applyBackground(lines: string[], theme: Theme, color: BaseTextColor, width: number): string[] { + const bgAnsi = isBackgroundColor(color) + ? theme.getBgAnsi(color as Parameters[0]) + : theme.getFgAnsi(resolveDividerColor(color)).replace(/\x1b\[38;/g, "\x1b[48;").replace(/\x1b\[39m/g, "\x1b[49m"); + if (!bgAnsi || bgAnsi === "\x1b[49m") return lines; + return lines.map((line) => { + const padding = Math.max(0, width - visibleWidth(line)); + return `${bgAnsi}${line}${" ".repeat(padding)}\x1b[49m`; + }); +} + +function applyBaseTextColor(theme: Theme, color: BaseTextColor, text: string): string { + if (isBackgroundColor(color)) { + const fgAnsi = theme + .getBgAnsi(color as Parameters[0]) + .replace(/\x1b\[48;/g, "\x1b[38;") + .replace(/\x1b\[49m/g, "\x1b[39m"); + return `${fgAnsi}${text}\x1b[39m`; + } + return theme.fg(resolveDividerColor(color), text); +} + +type PiSettings = { + enabledModels?: unknown; +}; + +const AGENT_SETTINGS_ENV = "PI_CODING_AGENT_DIR"; +const DEFAULT_AGENT_DIR = join(homedir(), ".pi", "agent"); +const PROJECT_SETTINGS_DIR = ".pi"; +const SETTINGS_FILE_NAME = "settings.json"; + +let scopedModelPatternsCache: { cwd: string; patterns: string[] } | undefined; + +function expandTilde(value: string): string { + if (value === "~") return homedir(); + if (value.startsWith("~/")) return join(homedir(), value.slice(2)); + return value; +} + +function resolveAgentSettingsPath(): string { + const envDir = process.env[AGENT_SETTINGS_ENV]; + const agentDir = envDir ? expandTilde(envDir) : DEFAULT_AGENT_DIR; + return join(agentDir, SETTINGS_FILE_NAME); +} + +function readPiSettings(path: string): PiSettings | null { + try { + if (!fs.existsSync(path)) return null; + const content = fs.readFileSync(path, "utf-8"); + return JSON.parse(content) as PiSettings; + } catch { + return null; + } +} + +function loadScopedModelPatterns(cwd: string): string[] { + if (scopedModelPatternsCache?.cwd === cwd) { + return scopedModelPatternsCache.patterns; + } + + const globalSettings = readPiSettings(resolveAgentSettingsPath()); + const projectSettingsPath = join(cwd, PROJECT_SETTINGS_DIR, SETTINGS_FILE_NAME); + const projectSettings = readPiSettings(projectSettingsPath); + + let enabledModels = Array.isArray(globalSettings?.enabledModels) + ? (globalSettings?.enabledModels as string[]) + : undefined; + + if (projectSettings && Object.prototype.hasOwnProperty.call(projectSettings, "enabledModels")) { + enabledModels = Array.isArray(projectSettings.enabledModels) + ? (projectSettings.enabledModels as string[]) + : []; + } + + const patterns = !enabledModels || enabledModels.length === 0 + ? [] + : enabledModels.filter((value) => typeof value === "string"); + scopedModelPatternsCache = { cwd, patterns }; + return patterns; +} + +/** + * Create the extension + */ +export default function createExtension(pi: ExtensionAPI) { + let lastContext: ExtensionContext | undefined; + let settings: Settings = loadSettings(); + let uiEnabled = true; + let currentUsage: UsageSnapshot | undefined; + let usageEntries: Partial> = {}; + let coreAvailable = false; + let coreSettings: CoreSettings = getFallbackCoreSettings(settings); + let fetchFailureTimer: NodeJS.Timeout | undefined; + const antigravityHiddenModels = new Set(["tab_flash_lite_preview"]); + let settingsWatcher: fs.FSWatcher | undefined; + let settingsPoll: NodeJS.Timeout | undefined; + let settingsDebounce: NodeJS.Timeout | undefined; + let settingsSnapshot = ""; + let settingsMtimeMs = 0; + let settingsWatchStarted = false; + let subCoreBootstrapAttempted = false; + + async function probeSubCore(timeoutMs = 200): Promise { + return new Promise((resolve) => { + let resolved = false; + const timer = setTimeout(() => { + if (!resolved) { + resolved = true; + resolve(false); + } + }, timeoutMs); + + const request: SubCoreRequest = { + type: "current", + reply: () => { + if (resolved) return; + resolved = true; + clearTimeout(timer); + resolve(true); + }, + }; + pi.events.emit("sub-core:request", request); + }); + } + + async function ensureSubCoreLoaded(): Promise { + if (subCoreBootstrapAttempted) return; + subCoreBootstrapAttempted = true; + const hasCore = await probeSubCore(); + if (hasCore) return; + try { + const module = await import("./sub-core/index.js"); + const createCore = module.default as undefined | ((api: ExtensionAPI) => void | Promise); + if (typeof createCore === "function") { + void createCore(pi); + return; + } + } catch (error) { + console.warn("Failed to auto-load sub-core:", error); + } + } + + + async function promptImportAction(ctx: ExtensionContext): Promise<"save-apply" | "save" | "cancel"> { + return new Promise((resolve) => { + ctx.ui.custom((_tui, theme, _kb, done) => { + const items = [ + { value: "save-apply", label: "Save & apply", description: "save and use this theme" }, + { value: "save", label: "Save", description: "save without applying" }, + { value: "cancel", label: "Cancel", description: "discard import" }, + ]; + const list = new SelectList(items, items.length, { + selectedPrefix: (t: string) => theme.fg("accent", t), + selectedText: (t: string) => theme.fg("accent", t), + description: (t: string) => theme.fg("muted", t), + scrollInfo: (t: string) => theme.fg("dim", t), + noMatch: (t: string) => theme.fg("warning", t), + }); + list.onSelect = (item) => { + done(undefined); + resolve(item.value as "save-apply" | "save" | "cancel"); + }; + list.onCancel = () => { + done(undefined); + resolve("cancel"); + }; + return list; + }); + }); + } + + async function promptImportString(ctx: ExtensionContext): Promise { + return new Promise((resolve) => { + ctx.ui.custom((_tui, theme, _kb, done) => { + const input = new Input(); + input.focused = true; + input.onSubmit = (value) => { + done(undefined); + resolve(value.trim()); + }; + input.onEscape = () => { + done(undefined); + resolve(undefined); + }; + const container = new Container(); + container.addChild(new Text(theme.fg("muted", "Paste Theme Share string"), 1, 0)); + container.addChild(new Spacer(1)); + container.addChild(input); + return { + render: (width: number) => container.render(width), + invalidate: () => container.invalidate(), + handleInput: (data: string) => input.handleInput(data), + }; + }); + }); + } + + async function promptImportName(ctx: ExtensionContext): Promise { + while (true) { + const name = await ctx.ui.input("Theme name", "Theme"); + if (name === undefined) return undefined; + const trimmed = name.trim(); + if (trimmed) return trimmed; + ctx.ui.notify("Enter a theme name", "warning"); + } + } + + const THEME_GIST_FILE_BASE = "pi-sub-bar Theme"; + const THEME_GIST_STATUS_KEY = "sub-bar:share"; + + function buildThemeGistFileName(name: string): string { + const trimmed = name.trim(); + if (!trimmed) return THEME_GIST_FILE_BASE; + const safeName = trimmed.replace(/[\\/:*?"<>|]+/g, "-").trim(); + return safeName ? `${THEME_GIST_FILE_BASE} ${safeName}` : THEME_GIST_FILE_BASE; + } + + async function createThemeGist(ctx: ExtensionContext, name: string, shareString: string): Promise { + const notify = (message: string, level: "info" | "warning" | "error") => { + if (ctx.hasUI) { + ctx.ui.notify(message, level); + return; + } + if (level === "error") { + console.error(message); + } else if (level === "warning") { + console.warn(message); + } else { + console.log(message); + } + }; + + try { + const authResult = await pi.exec("gh", ["auth", "status"]); + if (authResult.code !== 0) { + notify("GitHub CLI is not logged in. Run 'gh auth login' first.", "error"); + return null; + } + } catch { + notify("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/", "error"); + return null; + } + + const tempDir = fs.mkdtempSync(join(tmpdir(), "pi-sub-bar-")); + const fileName = buildThemeGistFileName(name); + const filePath = join(tempDir, fileName); + fs.writeFileSync(filePath, shareString, "utf-8"); + + if (ctx.hasUI) { + ctx.ui.setStatus(THEME_GIST_STATUS_KEY, "Creating gist..."); + } + + try { + const result = await pi.exec("gh", ["gist", "create", "--public=false", filePath]); + if (result.code !== 0) { + const errorMsg = result.stderr?.trim() || "Unknown error"; + notify(`Failed to create gist: ${errorMsg}`, "error"); + return null; + } + const gistUrl = result.stdout?.trim(); + if (!gistUrl) { + notify("Failed to create gist: empty response", "error"); + return null; + } + return gistUrl; + } catch (error) { + notify(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`, "error"); + return null; + } finally { + if (ctx.hasUI) { + ctx.ui.setStatus(THEME_GIST_STATUS_KEY, undefined); + } + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } + } + + async function shareThemeString( + ctx: ExtensionContext, + name: string, + shareString: string, + mode: "prompt" | "gist" | "string" = "prompt", + ): Promise { + const trimmedName = name.trim(); + const notify = (message: string, level: "info" | "warning" | "error") => { + if (ctx.hasUI) { + ctx.ui.notify(message, level); + return; + } + if (level === "error") { + console.error(message); + } else if (level === "warning") { + console.warn(message); + } else { + console.log(message); + } + }; + let resolvedMode = mode; + if (resolvedMode === "prompt") { + if (!ctx.hasUI) { + resolvedMode = "string"; + } else { + const wantsGist = await ctx.ui.confirm("Share Theme", "Upload to a secret GitHub gist?"); + resolvedMode = wantsGist ? "gist" : "string"; + } + } + + if (resolvedMode === "gist") { + const gistUrl = await createThemeGist(ctx, trimmedName, shareString); + if (gistUrl) { + pi.sendMessage({ + customType: "sub-bar", + content: `Theme gist:\n${gistUrl}`, + display: true, + }); + notify("Theme gist posted to chat", "info"); + return; + } + notify("Posting share string instead.", "warning"); + } + + pi.sendMessage({ + customType: "sub-bar", + content: `Theme share string:\n${shareString}`, + display: true, + }); + notify("Theme share string posted to chat", "info"); + } + + function readSettingsFile(): string | undefined { + try { + return fs.readFileSync(SETTINGS_PATH, "utf-8"); + } catch { + return undefined; + } + } + + function applySettingsFromDisk(): void { + clearSettingsCache(); + const loaded = loadSettings(); + settings = { + ...settings, + version: loaded.version, + display: loaded.display, + providers: loaded.providers, + displayThemes: loaded.displayThemes, + displayUserTheme: loaded.displayUserTheme, + pinnedProvider: loaded.pinnedProvider, + keybindings: loaded.keybindings, + }; + coreSettings = getFallbackCoreSettings(settings); + updateFetchFailureTicker(); + void ensurePinnedEntries(settings.pinnedProvider ?? null); + if (lastContext) { + renderCurrent(lastContext); + } + } + + function refreshSettingsSnapshot(): void { + const content = readSettingsFile(); + if (!content || content === settingsSnapshot) return; + try { + JSON.parse(content); + } catch { + return; + } + settingsSnapshot = content; + applySettingsFromDisk(); + } + + function checkSettingsFile(): void { + try { + const stat = fs.statSync(SETTINGS_PATH, { throwIfNoEntry: false }); + if (!stat || !stat.mtimeMs) return; + if (stat.mtimeMs === settingsMtimeMs) return; + settingsMtimeMs = stat.mtimeMs; + refreshSettingsSnapshot(); + } catch { + // Ignore missing files + } + } + + function scheduleSettingsRefresh(): void { + if (settingsDebounce) clearTimeout(settingsDebounce); + settingsDebounce = setTimeout(() => checkSettingsFile(), 200); + } + + function startSettingsWatch(): void { + if (settingsWatchStarted) return; + settingsWatchStarted = true; + if (!settingsSnapshot) { + const content = readSettingsFile(); + if (content) { + settingsSnapshot = content; + try { + const stat = fs.statSync(SETTINGS_PATH, { throwIfNoEntry: false }); + if (stat?.mtimeMs) settingsMtimeMs = stat.mtimeMs; + } catch { + // Ignore + } + } + } + try { + settingsWatcher = fs.watch(SETTINGS_PATH, scheduleSettingsRefresh); + settingsWatcher.unref?.(); + } catch { + settingsWatcher = undefined; + } + settingsPoll = setInterval(() => checkSettingsFile(), 2000); + settingsPoll.unref?.(); + } + + function formatUsageContent( + ctx: ExtensionContext, + theme: Theme, + usage: UsageSnapshot | undefined, + contentWidth: number, + message?: string, + options?: { forceNoFill?: boolean } + ): string[] { + const paddingLeft = settings.display.paddingLeft ?? 0; + const paddingRight = settings.display.paddingRight ?? 0; + const innerWidth = Math.max(1, contentWidth - paddingLeft - paddingRight); + const alignment = settings.display.alignment ?? "left"; + const configuredHasFill = settings.display.barWidth === "fill" || settings.display.dividerBlanks === "fill"; + const hasFill = options?.forceNoFill ? false : configuredHasFill; + const wantsSplit = options?.forceNoFill ? false : alignment === "split"; + const shouldAlign = !hasFill && !wantsSplit && (alignment === "center" || alignment === "right"); + const baseTextColor = resolveBaseTextColor(settings.display.baseTextColor); + const scopedModelPatterns = loadScopedModelPatterns(ctx.cwd); + const modelInfo = ctx.model + ? { provider: ctx.model.provider, id: ctx.model.id, scopedModelPatterns } + : { scopedModelPatterns }; + + // Get context usage info from pi framework + const ctxUsage = ctx.getContextUsage?.(); + const contextInfo: ContextInfo | undefined = ctxUsage && ctxUsage.contextWindow > 0 && ctxUsage.tokens != null && ctxUsage.percent != null + ? { tokens: ctxUsage.tokens, contextWindow: ctxUsage.contextWindow, percent: ctxUsage.percent } + : undefined; + + const formatted = message + ? applyBaseTextColor(theme, baseTextColor, message) + : (!usage) + ? undefined + : (hasFill || wantsSplit) + ? formatUsageStatusWithWidth(theme, usage, innerWidth, modelInfo, settings, { labelGapFill: wantsSplit }, contextInfo) + : formatUsageStatus(theme, usage, modelInfo, settings, contextInfo); + + const alignLine = (line: string) => { + if (!shouldAlign) return line; + const lineWidth = visibleWidth(line); + if (lineWidth >= innerWidth) return line; + const padding = innerWidth - lineWidth; + const leftPad = alignment === "center" ? Math.floor(padding / 2) : padding; + return " ".repeat(leftPad) + line; + }; + + let lines: string[] = []; + if (!formatted) { + lines = []; + } else if (settings.display.overflow === "wrap") { + lines = wrapTextWithAnsi(formatted, innerWidth).map(alignLine); + } else { + const trimmed = alignLine(truncateToWidth(formatted, innerWidth, theme.fg("dim", "..."))); + lines = [trimmed]; + } + + if (paddingLeft > 0 || paddingRight > 0) { + const leftPad = " ".repeat(paddingLeft); + const rightPad = " ".repeat(paddingRight); + lines = lines.map((line) => `${leftPad}${line}${rightPad}`); + } + + return lines; + } + + function renderUsageWidget(ctx: ExtensionContext, usage: UsageSnapshot | undefined, message?: string): void { + if (!ctx.hasUI || !uiEnabled) { + return; + } + + + if (!usage && !message) { + ctx.ui.setWidget("usage", undefined); + return; + } + + const setWidgetWithPlacement = (ctx.ui as unknown as { setWidget: (...args: unknown[]) => void }).setWidget; + setWidgetWithPlacement( + "usage", + (_tui: unknown, theme: Theme) => ({ + render(width: number) { + const safeWidth = Math.max(1, width); + const showTopDivider = settings.display.showTopDivider ?? false; + const showBottomDivider = settings.display.showBottomDivider ?? true; + const dividerChar = settings.display.dividerCharacter ?? "•"; + const dividerColor: ThemeColor = resolveDividerColor(settings.display.dividerColor); + const dividerConnect = settings.display.dividerFooterJoin ?? false; + const dividerLine = theme.fg(dividerColor, "─".repeat(safeWidth)); + + let lines = formatUsageContent(ctx, theme, usage, safeWidth, message); + + if (showTopDivider) { + const baseLine = lines.length > 0 ? lines[0] : ""; + const topLine = dividerConnect + ? buildDividerLine(safeWidth, baseLine, dividerChar, dividerConnect, "top", dividerColor, theme) + : dividerLine; + lines = [topLine, ...lines]; + } + if (showBottomDivider) { + const baseLine = lines.length > 0 ? lines[lines.length - 1] : ""; + const footerLine = dividerConnect + ? buildDividerLine(safeWidth, baseLine, dividerChar, dividerConnect, "bottom", dividerColor, theme) + : dividerLine; + lines = [...lines, footerLine]; + } + + const backgroundColor = resolveBaseTextColor(settings.display.backgroundColor); + return applyBackground(lines, theme, backgroundColor, safeWidth); + }, + invalidate() {}, + }), + { placement: "belowEditor" }, + ); + } + + function resolveDisplayedUsage(): UsageSnapshot | undefined { + const pinned = settings.pinnedProvider ?? null; + if (pinned) { + return usageEntries[pinned as ProviderName] ?? currentUsage; + } + return currentUsage; + } + + function syncAntigravityModels(usage?: UsageSnapshot): void { + if (!usage || usage.provider !== "antigravity") return; + const normalizeModel = (label: string) => label.toLowerCase().replace(/\s+/g, "_"); + const labels = usage.windows + .map((window) => window.label?.trim()) + .filter((label): label is string => Boolean(label)) + .filter((label) => !antigravityHiddenModels.has(normalizeModel(label))); + const uniqueModels = Array.from(new Set(labels)); + const antigravitySettings = settings.providers.antigravity; + const visibility = { ...(antigravitySettings.modelVisibility ?? {}) }; + const modelSet = new Set(uniqueModels); + let changed = false; + + for (const model of uniqueModels) { + if (!(model in visibility)) { + visibility[model] = false; + changed = true; + } + } + + for (const existing of Object.keys(visibility)) { + if (!modelSet.has(existing)) { + delete visibility[existing]; + changed = true; + } + } + + const currentOrder = antigravitySettings.modelOrder ?? []; + const orderChanged = currentOrder.length !== uniqueModels.length + || currentOrder.some((model, index) => model !== uniqueModels[index]); + if (orderChanged) { + changed = true; + } + + if (!changed) return; + antigravitySettings.modelVisibility = visibility; + antigravitySettings.modelOrder = uniqueModels; + saveSettings(settings); + } + + function updateEntries(entries: ProviderUsageEntry[] | undefined): void { + if (!entries) return; + const next: Partial> = {}; + for (const entry of entries) { + if (!entry.usage) continue; + next[entry.provider] = entry.usage; + } + usageEntries = next; + syncAntigravityModels(next.antigravity); + updateFetchFailureTicker(); + } + + function updateFetchFailureTicker(): void { + if (!uiEnabled) { + if (fetchFailureTimer) { + clearInterval(fetchFailureTimer); + fetchFailureTimer = undefined; + } + return; + } + const usage = resolveDisplayedUsage(); + const shouldTick = Boolean(usage?.error && usage.lastSuccessAt); + if (shouldTick && !fetchFailureTimer) { + fetchFailureTimer = setInterval(() => { + if (!lastContext) return; + renderCurrent(lastContext); + }, 60000); + fetchFailureTimer.unref?.(); + } + if (!shouldTick && fetchFailureTimer) { + clearInterval(fetchFailureTimer); + fetchFailureTimer = undefined; + } + } + + function renderCurrent(ctx: ExtensionContext): void { + if (!coreAvailable) { + renderUsageWidget(ctx, undefined, "pi-sub-core required. install with: pi install npm:@marckrenn/pi-sub-core"); + return; + } + const usage = resolveDisplayedUsage(); + renderUsageWidget(ctx, usage); + } + + function updateUsage(usage: UsageSnapshot | undefined): void { + currentUsage = usage; + syncAntigravityModels(usage); + updateFetchFailureTicker(); + if (lastContext) { + renderCurrent(lastContext); + } + } + + function applyCoreSettings(next?: CoreSettings): void { + if (!next) return; + coreSettings = next; + settings.behavior = next.behavior ?? settings.behavior; + settings.statusRefresh = next.statusRefresh ?? settings.statusRefresh; + settings.providerOrder = next.providerOrder ?? settings.providerOrder; + settings.defaultProvider = next.defaultProvider ?? settings.defaultProvider; + } + + function applyCoreSettingsPatch(patch: Partial): void { + if (patch.providers) { + for (const [provider, value] of Object.entries(patch.providers)) { + const key = provider as ProviderName; + const current = coreSettings.providers[key]; + if (!current) continue; + coreSettings.providers[key] = { ...current, ...value }; + } + } + if (patch.behavior) { + coreSettings.behavior = { ...coreSettings.behavior, ...patch.behavior }; + } + if (patch.statusRefresh) { + coreSettings.statusRefresh = { ...coreSettings.statusRefresh, ...patch.statusRefresh }; + } + if (patch.providerOrder) { + coreSettings.providerOrder = [...patch.providerOrder]; + } + if (patch.defaultProvider !== undefined) { + coreSettings.defaultProvider = patch.defaultProvider; + } + } + + function emitCoreAction(action: SubCoreAction): void { + pi.events.emit("sub-core:action", action); + } + + function requestCoreState(timeoutMs = 1000): Promise { + return new Promise((resolve) => { + let resolved = false; + const timer = setTimeout(() => { + if (!resolved) { + resolved = true; + resolve(undefined); + } + }, timeoutMs); + + const request: SubCoreRequest = { + type: "current", + includeSettings: true, + reply: (payload) => { + if (resolved) return; + resolved = true; + clearTimeout(timer); + applyCoreSettings(payload.settings); + resolve(payload.state); + }, + }; + + pi.events.emit("sub-core:request", request); + }); + } + + function requestCoreEntries(timeoutMs = 1000): Promise { + return new Promise((resolve) => { + let resolved = false; + const timer = setTimeout(() => { + if (!resolved) { + resolved = true; + resolve(undefined); + } + }, timeoutMs); + + const request: SubCoreRequest = { + type: "entries", + reply: (payload) => { + if (resolved) return; + resolved = true; + clearTimeout(timer); + resolve(payload.entries); + }, + }; + + pi.events.emit("sub-core:request", request); + }); + } + + async function ensurePinnedEntries(pinned: ProviderName | null): Promise { + if (!pinned) return; + if (usageEntries[pinned]) return; + const entries = await requestCoreEntries(); + updateEntries(entries); + if (lastContext) { + renderCurrent(lastContext); + } + } + + pi.events.on("sub-core:update-all", (payload) => { + coreAvailable = true; + const state = payload as { state?: SubCoreAllState }; + updateEntries(state.state?.entries); + if (lastContext) { + renderCurrent(lastContext); + } + }); + + pi.events.on("sub-core:update-current", (payload) => { + coreAvailable = true; + const state = payload as { state?: SubCoreState }; + updateUsage(state.state?.usage); + }); + + pi.events.on("sub-core:ready", (payload) => { + coreAvailable = true; + const state = payload as { state?: SubCoreState; settings?: CoreSettings }; + applyCoreSettings(state.settings); + updateUsage(state.state?.usage); + }); + + pi.events.on("sub-core:settings:updated", (payload) => { + const update = payload as { settings?: CoreSettings }; + applyCoreSettings(update.settings); + if (lastContext) { + renderCurrent(lastContext); + } + }); + + // Register command to open settings + pi.registerCommand("sub-bar:settings", { + description: "Open sub-bar settings", + handler: async (_args, ctx) => { + const newSettings = await showSettingsUI(ctx, { + coreSettings, + onOpenCoreSettings: async () => { + ctx.ui.setEditorText("/sub-core:settings"); + }, + onSettingsChange: async (updatedSettings) => { + const previousPinned = settings.pinnedProvider ?? null; + settings = updatedSettings; + updateFetchFailureTicker(); + if (settings.pinnedProvider && settings.pinnedProvider !== previousPinned) { + void ensurePinnedEntries(settings.pinnedProvider); + } + if (lastContext) { + renderCurrent(lastContext); + } + }, + onCoreSettingsChange: async (patch, _next) => { + applyCoreSettingsPatch(patch); + pi.events.emit("sub-core:settings:patch", { patch }); + if (lastContext) { + renderCurrent(lastContext); + } + }, + onDisplayThemeApplied: (name, options) => { + const content = options?.source === "manual" + ? `sub-bar Theme ${name} loaded` + : `sub-bar Theme ${name} loaded / applied / saved. Restore settings in /sub-bar:settings -> Themes -> Load & Manage themes`; + pi.sendMessage({ + customType: "sub-bar", + content, + display: true, + }); + }, + onDisplayThemeShared: (name, shareString, mode) => shareThemeString(ctx, name, shareString, mode ?? "prompt"), + }); + settings = newSettings; + void ensurePinnedEntries(settings.pinnedProvider ?? null); + if (lastContext) { + renderCurrent(lastContext); + } + }, + }); + + pi.registerCommand("sub-bar:import", { + description: "Import a shared display theme", + handler: async (args, ctx) => { + let input = String(args ?? "").trim(); + if (input.startsWith("/sub-bar:import")) { + input = input.replace(/^\/sub-bar:import\s*/i, "").trim(); + } else if (input.startsWith("sub-bar:import")) { + input = input.replace(/^sub-bar:import\s*/i, "").trim(); + } + if (!input) { + const typed = await promptImportString(ctx); + if (!typed) return; + input = typed; + } + const decoded = decodeDisplayShareString(input); + if (!decoded) { + ctx.ui.notify("Invalid theme share string", "error"); + return; + } + const backup = { ...settings.display }; + settings.display = { ...decoded.display }; + if (lastContext) { + renderUsageWidget(lastContext, currentUsage); + } + + const action = await promptImportAction(ctx); + let resolvedName = decoded.name; + if ((action === "save-apply" || action === "save") && !decoded.hasName) { + const providedName = await promptImportName(ctx); + if (!providedName) { + settings.display = { ...backup }; + if (lastContext) { + renderUsageWidget(lastContext, currentUsage); + } + return; + } + resolvedName = providedName; + } + const notifyImported = (name: string) => { + const message = decoded.isNewerVersion + ? `Imported ${name} (newer version, some fields may be ignored)` + : `Imported ${name}`; + ctx.ui.notify(message, decoded.isNewerVersion ? "warning" : "info"); + }; + + if (action === "save-apply") { + settings.displayUserTheme = { ...backup }; + settings = upsertDisplayTheme(settings, resolvedName, decoded.display, "imported"); + settings.display = { ...decoded.display }; + saveSettings(settings); + if (lastContext) { + renderUsageWidget(lastContext, currentUsage); + } + notifyImported(resolvedName); + pi.sendMessage({ + customType: "sub-bar", + content: `sub-bar Theme ${resolvedName} loaded`, + display: true, + }); + return; + } + + if (action === "save") { + settings = upsertDisplayTheme(settings, resolvedName, decoded.display, "imported"); + settings.display = { ...backup }; + saveSettings(settings); + notifyImported(resolvedName); + if (lastContext) { + renderUsageWidget(lastContext, currentUsage); + } + return; + } + + settings.display = { ...backup }; + if (lastContext) { + renderUsageWidget(lastContext, currentUsage); + } + }, + }); + + // Register shortcut to cycle providers + const cycleProviderKey = settings.keybindings?.cycleProvider || "ctrl+alt+p"; + if (cycleProviderKey !== "none") { + pi.registerShortcut(cycleProviderKey as KeyId, { + description: "Cycle usage provider", + handler: async () => { + emitCoreAction({ type: "cycleProvider" }); + }, + }); + } + + // Register shortcut to toggle reset timer format + const toggleResetFormatKey = settings.keybindings?.toggleResetFormat || "ctrl+alt+r"; + if (toggleResetFormatKey !== "none") { + pi.registerShortcut(toggleResetFormatKey as KeyId, { + description: "Toggle reset timer format", + handler: async () => { + settings.display.resetTimeFormat = settings.display.resetTimeFormat === "datetime" ? "relative" : "datetime"; + saveSettings(settings); + if (lastContext && currentUsage) { + renderUsageWidget(lastContext, currentUsage); + } + }, + }); + } + + pi.on("session_start", async (_event, ctx) => { + lastContext = ctx; + uiEnabled = ctx.hasUI; + if (!uiEnabled) { + return; + } + settings = loadSettings(); + coreSettings = getFallbackCoreSettings(settings); + if (!settingsSnapshot) { + const content = readSettingsFile(); + if (content) { + settingsSnapshot = content; + try { + const stat = fs.statSync(SETTINGS_PATH, { throwIfNoEntry: false }); + if (stat?.mtimeMs) settingsMtimeMs = stat.mtimeMs; + } catch { + // Ignore + } + } + } + + const watchTimer = setTimeout(() => startSettingsWatch(), 0); + watchTimer.unref?.(); + + const sessionContext = ctx; + void (async () => { + await ensureSubCoreLoaded(); + if (!lastContext || lastContext !== sessionContext || !uiEnabled) return; + const state = await requestCoreState(); + if (!lastContext || lastContext !== sessionContext || !uiEnabled) return; + if (state) { + coreAvailable = true; + updateUsage(state.usage); + if (settings.pinnedProvider) { + const entries = await requestCoreEntries(); + if (!lastContext || lastContext !== sessionContext || !uiEnabled) return; + updateEntries(entries); + if (lastContext) { + renderCurrent(lastContext); + } + } + } else if (lastContext && !coreAvailable) { + coreAvailable = false; + renderCurrent(lastContext); + } + })(); + }); + + pi.on("model_select" as unknown as "session_start", async (_event: unknown, ctx: ExtensionContext) => { + lastContext = ctx; + if (!uiEnabled || !ctx.hasUI) { + return; + } + if (currentUsage) { + renderUsageWidget(ctx, currentUsage); + } + }); + + pi.on("session_shutdown", async () => { + lastContext = undefined; + if (fetchFailureTimer) { + clearInterval(fetchFailureTimer); + fetchFailureTimer = undefined; + } + }); + +} diff --git a/pi/files/agent/extensions/sub-bar/package-lock.json b/pi/files/agent/extensions/sub-bar/package-lock.json new file mode 100644 index 0000000..1568344 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/package-lock.json @@ -0,0 +1,2718 @@ +{ + "name": "sub-bar", + "version": "1.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sub-bar", + "version": "1.0.1", + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.8.0" + }, + "peerDependencies": { + "@mariozechner/pi-coding-agent": "*" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.71.2", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz", + "integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz", + "integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@google/genai": { + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.34.0.tgz", + "integrity": "sha512-vu53UMPvjmb7PGzlYu6Tzxso8Dfhn+a7eQFaS2uNemVtDZKwzSpJ5+ikqBbXplF7RGB1STcVDqCkPvquiwb2sw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "google-auth-library": "^10.3.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.24.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "peer": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@mariozechner/clipboard": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.0.tgz", + "integrity": "sha512-tQrCRAtr58BLmWcvwCqlJo5GJgqBGb3zwOBFFBKCEKvRgD8y/EawhCyXsfOh9XOOde1NTAYsYuYyVOYw2tLnoQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.0", + "@mariozechner/clipboard-darwin-universal": "0.3.0", + "@mariozechner/clipboard-darwin-x64": "0.3.0", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.0", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.0", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.0", + "@mariozechner/clipboard-linux-x64-musl": "0.3.0", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.0", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.0" + } + }, + "node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.0.tgz", + "integrity": "sha512-7i4bitLzRSij0fj6q6tPmmf+JrwHqfBsBmf8mOcLVv0LVexD+4gEsyMait4i92exKYmCfna6uHKVS84G4nqehg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.0.tgz", + "integrity": "sha512-FVZLGdIkmvqtPQjD0GQwKLVheL+zV7DjA6I5NcsHGjBeWpG2nACS6COuelNf8ruMoPxJFw7RoB4fjw6mmjT+Nw==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.0.tgz", + "integrity": "sha512-KuurQYEqRhalvBji3CH5xIq1Ts23IgVRE3rjanhqFDI77luOhCnlNbDtqv3No5OxJhEBLykQNrAzfgjqPsPWdA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.0.tgz", + "integrity": "sha512-nWpGMlk43bch7ztGfnALcSi5ZREVziPYzrFKjoJimbwaiULrfY0fGce0gWBynP9ak0nHgDLp0nSa7b4cCl+cIw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.0.tgz", + "integrity": "sha512-4BC08CIaOXSSAGRZLEjqJmQfioED8ohAzwt0k2amZPEbH96YKoBNorq5EdwPf5VT+odS0DeyCwhwtxokRLZIvQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.0.tgz", + "integrity": "sha512-GpNY5Y9nOzr0Vt0Qi5U88qwe6piiIHk44kSMexl8ns90LluN5UTNYmyfi7Xq3/lmPZCpnB2xvBTYbsXCxnopIA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.0.tgz", + "integrity": "sha512-+PnR48/x9GMY5Kh8BLjzHMx6trOegMtxAuqTM9X/bhV3QuW6sLLd7nojDHSGj/ZueK6i0tcQxvOrgNLozVtNDA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.0.tgz", + "integrity": "sha512-+dy2vZ1Ph4EYj0cotB+bVUVk/uKl2bh9LOp/zlnFqoCCYDN6sm+L0VyIOPPo3hjoEVdGpHe1MUxp3qG/OLwXgg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.0.tgz", + "integrity": "sha512-dfpHrUpKHl7ad3xVGE1+gIN3cEnjjPZa4I0BIYMuj2OKq07Gf1FKTXMypB41rDFv6XNzcfhYQnY+ZNgIu9FB8A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/pi-agent-core": { + "version": "0.42.5", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-agent-core/-/pi-agent-core-0.42.5.tgz", + "integrity": "sha512-z3S9xqgkCeVxusWRmZMJK/KaRzVIxQ+7bXc2r2Adkx0S7prTyxGS+OOnnxff2kgjpQ6WV5W01lxcerjSQDx4XA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@mariozechner/pi-ai": "^0.42.5", + "@mariozechner/pi-tui": "^0.42.5" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@mariozechner/pi-ai": { + "version": "0.42.5", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.42.5.tgz", + "integrity": "sha512-/3NqKOSDXJge7RpDMXgK4vHm2qFMjol7CNwM3Dd477RE4I+tsQtL+xcpnB5I94phjsPUw83QSLHp5QzJb5fkpQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@anthropic-ai/sdk": "0.71.2", + "@google/genai": "1.34.0", + "@mistralai/mistralai": "1.10.0", + "@sinclair/typebox": "^0.34.41", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "chalk": "^5.6.2", + "openai": "6.10.0", + "partial-json": "^0.1.7", + "zod-to-json-schema": "^3.24.6" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@mariozechner/pi-coding-agent": { + "version": "0.42.5", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-coding-agent/-/pi-coding-agent-0.42.5.tgz", + "integrity": "sha512-aGCQBiKvJJpSn1avCdTzeU8iWU0rMh3+iZKTfTE0MLuyvzcUoHxWnyzeEkJ2XddqyDsqv4K+E388+juFgFlbYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@mariozechner/clipboard": "^0.3.0", + "@mariozechner/pi-agent-core": "^0.42.5", + "@mariozechner/pi-ai": "^0.42.5", + "@mariozechner/pi-tui": "^0.42.5", + "chalk": "^5.5.0", + "cli-highlight": "^2.1.11", + "diff": "^8.0.2", + "file-type": "^21.1.1", + "glob": "^11.0.3", + "jiti": "^2.6.1", + "marked": "^15.0.12", + "minimatch": "^10.1.1", + "proper-lockfile": "^4.1.2", + "sharp": "^0.34.2" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@mariozechner/pi-tui": { + "version": "0.42.5", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.42.5.tgz", + "integrity": "sha512-/fkMl7fpKWMB9uXlhLXLUKkcd6tYBiJCehVYrQVj/VjqFM44k0FOP9+2+sA9F8BGDCAJqDNbayg1prY5DTMyDw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/mime-types": "^2.1.4", + "chalk": "^5.5.0", + "get-east-asian-width": "^1.3.0", + "marked": "^15.0.12", + "mime-types": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@mistralai/mistralai": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.10.0.tgz", + "integrity": "sha512-tdIgWs4Le8vpvPiUEWne6tK0qbVc+jMenujnvTqOjogrJUsCSQhus0tHTU1avDDh5//Rq2dFgP9mWRAdIEoBqg==", + "peer": true, + "dependencies": { + "zod": "^3.20.0", + "zod-to-json-schema": "^3.24.1" + } + }, + "node_modules/@mistralai/mistralai/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.47", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.47.tgz", + "integrity": "sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw==", + "license": "MIT", + "peer": true + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/mime-types": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz", + "integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/node": { + "version": "22.19.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.5.tgz", + "integrity": "sha512-HfF8+mYcHPcPypui3w3mvzuIErlNOh2OAG+BCeBZCEwyiD5ls2SiCwEyT47OELtf7M3nHxBdu0FsmzdKxkN52Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT", + "peer": true + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "peer": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": "*" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-highlight": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", + "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", + "license": "ISC", + "peer": true, + "dependencies": { + "chalk": "^4.0.0", + "highlight.js": "^10.7.1", + "mz": "^2.4.0", + "parse5": "^5.1.1", + "parse5-htmlparser2-tree-adapter": "^6.0.0", + "yargs": "^16.0.0" + }, + "bin": { + "highlight": "bin/highlight" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, + "node_modules/cli-highlight/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "peer": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "license": "ISC", + "peer": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "peer": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "peer": true + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT", + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", + "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT", + "peer": true + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT", + "peer": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT", + "peer": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT", + "peer": true + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/file-type": { + "version": "21.3.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz", + "integrity": "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "peer": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "peer": true, + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "peer": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "peer": true + }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": "*" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "peer": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "peer": true + }, + "node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "peer": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "peer": true, + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "license": "BlueOak-1.0.0", + "peer": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "peer": true + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "peer": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/openai": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.10.0.tgz", + "integrity": "sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0", + "peer": true + }, + "node_modules/parse5": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", + "license": "MIT", + "peer": true + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "license": "MIT", + "peer": true, + "dependencies": { + "parse5": "^6.0.1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "license": "MIT", + "peer": true + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT", + "peer": true + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "peer": true + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "peer": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/rimraf/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC", + "peer": true + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "peer": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "peer": true + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strtok3": { + "version": "10.3.4", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", + "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "peer": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "peer": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "peer": true, + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT", + "peer": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true, + "peer": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "peer": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "peer": true + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "license": "MIT", + "peer": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "peer": true + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zod": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peer": true, + "peerDependencies": { + "zod": "^3.25 || ^4" + } + } + } +} diff --git a/pi/files/agent/extensions/sub-bar/package.json b/pi/files/agent/extensions/sub-bar/package.json new file mode 100644 index 0000000..76f4aae --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/package.json @@ -0,0 +1,16 @@ +{ + "name": "sub-bar", + "version": "1.3.0", + "description": "Usage widget extension for pi-coding-agent", + "type": "module", + "license": "MIT", + "pi": { + "extensions": [ + "./index.ts", + "./sub-core/index.ts" + ] + }, + "peerDependencies": { + "@mariozechner/pi-coding-agent": "*" + } +} diff --git a/pi/files/agent/extensions/sub-bar/shared.ts b/pi/files/agent/extensions/sub-bar/shared.ts new file mode 100644 index 0000000..dfe2137 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/shared.ts @@ -0,0 +1,6 @@ +/** + * Re-export shared types and metadata from src/shared. + * Files in src/settings/, src/providers/, src/usage/ import "../../shared.js" + * which resolves here. + */ +export * from "./src/shared.js"; diff --git a/pi/files/agent/extensions/sub-bar/src/core-settings.ts b/pi/files/agent/extensions/sub-bar/src/core-settings.ts new file mode 100644 index 0000000..92e507d --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/core-settings.ts @@ -0,0 +1,25 @@ +/** + * Core settings fallbacks for sub-bar when sub-core settings are unavailable. + */ + +import type { CoreSettings } from "./shared.js"; +import type { Settings } from "./settings-types.js"; +import { PROVIDERS, PROVIDER_METADATA } from "./providers/metadata.js"; + +export function getFallbackCoreSettings(settings: Settings): CoreSettings { + const providers = {} as CoreSettings["providers"]; + for (const provider of PROVIDERS) { + providers[provider] = { + enabled: "auto", + fetchStatus: Boolean(PROVIDER_METADATA[provider]?.status), + }; + } + + return { + providers, + behavior: settings.behavior, + statusRefresh: settings.statusRefresh ?? settings.behavior, + providerOrder: settings.providerOrder, + defaultProvider: settings.defaultProvider ?? null, + }; +} diff --git a/pi/files/agent/extensions/sub-bar/src/dividers.ts b/pi/files/agent/extensions/sub-bar/src/dividers.ts new file mode 100644 index 0000000..79bbb14 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/dividers.ts @@ -0,0 +1,48 @@ +import type { Theme, ThemeColor } from "@mariozechner/pi-coding-agent"; +import { visibleWidth } from "@mariozechner/pi-tui"; +import type { DividerCharacter } from "./settings-types.js"; + +const ANSI_REGEX = /\x1b\[[0-9;]*m/g; +const SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +const DIVIDER_JOIN_MAP: Partial> = { + "|": { top: "┬", bottom: "┴", line: "─" }, + "│": { top: "┬", bottom: "┴", line: "─" }, + "┆": { top: "┬", bottom: "┴", line: "─" }, + "┃": { top: "┳", bottom: "┻", line: "━" }, + "┇": { top: "┳", bottom: "┻", line: "━" }, + "║": { top: "╦", bottom: "╩", line: "═" }, +}; + +export function buildDividerLine( + width: number, + baseLine: string, + dividerChar: DividerCharacter, + joinEnabled: boolean, + position: "top" | "bottom", + dividerColor: ThemeColor, + theme: Theme +): string { + let lineChar = "─"; + let joinChar: string | undefined; + if (joinEnabled) { + const joinInfo = DIVIDER_JOIN_MAP[dividerChar]; + if (joinInfo) { + lineChar = joinInfo.line; + joinChar = position === "top" ? joinInfo.top : joinInfo.bottom; + } + } + const lineChars = Array.from(lineChar.repeat(Math.max(1, width))); + if (joinChar) { + const stripped = baseLine.replace(ANSI_REGEX, ""); + let pos = 0; + for (const { segment } of SEGMENTER.segment(stripped)) { + if (pos >= lineChars.length) break; + if (segment === dividerChar) { + lineChars[pos] = joinChar; + } + pos += Math.max(1, visibleWidth(segment)); + } + } + return theme.fg(dividerColor, lineChars.join("")); +} diff --git a/pi/files/agent/extensions/sub-bar/src/errors.ts b/pi/files/agent/extensions/sub-bar/src/errors.ts new file mode 100644 index 0000000..b7487d9 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/errors.ts @@ -0,0 +1,71 @@ +/** + * Error utilities for the sub-bar extension + */ + +import type { UsageError, UsageErrorCode } from "./types.js"; + +export function createError(code: UsageErrorCode, message: string, httpStatus?: number): UsageError { + return { code, message, httpStatus }; +} + +export function noCredentials(): UsageError { + return createError("NO_CREDENTIALS", "No credentials found"); +} + +export function noCli(cliName: string): UsageError { + return createError("NO_CLI", `${cliName} CLI not found`); +} + +export function notLoggedIn(): UsageError { + return createError("NOT_LOGGED_IN", "Not logged in"); +} + +export function fetchFailed(reason?: string): UsageError { + return createError("FETCH_FAILED", reason ?? "Fetch failed"); +} + +export function httpError(status: number): UsageError { + return createError("HTTP_ERROR", `HTTP ${status}`, status); +} + +export function apiError(message: string): UsageError { + return createError("API_ERROR", message); +} + +export function timeout(): UsageError { + return createError("TIMEOUT", "Request timed out"); +} + +/** + * Check if an error should be considered "no data available" vs actual error + * These are expected states when provider isn't configured + */ +export function isExpectedMissingData(error: UsageError): boolean { + const ignoreCodes = new Set(["NO_CREDENTIALS", "NO_CLI", "NOT_LOGGED_IN"]); + return ignoreCodes.has(error.code); +} + +/** + * Format error for display in the usage widget + */ +export function formatErrorForDisplay(error: UsageError): string { + switch (error.code) { + case "NO_CREDENTIALS": + return "No creds"; + case "NO_CLI": + return "No CLI"; + case "NOT_LOGGED_IN": + return "Not logged in"; + case "HTTP_ERROR": + if (error.httpStatus === 401) { + return "token no longer valid – please /login again"; + } + return `${error.httpStatus}`; + case "FETCH_FAILED": + case "API_ERROR": + case "TIMEOUT": + case "UNKNOWN": + default: + return "Fetch failed"; + } +} diff --git a/pi/files/agent/extensions/sub-bar/src/formatting.ts b/pi/files/agent/extensions/sub-bar/src/formatting.ts new file mode 100644 index 0000000..05a0def --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/formatting.ts @@ -0,0 +1,937 @@ +/** + * UI formatting utilities for the sub-bar extension + */ + +import type { Theme } from "@mariozechner/pi-coding-agent"; +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import type { RateWindow, UsageSnapshot, ProviderStatus, ModelInfo } from "./types.js"; +import type { + BaseTextColor, + BarStyle, + BarType, + BarCharacter, + BarWidth, + ColorScheme, + DividerBlanks, + ResetTimerContainment, + Settings, +} from "./settings-types.js"; +import { isBackgroundColor, resolveBaseTextColor, resolveDividerColor } from "./settings-types.js"; +import { formatErrorForDisplay, isExpectedMissingData } from "./errors.js"; +import { getStatusIcon, getStatusLabel } from "./status.js"; +import { shouldShowWindow } from "./providers/windows.js"; +import { getUsageExtras } from "./providers/extras.js"; +import { normalizeTokens } from "./utils.js"; + +export interface UsageWindowParts { + label: string; + bar: string; + pct: string; + reset: string; +} + +/** + * Context window usage info from the pi framework + */ +export interface ContextInfo { + tokens: number; + contextWindow: number; + percent: number; +} + +type ModelInput = ModelInfo | string | undefined; + +function resolveModelInfo(model?: ModelInput): ModelInfo | undefined { + if (!model) return undefined; + return typeof model === "string" ? { id: model } : model; +} + +function isCodexSparkModel(model?: ModelInput): boolean { + const tokens = normalizeTokens(typeof model === "string" ? model : model?.id ?? ""); + return tokens.includes("codex") && tokens.includes("spark"); +} + +function isCodexSparkWindow(window: RateWindow): boolean { + const tokens = normalizeTokens(window.label ?? ""); + return tokens.includes("codex") && tokens.includes("spark"); +} + +function getDisplayWindowLabel(window: RateWindow, model?: ModelInput): string { + if (!isCodexSparkWindow(window)) return window.label; + if (!isCodexSparkModel(model)) return window.label; + const parts = window.label.trim().split(/\s+/); + const suffix = parts.at(-1) ?? ""; + if (/^\d+h$/i.test(suffix) || /^day$/i.test(suffix) || /^week$/i.test(suffix)) { + return suffix; + } + return window.label; +} + +/** + * Get the characters to use for progress bars + */ +function getBarCharacters(barCharacter: BarCharacter): { filled: string; empty: string } { + let filled = "━"; + let empty = "━"; + switch (barCharacter) { + case "light": + filled = "─"; + empty = "─"; + break; + case "heavy": + filled = "━"; + empty = "━"; + break; + case "double": + filled = "═"; + empty = "═"; + break; + case "block": + filled = "█"; + empty = "█"; + break; + default: { + const raw = String(barCharacter); + const trimmed = raw.trim(); + if (!trimmed) return { filled, empty }; + const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + const segments = Array.from(segmenter.segment(raw), (entry) => entry.segment); + const first = segments[0] ?? trimmed[0] ?? "━"; + const second = segments[1]; + filled = first; + empty = second ?? first; + break; + } + } + return { filled, empty }; +} + +/** + * Get color based on percentage and color scheme + */ +function getUsageColor( + percent: number, + isRemaining: boolean, + colorScheme: ColorScheme, + errorThreshold: number = 25, + warningThreshold: number = 50, + successThreshold: number = 75 +): "error" | "warning" | "base" | "success" { + if (colorScheme === "monochrome") { + return "base"; + } + + // For remaining percentage (Codex style), invert the logic + const effectivePercent = isRemaining ? percent : 100 - percent; + + if (colorScheme === "success-base-warning-error") { + // >75%: success, >50%: base, >25%: warning, <=25%: error + if (effectivePercent < errorThreshold) return "error"; + if (effectivePercent < warningThreshold) return "warning"; + if (effectivePercent < successThreshold) return "base"; + return "success"; + } + + // base-warning-error (default) + // >50%: base, >25%: warning, <=25%: error + if (effectivePercent < errorThreshold) return "error"; + if (effectivePercent < warningThreshold) return "warning"; + return "base"; +} + +function clampPercent(value: number): number { + return Math.max(0, Math.min(100, value)); +} + +function getStatusColor( + indicator: NonNullable["indicator"], + colorScheme: ColorScheme +): "error" | "warning" | "success" | "base" { + if (colorScheme === "monochrome") { + return "base"; + } + if (indicator === "minor" || indicator === "maintenance") { + return "warning"; + } + if (indicator === "major" || indicator === "critical") { + return "error"; + } + if (indicator === "none") { + return colorScheme === "success-base-warning-error" ? "success" : "base"; + } + return "base"; +} + +function resolveStatusTintColor( + color: "error" | "warning" | "success" | "base", + baseTextColor: BaseTextColor +): BaseTextColor { + return color === "base" ? baseTextColor : color; +} + +function fgFromBgAnsi(ansi: string): string { + return ansi.replace(/\x1b\[48;/g, "\x1b[38;").replace(/\x1b\[49m/g, "\x1b[39m"); +} + +function applyBaseTextColor(theme: Theme, color: BaseTextColor, text: string): string { + if (isBackgroundColor(color)) { + const fgAnsi = fgFromBgAnsi(theme.getBgAnsi(color as Parameters[0])); + return `${fgAnsi}${text}\x1b[39m`; + } + return theme.fg(resolveDividerColor(color), text); +} + +function resolveUsageColorTargets(settings?: Settings): { + title: boolean; + timer: boolean; + bar: boolean; + usageLabel: boolean; + status: boolean; +} { + const targets = settings?.display.usageColorTargets; + return { + title: targets?.title ?? true, + timer: targets?.timer ?? true, + bar: targets?.bar ?? true, + usageLabel: targets?.usageLabel ?? true, + status: targets?.status ?? true, + }; +} + +function formatElapsedSince(timestamp: number): string { + const diffMs = Date.now() - timestamp; + if (diffMs < 60000) { + const seconds = Math.max(1, Math.floor(diffMs / 1000)); + return `${seconds}s`; + } + + const diffMins = Math.floor(diffMs / 60000); + if (diffMins < 60) return `${diffMins}m`; + + const hours = Math.floor(diffMins / 60); + const mins = diffMins % 60; + if (hours < 24) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`; + + const days = Math.floor(hours / 24); + const remHours = hours % 24; + return remHours > 0 ? `${days}d${remHours}h` : `${days}d`; +} + +const RESET_CONTAINMENT_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +function wrapResetContainment(text: string, containment: ResetTimerContainment): { wrapped: string; attachWithSpace: boolean } { + switch (containment) { + case "none": + return { wrapped: text, attachWithSpace: true }; + case "blank": + return { wrapped: text, attachWithSpace: true }; + case "[]": + return { wrapped: `[${text}]`, attachWithSpace: true }; + case "<>": + return { wrapped: `<${text}>`, attachWithSpace: true }; + case "()": + return { wrapped: `(${text})`, attachWithSpace: true }; + default: { + const trimmed = String(containment).trim(); + if (!trimmed) return { wrapped: `(${text})`, attachWithSpace: true }; + const segments = Array.from(RESET_CONTAINMENT_SEGMENTER.segment(trimmed), (entry) => entry.segment) + .map((segment) => segment.trim()) + .filter(Boolean); + if (segments.length === 0) return { wrapped: `(${text})`, attachWithSpace: true }; + const left = segments[0]; + const right = segments[1] ?? left; + return { wrapped: `${left}${text}${right}`, attachWithSpace: true }; + } + } +} + +function formatResetDateTime(resetAt: string): string { + const date = new Date(resetAt); + if (Number.isNaN(date.getTime())) return resetAt; + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date); +} + +function getBarTypeLevels(barType: BarType): string[] | null { + switch (barType) { + case "horizontal-single": + return ["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"]; + case "vertical": + return ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; + case "braille": + return ["⡀", "⡄", "⣄", "⣆", "⣇", "⣧", "⣷", "⣿"]; + case "shade": + return ["░", "▒", "▓", "█"]; + default: + return null; + } +} + +function renderBarSegments( + percent: number, + width: number, + levels: string[], + options?: { allowMinimum?: boolean; emptyChar?: string } +): { segments: Array<{ char: string; filled: boolean }>; minimal: boolean } { + const totalUnits = Math.max(1, width) * levels.length; + let filledUnits = Math.round((percent / 100) * totalUnits); + let minimal = false; + if (options?.allowMinimum && percent > 0 && filledUnits === 0) { + filledUnits = 1; + minimal = true; + } + const emptyChar = options?.emptyChar ?? " "; + const segments: Array<{ char: string; filled: boolean }> = []; + for (let i = 0; i < Math.max(1, width); i++) { + if (filledUnits >= levels.length) { + segments.push({ char: levels[levels.length - 1], filled: true }); + filledUnits -= levels.length; + continue; + } + if (filledUnits > 0) { + segments.push({ char: levels[Math.min(levels.length - 1, filledUnits - 1)], filled: true }); + filledUnits = 0; + continue; + } + segments.push({ char: emptyChar, filled: false }); + } + return { segments, minimal }; +} + +function formatProviderLabel(theme: Theme, usage: UsageSnapshot, settings?: Settings, model?: ModelInput): string { + const showProviderName = settings?.display.showProviderName ?? true; + const showStatus = settings?.providers[usage.provider]?.showStatus ?? true; + const error = usage.error; + const fetchError = Boolean(error && !isExpectedMissingData(error)); + const baseStatus = showStatus ? usage.status : undefined; + const lastSuccessAt = usage.lastSuccessAt; + const elapsed = lastSuccessAt ? formatElapsedSince(lastSuccessAt) : undefined; + const fetchDescription = elapsed + ? (elapsed === "just now" ? "Last upd.: just now" : `Last upd.: ${elapsed} ago`) + : "Fetch failed"; + const fetchStatus: ProviderStatus | undefined = fetchError + ? { indicator: "minor", description: fetchDescription } + : undefined; + const status = showStatus ? (fetchStatus ?? baseStatus) : undefined; + const statusDismissOk = settings?.display.statusDismissOk ?? true; + const statusModeRaw = settings?.display.statusIndicatorMode ?? "icon"; + const statusMode = statusModeRaw === "icon" || statusModeRaw === "text" || statusModeRaw === "icon+text" + ? statusModeRaw + : "icon"; + const statusIconPack = settings?.display.statusIconPack ?? "emoji"; + const statusIconCustom = settings?.display.statusIconCustom; + const providerLabelSetting = settings?.display.providerLabel ?? "none"; + const showColon = settings?.display.providerLabelColon ?? true; + const boldProviderLabel = settings?.display.providerLabelBold ?? false; + const baseTextColor = resolveBaseTextColor(settings?.display.baseTextColor); + const usageTargets = resolveUsageColorTargets(settings); + + const statusActive = Boolean(status && (!statusDismissOk || status.indicator !== "none")); + const showIcon = statusActive && (statusMode === "icon" || statusMode === "icon+text"); + const showText = statusActive && (statusMode === "text" || statusMode === "icon+text"); + + const labelSuffix = providerLabelSetting === "plan" + ? "Plan" + : providerLabelSetting === "subscription" + ? "Subscription" + : providerLabelSetting === "sub" + ? "Sub." + : providerLabelSetting === "none" + ? "" + : String(providerLabelSetting); + + const rawName = usage.displayName?.trim() ?? ""; + const baseName = rawName.replace(/\s+(plan|subscription|sub\.?)[\s]*$/i, "").trim(); + const resolvedProviderName = baseName || rawName; + const isSpark = usage.provider === "codex" && isCodexSparkModel(model); + const providerName = isSpark ? `${resolvedProviderName} (Spark)` : resolvedProviderName; + const providerLabel = showProviderName + ? [providerName, labelSuffix].filter(Boolean).join(" ") + : ""; + const providerLabelWithColon = providerLabel && showColon ? `${providerLabel}:` : providerLabel; + + const icon = showIcon && status ? getStatusIcon(status, statusIconPack, statusIconCustom) : ""; + const statusText = showText && status ? getStatusLabel(status) : ""; + const rawStatusColor = status + ? getStatusColor(status.indicator, settings?.display.colorScheme ?? "base-warning-error") + : "base"; + const statusTint = usageTargets.status + ? resolveStatusTintColor(rawStatusColor, baseTextColor) + : baseTextColor; + const statusColor = statusTint; + const dividerEnabled = settings?.display.statusProviderDivider ?? false; + const dividerChar = settings?.display.dividerCharacter ?? "│"; + const dividerColor = resolveDividerColor(settings?.display.dividerColor); + const dividerGlyph = dividerChar === "none" + ? "" + : dividerChar === "blank" + ? " " + : dividerChar; + + const statusParts: string[] = []; + if (icon) statusParts.push(applyBaseTextColor(theme, statusColor, icon)); + if (statusText) statusParts.push(applyBaseTextColor(theme, statusColor, statusText)); + + const parts: string[] = []; + if (statusParts.length > 0) { + parts.push(statusParts.join(" ")); + } + if (providerLabelWithColon) { + if (statusParts.length > 0 && dividerEnabled && dividerGlyph) { + parts.push(theme.fg(dividerColor, dividerGlyph)); + } + const colored = applyBaseTextColor(theme, baseTextColor, providerLabelWithColon); + parts.push(boldProviderLabel ? theme.bold(colored) : colored); + } + if (parts.length === 0) return ""; + return parts.join(" "); +} + +/** + * Format a single usage window as a styled string + */ +export function formatUsageWindow( + theme: Theme, + window: RateWindow, + isCodex: boolean, + settings?: Settings, + usage?: UsageSnapshot, + options?: { useNormalColors?: boolean; barWidthOverride?: number }, + model?: ModelInput +): string { + const parts = formatUsageWindowParts(theme, window, isCodex, settings, usage, options, model); + const baseTextColor = resolveBaseTextColor(settings?.display.baseTextColor); + const usageTargets = resolveUsageColorTargets(settings); + + // Special handling for Extra usage label + if (window.label.startsWith("Extra [")) { + const match = window.label.match(/^(Extra \[)(on|active)(\] .*)$/); + if (match) { + const [, prefix, status, suffix] = match; + const styledLabel = + status === "active" + ? applyBaseTextColor(theme, baseTextColor, prefix) + + theme.fg("text", status) + + applyBaseTextColor(theme, baseTextColor, suffix) + : applyBaseTextColor(theme, baseTextColor, window.label); + const extraParts = [styledLabel, parts.bar, parts.pct].filter(Boolean); + return extraParts.join(" "); + } + if (!usageTargets.title) { + const extraParts = [applyBaseTextColor(theme, baseTextColor, window.label), parts.bar, parts.pct].filter(Boolean); + return extraParts.join(" "); + } + const extraColor = getUsageColor(window.usedPercent, false, settings?.display.colorScheme ?? "base-warning-error"); + const extraTextColor = (options?.useNormalColors && extraColor === "base") + ? "text" + : extraColor === "base" + ? baseTextColor + : extraColor; + const extraParts = [applyBaseTextColor(theme, extraTextColor, window.label), parts.bar, parts.pct].filter(Boolean); + return extraParts.join(" "); + } + + const joinedParts = [parts.label, parts.bar, parts.pct, parts.reset].filter(Boolean); + return joinedParts.join(" "); +} + +export function formatUsageWindowParts( + theme: Theme, + window: RateWindow, + isCodex: boolean, + settings?: Settings, + usage?: UsageSnapshot, + options?: { useNormalColors?: boolean; barWidthOverride?: number }, + model?: ModelInput +): UsageWindowParts { + const barStyle: BarStyle = settings?.display.barStyle ?? "both"; + const barWidthSetting = settings?.display.barWidth; + const containBar = settings?.display.containBar ?? false; + const barWidth = options?.barWidthOverride ?? (typeof barWidthSetting === "number" ? barWidthSetting : 6); + const barType: BarType = settings?.display.barType ?? "horizontal-bar"; + const brailleFillEmpty = settings?.display.brailleFillEmpty ?? false; + const brailleFullBlocks = settings?.display.brailleFullBlocks ?? false; + const barCharacter: BarCharacter = settings?.display.barCharacter ?? "heavy"; + const colorScheme: ColorScheme = settings?.display.colorScheme ?? "base-warning-error"; + const resetTimePosition = settings?.display.resetTimePosition ?? "front"; + const resetTimeFormat = settings?.display.resetTimeFormat ?? "relative"; + const showUsageLabels = settings?.display.showUsageLabels ?? true; + const showWindowTitle = settings?.display.showWindowTitle ?? true; + const boldWindowTitle = settings?.display.boldWindowTitle ?? false; + const baseTextColor = resolveBaseTextColor(settings?.display.baseTextColor); + const errorThreshold = settings?.display.errorThreshold ?? 25; + const warningThreshold = settings?.display.warningThreshold ?? 50; + const successThreshold = settings?.display.successThreshold ?? 75; + + const rawUsedPct = Math.round(window.usedPercent); + const usedPct = clampPercent(rawUsedPct); + const displayPct = isCodex ? clampPercent(100 - usedPct) : usedPct; + const isRemaining = isCodex; + + const barPercent = clampPercent(displayPct); + const filled = Math.round((barPercent / 100) * barWidth); + const empty = Math.max(0, barWidth - filled); + + const baseColor = getUsageColor(displayPct, isRemaining, colorScheme, errorThreshold, warningThreshold, successThreshold); + const usageTargets = resolveUsageColorTargets(settings); + const usageTextColor = (options?.useNormalColors && baseColor === "base") + ? "text" + : baseColor === "base" + ? baseTextColor + : baseColor; + const neutralTextColor = options?.useNormalColors ? "text" : baseTextColor; + const titleColor = usageTargets.title ? usageTextColor : neutralTextColor; + const timerColor = usageTargets.timer ? usageTextColor : neutralTextColor; + const usageLabelColor = usageTargets.usageLabel ? usageTextColor : neutralTextColor; + const barUsageColor = (options?.useNormalColors && baseColor === "base") ? "text" : baseColor === "base" ? "muted" : baseColor; + const neutralBarColor = baseTextColor === "dim" ? "dim" : "muted"; + const barColor = usageTargets.bar ? barUsageColor : neutralBarColor; + const { filled: filledChar, empty: emptyChar } = getBarCharacters(barCharacter); + + const emptyColor = "dim"; + + let barStr = ""; + if ((barStyle === "bar" || barStyle === "both") && barWidth > 0) { + let levels = getBarTypeLevels(barType); + if (barType === "braille" && brailleFullBlocks) { + levels = ["⣿"]; + } + if (!levels || barType === "horizontal-bar") { + const filledCharWidth = Math.max(1, visibleWidth(filledChar)); + const emptyCharWidth = Math.max(1, visibleWidth(emptyChar)); + const segmentCount = barWidth > 0 ? Math.floor(barWidth / filledCharWidth) : 0; + const filledSegments = segmentCount > 0 ? Math.round((barPercent / 100) * segmentCount) : 0; + const filledStr = filledChar.repeat(filledSegments); + const filledWidth = filledSegments * filledCharWidth; + const remainingWidth = Math.max(0, barWidth - filledWidth); + const emptySegments = emptyCharWidth > 0 ? Math.floor(remainingWidth / emptyCharWidth) : 0; + const emptyStr = emptyChar.repeat(emptySegments); + const emptyRendered = emptyChar === " " ? emptyStr : theme.fg(emptyColor, emptyStr); + barStr = theme.fg(barColor as Parameters[0], filledStr) + emptyRendered; + const barVisualWidth = visibleWidth(barStr); + if (barVisualWidth < barWidth) { + barStr += " ".repeat(barWidth - barVisualWidth); + } + } else { + const emptyChar = barType === "braille" && brailleFillEmpty && barWidth > 1 ? "⣿" : " "; + const { segments, minimal } = renderBarSegments(barPercent, barWidth, levels, { + allowMinimum: true, + emptyChar, + }); + const filledColor = minimal ? "dim" : barColor; + barStr = segments + .map((segment) => { + if (segment.filled) { + return theme.fg(filledColor as Parameters[0], segment.char); + } + if (segment.char === " ") { + return segment.char; + } + return theme.fg("dim", segment.char); + }) + .join(""); + } + + if (settings?.display.containBar && barStr) { + const leftCap = theme.fg(barColor as Parameters[0], "▕"); + const rightCap = theme.fg(barColor as Parameters[0], "▏"); + barStr = leftCap + barStr + rightCap; + } + } + + let pctStr = ""; + if (barStyle === "percentage" || barStyle === "both") { + // Special handling for Copilot Month window - can show percentage or requests + if (window.label === "Month" && usage?.provider === "copilot") { + const quotaDisplay = settings?.providers.copilot.quotaDisplay ?? "percentage"; + if (quotaDisplay === "requests" && usage.requestsRemaining !== undefined && usage.requestsEntitlement !== undefined) { + const used = usage.requestsEntitlement - usage.requestsRemaining; + const suffix = showUsageLabels ? " used" : ""; + pctStr = applyBaseTextColor(theme, usageLabelColor, `${used}/${usage.requestsEntitlement}${suffix}`); + } else { + const suffix = showUsageLabels ? " used" : ""; + pctStr = applyBaseTextColor(theme, usageLabelColor, `${usedPct}%${suffix}`); + } + } else if (isCodex) { + const suffix = showUsageLabels ? " rem." : ""; + pctStr = applyBaseTextColor(theme, usageLabelColor, `${displayPct}%${suffix}`); + } else { + const suffix = showUsageLabels ? " used" : ""; + pctStr = applyBaseTextColor(theme, usageLabelColor, `${usedPct}%${suffix}`); + } + } + + const isActiveReset = window.resetDescription === "__ACTIVE__"; + const resetText = isActiveReset + ? undefined + : resetTimeFormat === "datetime" + ? (window.resetAt ? formatResetDateTime(window.resetAt) : window.resetDescription) + : window.resetDescription; + const resetContainment = settings?.display.resetTimeContainment ?? "()"; + const leftSuffix = resetText && resetTimeFormat === "relative" && showUsageLabels ? " left" : ""; + + const displayLabel = getDisplayWindowLabel(window, model); + const coloredTitle = applyBaseTextColor(theme, titleColor, displayLabel); + const titlePart = showWindowTitle ? (boldWindowTitle ? theme.bold(coloredTitle) : coloredTitle) : ""; + + let labelPart = titlePart; + if (resetText) { + const resetBody = `${resetText}${leftSuffix}`; + const { wrapped, attachWithSpace } = wrapResetContainment(resetBody, resetContainment); + const coloredReset = applyBaseTextColor(theme, timerColor, wrapped); + if (resetTimePosition === "front") { + if (!titlePart) { + labelPart = coloredReset; + } else { + labelPart = attachWithSpace ? `${titlePart} ${coloredReset}` : `${titlePart}${coloredReset}`; + } + } else if (resetTimePosition === "integrated") { + labelPart = titlePart ? `${applyBaseTextColor(theme, timerColor, `${wrapped}/`)}${titlePart}` : coloredReset; + } else if (resetTimePosition === "back") { + labelPart = titlePart; + } + } else if (!titlePart) { + labelPart = ""; + } + + const resetPart = + resetTimePosition === "back" && resetText + ? applyBaseTextColor(theme, timerColor, wrapResetContainment(`${resetText}${leftSuffix}`, resetContainment).wrapped) + : ""; + + return { + label: labelPart, + bar: barStr, + pct: pctStr, + reset: resetPart, + }; +} + +/** + * Format context window usage as a progress bar + */ +export function formatContextBar( + theme: Theme, + context: ContextInfo, + settings?: Settings, + options?: { barWidthOverride?: number } +): string { + // Create a pseudo-RateWindow for context display + const contextWindow: RateWindow = { + label: "Ctx", + usedPercent: context.percent, + // No reset description for context + }; + // Format using the same window formatting logic, but with "used" semantics (not inverted) + return formatUsageWindow(theme, contextWindow, false, settings, undefined, options); +} + +/** + * Format a complete usage snapshot as a usage line + */ +export function formatUsageStatus( + theme: Theme, + usage: UsageSnapshot, + model?: ModelInput, + settings?: Settings, + context?: ContextInfo +): string | undefined { + const baseTextColor = resolveBaseTextColor(settings?.display.baseTextColor); + const modelInfo = resolveModelInfo(model); + const label = formatProviderLabel(theme, usage, settings, modelInfo); + + // If no windows, just show the provider name with error + if (usage.windows.length === 0) { + const errorMsg = usage.error + ? applyBaseTextColor(theme, baseTextColor, `(${formatErrorForDisplay(usage.error)})`) + : ""; + if (!label) { + return errorMsg; + } + return errorMsg ? `${label} ${errorMsg}` : label; + } + + // Build usage bars + const parts: string[] = []; + const isCodex = usage.provider === "codex"; + const invertUsage = isCodex && (settings?.providers.codex.invertUsage ?? false); + const modelId = modelInfo?.id; + + // Add context bar as leftmost element if enabled + const showContextBar = settings?.display.showContextBar ?? false; + if (showContextBar && context && context.contextWindow > 0) { + parts.push(formatContextBar(theme, context, settings)); + } + + for (const w of usage.windows) { + // Skip windows that are disabled in settings + if (!shouldShowWindow(usage, w, settings, modelInfo)) { + continue; + } + parts.push(formatUsageWindow(theme, w, invertUsage, settings, usage, undefined, modelInfo)); + } + + // Add extra usage lines (extra usage off, copilot multiplier, etc.) + const extras = getUsageExtras(usage, settings, modelId); + for (const extra of extras) { + parts.push(applyBaseTextColor(theme, baseTextColor, extra.label)); + } + + // Build divider from settings + const dividerChar = settings?.display.dividerCharacter ?? "•"; + const dividerColor = resolveDividerColor(settings?.display.dividerColor); + const blanksSetting = settings?.display.dividerBlanks ?? 1; + const showProviderDivider = settings?.display.showProviderDivider ?? false; + const blanksPerSide = typeof blanksSetting === "number" ? blanksSetting : 1; + const spacing = " ".repeat(blanksPerSide); + const charToDisplay = dividerChar === "blank" ? " " : dividerChar === "none" ? "" : dividerChar; + const divider = charToDisplay ? spacing + theme.fg(dividerColor, charToDisplay) + spacing : spacing + spacing; + const labelGap = label && parts.length > 0 + ? showProviderDivider && charToDisplay !== "" + ? divider + : spacing + : ""; + + return label + labelGap + parts.join(divider); +} + +export function formatUsageStatusWithWidth( + theme: Theme, + usage: UsageSnapshot, + width: number, + model?: ModelInput, + settings?: Settings, + options?: { labelGapFill?: boolean }, + context?: ContextInfo +): string | undefined { + const labelGapFill = options?.labelGapFill ?? false; + const baseTextColor = resolveBaseTextColor(settings?.display.baseTextColor); + const modelInfo = resolveModelInfo(model); + const label = formatProviderLabel(theme, usage, settings, modelInfo); + const showContextBar = settings?.display.showContextBar ?? false; + const hasContext = showContextBar && context && context.contextWindow > 0; + + // If no windows, just show the provider name with error + if (usage.windows.length === 0) { + const errorMsg = usage.error + ? applyBaseTextColor(theme, baseTextColor, `(${formatErrorForDisplay(usage.error)})`) + : ""; + if (!label) { + return errorMsg; + } + return errorMsg ? `${label} ${errorMsg}` : label; + } + + const barStyle: BarStyle = settings?.display.barStyle ?? "both"; + const hasBar = barStyle === "bar" || barStyle === "both"; + const barWidthSetting = settings?.display.barWidth ?? 6; + const dividerBlanksSetting = settings?.display.dividerBlanks ?? 1; + const dividerColor = resolveDividerColor(settings?.display.dividerColor); + const showProviderDivider = settings?.display.showProviderDivider ?? false; + const containBar = settings?.display.containBar ?? false; + + const barFill = barWidthSetting === "fill"; + const barBaseWidth = typeof barWidthSetting === "number" ? barWidthSetting : (hasBar ? 1 : 0); + const barContainerExtra = containBar && hasBar ? 2 : 0; + const barBaseContentWidth = barFill ? 0 : barBaseWidth; + const barBaseWidthCalc = barFill ? 0 : barBaseContentWidth + barContainerExtra; + const barTotalBaseWidth = barBaseWidthCalc; + const baseDividerBlanks = typeof dividerBlanksSetting === "number" ? dividerBlanksSetting : 1; + + const dividerFill = dividerBlanksSetting === "fill"; + + // Build usage windows + const windows: RateWindow[] = []; + const isCodex = usage.provider === "codex"; + const invertUsage = isCodex && (settings?.providers.codex.invertUsage ?? false); + const modelId = modelInfo?.id; + + // Add context window as first entry if enabled + let contextWindowIndex = -1; + if (hasContext) { + contextWindowIndex = windows.length; + windows.push({ + label: "Ctx", + usedPercent: context!.percent, + }); + } + + for (const w of usage.windows) { + if (!shouldShowWindow(usage, w, settings, modelInfo)) { + continue; + } + windows.push(w); + } + + const barEligibleCount = hasBar ? windows.length : 0; + const extras = getUsageExtras(usage, settings, modelId); + const extraParts = extras.map((extra) => applyBaseTextColor(theme, baseTextColor, extra.label)); + + const barSpacerWidth = hasBar ? 1 : 0; + const baseWindowWidths = windows.map((w, i) => { + // Context window uses false for invertUsage (always show used percentage) + const isContext = i === contextWindowIndex; + return ( + visibleWidth( + formatUsageWindow( + theme, + w, + isContext ? false : invertUsage, + settings, + isContext ? undefined : usage, + { barWidthOverride: 0 }, + modelInfo + ) + ) + barSpacerWidth + ); + }); + const extraWidths = extraParts.map((part) => visibleWidth(part)); + + const partCount = windows.length + extraParts.length; + const dividerCount = Math.max(0, partCount - 1); + const dividerChar = settings?.display.dividerCharacter ?? "•"; + const charToDisplay = dividerChar === "blank" ? " " : dividerChar === "none" ? "" : dividerChar; + const dividerBaseWidth = (charToDisplay ? 1 : 0) + baseDividerBlanks * 2; + const labelGapEnabled = partCount > 0 && (label !== "" || labelGapFill); + const providerDividerActive = showProviderDivider && charToDisplay !== "" && label !== ""; + const labelGapBaseWidth = labelGapEnabled + ? providerDividerActive + ? dividerBaseWidth + : baseDividerBlanks + : 0; + + const labelWidth = visibleWidth(label); + const baseTotalWidth = + labelWidth + + labelGapBaseWidth + + baseWindowWidths.reduce((sum, w) => sum + w, 0) + + extraWidths.reduce((sum, w) => sum + w, 0) + + (barEligibleCount * barTotalBaseWidth) + + (dividerCount * dividerBaseWidth); + + let remainingWidth = width - baseTotalWidth; + if (remainingWidth < 0) { + remainingWidth = 0; + } + + const useBars = barFill && barEligibleCount > 0; + const labelGapUnits = labelGapEnabled ? (providerDividerActive ? 2 : 1) : 0; + const dividerSlots = dividerCount + (labelGapEnabled ? 1 : 0); + const dividerUnits = dividerCount * 2 + labelGapUnits; + const useDividers = dividerFill && dividerUnits > 0; + + let barExtraTotal = 0; + let dividerExtraTotal = 0; + if (remainingWidth > 0 && (useBars || useDividers)) { + const barWeight = useBars ? barEligibleCount : 0; + const dividerWeight = useDividers ? dividerUnits : 0; + const totalWeight = barWeight + dividerWeight; + if (totalWeight > 0) { + barExtraTotal = Math.floor((remainingWidth * barWeight) / totalWeight); + dividerExtraTotal = remainingWidth - barExtraTotal; + } + } + + const barWidths: number[] = windows.map(() => barBaseWidthCalc); + if (useBars && barEligibleCount > 0) { + const perBar = Math.floor(barExtraTotal / barEligibleCount); + let remainder = barExtraTotal % barEligibleCount; + for (let i = 0; i < barWidths.length; i++) { + barWidths[i] = barBaseWidthCalc + perBar + (remainder > 0 ? 1 : 0); + if (remainder > 0) remainder -= 1; + } + } + + let labelBlanks = labelGapEnabled ? baseDividerBlanks : 0; + const dividerBlanks: number[] = []; + if (dividerUnits > 0) { + const baseUnit = useDividers ? Math.floor(dividerExtraTotal / dividerUnits) : 0; + let remainderUnits = useDividers ? dividerExtraTotal % dividerUnits : 0; + if (labelGapEnabled) { + if (useDividers && providerDividerActive) { + let extraUnits = baseUnit * 2; + if (remainderUnits >= 2) { + extraUnits += 2; + remainderUnits -= 2; + } + labelBlanks = baseDividerBlanks + Math.floor(extraUnits / 2); + } else if (useDividers) { + labelBlanks = baseDividerBlanks + baseUnit + (remainderUnits > 0 ? 1 : 0); + if (remainderUnits > 0) remainderUnits -= 1; + } + } + for (let i = 0; i < dividerCount; i++) { + let extraUnits = baseUnit * 2; + if (remainderUnits >= 2) { + extraUnits += 2; + remainderUnits -= 2; + } + const blanks = baseDividerBlanks + Math.floor(extraUnits / 2); + dividerBlanks.push(blanks); + } + } + + const parts: string[] = []; + for (let i = 0; i < windows.length; i++) { + const totalWidth = barWidths[i] ?? barBaseWidthCalc; + const contentWidth = containBar ? Math.max(0, totalWidth - barContainerExtra) : totalWidth; + const isContext = i === contextWindowIndex; + parts.push( + formatUsageWindow( + theme, + windows[i], + isContext ? false : invertUsage, + settings, + isContext ? undefined : usage, + { barWidthOverride: contentWidth }, + modelInfo + ) + ); + } + for (const extra of extraParts) { + parts.push(extra); + } + + let rest = ""; + for (let i = 0; i < parts.length; i++) { + rest += parts[i]; + if (i < dividerCount) { + const blanks = dividerBlanks[i] ?? baseDividerBlanks; + const spacing = " ".repeat(Math.max(0, blanks)); + rest += charToDisplay + ? spacing + theme.fg(dividerColor, charToDisplay) + spacing + : spacing + spacing; + } + } + + let labelGapExtra = 0; + if (labelGapFill && labelGapEnabled) { + const restWidth = visibleWidth(rest); + const labelGapWidth = providerDividerActive + ? (Math.max(0, labelBlanks) * 2) + (charToDisplay ? 1 : 0) + : Math.max(0, labelBlanks); + const totalWidth = visibleWidth(label) + restWidth + labelGapWidth; + labelGapExtra = Math.max(0, width - totalWidth); + } + + let output = label; + if (labelGapEnabled) { + if (providerDividerActive) { + const spacing = " ".repeat(Math.max(0, labelBlanks)); + output += spacing + theme.fg(dividerColor, charToDisplay) + spacing + " ".repeat(labelGapExtra); + } else { + output += " ".repeat(Math.max(0, labelBlanks + labelGapExtra)); + } + } + output += rest; + + if (width > 0 && visibleWidth(output) > width) { + return truncateToWidth(output, width, ""); + } + + return output; +} diff --git a/pi/files/agent/extensions/sub-bar/src/paths.ts b/pi/files/agent/extensions/sub-bar/src/paths.ts new file mode 100644 index 0000000..19fb840 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/paths.ts @@ -0,0 +1,21 @@ +/** + * Shared path helpers for sub-bar settings storage. + */ + +import { getAgentDir } from "@mariozechner/pi-coding-agent"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SETTINGS_FILE_NAME = "pi-sub-bar-settings.json"; + +export function getExtensionDir(): string { + return join(dirname(fileURLToPath(import.meta.url)), ".."); +} + +export function getSettingsPath(): string { + return join(getAgentDir(), SETTINGS_FILE_NAME); +} + +export function getLegacySettingsPath(): string { + return join(getExtensionDir(), "settings.json"); +} diff --git a/pi/files/agent/extensions/sub-bar/src/providers/extras.ts b/pi/files/agent/extensions/sub-bar/src/providers/extras.ts new file mode 100644 index 0000000..a7d7e13 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/providers/extras.ts @@ -0,0 +1,21 @@ +/** + * Provider-specific extra usage lines (non-window info). + */ + +import type { UsageSnapshot } from "../types.js"; +import type { Settings } from "../settings-types.js"; +import { PROVIDER_METADATA, type UsageExtra } from "./metadata.js"; + +export type { UsageExtra } from "./metadata.js"; + +export function getUsageExtras( + usage: UsageSnapshot, + settings?: Settings, + modelId?: string +): UsageExtra[] { + const handler = PROVIDER_METADATA[usage.provider]?.getExtras; + if (handler) { + return handler(usage, settings, modelId); + } + return []; +} diff --git a/pi/files/agent/extensions/sub-bar/src/providers/metadata.ts b/pi/files/agent/extensions/sub-bar/src/providers/metadata.ts new file mode 100644 index 0000000..c172c30 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/providers/metadata.ts @@ -0,0 +1,202 @@ +/** + * Provider metadata shared across the extension. + */ + +import type { RateWindow, UsageSnapshot, ProviderName, ModelInfo } from "../types.js"; +import type { Settings } from "../settings-types.js"; +import { getModelMultiplier, normalizeTokens } from "../utils.js"; +import { PROVIDER_METADATA as BASE_METADATA, type ProviderMetadata as BaseProviderMetadata } from "../../shared.js"; + +export { PROVIDERS, PROVIDER_DISPLAY_NAMES } from "../../shared.js"; +export type { ProviderStatusConfig, ProviderDetectionConfig } from "../../shared.js"; + +export interface UsageExtra { + label: string; +} + +export interface ProviderMetadata extends BaseProviderMetadata { + isWindowVisible?: (usage: UsageSnapshot, window: RateWindow, settings?: Settings, model?: ModelInfo) => boolean; + getExtras?: (usage: UsageSnapshot, settings?: Settings, modelId?: string) => UsageExtra[]; +} + +const anthropicWindowVisible: ProviderMetadata["isWindowVisible"] = (_usage, window, settings, _model) => { + if (!settings) return true; + const ps = settings.providers.anthropic; + if (window.label === "5h") return ps.windows.show5h; + if (window.label === "Week") return ps.windows.show7d; + if (window.label.startsWith("Extra [")) return ps.windows.showExtra; + return true; +}; + +const copilotWindowVisible: ProviderMetadata["isWindowVisible"] = (_usage, window, settings, _model) => { + if (!settings) return true; + const ps = settings.providers.copilot; + if (window.label === "Month") return ps.windows.showMonth; + return true; +}; + +const geminiWindowVisible: ProviderMetadata["isWindowVisible"] = (_usage, window, settings, _model) => { + if (!settings) return true; + const ps = settings.providers.gemini; + if (window.label === "Pro") return ps.windows.showPro; + if (window.label === "Flash") return ps.windows.showFlash; + return true; +}; + +const antigravityWindowVisible: ProviderMetadata["isWindowVisible"] = (_usage, window, settings, model) => { + if (!settings) return true; + const ps = settings.providers.antigravity; + const label = window.label.trim(); + const normalized = label.toLowerCase().replace(/\s+/g, "_"); + if (normalized === "tab_flash_lite_preview") return false; + + const labelTokens = normalizeTokens(label); + + const modelProvider = model?.provider?.toLowerCase() ?? ""; + const modelId = model?.id; + const providerMatches = modelProvider.includes("antigravity"); + if (ps.showCurrentModel && providerMatches && modelId) { + const modelTokens = normalizeTokens(modelId); + const match = modelTokens.length > 0 && modelTokens.every((token) => labelTokens.includes(token)); + if (match) return true; + } + + if (ps.showScopedModels) { + const scopedPatterns = model?.scopedModelPatterns ?? []; + const matchesScoped = scopedPatterns.some((pattern) => { + if (!pattern) return false; + const [rawPattern] = pattern.split(":"); + const trimmed = rawPattern?.trim(); + if (!trimmed) return false; + const hasProvider = trimmed.includes("/"); + if (!hasProvider) return false; + const providerPart = trimmed.slice(0, trimmed.indexOf("/")).trim().toLowerCase(); + if (!providerPart.includes("antigravity")) return false; + const base = trimmed.slice(trimmed.lastIndexOf("/") + 1); + const tokens = normalizeTokens(base); + return tokens.length > 0 && tokens.every((token) => labelTokens.includes(token)); + }); + if (matchesScoped) return true; + } + + const visibility = ps.modelVisibility?.[label]; + return visibility === true; +}; + +const codexWindowVisible: ProviderMetadata["isWindowVisible"] = (_usage, window, settings, model) => { + if (!settings) return true; + const ps = settings.providers.codex; + const isSparkModel = isCodexSparkModel(model); + const isSparkWindow = isCodexSparkWindow(window); + if (isSparkWindow) { + if (!isSparkModel) return false; + return shouldShowCodexWindowBySetting(ps, window); + } + if (isSparkModel) { + return false; + } + return shouldShowCodexWindowBySetting(ps, window); +}; + +const isCodexSparkModel = (model?: ModelInfo): boolean => { + const tokens = normalizeTokens(model?.id ?? ""); + return tokens.includes("codex") && tokens.includes("spark"); +}; + +const isCodexSparkWindow = (window: RateWindow): boolean => { + const tokens = normalizeTokens(window.label ?? ""); + return tokens.includes("codex") && tokens.includes("spark"); +}; + +const shouldShowCodexWindowBySetting = ( + ps: Settings["providers"]["codex"], + window: RateWindow +): boolean => { + if (window.label === "") return true; + if (/\b\d+h$/.test(window.label.trim())) { + return ps.windows.showPrimary; + } + if (window.label === "Day" || window.label === "Week" || /\b(day|week)\b/.test(window.label.toLowerCase())) { + return ps.windows.showSecondary; + } + return true; +}; + +const kiroWindowVisible: ProviderMetadata["isWindowVisible"] = (_usage, window, settings, _model) => { + if (!settings) return true; + const ps = settings.providers.kiro; + if (window.label === "Credits") return ps.windows.showCredits; + return true; +}; + +const zaiWindowVisible: ProviderMetadata["isWindowVisible"] = (_usage, window, settings, _model) => { + if (!settings) return true; + const ps = settings.providers.zai; + if (window.label === "Tokens") return ps.windows.showTokens; + if (window.label === "Monthly") return ps.windows.showMonthly; + return true; +}; + +const anthropicExtras: ProviderMetadata["getExtras"] = (usage, settings) => { + const extras: UsageExtra[] = []; + const showExtraWindow = settings?.providers.anthropic.windows.showExtra ?? true; + if (showExtraWindow && usage.extraUsageEnabled === false) { + extras.push({ label: "Extra [off]" }); + } + return extras; +}; + +const copilotExtras: ProviderMetadata["getExtras"] = (usage, settings, modelId) => { + const extras: UsageExtra[] = []; + const showMultiplier = settings?.providers.copilot.showMultiplier ?? true; + const showRequestsLeft = settings?.providers.copilot.showRequestsLeft ?? true; + if (!showMultiplier) return extras; + + const multiplier = getModelMultiplier(modelId); + const remaining = usage.requestsRemaining; + if (multiplier !== undefined) { + let multiplierStr = `Model multiplier: ${multiplier}x`; + if (showRequestsLeft && remaining !== undefined) { + const leftCount = Math.floor(remaining / Math.max(multiplier, 0.0001)); + multiplierStr += ` (${leftCount} req. left)`; + } + extras.push({ label: multiplierStr }); + } + return extras; +}; + +export const PROVIDER_METADATA: Record = { + anthropic: { + ...BASE_METADATA.anthropic, + isWindowVisible: anthropicWindowVisible, + getExtras: anthropicExtras, + }, + copilot: { + ...BASE_METADATA.copilot, + isWindowVisible: copilotWindowVisible, + getExtras: copilotExtras, + }, + gemini: { + ...BASE_METADATA.gemini, + isWindowVisible: geminiWindowVisible, + }, + antigravity: { + ...BASE_METADATA.antigravity, + isWindowVisible: antigravityWindowVisible, + }, + codex: { + ...BASE_METADATA.codex, + isWindowVisible: codexWindowVisible, + }, + kiro: { + ...BASE_METADATA.kiro, + isWindowVisible: kiroWindowVisible, + }, + zai: { + ...BASE_METADATA.zai, + isWindowVisible: zaiWindowVisible, + }, + "opencode-go": { + ...BASE_METADATA["opencode-go"], + }, +}; diff --git a/pi/files/agent/extensions/sub-bar/src/providers/settings.ts b/pi/files/agent/extensions/sub-bar/src/providers/settings.ts new file mode 100644 index 0000000..e9ca357 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/providers/settings.ts @@ -0,0 +1,359 @@ +/** + * Provider-specific settings helpers. + */ + +import type { SettingItem } from "@mariozechner/pi-tui"; +import type { ProviderName } from "../types.js"; +import type { + Settings, + BaseProviderSettings, + AnthropicProviderSettings, + CopilotProviderSettings, + GeminiProviderSettings, + AntigravityProviderSettings, + CodexProviderSettings, + KiroProviderSettings, + ZaiProviderSettings, +} from "../settings-types.js"; + +function buildBaseProviderItems(ps: BaseProviderSettings): SettingItem[] { + return [ + { + id: "showStatus", + label: "Show Status Indicator", + currentValue: ps.showStatus ? "on" : "off", + values: ["on", "off"], + description: "Show status indicator for this provider.", + }, + ]; +} + +function applyBaseProviderSetting(ps: BaseProviderSettings, id: string, value: string): boolean { + switch (id) { + case "showStatus": + ps.showStatus = value === "on"; + return true; + default: + return false; + } +} + +/** + * Build settings items for a specific provider. + */ +export function buildProviderSettingsItems(settings: Settings, provider: ProviderName): SettingItem[] { + const ps = settings.providers[provider]; + const items: SettingItem[] = [...buildBaseProviderItems(ps)]; + + if (provider === "anthropic") { + const anthroSettings = ps as AnthropicProviderSettings; + items.push( + { + id: "show5h", + label: "Show 5h Window", + currentValue: anthroSettings.windows.show5h ? "on" : "off", + values: ["on", "off"], + description: "Show the 5-hour usage window.", + }, + { + id: "show7d", + label: "Show Week Window", + currentValue: anthroSettings.windows.show7d ? "on" : "off", + values: ["on", "off"], + description: "Show the weekly usage window.", + }, + { + id: "showExtra", + label: "Show Extra Window", + currentValue: anthroSettings.windows.showExtra ? "on" : "off", + values: ["on", "off"], + description: "Show the extra usage window.", + }, + ); + } + + if (provider === "copilot") { + const copilotSettings = ps as CopilotProviderSettings; + items.push( + { + id: "showMultiplier", + label: "Show Model Multiplier", + currentValue: copilotSettings.showMultiplier ? "on" : "off", + values: ["on", "off"], + description: "Show request cost multiplier for the current model.", + }, + { + id: "showRequestsLeft", + label: "Show Requests Remaining", + currentValue: copilotSettings.showRequestsLeft ? "on" : "off", + values: ["on", "off"], + description: "Estimate requests remaining based on the multiplier.", + }, + { + id: "quotaDisplay", + label: "Show Quota in", + currentValue: copilotSettings.quotaDisplay, + values: ["percentage", "requests"], + description: "Display Copilot usage as percentage or requests.", + }, + { + id: "showMonth", + label: "Show Month Window", + currentValue: copilotSettings.windows.showMonth ? "on" : "off", + values: ["on", "off"], + description: "Show the monthly usage window.", + }, + ); + } + + if (provider === "gemini") { + const geminiSettings = ps as GeminiProviderSettings; + items.push( + { + id: "showPro", + label: "Show Pro Window", + currentValue: geminiSettings.windows.showPro ? "on" : "off", + values: ["on", "off"], + description: "Show the Pro quota window.", + }, + { + id: "showFlash", + label: "Show Flash Window", + currentValue: geminiSettings.windows.showFlash ? "on" : "off", + values: ["on", "off"], + description: "Show the Flash quota window.", + }, + ); + } + + if (provider === "antigravity") { + const antigravitySettings = ps as AntigravityProviderSettings; + items.push( + { + id: "showCurrentModel", + label: "Always Show Current Model", + currentValue: antigravitySettings.showCurrentModel ? "on" : "off", + values: ["on", "off"], + description: "Show the active Antigravity model even if hidden.", + }, + { + id: "showScopedModels", + label: "Show Scoped Models", + currentValue: antigravitySettings.showScopedModels ? "on" : "off", + values: ["on", "off"], + description: "Show Antigravity models that are in the scoped model rotation.", + }, + ); + + const modelVisibility = antigravitySettings.modelVisibility ?? {}; + const modelOrder = antigravitySettings.modelOrder?.length + ? antigravitySettings.modelOrder + : Object.keys(modelVisibility).sort((a, b) => a.localeCompare(b)); + const seenModels = new Set(); + + for (const model of modelOrder) { + if (!model || seenModels.has(model)) continue; + seenModels.add(model); + const normalized = model.toLowerCase().replace(/\s+/g, "_"); + if (normalized === "tab_flash_lite_preview") continue; + const visible = modelVisibility[model] !== false; + items.push({ + id: `model:${model}`, + label: model, + currentValue: visible ? "on" : "off", + values: ["on", "off"], + description: "Toggle this model window.", + }); + } + } + + if (provider === "codex") { + const codexSettings = ps as CodexProviderSettings; + items.push( + { + id: "invertUsage", + label: "Invert Usage", + currentValue: codexSettings.invertUsage ? "on" : "off", + values: ["on", "off"], + description: "Show remaining-style usage for Codex.", + }, + { + id: "showPrimary", + label: "Show Primary Window", + currentValue: codexSettings.windows.showPrimary ? "on" : "off", + values: ["on", "off"], + description: "Show the primary usage window.", + }, + { + id: "showSecondary", + label: "Show Secondary Window", + currentValue: codexSettings.windows.showSecondary ? "on" : "off", + values: ["on", "off"], + description: "Show secondary windows (day/week).", + }, + ); + } + + if (provider === "kiro") { + const kiroSettings = ps as KiroProviderSettings; + items.push({ + id: "showCredits", + label: "Show Credits Window", + currentValue: kiroSettings.windows.showCredits ? "on" : "off", + values: ["on", "off"], + description: "Show the credits usage window.", + }); + } + + if (provider === "zai") { + const zaiSettings = ps as ZaiProviderSettings; + items.push( + { + id: "showTokens", + label: "Show Tokens Window", + currentValue: zaiSettings.windows.showTokens ? "on" : "off", + values: ["on", "off"], + description: "Show the tokens usage window.", + }, + { + id: "showMonthly", + label: "Show Monthly Window", + currentValue: zaiSettings.windows.showMonthly ? "on" : "off", + values: ["on", "off"], + description: "Show the monthly usage window.", + }, + ); + } + + return items; +} + +/** + * Apply a provider settings change in-place. + */ +export function applyProviderSettingsChange( + settings: Settings, + provider: ProviderName, + id: string, + value: string +): Settings { + const ps = settings.providers[provider]; + if (applyBaseProviderSetting(ps, id, value)) { + return settings; + } + + if (provider === "anthropic") { + const anthroSettings = ps as AnthropicProviderSettings; + switch (id) { + case "show5h": + anthroSettings.windows.show5h = value === "on"; + break; + case "show7d": + anthroSettings.windows.show7d = value === "on"; + break; + case "showExtra": + anthroSettings.windows.showExtra = value === "on"; + break; + } + } + + if (provider === "copilot") { + const copilotSettings = ps as CopilotProviderSettings; + switch (id) { + case "showMultiplier": + copilotSettings.showMultiplier = value === "on"; + break; + case "showRequestsLeft": + copilotSettings.showRequestsLeft = value === "on"; + break; + case "quotaDisplay": + copilotSettings.quotaDisplay = value as "percentage" | "requests"; + break; + case "showMonth": + copilotSettings.windows.showMonth = value === "on"; + break; + } + } + + if (provider === "gemini") { + const geminiSettings = ps as GeminiProviderSettings; + switch (id) { + case "showPro": + geminiSettings.windows.showPro = value === "on"; + break; + case "showFlash": + geminiSettings.windows.showFlash = value === "on"; + break; + } + } + + if (provider === "antigravity") { + const antigravitySettings = ps as AntigravityProviderSettings; + switch (id) { + case "showModels": + antigravitySettings.windows.showModels = value === "on"; + break; + case "showCurrentModel": + antigravitySettings.showCurrentModel = value === "on"; + break; + case "showScopedModels": + antigravitySettings.showScopedModels = value === "on"; + break; + default: + if (id.startsWith("model:")) { + const model = id.slice("model:".length); + if (model) { + if (!antigravitySettings.modelVisibility) { + antigravitySettings.modelVisibility = {}; + } + antigravitySettings.modelVisibility[model] = value === "on"; + if (!antigravitySettings.modelOrder) { + antigravitySettings.modelOrder = []; + } + if (!antigravitySettings.modelOrder.includes(model)) { + antigravitySettings.modelOrder.push(model); + } + } + } + break; + } + } + + if (provider === "codex") { + const codexSettings = ps as CodexProviderSettings; + switch (id) { + case "invertUsage": + codexSettings.invertUsage = value === "on"; + break; + case "showPrimary": + codexSettings.windows.showPrimary = value === "on"; + break; + case "showSecondary": + codexSettings.windows.showSecondary = value === "on"; + break; + } + } + + if (provider === "kiro") { + const kiroSettings = ps as KiroProviderSettings; + switch (id) { + case "showCredits": + kiroSettings.windows.showCredits = value === "on"; + break; + } + } + + if (provider === "zai") { + const zaiSettings = ps as ZaiProviderSettings; + switch (id) { + case "showTokens": + zaiSettings.windows.showTokens = value === "on"; + break; + case "showMonthly": + zaiSettings.windows.showMonthly = value === "on"; + break; + } + } + + return settings; +} diff --git a/pi/files/agent/extensions/sub-bar/src/providers/windows.ts b/pi/files/agent/extensions/sub-bar/src/providers/windows.ts new file mode 100644 index 0000000..e9bb515 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/providers/windows.ts @@ -0,0 +1,23 @@ +/** + * Provider-specific window visibility rules. + */ + +import type { RateWindow, UsageSnapshot, ModelInfo } from "../types.js"; +import type { Settings } from "../settings-types.js"; +import { PROVIDER_METADATA } from "./metadata.js"; + +/** + * Check if a window should be shown based on settings. + */ +export function shouldShowWindow( + usage: UsageSnapshot, + window: RateWindow, + settings?: Settings, + model?: ModelInfo +): boolean { + const handler = PROVIDER_METADATA[usage.provider]?.isWindowVisible; + if (handler) { + return handler(usage, window, settings, model); + } + return true; +} diff --git a/pi/files/agent/extensions/sub-bar/src/settings-types.ts b/pi/files/agent/extensions/sub-bar/src/settings-types.ts new file mode 100644 index 0000000..06384fe --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/settings-types.ts @@ -0,0 +1,611 @@ +/** + * Settings types and defaults for sub-bar + */ + +import type { CoreSettings, ProviderName } from "./shared.js"; +import { PROVIDERS } from "./shared.js"; +import type { ThemeColor } from "@mariozechner/pi-coding-agent"; + +/** + * Bar display style + */ +export type BarStyle = "bar" | "percentage" | "both"; + +/** + * Bar rendering type + */ +export type BarType = "horizontal-bar" | "horizontal-single" | "vertical" | "braille" | "shade"; + +/** + * Color scheme for usage bars + */ +export type ColorScheme = "monochrome" | "base-warning-error" | "success-base-warning-error"; + +/** + * Progress bar character style + */ +export type BarCharacter = "light" | "heavy" | "double" | "block" | (string & {}); + +/** + * Divider character style + */ +export type DividerCharacter = + | "none" + | "blank" + | "|" + | "│" + | "┃" + | "┆" + | "┇" + | "║" + | "•" + | "●" + | "○" + | "◇" + | (string & {}); + +/** + * Widget overflow mode + */ +export type OverflowMode = "truncate" | "wrap"; +export type WidgetWrapping = OverflowMode; + +/** + * Widget placement + */ +export type WidgetPlacement = "belowEditor"; + +/** + * Alignment for the widget + */ +export type DisplayAlignment = "left" | "center" | "right" | "split"; + +/** + * Provider label prefix + */ +export type ProviderLabel = "plan" | "subscription" | "sub" | "none" | (string & {}); + +/** + * Reset timer format + */ +export type ResetTimeFormat = "relative" | "datetime"; + +/** + * Reset timer containment style + */ +export type ResetTimerContainment = "none" | "blank" | "()" | "[]" | "<>" | (string & {}); + +/** + * Status indicator display mode + */ +export type StatusIndicatorMode = "icon" | "text" | "icon+text"; + +/** + * Status icon pack selection + */ +export type StatusIconPack = "minimal" | "emoji" | "custom"; + +export interface UsageColorTargets { + title: boolean; + timer: boolean; + bar: boolean; + usageLabel: boolean; + status: boolean; +} + +/** + * Divider color options (subset of theme colors). + */ +export const DIVIDER_COLOR_OPTIONS = [ + "primary", + "text", + "muted", + "dim", + "success", + "warning", + "error", + "border", + "borderMuted", + "borderAccent", +] as const; + +export type DividerColor = (typeof DIVIDER_COLOR_OPTIONS)[number]; + +/** + * Background color options (theme background colors). + */ +export const BACKGROUND_COLOR_OPTIONS = [ + "selectedBg", + "userMessageBg", + "customMessageBg", + "toolPendingBg", + "toolSuccessBg", + "toolErrorBg", +] as const; + +export type BackgroundColor = (typeof BACKGROUND_COLOR_OPTIONS)[number]; + +/** + * Base text/background color options. + */ +export const BASE_COLOR_OPTIONS = [...DIVIDER_COLOR_OPTIONS, ...BACKGROUND_COLOR_OPTIONS] as const; + +/** + * Base text color for widget labels + */ +export type BaseTextColor = (typeof BASE_COLOR_OPTIONS)[number]; + +export function normalizeDividerColor(value?: string): DividerColor { + if (!value) return "borderMuted"; + if (value === "accent" || value === "primary") return "primary"; + if ((DIVIDER_COLOR_OPTIONS as readonly string[]).includes(value)) { + return value as DividerColor; + } + return "borderMuted"; +} + +export function resolveDividerColor(value?: string): ThemeColor { + const normalized = normalizeDividerColor(value); + switch (normalized) { + case "primary": + return "accent"; + case "border": + case "borderMuted": + case "borderAccent": + case "success": + case "warning": + case "error": + case "muted": + case "dim": + case "text": + return normalized as ThemeColor; + default: + return "borderMuted"; + } +} + +export function isBackgroundColor(value?: BaseTextColor): value is BackgroundColor { + return !!value && (BACKGROUND_COLOR_OPTIONS as readonly string[]).includes(value); +} + +export function normalizeBaseTextColor(value?: string): BaseTextColor { + if (!value) return "dim"; + if (value === "accent" || value === "primary") return "primary"; + if ((BASE_COLOR_OPTIONS as readonly string[]).includes(value)) { + return value as BaseTextColor; + } + return "dim"; +} + +export function resolveBaseTextColor(value?: string): BaseTextColor { + return normalizeBaseTextColor(value); +} + +/** + * Bar width configuration + */ +export type BarWidth = number | "fill"; + +/** + * Divider blank spacing configuration + */ +export type DividerBlanks = number | "fill"; + +/** + * Provider settings (UI-only) + */ +export interface BaseProviderSettings { + /** Show status indicator */ + showStatus: boolean; +} + +export interface AnthropicProviderSettings extends BaseProviderSettings { + windows: { + show5h: boolean; + show7d: boolean; + showExtra: boolean; + }; +} + +export interface CopilotProviderSettings extends BaseProviderSettings { + showMultiplier: boolean; + showRequestsLeft: boolean; + quotaDisplay: "percentage" | "requests"; + windows: { + showMonth: boolean; + }; +} + +export interface GeminiProviderSettings extends BaseProviderSettings { + windows: { + showPro: boolean; + showFlash: boolean; + }; +} + +export interface AntigravityProviderSettings extends BaseProviderSettings { + showCurrentModel: boolean; + showScopedModels: boolean; + windows: { + showModels: boolean; + }; + modelVisibility: Record; + modelOrder: string[]; +} + +export interface CodexProviderSettings extends BaseProviderSettings { + invertUsage: boolean; + windows: { + showPrimary: boolean; + showSecondary: boolean; + }; +} + +export interface KiroProviderSettings extends BaseProviderSettings { + windows: { + showCredits: boolean; + }; +} + +export interface ZaiProviderSettings extends BaseProviderSettings { + windows: { + showTokens: boolean; + showMonthly: boolean; + }; +} + +export interface ProviderSettingsMap { + anthropic: AnthropicProviderSettings; + copilot: CopilotProviderSettings; + gemini: GeminiProviderSettings; + antigravity: AntigravityProviderSettings; + codex: CodexProviderSettings; + kiro: KiroProviderSettings; + zai: ZaiProviderSettings; + "opencode-go": BaseProviderSettings; +} + +export type { BehaviorSettings, CoreSettings } from "./shared.js"; + +/** + * Keybinding settings. + * Values are key-combo strings accepted by pi's registerShortcut (e.g. "ctrl+alt+p"). + * Use "none" to disable a shortcut. + * Changes take effect after pi restart. + */ +export interface KeybindingSettings { + /** Shortcut to cycle through providers */ + cycleProvider: string; + /** Shortcut to toggle reset timer format */ + toggleResetFormat: string; +} + +/** + * Display settings + */ +export interface DisplaySettings { + /** Alignment */ + alignment: DisplayAlignment; + /** Bar display style */ + barStyle: BarStyle; + /** Bar type */ + barType: BarType; + /** Width of the progress bar in characters */ + barWidth: BarWidth; + /** Progress bar character */ + barCharacter: BarCharacter; + /** Contain bar within ▕ and ▏ */ + containBar: boolean; + /** Fill empty braille segments with dim full blocks */ + brailleFillEmpty: boolean; + /** Use full braille blocks for filled segments */ + brailleFullBlocks: boolean; + /** Color scheme for bars */ + colorScheme: ColorScheme; + /** Elements colored by the usage scheme */ + usageColorTargets: UsageColorTargets; + /** Reset time display position */ + resetTimePosition: "off" | "front" | "back" | "integrated"; + /** Reset time format */ + resetTimeFormat: ResetTimeFormat; + /** Reset timer containment */ + resetTimeContainment: ResetTimerContainment; + /** Status indicator mode */ + statusIndicatorMode: StatusIndicatorMode; + /** Status icon pack */ + statusIconPack: StatusIconPack; + /** Custom status icon pack (four characters) */ + statusIconCustom: string; + /** Show divider between status and provider */ + statusProviderDivider: boolean; + /** Dismiss status when operational */ + statusDismissOk: boolean; + /** Show provider display name */ + showProviderName: boolean; + /** Provider label prefix */ + providerLabel: ProviderLabel; + /** Show colon after provider label */ + providerLabelColon: boolean; + /** Bold provider name and colon */ + providerLabelBold: boolean; + /** Base text color for widget labels */ + baseTextColor: BaseTextColor; + /** Background color for the widget line */ + backgroundColor: BaseTextColor; + /** Show window titles (5h, Week, etc.) */ + showWindowTitle: boolean; + /** Bold window titles (5h, Week, etc.) */ + boldWindowTitle: boolean; + /** Show usage labels (used/rem.) */ + showUsageLabels: boolean; + /** Divider character */ + dividerCharacter: DividerCharacter; + /** Divider color */ + dividerColor: DividerColor; + /** Blanks before and after divider */ + dividerBlanks: DividerBlanks; + /** Show divider between provider label and usage */ + showProviderDivider: boolean; + /** Connect divider glyphs to the bottom divider line */ + dividerFooterJoin: boolean; + /** Show divider line above the bar */ + showTopDivider: boolean; + /** Show divider line below the bar */ + showBottomDivider: boolean; + /** Widget overflow mode */ + overflow: OverflowMode; + /** Left padding inside widget */ + paddingLeft: number; + /** Right padding inside widget */ + paddingRight: number; + /** Widget placement */ + widgetPlacement: WidgetPlacement; + /** Show context window usage as leftmost progress bar */ + showContextBar: boolean; + /** Error threshold (percentage remaining below this = red) */ + errorThreshold: number; + /** Warning threshold (percentage remaining below this = yellow) */ + warningThreshold: number; + /** Success threshold (percentage remaining above this = green, gradient only) */ + successThreshold: number; +} + + +/** + * All settings + */ +export interface DisplayTheme { + id: string; + name: string; + display: DisplaySettings; + source?: "saved" | "imported"; +} + +export interface Settings extends Omit { + /** Version for migration */ + version: number; + /** Provider-specific UI settings */ + providers: ProviderSettingsMap; + /** Display settings */ + display: DisplaySettings; + /** Stored display themes */ + displayThemes: DisplayTheme[]; + /** Snapshot of the previous display theme */ + displayUserTheme: DisplaySettings | null; + /** Pinned provider override for display */ + pinnedProvider: ProviderName | null; + /** Keybinding settings (changes require pi restart) */ + keybindings: KeybindingSettings; +} + +/** + * Current settings version + */ +export const SETTINGS_VERSION = 2; + +/** + * Default settings + */ +export function getDefaultSettings(): Settings { + return { + version: SETTINGS_VERSION, + providers: { + anthropic: { + showStatus: true, + windows: { + show5h: true, + show7d: true, + showExtra: false, + }, + }, + copilot: { + showStatus: true, + showMultiplier: true, + showRequestsLeft: true, + quotaDisplay: "percentage", + windows: { + showMonth: true, + }, + }, + gemini: { + showStatus: true, + windows: { + showPro: true, + showFlash: true, + }, + }, + antigravity: { + showStatus: true, + showCurrentModel: true, + showScopedModels: true, + windows: { + showModels: true, + }, + modelVisibility: {}, + modelOrder: [], + }, + codex: { + showStatus: true, + invertUsage: false, + windows: { + showPrimary: true, + showSecondary: true, + }, + }, + kiro: { + showStatus: false, + windows: { + showCredits: true, + }, + }, + zai: { + showStatus: false, + windows: { + showTokens: true, + showMonthly: true, + }, + }, + "opencode-go": { + showStatus: false, + }, + }, + display: { + alignment: "split", + barStyle: "both", + barType: "horizontal-bar", + barWidth: "fill", + barCharacter: "heavy", + containBar: false, + brailleFillEmpty: false, + brailleFullBlocks: false, + colorScheme: "base-warning-error", + usageColorTargets: { + title: true, + timer: true, + bar: true, + usageLabel: true, + status: true, + }, + resetTimePosition: "front", + resetTimeFormat: "relative", + resetTimeContainment: "blank", + statusIndicatorMode: "icon", + statusIconPack: "emoji", + statusIconCustom: "✓⚠×?", + statusProviderDivider: false, + statusDismissOk: true, + showProviderName: true, + providerLabel: "none", + providerLabelColon: false, + providerLabelBold: true, + baseTextColor: "muted", + backgroundColor: "text", + showWindowTitle: true, + boldWindowTitle: true, + showUsageLabels: true, + dividerCharacter: "│", + dividerColor: "dim", + dividerBlanks: 1, + showProviderDivider: true, + dividerFooterJoin: true, + showTopDivider: false, + showBottomDivider: true, + paddingLeft: 1, + paddingRight: 1, + widgetPlacement: "belowEditor", + showContextBar: false, + errorThreshold: 25, + warningThreshold: 50, + overflow: "truncate", + successThreshold: 75, + }, + + displayThemes: [], + displayUserTheme: null, + pinnedProvider: null, + + keybindings: { + cycleProvider: "ctrl+alt+p", + toggleResetFormat: "ctrl+alt+r", + }, + + behavior: { + refreshInterval: 60, + minRefreshInterval: 10, + refreshOnTurnStart: false, + refreshOnToolResult: false, + }, + statusRefresh: { + refreshInterval: 60, + minRefreshInterval: 10, + refreshOnTurnStart: false, + refreshOnToolResult: false, + }, + providerOrder: [...PROVIDERS], + defaultProvider: null, + }; +} + +/** + * Deep merge two objects + */ +function deepMerge(target: T, source: Partial): T { + const result = { ...target }; + for (const key of Object.keys(source) as (keyof T)[]) { + const sourceValue = source[key]; + const targetValue = target[key]; + if ( + sourceValue !== undefined && + typeof sourceValue === "object" && + sourceValue !== null && + !Array.isArray(sourceValue) && + typeof targetValue === "object" && + targetValue !== null && + !Array.isArray(targetValue) + ) { + result[key] = deepMerge(targetValue, sourceValue as Partial); + } else if (sourceValue !== undefined) { + result[key] = sourceValue as T[keyof T]; + } + } + return result; +} + +/** + * Merge settings with defaults (no legacy migrations). + */ +export function mergeSettings(loaded: Partial): Settings { + const migrated = migrateSettings(loaded); + return deepMerge(getDefaultSettings(), migrated); +} + +function migrateDisplaySettings(display?: Partial | null): void { + if (!display) return; + const displayAny = display as Partial & { widgetWrapping?: OverflowMode; paddingX?: number }; + if (displayAny.widgetWrapping !== undefined && displayAny.overflow === undefined) { + displayAny.overflow = displayAny.widgetWrapping; + } + if (displayAny.paddingX !== undefined) { + if (displayAny.paddingLeft === undefined) { + displayAny.paddingLeft = displayAny.paddingX; + } + if (displayAny.paddingRight === undefined) { + displayAny.paddingRight = displayAny.paddingX; + } + delete (displayAny as { paddingX?: unknown }).paddingX; + } + if ("widgetWrapping" in displayAny) { + delete (displayAny as { widgetWrapping?: unknown }).widgetWrapping; + } +} + +function migrateSettings(loaded: Partial): Partial { + migrateDisplaySettings(loaded.display); + migrateDisplaySettings(loaded.displayUserTheme); + if (Array.isArray(loaded.displayThemes)) { + for (const theme of loaded.displayThemes) { + migrateDisplaySettings(theme.display as Partial | undefined); + } + } + return loaded; +} diff --git a/pi/files/agent/extensions/sub-bar/src/settings-ui.ts b/pi/files/agent/extensions/sub-bar/src/settings-ui.ts new file mode 100644 index 0000000..4a88702 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/settings-ui.ts @@ -0,0 +1,5 @@ +/** + * Settings UI entry point (re-export). + */ + +export { showSettingsUI } from "./settings/ui.js"; diff --git a/pi/files/agent/extensions/sub-bar/src/settings.ts b/pi/files/agent/extensions/sub-bar/src/settings.ts new file mode 100644 index 0000000..1b1d3d3 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/settings.ts @@ -0,0 +1,176 @@ +/** + * Settings persistence for sub-bar + */ + +import * as path from "node:path"; +import type { Settings } from "./settings-types.js"; +import { getDefaultSettings, mergeSettings } from "./settings-types.js"; +import { getStorage } from "./storage.js"; +import { getLegacySettingsPath, getSettingsPath } from "./paths.js"; + +/** + * Settings file path + */ +export const SETTINGS_PATH = getSettingsPath(); +const LEGACY_SETTINGS_PATH = getLegacySettingsPath(); + +/** + * In-memory settings cache + */ +let cachedSettings: Settings | undefined; + +/** + * Ensure the settings directory exists + */ +function ensureSettingsDir(): void { + const storage = getStorage(); + const dir = path.dirname(SETTINGS_PATH); + storage.ensureDir(dir); +} + +/** + * Parse settings file contents + */ +function parseSettings(content: string): Settings { + const loaded = JSON.parse(content) as Partial; + return mergeSettings({ + version: loaded.version, + display: loaded.display, + providers: loaded.providers, + displayThemes: loaded.displayThemes, + displayUserTheme: loaded.displayUserTheme, + pinnedProvider: loaded.pinnedProvider, + keybindings: loaded.keybindings, + } as Partial); +} + +function loadSettingsFromDisk(settingsPath: string): Settings | null { + const storage = getStorage(); + if (storage.exists(settingsPath)) { + const content = storage.readFile(settingsPath); + if (content) { + return parseSettings(content); + } + } + return null; +} + +function tryLoadSettings(settingsPath: string): Settings | null { + try { + return loadSettingsFromDisk(settingsPath); + } catch (error) { + console.error(`Failed to load settings from ${settingsPath}:`, error); + return null; + } +} + +/** + * Load settings from disk + */ +export function loadSettings(): Settings { + if (cachedSettings) { + return cachedSettings; + } + + const diskSettings = tryLoadSettings(SETTINGS_PATH); + if (diskSettings) { + cachedSettings = diskSettings; + return cachedSettings; + } + + const legacySettings = tryLoadSettings(LEGACY_SETTINGS_PATH); + if (legacySettings) { + const saved = saveSettings(legacySettings); + if (saved) { + getStorage().removeFile(LEGACY_SETTINGS_PATH); + } + cachedSettings = legacySettings; + return cachedSettings; + } + + // Return defaults if file doesn't exist or failed to load + cachedSettings = getDefaultSettings(); + return cachedSettings; +} + +/** + * Save settings to disk + */ +export function saveSettings(settings: Settings): boolean { + const storage = getStorage(); + try { + ensureSettingsDir(); + let next = settings; + if (cachedSettings) { + const diskSettings = loadSettingsFromDisk(SETTINGS_PATH); + if (diskSettings) { + const displayChanged = JSON.stringify(settings.display) !== JSON.stringify(cachedSettings.display); + const providersChanged = JSON.stringify(settings.providers) !== JSON.stringify(cachedSettings.providers); + const themesChanged = JSON.stringify(settings.displayThemes) !== JSON.stringify(cachedSettings.displayThemes); + const userThemeChanged = JSON.stringify(settings.displayUserTheme) !== JSON.stringify(cachedSettings.displayUserTheme); + const pinnedChanged = settings.pinnedProvider !== cachedSettings.pinnedProvider; + const keybindingsChanged = JSON.stringify(settings.keybindings) !== JSON.stringify(cachedSettings.keybindings); + + next = { + ...diskSettings, + version: settings.version, + display: displayChanged ? settings.display : diskSettings.display, + providers: providersChanged ? settings.providers : diskSettings.providers, + displayThemes: themesChanged ? settings.displayThemes : diskSettings.displayThemes, + displayUserTheme: userThemeChanged ? settings.displayUserTheme : diskSettings.displayUserTheme, + pinnedProvider: pinnedChanged ? settings.pinnedProvider : diskSettings.pinnedProvider, + keybindings: keybindingsChanged ? settings.keybindings : diskSettings.keybindings, + }; + } + } + const content = JSON.stringify({ + version: next.version, + display: next.display, + providers: next.providers, + displayThemes: next.displayThemes, + displayUserTheme: next.displayUserTheme, + pinnedProvider: next.pinnedProvider, + keybindings: next.keybindings, + }, null, 2); + storage.writeFile(SETTINGS_PATH, content); + cachedSettings = next; + return true; + } catch (error) { + console.error(`Failed to save settings to ${SETTINGS_PATH}:`, error); + return false; + } +} + +/** + * Reset settings to defaults + */ +export function resetSettings(): Settings { + const defaults = getDefaultSettings(); + const current = getSettings(); + const next = { + ...current, + display: defaults.display, + providers: defaults.providers, + displayThemes: defaults.displayThemes, + displayUserTheme: defaults.displayUserTheme, + pinnedProvider: defaults.pinnedProvider, + keybindings: defaults.keybindings, + version: defaults.version, + }; + saveSettings(next); + return next; +} + +/** + * Get current settings (cached) + */ +export function getSettings(): Settings { + return loadSettings(); +} + +/** + * Clear the settings cache (force reload on next access) + */ +export function clearSettingsCache(): void { + cachedSettings = undefined; +} diff --git a/pi/files/agent/extensions/sub-bar/src/settings/display.ts b/pi/files/agent/extensions/sub-bar/src/settings/display.ts new file mode 100644 index 0000000..7f5afda --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/settings/display.ts @@ -0,0 +1,718 @@ +/** + * Display settings UI helpers. + */ + +import type { SettingItem } from "@mariozechner/pi-tui"; +import type { + Settings, + BarStyle, + BarType, + ColorScheme, + BarCharacter, + DividerCharacter, + WidgetWrapping, + DisplayAlignment, + BarWidth, + DividerBlanks, + ProviderLabel, + BaseTextColor, + ResetTimeFormat, + ResetTimerContainment, + StatusIndicatorMode, + StatusIconPack, + DividerColor, + UsageColorTargets, +} from "../settings-types.js"; +import { + BASE_COLOR_OPTIONS, + DIVIDER_COLOR_OPTIONS, + normalizeBaseTextColor, + normalizeDividerColor, +} from "../settings-types.js"; +import { CUSTOM_OPTION } from "../ui/settings-list.js"; + +export function buildDisplayLayoutItems(settings: Settings): SettingItem[] { + return [ + { + id: "showContextBar", + label: "Show Context Bar", + currentValue: settings.display.showContextBar ? "on" : "off", + values: ["on", "off"], + description: "Show context window usage as leftmost progress bar.", + }, + { + id: "alignment", + label: "Alignment", + currentValue: settings.display.alignment, + values: ["left", "center", "right", "split"] as DisplayAlignment[], + description: "Align the usage line inside the widget.", + }, + { + id: "overflow", + label: "Overflow", + currentValue: settings.display.overflow, + values: ["truncate", "wrap"] as WidgetWrapping[], + description: "Wrap the usage line or truncate with ellipsis (requires bar width ≠ fill and alignment ≠ split).", + }, + { + id: "paddingLeft", + label: "Padding Left", + currentValue: String(settings.display.paddingLeft ?? 0), + values: ["0", "1", "2", "3", "4", CUSTOM_OPTION], + description: "Add left padding inside the widget.", + }, + { + id: "paddingRight", + label: "Padding Right", + currentValue: String(settings.display.paddingRight ?? 0), + values: ["0", "1", "2", "3", "4", CUSTOM_OPTION], + description: "Add right padding inside the widget.", + }, + ]; +} + +export function buildDisplayResetItems(settings: Settings): SettingItem[] { + return [ + { + id: "resetTimePosition", + label: "Reset Timer", + currentValue: settings.display.resetTimePosition, + values: ["off", "front", "back", "integrated"], + description: "Where to show the reset timer in each window.", + }, + { + id: "resetTimeFormat", + label: "Reset Timer Format", + currentValue: settings.display.resetTimeFormat ?? "relative", + values: ["relative", "datetime"] as ResetTimeFormat[], + description: "Show relative countdown or reset datetime.", + }, + { + id: "resetTimeContainment", + label: "Reset Timer Containment", + currentValue: settings.display.resetTimeContainment ?? "()", + values: ["none", "blank", "()", "[]", "<>", CUSTOM_OPTION] as ResetTimerContainment[], + description: "Wrapping characters for the reset timer (custom supported).", + }, + ]; +} + +export function resolveUsageColorTargets(targets?: UsageColorTargets): UsageColorTargets { + return { + title: targets?.title ?? true, + timer: targets?.timer ?? true, + bar: targets?.bar ?? true, + usageLabel: targets?.usageLabel ?? true, + status: targets?.status ?? true, + }; +} + +export function formatUsageColorTargetsSummary(targets?: UsageColorTargets): string { + const resolved = resolveUsageColorTargets(targets); + const enabled = [ + resolved.title ? "Title" : null, + resolved.timer ? "Timer" : null, + resolved.bar ? "Bar" : null, + resolved.usageLabel ? "Usage label" : null, + resolved.status ? "Status" : null, + ].filter(Boolean) as string[]; + if (enabled.length === 0) return "off"; + if (enabled.length === 5) return "all"; + return enabled.join(", "); +} + +export function buildUsageColorTargetItems(settings: Settings): SettingItem[] { + const targets = resolveUsageColorTargets(settings.display.usageColorTargets); + return [ + { + id: "usageColorTitle", + label: "Title", + currentValue: targets.title ? "on" : "off", + values: ["on", "off"], + description: "Color the window title by usage.", + }, + { + id: "usageColorTimer", + label: "Timer", + currentValue: targets.timer ? "on" : "off", + values: ["on", "off"], + description: "Color the reset timer by usage.", + }, + { + id: "usageColorBar", + label: "Bar", + currentValue: targets.bar ? "on" : "off", + values: ["on", "off"], + description: "Color the usage bar by usage.", + }, + { + id: "usageColorLabel", + label: "Usage label", + currentValue: targets.usageLabel ? "on" : "off", + values: ["on", "off"], + description: "Color the percentage text by usage.", + }, + { + id: "usageColorStatus", + label: "Status", + currentValue: targets.status ? "on" : "off", + values: ["on", "off"], + description: "Color the status indicator by status.", + }, + ]; +} + +export function buildDisplayColorItems(settings: Settings): SettingItem[] { + return [ + { + id: "baseTextColor", + label: "Base Color", + currentValue: normalizeBaseTextColor(settings.display.baseTextColor), + values: [...BASE_COLOR_OPTIONS] as BaseTextColor[], + description: "Base color for neutral labels and dividers.", + }, + { + id: "backgroundColor", + label: "Background Color", + currentValue: normalizeBaseTextColor(settings.display.backgroundColor), + values: [...BASE_COLOR_OPTIONS] as BaseTextColor[], + description: "Background color for the widget line.", + }, + { + id: "colorScheme", + label: "Color Indicator Scheme", + currentValue: settings.display.colorScheme, + values: [ + "base-warning-error", + "success-base-warning-error", + "monochrome", + ] as ColorScheme[], + description: "Choose how usage/status indicators are color-coded.", + }, + { + id: "usageColorTargets", + label: "Color Indicator Targets", + currentValue: formatUsageColorTargetsSummary(settings.display.usageColorTargets), + description: "Pick which elements use the indicator colors.", + }, + { + id: "errorThreshold", + label: "Error Threshold (%)", + currentValue: String(settings.display.errorThreshold), + values: ["10", "15", "20", "25", "30", "35", "40", CUSTOM_OPTION], + description: "Percent remaining below which usage is red.", + }, + { + id: "warningThreshold", + label: "Warning Threshold (%)", + currentValue: String(settings.display.warningThreshold), + values: ["30", "40", "50", "60", "70", CUSTOM_OPTION], + description: "Percent remaining below which usage is yellow.", + }, + { + id: "successThreshold", + label: "Success Threshold (%)", + currentValue: String(settings.display.successThreshold), + values: ["60", "70", "75", "80", "90", CUSTOM_OPTION], + description: "Percent remaining above which usage is green.", + }, + ]; +} + +export function buildDisplayBarItems(settings: Settings): SettingItem[] { + const items: SettingItem[] = [ + { + id: "barType", + label: "Bar Type", + currentValue: settings.display.barType, + values: [ + "horizontal-bar", + "horizontal-single", + "vertical", + "braille", + "shade", + ] as BarType[], + description: "Choose the bar glyph style for usage.", + }, + ]; + + if (settings.display.barType === "horizontal-bar") { + items.push({ + id: "barCharacter", + label: "H. Bar Character", + currentValue: settings.display.barCharacter, + values: ["light", "heavy", "double", "block", CUSTOM_OPTION], + description: "Custom bar character(s), set 1 or 2 (fill/empty)", + }); + } + + items.push( + { + id: "barWidth", + label: "Bar Width", + currentValue: String(settings.display.barWidth), + values: ["1", "4", "6", "8", "10", "12", "fill", CUSTOM_OPTION], + description: "Set the bar width or fill available space.", + }, + { + id: "containBar", + label: "Contain Bar", + currentValue: settings.display.containBar ? "on" : "off", + values: ["on", "off"], + description: "Wrap the bar with ▕ and ▏ caps.", + }, + ); + + if (settings.display.barType === "braille") { + items.push( + { + id: "brailleFillEmpty", + label: "Braille Empty Fill", + currentValue: settings.display.brailleFillEmpty ? "on" : "off", + values: ["on", "off"], + description: "Fill empty braille cells with dim blocks.", + }, + { + id: "brailleFullBlocks", + label: "Braille Full Blocks", + currentValue: settings.display.brailleFullBlocks ? "on" : "off", + values: ["on", "off"], + description: "Use full 8-dot braille blocks for filled segments.", + }, + ); + } + + items.push({ + id: "barStyle", + label: "Bar Style", + currentValue: settings.display.barStyle, + values: ["bar", "percentage", "both"] as BarStyle[], + description: "Show bar, percentage, or both.", + }); + + return items; +} + +export function buildDisplayProviderItems(settings: Settings): SettingItem[] { + return [ + { + id: "showProviderName", + label: "Show Provider Name", + currentValue: settings.display.showProviderName ? "on" : "off", + values: ["on", "off"], + description: "Toggle the provider name prefix.", + }, + { + id: "providerLabel", + label: "Provider Label", + currentValue: settings.display.providerLabel, + values: ["none", "plan", "subscription", "sub", CUSTOM_OPTION] as (ProviderLabel | typeof CUSTOM_OPTION)[], + description: "Suffix appended after the provider name.", + }, + { + id: "providerLabelColon", + label: "Provider Label Colon", + currentValue: settings.display.providerLabelColon ? "on" : "off", + values: ["on", "off"], + description: "Show a colon after the provider label.", + }, + { + id: "providerLabelBold", + label: "Show in Bold", + currentValue: settings.display.providerLabelBold ? "on" : "off", + values: ["on", "off"], + description: "Bold the provider name and colon.", + }, + { + id: "showUsageLabels", + label: "Show Usage Labels", + currentValue: settings.display.showUsageLabels ? "on" : "off", + values: ["on", "off"], + description: "Show “used/rem.” labels after percentages.", + }, + { + id: "showWindowTitle", + label: "Show Title", + currentValue: settings.display.showWindowTitle ? "on" : "off", + values: ["on", "off"], + description: "Show window titles like 5h, Week, etc.", + }, + { + id: "boldWindowTitle", + label: "Bold Title", + currentValue: settings.display.boldWindowTitle ? "on" : "off", + values: ["on", "off"], + description: "Bold window titles like 5h, Week, etc.", + }, + ]; +} + +const STATUS_ICON_PACK_PREVIEW = { + minimal: "minimal (✓ ⚠ × ?)", + emoji: "emoji (✅ ⚠️ 🔴 ❓)", + faces: "faces (😎 😳 😵 🤔)", +} as const; + +const STATUS_ICON_FACES_PRESET = "😎😳😵🤔"; + +const STATUS_ICON_CUSTOM_FALLBACK = ["✓", "⚠", "×", "?"]; +const STATUS_ICON_CUSTOM_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +function resolveCustomStatusIcons(value?: string): [string, string, string, string] { + if (!value) return STATUS_ICON_CUSTOM_FALLBACK as [string, string, string, string]; + const segments = Array.from(STATUS_ICON_CUSTOM_SEGMENTER.segment(value), (entry) => entry.segment) + .map((segment) => segment.trim()) + .filter(Boolean); + if (segments.length < 3) return STATUS_ICON_CUSTOM_FALLBACK as [string, string, string, string]; + if (segments.length === 3) { + return [segments[0], segments[1], segments[2], STATUS_ICON_CUSTOM_FALLBACK[3]] as [string, string, string, string]; + } + return [segments[0], segments[1], segments[2], segments[3]] as [string, string, string, string]; +} + +function formatCustomStatusIcons(value?: string): string { + return resolveCustomStatusIcons(value).join(" "); +} + +function formatStatusIconPack(pack: Exclude): string { + return STATUS_ICON_PACK_PREVIEW[pack] ?? pack; +} + +function parseStatusIconPack(value: string): StatusIconPack { + if (value.startsWith("minimal")) return "minimal"; + if (value.startsWith("emoji")) return "emoji"; + return "emoji"; +} + +export function buildDisplayStatusItems(settings: Settings): SettingItem[] { + const rawMode = settings.display.statusIndicatorMode ?? "icon"; + const mode: StatusIndicatorMode = rawMode === "text" || rawMode === "icon+text" || rawMode === "icon" + ? rawMode + : "icon"; + const items: SettingItem[] = [ + { + id: "statusIndicatorMode", + label: "Status Mode", + currentValue: mode, + values: ["icon", "text", "icon+text"] as StatusIndicatorMode[], + description: "Use icons, text, or both for status indicators.", + }, + ]; + + if (mode === "icon" || mode === "icon+text") { + const pack = settings.display.statusIconPack ?? "emoji"; + const customIcons = settings.display.statusIconCustom; + items.push({ + id: "statusIconPack", + label: "Status Icon Pack", + currentValue: pack === "custom" ? formatCustomStatusIcons(customIcons) : formatStatusIconPack(pack), + values: [ + formatStatusIconPack("minimal"), + formatStatusIconPack("emoji"), + STATUS_ICON_PACK_PREVIEW.faces, + CUSTOM_OPTION, + ], + description: "Pick the icon set used for status indicators. Choose custom to edit icons (OK/warn/error/unknown).", + }); + } + + items.push( + { + id: "statusDismissOk", + label: "Dismiss Operational Status", + currentValue: settings.display.statusDismissOk ? "on" : "off", + values: ["on", "off"], + description: "Hide status indicators when there are no incidents.", + } + ); + + return items; +} + +export function buildDisplayDividerItems(settings: Settings): SettingItem[] { + return [ + { + id: "dividerCharacter", + label: "Divider Character", + currentValue: settings.display.dividerCharacter, + values: ["none", "blank", "|", "│", "┃", "┆", "┇", "║", "•", "●", "○", "◇", CUSTOM_OPTION] as DividerCharacter[], + description: "Choose the divider glyph between windows.", + }, + { + id: "dividerColor", + label: "Divider Color", + currentValue: normalizeDividerColor(settings.display.dividerColor ?? "borderMuted"), + values: [...DIVIDER_COLOR_OPTIONS] as DividerColor[], + description: "Color used for divider glyphs and lines.", + }, + { + id: "statusProviderDivider", + label: "Status/Provider Divider", + currentValue: settings.display.statusProviderDivider ? "on" : "off", + values: ["on", "off"], + description: "Add a divider between status and provider label.", + }, + { + id: "dividerBlanks", + label: "Blanks Before/After Divider", + currentValue: String(settings.display.dividerBlanks), + values: ["0", "1", "2", "3", "fill", CUSTOM_OPTION], + description: "Padding around the divider character.", + }, + { + id: "showProviderDivider", + label: "Show Provider Divider", + currentValue: settings.display.showProviderDivider ? "on" : "off", + values: ["on", "off"], + description: "Show the divider after the provider label.", + }, + { + id: "showTopDivider", + label: "Show Top Divider", + currentValue: settings.display.showTopDivider ? "on" : "off", + values: ["on", "off"], + description: "Show a divider line above the widget.", + }, + { + id: "showBottomDivider", + label: "Show Bottom Divider", + currentValue: settings.display.showBottomDivider ? "on" : "off", + values: ["on", "off"], + description: "Show a divider line below the widget.", + }, + { + id: "dividerFooterJoin", + label: "Connect Dividers", + currentValue: settings.display.dividerFooterJoin ? "on" : "off", + values: ["on", "off"], + description: "Draw reverse-T connectors for top/bottom dividers.", + }, + + ]; +} + +function clampNumber(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function parseClampedNumber(value: string, min: number, max: number): number | null { + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed)) return null; + return clampNumber(parsed, min, max); +} + +export function applyDisplayChange(settings: Settings, id: string, value: string): Settings { + switch (id) { + case "alignment": + settings.display.alignment = value as DisplayAlignment; + break; + case "barType": + settings.display.barType = value as BarType; + break; + case "barStyle": + settings.display.barStyle = value as BarStyle; + break; + case "barWidth": { + if (value === "fill") { + settings.display.barWidth = "fill" as BarWidth; + break; + } + const parsed = parseClampedNumber(value, 0, 100); + if (parsed !== null) { + settings.display.barWidth = parsed; + } + break; + } + case "containBar": + settings.display.containBar = value === "on"; + break; + case "barCharacter": + settings.display.barCharacter = value as BarCharacter; + break; + case "brailleFillEmpty": + settings.display.brailleFillEmpty = value === "on"; + break; + case "brailleFullBlocks": + settings.display.brailleFullBlocks = value === "on"; + break; + case "colorScheme": + settings.display.colorScheme = value as ColorScheme; + break; + case "usageColorTitle": + settings.display.usageColorTargets = { + ...resolveUsageColorTargets(settings.display.usageColorTargets), + title: value === "on", + }; + break; + case "usageColorTimer": + settings.display.usageColorTargets = { + ...resolveUsageColorTargets(settings.display.usageColorTargets), + timer: value === "on", + }; + break; + case "usageColorBar": + settings.display.usageColorTargets = { + ...resolveUsageColorTargets(settings.display.usageColorTargets), + bar: value === "on", + }; + break; + case "usageColorLabel": + settings.display.usageColorTargets = { + ...resolveUsageColorTargets(settings.display.usageColorTargets), + usageLabel: value === "on", + }; + break; + case "usageColorStatus": + settings.display.usageColorTargets = { + ...resolveUsageColorTargets(settings.display.usageColorTargets), + status: value === "on", + }; + break; + case "usageColorTargets": + settings.display.usageColorTargets = resolveUsageColorTargets(settings.display.usageColorTargets); + break; + case "resetTimePosition": + settings.display.resetTimePosition = value as "off" | "front" | "back" | "integrated"; + break; + case "resetTimeFormat": + settings.display.resetTimeFormat = value as ResetTimeFormat; + break; + case "resetTimeContainment": + if (value === CUSTOM_OPTION) { + break; + } + settings.display.resetTimeContainment = value as ResetTimerContainment; + break; + case "statusIndicatorMode": + settings.display.statusIndicatorMode = value as StatusIndicatorMode; + break; + case "statusIconPack": + if (value === CUSTOM_OPTION) { + settings.display.statusIconPack = "custom"; + break; + } + if (value.startsWith("minimal") || value.startsWith("emoji")) { + settings.display.statusIconPack = parseStatusIconPack(value); + break; + } + if (value.startsWith("faces")) { + settings.display.statusIconCustom = STATUS_ICON_FACES_PRESET; + settings.display.statusIconPack = "custom"; + break; + } + settings.display.statusIconCustom = value; + settings.display.statusIconPack = "custom"; + break; + case "statusIconCustom": + settings.display.statusIconCustom = value; + settings.display.statusIconPack = "custom"; + break; + case "statusProviderDivider": + settings.display.statusProviderDivider = value === "on"; + break; + case "statusDismissOk": + settings.display.statusDismissOk = value === "on"; + break; + case "showProviderName": + settings.display.showProviderName = value === "on"; + break; + case "providerLabel": + settings.display.providerLabel = value as ProviderLabel; + break; + case "providerLabelColon": + settings.display.providerLabelColon = value === "on"; + break; + case "providerLabelBold": + settings.display.providerLabelBold = value === "on"; + break; + case "baseTextColor": + settings.display.baseTextColor = normalizeBaseTextColor(value); + break; + case "backgroundColor": + settings.display.backgroundColor = normalizeBaseTextColor(value); + break; + case "showUsageLabels": + settings.display.showUsageLabels = value === "on"; + break; + case "showWindowTitle": + settings.display.showWindowTitle = value === "on"; + break; + case "boldWindowTitle": + settings.display.boldWindowTitle = value === "on"; + break; + case "showContextBar": + settings.display.showContextBar = value === "on"; + break; + case "paddingLeft": { + const parsed = parseClampedNumber(value, 0, 100); + if (parsed !== null) { + settings.display.paddingLeft = parsed; + } + break; + } + case "paddingRight": { + const parsed = parseClampedNumber(value, 0, 100); + if (parsed !== null) { + settings.display.paddingRight = parsed; + } + break; + } + case "dividerCharacter": + settings.display.dividerCharacter = value as DividerCharacter; + break; + case "dividerColor": + settings.display.dividerColor = normalizeDividerColor(value); + break; + case "dividerBlanks": { + if (value === "fill") { + settings.display.dividerBlanks = "fill" as DividerBlanks; + break; + } + const parsed = parseClampedNumber(value, 0, 100); + if (parsed !== null) { + settings.display.dividerBlanks = parsed; + } + break; + } + case "showProviderDivider": + settings.display.showProviderDivider = value === "on"; + break; + case "dividerFooterJoin": + settings.display.dividerFooterJoin = value === "on"; + break; + case "showTopDivider": + settings.display.showTopDivider = value === "on"; + break; + case "showBottomDivider": + settings.display.showBottomDivider = value === "on"; + break; + case "overflow": + settings.display.overflow = value as WidgetWrapping; + break; + case "widgetWrapping": + settings.display.overflow = value as WidgetWrapping; + break; + case "errorThreshold": { + const parsed = parseClampedNumber(value, 0, 100); + if (parsed !== null) { + settings.display.errorThreshold = parsed; + } + break; + } + case "warningThreshold": { + const parsed = parseClampedNumber(value, 0, 100); + if (parsed !== null) { + settings.display.warningThreshold = parsed; + } + break; + } + case "successThreshold": { + const parsed = parseClampedNumber(value, 0, 100); + if (parsed !== null) { + settings.display.successThreshold = parsed; + } + break; + } + } + return settings; +} diff --git a/pi/files/agent/extensions/sub-bar/src/settings/menu.ts b/pi/files/agent/extensions/sub-bar/src/settings/menu.ts new file mode 100644 index 0000000..771d5b0 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/settings/menu.ts @@ -0,0 +1,183 @@ +/** + * Settings menu item builders. + */ + +import type { SelectItem } from "@mariozechner/pi-tui"; +import type { CoreProviderSettingsMap } from "../../shared.js"; +import type { Settings } from "../settings-types.js"; +import type { ProviderName } from "../types.js"; +import { PROVIDERS, PROVIDER_DISPLAY_NAMES } from "../providers/metadata.js"; + +export type TooltipSelectItem = SelectItem & { tooltip?: string }; + +export function buildMainMenuItems(settings: Settings, pinnedProvider?: ProviderName | null): TooltipSelectItem[] { + const pinnedLabel = pinnedProvider ? PROVIDER_DISPLAY_NAMES[pinnedProvider] : "auto (current provider)"; + const kb = settings.keybindings; + const kbDesc = `cycle: ${kb.cycleProvider}, reset: ${kb.toggleResetFormat}`; + return [ + { + value: "display-theme", + label: "Themes", + description: "save, manage, share", + tooltip: "Save, load, and share display themes.", + }, + { + value: "display", + label: "Adv. Display Settings", + description: "layout, bars, colors", + tooltip: "Adjust layout, colors, bar styling, status indicators, and dividers.", + }, + { + value: "providers", + label: "Provider Settings", + description: "provider specific settings", + tooltip: "Configure provider display toggles and window visibility.", + }, + { + value: "pin-provider", + label: "Provider Shown", + description: pinnedLabel, + tooltip: "Select which provider is shown in the widget.", + }, + { + value: "keybindings", + label: "Keybindings", + description: kbDesc, + tooltip: "Configure keyboard shortcuts. Changes take effect after pi restart.", + }, + { + value: "open-core-settings", + label: "Additional settings", + description: "in /sub-core:settings", + tooltip: "Open /sub-core:settings for refresh behavior and provider enablement.", + }, + ]; +} + +export function buildProviderListItems(settings: Settings, coreProviders?: CoreProviderSettingsMap): TooltipSelectItem[] { + const orderedProviders = settings.providerOrder.length > 0 ? settings.providerOrder : PROVIDERS; + const items: TooltipSelectItem[] = orderedProviders.map((provider) => { + const ps = settings.providers[provider]; + const core = coreProviders?.[provider]; + const enabledValue = core + ? core.enabled === "auto" + ? "auto" + : core.enabled === true || core.enabled === "on" + ? "on" + : "off" + : "auto"; + const status = ps.showStatus ? "status on" : "status off"; + return { + value: `provider-${provider}`, + label: PROVIDER_DISPLAY_NAMES[provider], + description: `enabled ${enabledValue}, ${status}`, + tooltip: `Configure ${PROVIDER_DISPLAY_NAMES[provider]} display settings.`, + }; + }); + + items.push({ + value: "reset-providers", + label: "Reset Provider Defaults", + description: "restore provider settings", + tooltip: "Restore provider display settings to their defaults.", + }); + + return items; +} + +export function buildDisplayMenuItems(): TooltipSelectItem[] { + return [ + { + value: "display-layout", + label: "Layout & Structure", + description: "alignment, wrapping, padding", + tooltip: "Control alignment, wrapping, and padding.", + }, + { + value: "display-bar", + label: "Bars", + description: "style, width, character", + tooltip: "Customize bar type, width, and bar styling.", + }, + { + value: "display-provider", + label: "Labels & Text", + description: "labels, titles, usage text", + tooltip: "Adjust provider label visibility and text styling.", + }, + { + value: "display-reset", + label: "Reset Timer", + description: "position, format, wrapping", + tooltip: "Control reset timer placement and formatting.", + }, + { + value: "display-status", + label: "Status", + description: "mode, icons, text", + tooltip: "Configure status mode and icon packs.", + }, + { + value: "display-divider", + label: "Dividers", + description: "character, blanks, status divider, lines", + tooltip: "Change divider character, spacing, status separator, and divider lines.", + }, + { + value: "display-color", + label: "Colors", + description: "base, scheme, thresholds", + tooltip: "Tune base colors, color scheme, and thresholds.", + }, + ]; +} + +export function buildDisplayThemeMenuItems(): TooltipSelectItem[] { + return [ + { + value: "display-theme-save", + label: "Save Theme", + description: "store current theme", + tooltip: "Save the current display theme with a custom name.", + }, + { + value: "display-theme-load", + label: "Load & Manage themes", + description: "load, share, rename and delete themes", + tooltip: "Load, share, delete, rename, and restore saved themes.", + }, + { + value: "display-theme-share", + label: "Share Theme", + description: "share current theme", + tooltip: "Post a share string for the current theme.", + }, + { + value: "display-theme-import", + label: "Import theme", + description: "from share string", + tooltip: "Import a shared theme string.", + }, + { + value: "display-theme-random", + label: "Random theme", + description: "generate a new theme", + tooltip: "Generate a random display theme as inspiration or a starting point.", + }, + { + value: "display-theme-restore", + label: "Restore previous state", + description: "restore your last theme", + tooltip: "Restore your previous display theme.", + }, + ]; +} + +export function buildProviderSettingsItems(settings: Settings): TooltipSelectItem[] { + return buildProviderListItems(settings); +} + +export function getProviderFromCategory(category: string): ProviderName | null { + const match = category.match(/^provider-(\w+)$/); + return match ? (match[1] as ProviderName) : null; +} diff --git a/pi/files/agent/extensions/sub-bar/src/settings/themes.ts b/pi/files/agent/extensions/sub-bar/src/settings/themes.ts new file mode 100644 index 0000000..31f8ade --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/settings/themes.ts @@ -0,0 +1,349 @@ +import type { Settings } from "../settings-types.js"; +import type { TooltipSelectItem } from "./menu.js"; + +type DisplaySettings = Settings["display"]; +type BarType = DisplaySettings["barType"]; +type BarStyle = DisplaySettings["barStyle"]; +type BarCharacter = DisplaySettings["barCharacter"]; +type BarWidth = DisplaySettings["barWidth"]; +type DividerCharacter = DisplaySettings["dividerCharacter"]; +type DividerBlanks = DisplaySettings["dividerBlanks"]; +type DisplayAlignment = DisplaySettings["alignment"]; +type OverflowMode = DisplaySettings["overflow"]; +type BaseTextColor = DisplaySettings["baseTextColor"]; +type DividerColor = DisplaySettings["dividerColor"]; +type ResetTimeFormat = DisplaySettings["resetTimeFormat"]; +type ResetTimerContainment = DisplaySettings["resetTimeContainment"]; +type StatusIndicatorMode = DisplaySettings["statusIndicatorMode"]; +type StatusIconPack = DisplaySettings["statusIconPack"]; +type ProviderLabel = DisplaySettings["providerLabel"]; + +const RANDOM_BAR_TYPES: BarType[] = ["horizontal-bar", "horizontal-single", "vertical", "braille", "shade"]; +const RANDOM_BAR_STYLES: BarStyle[] = ["bar", "percentage", "both"]; +const RANDOM_BAR_WIDTHS: BarWidth[] = [1, 4, 6, 8, 10, 12, "fill"]; +const RANDOM_BAR_CHARACTERS: BarCharacter[] = [ + "light", + "heavy", + "double", + "block", + "▮▯", + "■□", + "●○", + "▲△", + "◆◇", + "🚀_", +]; +const RANDOM_ALIGNMENTS: DisplayAlignment[] = ["left", "center", "right", "split"]; +const RANDOM_OVERFLOW: OverflowMode[] = ["truncate", "wrap"]; +const RANDOM_RESET_POSITIONS: DisplaySettings["resetTimePosition"][] = ["off", "front", "back", "integrated"]; +const RANDOM_RESET_FORMATS: ResetTimeFormat[] = ["relative", "datetime"]; +const RANDOM_RESET_CONTAINMENTS: ResetTimerContainment[] = ["none", "blank", "()", "[]", "<>"]; +const RANDOM_STATUS_MODES: StatusIndicatorMode[] = ["icon", "text", "icon+text"]; +const RANDOM_STATUS_PACKS: StatusIconPack[] = ["minimal", "emoji"]; +const RANDOM_PROVIDER_LABELS: ProviderLabel[] = ["plan", "subscription", "sub", "none"]; +const RANDOM_DIVIDER_CHARACTERS: DividerCharacter[] = ["none", "blank", "|", "│", "┃", "┆", "┇", "║", "•", "●", "○", "◇"]; +const RANDOM_DIVIDER_BLANKS: DividerBlanks[] = [0, 1, 2, 3]; +const RANDOM_COLOR_SCHEMES: DisplaySettings["colorScheme"][] = [ + "base-warning-error", + "success-base-warning-error", + "monochrome", +]; +const RANDOM_BASE_TEXT_COLORS: BaseTextColor[] = ["dim", "muted", "text", "primary", "success", "warning", "error", "border", "borderMuted"]; +const RANDOM_BACKGROUND_COLORS: BaseTextColor[] = [ + "text", + "selectedBg", + "userMessageBg", + "customMessageBg", + "toolPendingBg", + "toolSuccessBg", + "toolErrorBg", +]; +const RANDOM_DIVIDER_COLORS: DividerColor[] = [ + "primary", + "text", + "muted", + "dim", + "success", + "warning", + "error", + "border", + "borderMuted", + "borderAccent", +]; +const RANDOM_PADDING: number[] = [0, 1, 2, 3, 4]; + +function pickRandom(items: readonly T[]): T { + return items[Math.floor(Math.random() * items.length)] ?? items[0]!; +} + +function randomBool(probability = 0.5): boolean { + return Math.random() < probability; +} + +const THEME_ID_LENGTH = 24; +const THEME_ID_FALLBACK = "theme"; + +function buildThemeId(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").slice(0, THEME_ID_LENGTH) || THEME_ID_FALLBACK; +} + +export interface DisplayThemeTarget { + id?: string; + name: string; + display: Settings["display"]; + deletable: boolean; +} + +export function buildDisplayThemeItems( + settings: Settings, +): TooltipSelectItem[] { + const items: TooltipSelectItem[] = []; + items.push({ + value: "user", + label: "Restore backup", + description: "restore your last theme", + tooltip: "Restore your previous display theme.", + }); + items.push({ + value: "default", + label: "Default", + description: "restore default settings", + tooltip: "Reset display settings to defaults.", + }); + items.push({ + value: "minimal", + label: "Default Minimal", + description: "compact display", + tooltip: "Apply the default minimal theme.", + }); + for (const theme of settings.displayThemes) { + const description = theme.source === "imported" ? "manually imported theme" : "manually saved theme"; + items.push({ + value: `theme:${theme.id}`, + label: theme.name, + description, + tooltip: `Manage ${theme.name}.`, + }); + } + return items; +} + +export function resolveDisplayThemeTarget( + value: string, + settings: Settings, + defaults: Settings, + fallbackUser: Settings["display"] | null, +): DisplayThemeTarget | null { + if (value === "user") { + const display = settings.displayUserTheme ?? fallbackUser ?? settings.display; + return { name: "Restore backup", display, deletable: false }; + } + if (value === "default") { + return { name: "Default", display: { ...defaults.display }, deletable: false }; + } + if (value === "minimal") { + return { + name: "Default Minimal", + display: { + ...defaults.display, + alignment: "split", + barStyle: "percentage", + barType: "horizontal-bar", + barWidth: 1, + barCharacter: "heavy", + containBar: true, + brailleFillEmpty: false, + brailleFullBlocks: false, + colorScheme: "base-warning-error", + usageColorTargets: { + title: true, + timer: true, + bar: true, + usageLabel: true, + status: true, + }, + resetTimePosition: "off", + resetTimeFormat: "relative", + resetTimeContainment: "blank", + statusIndicatorMode: "icon", + statusIconPack: "minimal", + statusProviderDivider: false, + statusDismissOk: true, + showProviderName: false, + providerLabel: "none", + providerLabelColon: false, + providerLabelBold: true, + baseTextColor: "muted", + backgroundColor: "text", + showWindowTitle: false, + boldWindowTitle: true, + showUsageLabels: false, + dividerCharacter: "none", + dividerColor: "dim", + dividerBlanks: 1, + showProviderDivider: true, + dividerFooterJoin: true, + showTopDivider: false, + showBottomDivider: false, + paddingLeft: 1, + paddingRight: 1, + widgetPlacement: "belowEditor", + errorThreshold: 25, + warningThreshold: 50, + overflow: "truncate", + successThreshold: 75, + }, + deletable: false, + }; + } + if (value.startsWith("theme:")) { + const id = value.replace("theme:", ""); + const theme = settings.displayThemes.find((entry) => entry.id === id); + if (!theme) return null; + return { id: theme.id, name: theme.name, display: theme.display, deletable: true }; + } + return null; +} + +export function buildRandomDisplay(base: DisplaySettings): DisplaySettings { + const display: DisplaySettings = { ...base }; + + display.alignment = pickRandom(RANDOM_ALIGNMENTS); + display.overflow = pickRandom(RANDOM_OVERFLOW); + const padding = pickRandom(RANDOM_PADDING); + display.paddingLeft = padding; + display.paddingRight = padding; + display.barStyle = pickRandom(RANDOM_BAR_STYLES); + display.barType = pickRandom(RANDOM_BAR_TYPES); + display.barWidth = pickRandom(RANDOM_BAR_WIDTHS); + display.barCharacter = pickRandom(RANDOM_BAR_CHARACTERS); + display.containBar = randomBool(); + display.brailleFillEmpty = randomBool(); + display.brailleFullBlocks = randomBool(); + display.colorScheme = pickRandom(RANDOM_COLOR_SCHEMES); + + const usageColorTargets = { + title: randomBool(), + timer: randomBool(), + bar: randomBool(), + usageLabel: randomBool(), + status: randomBool(), + }; + if (!usageColorTargets.title && !usageColorTargets.timer && !usageColorTargets.bar && !usageColorTargets.usageLabel && !usageColorTargets.status) { + usageColorTargets.bar = true; + } + display.usageColorTargets = usageColorTargets; + display.resetTimePosition = pickRandom(RANDOM_RESET_POSITIONS); + display.resetTimeFormat = pickRandom(RANDOM_RESET_FORMATS); + display.resetTimeContainment = pickRandom(RANDOM_RESET_CONTAINMENTS); + display.statusIndicatorMode = pickRandom(RANDOM_STATUS_MODES); + display.statusIconPack = pickRandom(RANDOM_STATUS_PACKS); + display.statusProviderDivider = randomBool(); + display.statusDismissOk = randomBool(); + display.showProviderName = randomBool(); + display.providerLabel = pickRandom(RANDOM_PROVIDER_LABELS); + display.providerLabelColon = display.providerLabel !== "none" && randomBool(); + display.providerLabelBold = randomBool(); + display.baseTextColor = pickRandom(RANDOM_BASE_TEXT_COLORS); + display.backgroundColor = pickRandom(RANDOM_BACKGROUND_COLORS); + display.boldWindowTitle = randomBool(); + display.showUsageLabels = randomBool(); + display.dividerCharacter = pickRandom(RANDOM_DIVIDER_CHARACTERS); + display.dividerColor = pickRandom(RANDOM_DIVIDER_COLORS); + display.dividerBlanks = pickRandom(RANDOM_DIVIDER_BLANKS); + display.showProviderDivider = randomBool(); + display.dividerFooterJoin = randomBool(); + display.showTopDivider = randomBool(); + display.showBottomDivider = randomBool(); + + if (display.dividerCharacter === "none") { + display.showProviderDivider = false; + display.dividerFooterJoin = false; + display.showTopDivider = false; + display.showBottomDivider = false; + } + if (display.providerLabel === "none") { + display.providerLabelColon = false; + } + + return display; +} + +export function buildThemeActionItems(target: DisplayThemeTarget): TooltipSelectItem[] { + const items: TooltipSelectItem[] = [ + { + value: "load", + label: "Load", + description: "apply this theme", + tooltip: "Apply the selected theme.", + }, + { + value: "share", + label: "Share", + description: "post share string", + tooltip: "Post a shareable theme string to chat.", + }, + ]; + if (target.deletable) { + items.push({ + value: "rename", + label: "Rename", + description: "rename saved theme", + tooltip: "Rename this saved theme.", + }); + items.push({ + value: "delete", + label: "Delete", + description: "remove saved theme", + tooltip: "Remove this theme from saved themes.", + }); + } + return items; +} + +export function upsertDisplayTheme( + settings: Settings, + name: string, + display: Settings["display"], + source?: "saved" | "imported", +): Settings { + const trimmed = name.trim() || "Theme"; + const id = buildThemeId(trimmed); + const snapshot = { ...display }; + const existing = settings.displayThemes.find((theme) => theme.id === id); + const resolvedSource = source ?? existing?.source ?? "saved"; + if (existing) { + existing.name = trimmed; + existing.display = snapshot; + existing.source = resolvedSource; + } else { + settings.displayThemes.push({ id, name: trimmed, display: snapshot, source: resolvedSource }); + } + return settings; +} + +export function renameDisplayTheme(settings: Settings, id: string, name: string): Settings { + const trimmed = name.trim() || "Theme"; + const nextId = buildThemeId(trimmed); + const existing = settings.displayThemes.find((theme) => theme.id === id); + if (!existing) return settings; + if (nextId === id) { + existing.name = trimmed; + return settings; + } + const collision = settings.displayThemes.find((theme) => theme.id === nextId); + if (collision) { + collision.name = trimmed; + collision.display = existing.display; + collision.source = existing.source; + settings.displayThemes = settings.displayThemes.filter((theme) => theme.id !== id); + return settings; + } + existing.id = nextId; + existing.name = trimmed; + return settings; +} + +export function saveDisplayTheme(settings: Settings, name: string): Settings { + return upsertDisplayTheme(settings, name, settings.display, "saved"); +} diff --git a/pi/files/agent/extensions/sub-bar/src/settings/ui.ts b/pi/files/agent/extensions/sub-bar/src/settings/ui.ts new file mode 100644 index 0000000..b8431a3 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/settings/ui.ts @@ -0,0 +1,1378 @@ +/** + * Settings UI for sub-bar + */ + +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { DynamicBorder, getSettingsListTheme } from "@mariozechner/pi-coding-agent"; +import { Container, Input, SelectList, Spacer, Text } from "@mariozechner/pi-tui"; +import { SettingsList, type SettingItem, CUSTOM_OPTION } from "../ui/settings-list.js"; +import type { ProviderName } from "../types.js"; +import type { Settings } from "../settings-types.js"; +import type { CoreSettings } from "../../shared.js"; +import { getFallbackCoreSettings } from "../core-settings.js"; +import { getDefaultSettings } from "../settings-types.js"; +import { getSettings, saveSettings } from "../settings.js"; +import { PROVIDER_DISPLAY_NAMES } from "../providers/metadata.js"; +import { buildProviderSettingsItems, applyProviderSettingsChange } from "../providers/settings.js"; +import { + buildDisplayLayoutItems, + buildDisplayResetItems, + buildDisplayColorItems, + buildDisplayBarItems, + buildDisplayProviderItems, + buildDisplayStatusItems, + buildDisplayDividerItems, + buildUsageColorTargetItems, + formatUsageColorTargetsSummary, + applyDisplayChange, +} from "./display.js"; +import { + buildMainMenuItems, + buildProviderListItems, + buildDisplayMenuItems, + buildDisplayThemeMenuItems, + getProviderFromCategory, + type TooltipSelectItem, +} from "./menu.js"; +import { + buildDisplayThemeItems, + buildThemeActionItems, + buildRandomDisplay, + resolveDisplayThemeTarget, + saveDisplayTheme, + renameDisplayTheme, + upsertDisplayTheme, +} from "./themes.js"; +import { + buildDisplayShareString, + buildDisplayShareStringWithoutName, + decodeDisplayShareString, + type DecodedDisplayShare, +} from "../share.js"; + +/** + * Settings category + */ +type ProviderCategory = `provider-${ProviderName}`; + +type SettingsCategory = + | "main" + | "providers" + | "pin-provider" + | ProviderCategory + | "keybindings" + | "display" + | "display-theme" + | "display-theme-save" + | "display-theme-share" + | "display-theme-load" + | "display-theme-action" + | "display-theme-import" + | "display-theme-import-action" + | "display-theme-import-name" + | "display-theme-rename" + | "display-theme-random" + | "display-theme-restore" + | "display-layout" + | "display-bar" + | "display-provider" + | "display-reset" + | "display-status" + | "display-divider" + | "display-color"; + +/** + * Show the settings UI + */ +export async function showSettingsUI( + ctx: ExtensionContext, + options?: { + coreSettings?: CoreSettings; + onSettingsChange?: (settings: Settings) => void | Promise; + onCoreSettingsChange?: (patch: Partial, next: CoreSettings) => void | Promise; + onOpenCoreSettings?: () => void | Promise; + onDisplayThemeApplied?: (name: string, options?: { source?: "manual" }) => void | Promise; + onDisplayThemeShared?: (name: string, shareString: string, mode?: "prompt" | "gist" | "string") => void | Promise; + } +): Promise { + const onSettingsChange = options?.onSettingsChange; + const onCoreSettingsChange = options?.onCoreSettingsChange; + const onOpenCoreSettings = options?.onOpenCoreSettings; + let settings = getSettings(); + let coreSettings = options?.coreSettings ?? getFallbackCoreSettings(settings); + const onDisplayThemeApplied = options?.onDisplayThemeApplied; + const onDisplayThemeShared = options?.onDisplayThemeShared; + let currentCategory: SettingsCategory = "main"; + + return new Promise((resolve) => { + ctx.ui.custom((tui, theme, _kb, done) => { + let container = new Container(); + let activeList: SelectList | SettingsList | { handleInput: (data: string) => void } | null = null; + let themeActionTarget: { id?: string; name: string; display: Settings["display"]; deletable: boolean } | null = null; + let displayPreviewBackup: Settings["display"] | null = null; + let randomThemeBackup: Settings["display"] | null = null; + let displayThemeSelection: string | null = null; + let pinnedProviderBackup: ProviderName | null | undefined; + let importCandidate: DecodedDisplayShare | null = null; + let importBackup: Settings["display"] | null = null; + let importPendingAction: "save" | "save-apply" | null = null; + let pendingShare: { name: string; shareString: string; backCategory: SettingsCategory } | null = null; + const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + + const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)); + + const buildInputSubmenu = ( + label: string, + parseValue: (value: string) => string | null, + formatInitial?: (value: string) => string, + description?: string, + ) => { + return (currentValue: string, done: (selectedValue?: string) => void) => { + const input = new Input(); + input.focused = true; + input.setValue(formatInitial ? formatInitial("") : ""); + input.onSubmit = (value) => { + const parsed = parseValue(value); + if (!parsed) return; + done(parsed); + }; + input.onEscape = () => { + done(); + }; + + const container = new Container(); + container.addChild(new Text(theme.fg("muted", label), 1, 0)); + if (description) { + container.addChild(new Text(theme.fg("dim", description), 1, 0)); + } + container.addChild(new Spacer(1)); + container.addChild(input); + + return { + render: (width: number) => container.render(width), + invalidate: () => container.invalidate(), + handleInput: (data: string) => input.handleInput(data), + }; + }; + }; + + const requestThemeShare = (name: string, shareString: string, backCategory: SettingsCategory) => { + pendingShare = { name, shareString, backCategory }; + displayThemeSelection = "display-theme-share"; + currentCategory = "display-theme-share"; + rebuild(); + tui.requestRender(); + }; + + const parseInteger = (raw: string, min: number, max: number): string | null => { + const trimmed = raw.trim().replace(/%$/, ""); + if (!trimmed) { + ctx.ui.notify("Enter a value", "warning"); + return null; + } + const parsed = Number.parseInt(trimmed, 10); + if (Number.isNaN(parsed)) { + ctx.ui.notify("Enter a number", "warning"); + return null; + } + return String(clamp(parsed, min, max)); + }; + + const parseBarWidth = (raw: string): string | null => { + const trimmed = raw.trim().toLowerCase(); + if (!trimmed) { + ctx.ui.notify("Enter a value", "warning"); + return null; + } + if (trimmed === "fill") return "fill"; + return parseInteger(trimmed, 0, 100); + }; + + const parseDividerBlanks = (raw: string): string | null => { + const trimmed = raw.trim().toLowerCase(); + if (!trimmed) { + ctx.ui.notify("Enter a value", "warning"); + return null; + } + if (trimmed === "fill") return "fill"; + return parseInteger(trimmed, 0, 100); + }; + + const parseResetContainment = (raw: string): string | null => { + const trimmed = raw.trim(); + if (!trimmed) { + ctx.ui.notify("Enter 1-2 characters", "warning"); + return null; + } + const normalized = trimmed.toLowerCase(); + if (["none", "blank", "()", "[]", "<>"].includes(normalized)) { + return normalized; + } + const segments = Array.from(segmenter.segment(trimmed), (entry) => entry.segment) + .map((segment) => segment.trim()) + .filter(Boolean); + if (segments.length === 0) { + ctx.ui.notify("Enter 1-2 characters", "warning"); + return null; + } + const first = segments[0]; + const second = segments[1] ?? first; + return `${first}${second}`; + }; + + const parseDividerCharacter = (raw: string): string | null => { + const trimmed = raw.trim(); + if (!trimmed) { + ctx.ui.notify("Enter a character", "warning"); + return null; + } + const normalized = trimmed.toLowerCase(); + if (normalized === "none" || normalized === "blank") { + return normalized; + } + const iterator = segmenter.segment(trimmed)[Symbol.iterator](); + const first = iterator.next().value?.segment ?? trimmed[0]; + return first; + }; + + const parseBarCharacter = (raw: string): string | null => { + const trimmed = raw.trim(); + if (!trimmed) { + ctx.ui.notify("Enter a character", "warning"); + return null; + } + const normalized = trimmed.toLowerCase(); + if (["light", "heavy", "double", "block"].includes(normalized)) { + return normalized; + } + const segments = Array.from(segmenter.segment(raw), (entry) => entry.segment).filter((segment) => segment !== "\n" && segment !== "\r"); + const first = segments[0] ?? trimmed[0]; + const second = segments[1]; + return second ? `${first}${second}` : first; + }; + + const parseStatusIconCustom = (raw: string): string | null => { + const trimmed = raw.trim(); + if (!trimmed) { + ctx.ui.notify("Enter four characters", "warning"); + return null; + } + const segments = Array.from(segmenter.segment(trimmed), (entry) => entry.segment) + .map((segment) => segment.trim()) + .filter(Boolean); + if (segments.length < 4) { + ctx.ui.notify("Enter four characters", "warning"); + return null; + } + return segments.slice(0, 4).join(""); + }; + + const parseProviderLabel = (raw: string): string | null => { + const trimmed = raw.trim(); + if (!trimmed) { + ctx.ui.notify("Enter a label", "warning"); + return null; + } + const normalized = trimmed.toLowerCase(); + if (["none", "plan", "subscription", "sub"].includes(normalized)) { + return normalized; + } + return trimmed; + }; + + const attachCustomInputs = ( + items: SettingItem[], + handlers: Record>, + ) => { + for (const item of items) { + if (!item.values || !item.values.includes(CUSTOM_OPTION)) continue; + const handler = handlers[item.id]; + if (!handler) continue; + item.submenu = handler; + } + }; + + const buildUsageColorSubmenu = () => { + return (_currentValue: string, done: (selectedValue?: string) => void) => { + const items = buildUsageColorTargetItems(settings); + const handleChange = (id: string, value: string) => { + settings = applyDisplayChange(settings, id, value); + saveSettings(settings); + if (onSettingsChange) void onSettingsChange(settings); + }; + const list = new SettingsList( + items, + Math.min(items.length + 2, 10), + getSettingsListTheme(), + handleChange, + () => { + done(formatUsageColorTargetsSummary(settings.display.usageColorTargets)); + } + ); + return list; + }; + }; + + function rebuild(): void { + container = new Container(); + let tooltipText: Text | null = null; + + const attachTooltip = (items: TooltipSelectItem[], selectList: SelectList): void => { + if (!items.some((item) => item.tooltip)) return; + const tooltipComponent = new Text("", 1, 0); + const setTooltip = (item?: TooltipSelectItem | null) => { + const tooltip = item?.tooltip?.trim(); + tooltipComponent.setText(tooltip ? theme.fg("dim", tooltip) : ""); + }; + setTooltip(selectList.getSelectedItem() as TooltipSelectItem | null); + const existingHandler = selectList.onSelectionChange; + selectList.onSelectionChange = (item) => { + if (existingHandler) existingHandler(item); + setTooltip(item as TooltipSelectItem); + tui.requestRender(); + }; + tooltipText = tooltipComponent; + }; + + // Top border + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + // Title + const titles: Record = { + main: "sub-bar Settings", + providers: "Provider Settings", + "pin-provider": "Provider Shown", + keybindings: "Keybindings", + display: "Adv. Display Settings", + "display-theme": "Themes", + "display-theme-save": "Save Theme", + "display-theme-share": "Share Theme", + "display-theme-rename": "Rename Theme", + "display-theme-load": "Load & Manage themes", + "display-theme-action": "Manage Theme", + "display-theme-import": "Import Theme", + "display-theme-import-name": "Name Theme", + "display-theme-restore": "Restore Theme", + "display-layout": "Layout & Structure", + "display-bar": "Bars", + "display-provider": "Labels & Text", + "display-reset": "Reset Timer", + "display-status": "Status", + "display-divider": "Dividers", + "display-color": "Colors", + }; + const providerCategory = getProviderFromCategory(currentCategory); + let title = providerCategory + ? `${PROVIDER_DISPLAY_NAMES[providerCategory]} Settings` + : (titles[currentCategory] ?? "sub-bar Settings"); + if (currentCategory === "display-theme-action" && themeActionTarget) { + title = `Manage ${themeActionTarget.name}`; + } + container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0)); + container.addChild(new Spacer(1)); + + if (currentCategory === "main") { + const items = buildMainMenuItems(settings, settings.pinnedProvider); + const selectList = new SelectList(items, Math.min(items.length, 10), { + selectedPrefix: (t: string) => theme.fg("accent", t), + selectedText: (t: string) => theme.fg("accent", t), + description: (t: string) => theme.fg("muted", t), + scrollInfo: (t: string) => theme.fg("dim", t), + noMatch: (t: string) => theme.fg("warning", t), + }); + attachTooltip(items, selectList); + selectList.onSelect = (item) => { + if (item.value === "open-core-settings") { + saveSettings(settings); + done(settings); + if (onOpenCoreSettings) { + setTimeout(() => void onOpenCoreSettings(), 0); + } + return; + } + currentCategory = item.value as SettingsCategory; + rebuild(); + tui.requestRender(); + }; + selectList.onCancel = () => { + saveSettings(settings); + done(settings); + }; + activeList = selectList; + container.addChild(selectList); + } else if (currentCategory === "pin-provider") { + if (pinnedProviderBackup === undefined) { + pinnedProviderBackup = settings.pinnedProvider ?? null; + } + const orderedProviders = settings.providerOrder.length > 0 ? settings.providerOrder : (Object.keys(settings.providers) as ProviderName[]); + const items: TooltipSelectItem[] = [ + { + value: "none", + label: "Auto", + description: "current provider", + tooltip: "Show the current provider automatically.", + }, + ...orderedProviders.map((provider) => ({ + value: provider, + label: PROVIDER_DISPLAY_NAMES[provider], + description: provider === settings.pinnedProvider ? "pinned" : "", + tooltip: `Pin ${PROVIDER_DISPLAY_NAMES[provider]} as the current provider.`, + })), + ]; + const selectList = new SelectList(items, Math.min(items.length, 10), { + selectedPrefix: (t: string) => theme.fg("accent", t), + selectedText: (t: string) => theme.fg("accent", t), + description: (t: string) => theme.fg("muted", t), + scrollInfo: (t: string) => theme.fg("dim", t), + noMatch: (t: string) => theme.fg("warning", t), + }); + attachTooltip(items, selectList); + selectList.onSelectionChange = (item) => { + if (!item) return; + const nextPinned = item.value === "none" ? null : (item.value as ProviderName); + if (settings.pinnedProvider === nextPinned) return; + settings.pinnedProvider = nextPinned; + if (onSettingsChange) void onSettingsChange(settings); + }; + selectList.onSelect = (item) => { + settings.pinnedProvider = item.value === "none" ? null : (item.value as ProviderName); + saveSettings(settings); + if (onSettingsChange) void onSettingsChange(settings); + pinnedProviderBackup = undefined; + currentCategory = "main"; + rebuild(); + tui.requestRender(); + }; + selectList.onCancel = () => { + if (pinnedProviderBackup !== undefined && settings.pinnedProvider !== pinnedProviderBackup) { + settings.pinnedProvider = pinnedProviderBackup; + if (onSettingsChange) void onSettingsChange(settings); + } + pinnedProviderBackup = undefined; + currentCategory = "main"; + rebuild(); + tui.requestRender(); + }; + activeList = selectList; + container.addChild(selectList); + } else if (currentCategory === "keybindings") { + const parseKeybinding = (raw: string): string | null => { + const trimmed = raw.trim().toLowerCase(); + if (!trimmed) { + ctx.ui.notify("Enter a key combo (e.g. ctrl+alt+p) or 'none' to disable", "warning"); + return null; + } + if (trimmed === "none") return "none"; + const parts = trimmed.split("+"); + const modifiers = new Set(["ctrl", "shift", "alt"]); + const baseKeys = parts.filter((p) => !modifiers.has(p)); + if (baseKeys.length !== 1) { + ctx.ui.notify("Invalid key combo. Use format like ctrl+alt+p or ctrl+s", "warning"); + return null; + } + return trimmed; + }; + + const kbItems: SettingItem[] = [ + { + id: "cycleProvider", + label: "Cycle Provider", + currentValue: settings.keybindings.cycleProvider, + description: "Shortcut to cycle through providers. Changes take effect after pi restart.", + submenu: buildInputSubmenu( + "Cycle Provider shortcut", + parseKeybinding, + undefined, + "Enter a key combo (e.g. ctrl+alt+p) or 'none' to disable.", + ), + }, + { + id: "toggleResetFormat", + label: "Toggle Reset Format", + currentValue: settings.keybindings.toggleResetFormat, + description: "Shortcut to toggle reset timer format. Changes take effect after pi restart.", + submenu: buildInputSubmenu( + "Toggle Reset Format shortcut", + parseKeybinding, + undefined, + "Enter a key combo (e.g. ctrl+alt+r) or 'none' to disable.", + ), + }, + ]; + + const handleKbChange = (id: string, value: string) => { + if (id === "cycleProvider" || id === "toggleResetFormat") { + settings.keybindings = { ...settings.keybindings, [id]: value }; + saveSettings(settings); + if (onSettingsChange) void onSettingsChange(settings); + } + }; + + const kbList = new SettingsList( + kbItems, + Math.min(kbItems.length + 3, 10), + getSettingsListTheme(), + handleKbChange, + () => { + currentCategory = "main"; + rebuild(); + tui.requestRender(); + }, + ); + activeList = kbList; + container.addChild(kbList); + } else if (currentCategory === "providers") { + const items = buildProviderListItems(settings, coreSettings.providers); + const selectList = new SelectList(items, Math.min(items.length, 10), { + selectedPrefix: (t: string) => theme.fg("accent", t), + selectedText: (t: string) => theme.fg("accent", t), + description: (t: string) => theme.fg("muted", t), + scrollInfo: (t: string) => theme.fg("dim", t), + noMatch: (t: string) => theme.fg("warning", t), + }); + attachTooltip(items, selectList); + selectList.onSelect = (item) => { + if (item.value === "reset-providers") { + const defaults = getDefaultSettings(); + settings.providers = { ...defaults.providers }; + saveSettings(settings); + if (onSettingsChange) void onSettingsChange(settings); + ctx.ui.notify("Provider settings reset to defaults", "info"); + rebuild(); + tui.requestRender(); + return; + } + currentCategory = item.value as SettingsCategory; + rebuild(); + tui.requestRender(); + }; + selectList.onCancel = () => { + currentCategory = "main"; + rebuild(); + tui.requestRender(); + }; + activeList = selectList; + container.addChild(selectList); + } else if (providerCategory) { + const items = buildProviderSettingsItems(settings, providerCategory); + const coreProvider = coreSettings.providers[providerCategory]; + const enabledValue = coreProvider.enabled === "auto" + ? "auto" + : coreProvider.enabled === true || coreProvider.enabled === "on" + ? "on" + : "off"; + items.unshift({ + id: "enabled", + label: "Enabled", + currentValue: enabledValue, + values: ["auto", "on", "off"], + description: "Auto enables if credentials are detected.", + }); + const handleChange = (id: string, value: string) => { + if (id === "enabled") { + const nextEnabled = value === "auto" ? "auto" : value === "on"; + coreProvider.enabled = nextEnabled; + if (onCoreSettingsChange) { + const patch = { + providers: { + [providerCategory]: { enabled: nextEnabled }, + }, + } as unknown as Partial; + void onCoreSettingsChange(patch, coreSettings); + } + return; + } + settings = applyProviderSettingsChange(settings, providerCategory, id, value); + saveSettings(settings); + if (onSettingsChange) void onSettingsChange(settings); + }; + const settingsHintText = "↓ navigate • ←/→ change • Enter/Space edit custom • Esc to cancel"; + const customTheme = { + ...getSettingsListTheme(), + hint: (text: string) => { + if (text.includes("Enter/Space")) { + return theme.fg("dim", settingsHintText); + } + return theme.fg("dim", text); + }, + }; + const settingsList = new SettingsList( + items, + Math.min(items.length + 2, 15), + customTheme, + handleChange, + () => { + currentCategory = "providers"; + rebuild(); + tui.requestRender(); + } + ); + activeList = settingsList; + container.addChild(settingsList); + } else if (currentCategory === "display") { + const items = buildDisplayMenuItems(); + const selectList = new SelectList(items, Math.min(items.length, 10), { + selectedPrefix: (t: string) => theme.fg("accent", t), + selectedText: (t: string) => theme.fg("accent", t), + description: (t: string) => theme.fg("muted", t), + scrollInfo: (t: string) => theme.fg("dim", t), + noMatch: (t: string) => theme.fg("warning", t), + }); + attachTooltip(items, selectList); + selectList.onSelect = (item) => { + currentCategory = item.value as SettingsCategory; + rebuild(); + tui.requestRender(); + }; + selectList.onCancel = () => { + currentCategory = "main"; + rebuild(); + tui.requestRender(); + }; + activeList = selectList; + container.addChild(selectList); + } else if (currentCategory === "display-theme") { + const items = buildDisplayThemeMenuItems(); + const selectList = new SelectList(items, Math.min(items.length, 10), { + selectedPrefix: (t: string) => theme.fg("accent", t), + selectedText: (t: string) => theme.fg("accent", t), + description: (t: string) => theme.fg("muted", t), + scrollInfo: (t: string) => theme.fg("dim", t), + noMatch: (t: string) => theme.fg("warning", t), + }); + if (displayThemeSelection) { + const index = items.findIndex((item) => item.value === displayThemeSelection); + if (index >= 0) { + selectList.setSelectedIndex(index); + } + } + attachTooltip(items, selectList); + selectList.onSelect = (item) => { + displayThemeSelection = item.value; + currentCategory = item.value as SettingsCategory; + pendingShare = null; + rebuild(); + tui.requestRender(); + }; + selectList.onCancel = () => { + currentCategory = "display"; + pendingShare = null; + rebuild(); + tui.requestRender(); + }; + activeList = selectList; + container.addChild(selectList); + } else if (currentCategory === "display-theme-save") { + const input = new Input(); + input.focused = true; + const titleText = new Text(theme.fg("muted", "Theme name"), 1, 0); + input.onSubmit = (value) => { + const trimmed = value.trim(); + if (!trimmed) { + ctx.ui.notify("Enter a theme name", "warning"); + return; + } + settings = saveDisplayTheme(settings, trimmed); + saveSettings(settings); + if (onSettingsChange) void onSettingsChange(settings); + ctx.ui.notify(`Theme ${trimmed} saved`, "info"); + const shareString = buildDisplayShareString(trimmed, settings.display); + if (onDisplayThemeShared) { + requestThemeShare(trimmed, shareString, "display-theme"); + return; + } + ctx.ui.notify(shareString, "info"); + currentCategory = "display-theme"; + rebuild(); + tui.requestRender(); + }; + input.onEscape = () => { + currentCategory = "display-theme"; + rebuild(); + tui.requestRender(); + }; + container.addChild(titleText); + container.addChild(new Spacer(1)); + container.addChild(input); + activeList = input; + } else if (currentCategory === "display-theme-share") { + displayThemeSelection = "display-theme-share"; + const shareTarget = pendingShare ?? { + name: "", + shareString: buildDisplayShareStringWithoutName(settings.display), + backCategory: "display-theme" as SettingsCategory, + }; + pendingShare = shareTarget; + + const shareItems: TooltipSelectItem[] = [ + { + value: "gist", + label: "Upload secret gist", + description: "share via GitHub gist", + tooltip: "Create a secret GitHub gist using the gh CLI.", + }, + { + value: "string", + label: "Post share string", + description: "share in chat", + tooltip: "Post the raw share string to chat.", + }, + { + value: "cancel", + label: "Cancel", + description: "discard share", + tooltip: "Cancel without sharing.", + }, + ]; + + const selectList = new SelectList(shareItems, shareItems.length, { + selectedPrefix: (t: string) => theme.fg("accent", t), + selectedText: (t: string) => theme.fg("accent", t), + description: (t: string) => theme.fg("muted", t), + scrollInfo: (t: string) => theme.fg("dim", t), + noMatch: (t: string) => theme.fg("warning", t), + }); + attachTooltip(shareItems, selectList); + + selectList.onSelect = (item) => { + if (item.value === "gist" || item.value === "string") { + if (onDisplayThemeShared) { + void onDisplayThemeShared(shareTarget.name, shareTarget.shareString, item.value as "gist" | "string"); + } else { + ctx.ui.notify(shareTarget.shareString, "info"); + } + } + pendingShare = null; + currentCategory = shareTarget.backCategory; + rebuild(); + tui.requestRender(); + }; + + selectList.onCancel = () => { + pendingShare = null; + currentCategory = shareTarget.backCategory; + rebuild(); + tui.requestRender(); + }; + + activeList = selectList; + container.addChild(selectList); + } else if (currentCategory === "display-theme-load") { + if (!displayPreviewBackup) { + displayPreviewBackup = { ...settings.display }; + } + const defaults = getDefaultSettings(); + const fallbackUser = settings.displayUserTheme ?? displayPreviewBackup; + const themeItems = buildDisplayThemeItems(settings); + + const selectList = new SelectList(themeItems, Math.min(themeItems.length, 10), { + selectedPrefix: (t: string) => theme.fg("accent", t), + selectedText: (t: string) => theme.fg("accent", t), + description: (t: string) => theme.fg("muted", t), + scrollInfo: (t: string) => theme.fg("dim", t), + noMatch: (t: string) => theme.fg("warning", t), + }); + selectList.onSelectionChange = (item) => { + if (!item) return; + const target = resolveDisplayThemeTarget(item.value, settings, defaults, fallbackUser); + if (!target) return; + settings.display = { ...target.display }; + if (onSettingsChange) void onSettingsChange(settings); + tui.requestRender(); + }; + attachTooltip(themeItems, selectList); + + selectList.onSelect = (item) => { + const target = resolveDisplayThemeTarget(item.value, settings, defaults, fallbackUser); + if (!target) return; + if (item.value.startsWith("theme:")) { + themeActionTarget = target; + currentCategory = "display-theme-action"; + rebuild(); + tui.requestRender(); + return; + } + + const backup = displayPreviewBackup ?? settings.display; + settings.displayUserTheme = { ...backup }; + settings.display = { ...target.display }; + saveSettings(settings); + if (onSettingsChange) void onSettingsChange(settings); + if (onDisplayThemeApplied) void onDisplayThemeApplied(target.name, { source: "manual" }); + displayPreviewBackup = null; + currentCategory = "display-theme"; + rebuild(); + tui.requestRender(); + }; + selectList.onCancel = () => { + if (displayPreviewBackup) { + settings.display = { ...displayPreviewBackup }; + if (onSettingsChange) void onSettingsChange(settings); + } + displayPreviewBackup = null; + currentCategory = "display-theme"; + rebuild(); + tui.requestRender(); + }; + activeList = selectList; + container.addChild(selectList); + } else if (currentCategory === "display-theme-random") { + if (!randomThemeBackup) { + randomThemeBackup = { ...settings.display }; + settings.displayUserTheme = { ...randomThemeBackup }; + } + displayThemeSelection = "display-theme-random"; + const randomDisplay = buildRandomDisplay(settings.display); + settings.display = { ...randomDisplay }; + saveSettings(settings); + if (onSettingsChange) void onSettingsChange(settings); + currentCategory = "display-theme"; + rebuild(); + tui.requestRender(); + } else if (currentCategory === "display-theme-restore") { + displayThemeSelection = "display-theme-restore"; + const defaults = getDefaultSettings(); + const fallbackUser = settings.displayUserTheme ?? settings.display; + const target = resolveDisplayThemeTarget("user", settings, defaults, fallbackUser); + if (target) { + const backup = displayPreviewBackup ?? settings.display; + settings.displayUserTheme = { ...backup }; + settings.display = { ...target.display }; + saveSettings(settings); + if (onSettingsChange) void onSettingsChange(settings); + if (onDisplayThemeApplied) void onDisplayThemeApplied(target.name, { source: "manual" }); + displayPreviewBackup = null; + } + currentCategory = "display-theme"; + rebuild(); + tui.requestRender(); + } else if (currentCategory === "display-theme-import") { + const input = new Input(); + input.focused = true; + const titleText = new Text(theme.fg("muted", "Paste Theme Share string"), 1, 0); + input.onSubmit = (value) => { + const trimmed = value.trim(); + if (!trimmed) { + ctx.ui.notify("Enter a theme share string", "warning"); + return; + } + const decoded = decodeDisplayShareString(trimmed); + if (!decoded) { + ctx.ui.notify("Invalid theme share string", "error"); + return; + } + if (!displayPreviewBackup) { + displayPreviewBackup = { ...settings.display }; + } + importBackup = displayPreviewBackup; + importCandidate = decoded; + settings.display = { ...decoded.display }; + if (onSettingsChange) void onSettingsChange(settings); + currentCategory = "display-theme-import-action"; + rebuild(); + tui.requestRender(); + }; + input.onEscape = () => { + currentCategory = "display-theme-load"; + rebuild(); + tui.requestRender(); + }; + container.addChild(titleText); + container.addChild(new Spacer(1)); + container.addChild(input); + activeList = input; + } else if (currentCategory === "display-theme-import-action") { + const candidate = importCandidate; + if (!candidate) { + currentCategory = "display-theme-load"; + rebuild(); + tui.requestRender(); + return; + } + + const importItems: TooltipSelectItem[] = [ + { + value: "save-apply", + label: "Save & apply", + description: "save and use this theme", + tooltip: "Save the theme and keep it applied.", + }, + { + value: "save", + label: "Save", + description: "save without applying", + tooltip: "Save the theme and restore the previous display.", + }, + { + value: "cancel", + label: "Cancel", + description: "discard import", + tooltip: "Discard and restore the previous display.", + }, + ]; + + const notifyImported = (name: string) => { + const message = candidate.isNewerVersion + ? `Imported ${name} (newer version, some fields may be ignored)` + : `Imported ${name}`; + ctx.ui.notify(message, candidate.isNewerVersion ? "warning" : "info"); + }; + + const restoreBackup = () => { + if (importBackup) { + settings.display = { ...importBackup }; + if (onSettingsChange) void onSettingsChange(settings); + } + }; + + const selectList = new SelectList(importItems, importItems.length, { + selectedPrefix: (t: string) => theme.fg("accent", t), + selectedText: (t: string) => theme.fg("accent", t), + description: (t: string) => theme.fg("muted", t), + scrollInfo: (t: string) => theme.fg("dim", t), + noMatch: (t: string) => theme.fg("warning", t), + }); + selectList.onSelectionChange = (item) => { + if (!item) return; + if (item.value === "save-apply") { + settings.display = { ...candidate.display }; + if (onSettingsChange) void onSettingsChange(settings); + return; + } + restoreBackup(); + }; + attachTooltip(importItems, selectList); + + selectList.onSelect = (item) => { + if ((item.value === "save-apply" || item.value === "save") && !candidate.hasName) { + importPendingAction = item.value as "save" | "save-apply"; + currentCategory = "display-theme-import-name"; + rebuild(); + tui.requestRender(); + return; + } + if (item.value === "save-apply") { + const resolvedName = candidate.name; + if (importBackup) { + settings.displayUserTheme = { ...importBackup }; + } + settings = upsertDisplayTheme(settings, resolvedName, candidate.display, "imported"); + settings.display = { ...candidate.display }; + saveSettings(settings); + if (onSettingsChange) void onSettingsChange(settings); + if (onDisplayThemeApplied) void onDisplayThemeApplied(resolvedName, { source: "manual" }); + notifyImported(resolvedName); + displayPreviewBackup = null; + importCandidate = null; + importBackup = null; + importPendingAction = null; + currentCategory = "display-theme"; + rebuild(); + tui.requestRender(); + return; + } + if (item.value === "save") { + const resolvedName = candidate.name; + settings = upsertDisplayTheme(settings, resolvedName, candidate.display, "imported"); + restoreBackup(); + saveSettings(settings); + notifyImported(resolvedName); + importCandidate = null; + importBackup = null; + importPendingAction = null; + currentCategory = "display-theme-load"; + rebuild(); + tui.requestRender(); + return; + } + restoreBackup(); + importCandidate = null; + importBackup = null; + importPendingAction = null; + currentCategory = "display-theme-load"; + rebuild(); + tui.requestRender(); + }; + selectList.onCancel = () => { + restoreBackup(); + importCandidate = null; + importBackup = null; + importPendingAction = null; + currentCategory = "display-theme-load"; + rebuild(); + tui.requestRender(); + }; + activeList = selectList; + container.addChild(selectList); + } else if (currentCategory === "display-theme-import-name") { + const candidate = importCandidate; + if (!candidate) { + currentCategory = "display-theme-load"; + rebuild(); + tui.requestRender(); + return; + } + + const notifyImported = (name: string) => { + const message = candidate.isNewerVersion + ? `Imported ${name} (newer version, some fields may be ignored)` + : `Imported ${name}`; + ctx.ui.notify(message, candidate.isNewerVersion ? "warning" : "info"); + }; + + const restoreBackup = () => { + if (importBackup) { + settings.display = { ...importBackup }; + if (onSettingsChange) void onSettingsChange(settings); + } + }; + + const input = new Input(); + input.focused = true; + const titleText = new Text(theme.fg("muted", "Theme name"), 1, 0); + input.onSubmit = (value) => { + const trimmed = value.trim(); + if (!trimmed) { + ctx.ui.notify("Enter a theme name", "warning"); + return; + } + const applyImport = importPendingAction === "save-apply"; + if (applyImport && importBackup) { + settings.displayUserTheme = { ...importBackup }; + } + settings = upsertDisplayTheme(settings, trimmed, candidate.display, "imported"); + if (applyImport) { + settings.display = { ...candidate.display }; + } else { + restoreBackup(); + } + saveSettings(settings); + if (onSettingsChange) void onSettingsChange(settings); + if (applyImport && onDisplayThemeApplied) { + void onDisplayThemeApplied(trimmed, { source: "manual" }); + } + notifyImported(trimmed); + displayPreviewBackup = null; + importCandidate = null; + importBackup = null; + importPendingAction = null; + currentCategory = applyImport ? "display-theme" : "display-theme-load"; + rebuild(); + tui.requestRender(); + }; + input.onEscape = () => { + importPendingAction = null; + currentCategory = "display-theme-import-action"; + rebuild(); + tui.requestRender(); + }; + container.addChild(titleText); + container.addChild(new Spacer(1)); + container.addChild(input); + activeList = input; + } else if (currentCategory === "display-theme-rename") { + const target = themeActionTarget; + if (!target || !target.id) { + currentCategory = "display-theme-load"; + rebuild(); + tui.requestRender(); + return; + } + + const input = new Input(); + input.focused = true; + const titleText = new Text(theme.fg("muted", `Rename ${target.name}`), 1, 0); + input.onSubmit = (value) => { + const trimmed = value.trim(); + if (!trimmed) { + ctx.ui.notify("Enter a theme name", "warning"); + return; + } + settings = renameDisplayTheme(settings, target.id!, trimmed); + saveSettings(settings); + if (onSettingsChange) void onSettingsChange(settings); + themeActionTarget = null; + currentCategory = "display-theme-load"; + rebuild(); + tui.requestRender(); + }; + input.onEscape = () => { + currentCategory = "display-theme-action"; + rebuild(); + tui.requestRender(); + }; + container.addChild(titleText); + container.addChild(new Spacer(1)); + container.addChild(input); + activeList = input; + } else if (currentCategory === "display-theme-action") { + const target = themeActionTarget; + if (!target) { + currentCategory = "display-theme-load"; + rebuild(); + tui.requestRender(); + return; + } + + const items = buildThemeActionItems(target); + + const selectList = new SelectList(items, items.length, { + selectedPrefix: (t: string) => theme.fg("accent", t), + selectedText: (t: string) => theme.fg("accent", t), + description: (t: string) => theme.fg("muted", t), + scrollInfo: (t: string) => theme.fg("dim", t), + noMatch: (t: string) => theme.fg("warning", t), + }); + attachTooltip(items, selectList); + + selectList.onSelect = (item) => { + if (item.value === "load") { + const backup = displayPreviewBackup ?? settings.display; + settings.displayUserTheme = { ...backup }; + settings.display = { ...target.display }; + saveSettings(settings); + if (onSettingsChange) void onSettingsChange(settings); + if (onDisplayThemeApplied) void onDisplayThemeApplied(target.name, { source: "manual" }); + displayPreviewBackup = null; + themeActionTarget = null; + currentCategory = "display-theme"; + rebuild(); + tui.requestRender(); + return; + } + if (item.value === "share") { + const shareString = buildDisplayShareString(target.name, target.display); + if (onDisplayThemeShared) { + requestThemeShare(target.name, shareString, "display-theme-load"); + return; + } + ctx.ui.notify(shareString, "info"); + themeActionTarget = null; + currentCategory = "display-theme-load"; + rebuild(); + tui.requestRender(); + return; + } + if (item.value === "rename" && target.deletable && target.id) { + currentCategory = "display-theme-rename"; + rebuild(); + tui.requestRender(); + return; + } + if (item.value === "delete" && target.deletable && target.id) { + settings.displayThemes = settings.displayThemes.filter((entry) => entry.id !== target.id); + saveSettings(settings); + if (displayPreviewBackup) { + settings.display = { ...displayPreviewBackup }; + if (onSettingsChange) void onSettingsChange(settings); + } + themeActionTarget = null; + currentCategory = "display-theme-load"; + rebuild(); + tui.requestRender(); + } + }; + selectList.onCancel = () => { + currentCategory = "display-theme-load"; + rebuild(); + tui.requestRender(); + }; + activeList = selectList; + container.addChild(selectList); + } else { + // Settings list for category + let items: SettingItem[]; + let handleChange: (id: string, value: string) => void; + let backCategory: SettingsCategory = "display"; + + switch (currentCategory) { + case "display-layout": + items = buildDisplayLayoutItems(settings); + break; + case "display-bar": + items = buildDisplayBarItems(settings); + break; + case "display-provider": + items = buildDisplayProviderItems(settings); + break; + case "display-reset": + items = buildDisplayResetItems(settings); + break; + case "display-status": + items = buildDisplayStatusItems(settings); + break; + case "display-divider": + items = buildDisplayDividerItems(settings); + break; + case "display-color": + items = buildDisplayColorItems(settings); + break; + default: + items = []; + } + + const customHandlers: Record> = {}; + if (currentCategory === "display-layout") { + customHandlers.paddingLeft = buildInputSubmenu("Padding Left", (value) => parseInteger(value, 0, 100)); + customHandlers.paddingRight = buildInputSubmenu("Padding Right", (value) => parseInteger(value, 0, 100)); + } + if (currentCategory === "display-color") { + customHandlers.errorThreshold = buildInputSubmenu("Error Threshold (%)", (value) => parseInteger(value, 0, 100)); + customHandlers.warningThreshold = buildInputSubmenu("Warning Threshold (%)", (value) => parseInteger(value, 0, 100)); + customHandlers.successThreshold = buildInputSubmenu("Success Threshold (%)", (value) => parseInteger(value, 0, 100)); + const usageColorItem = items.find((item) => item.id === "usageColorTargets"); + if (usageColorItem) { + usageColorItem.submenu = buildUsageColorSubmenu(); + } + } + if (currentCategory === "display-bar") { + customHandlers.barWidth = buildInputSubmenu("Bar Width", parseBarWidth); + customHandlers.barCharacter = buildInputSubmenu( + "Bar Character", + parseBarCharacter, + undefined, + "Custom bar character(s), set 1 or 2 (fill/empty)", + ); + } + if (currentCategory === "display-provider") { + customHandlers.providerLabel = buildInputSubmenu("Provider Label", parseProviderLabel); + } + if (currentCategory === "display-reset") { + customHandlers.resetTimeContainment = buildInputSubmenu( + "Reset Timer Containment", + parseResetContainment, + undefined, + "Enter 1-2 characters for left/right wrap (e.g. <>).", + ); + } + if (currentCategory === "display-status") { + customHandlers.statusIconPack = buildInputSubmenu( + "Custom Status Icons", + parseStatusIconCustom, + undefined, + "Enter four characters in order: OK, warning, error, unknown (e.g. ✓⚠×?). Applied to none, minor/maintenance, major/critical, and unknown statuses.", + ); + } + if (currentCategory === "display-divider") { + customHandlers.dividerCharacter = buildInputSubmenu("Divider Character", parseDividerCharacter); + customHandlers.dividerBlanks = buildInputSubmenu("Divider Blanks", parseDividerBlanks); + } + attachCustomInputs(items, customHandlers); + + handleChange = (id, value) => { + const previousStatusPack = settings.display.statusIconPack; + settings = applyDisplayChange(settings, id, value); + saveSettings(settings); + if (onSettingsChange) void onSettingsChange(settings); + if (currentCategory === "display-bar" && id === "barType") { + rebuild(); + tui.requestRender(); + return; + } + if (currentCategory === "display-status") { + if (id === "statusIndicatorMode") { + rebuild(); + tui.requestRender(); + return; + } + } + }; + + const settingsHintText = "↓ navigate • ←/→ change • Enter/Space edit custom • Esc to cancel"; + const customTheme = { + ...getSettingsListTheme(), + hint: (text: string) => { + if (text.includes("Enter/Space")) { + return theme.fg("dim", settingsHintText); + } + return theme.fg("dim", text); + }, + }; + const settingsList = new SettingsList( + items, + Math.min(items.length + 2, 15), + customTheme, + handleChange, + () => { + currentCategory = backCategory; + rebuild(); + tui.requestRender(); + } + ); + activeList = settingsList; + container.addChild(settingsList); + } + + // Help text + const usesSettingsList = + Boolean(providerCategory) || + currentCategory === "keybindings" || + currentCategory === "display-layout" || + currentCategory === "display-bar" || + currentCategory === "display-provider" || + currentCategory === "display-reset" || + currentCategory === "display-status" || + currentCategory === "display-divider" || + currentCategory === "display-color"; + if (!usesSettingsList) { + let helpText: string; + if ( + currentCategory === "display-theme-save" || + currentCategory === "display-theme-import-name" || + currentCategory === "display-theme-rename" + ) { + helpText = "Type name • Enter to save • Esc back"; + } else if (currentCategory === "display-theme-import") { + helpText = "Paste theme share string • Enter to import • Esc back"; + } else if ( + currentCategory === "main" || + currentCategory === "providers" || + currentCategory === "display" || + currentCategory === "display-theme" || + currentCategory === "display-theme-load" || + currentCategory === "display-theme-action" || + currentCategory === "display-theme-random" || + currentCategory === "display-theme-restore" + ) { + helpText = "↑↓ navigate • Enter/Space select • Esc back"; + } else { + helpText = "↑↓ navigate • Enter/Space to change • Esc to cancel"; + } + if (tooltipText) { + container.addChild(new Spacer(1)); + container.addChild(tooltipText); + } + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("dim", helpText), 1, 0)); + } + + // Bottom border + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + } + + rebuild(); + + return { + render(width: number) { + return container.render(width); + }, + invalidate() { + container.invalidate(); + }, + handleInput(data: string) { + if (data === " ") { + if (activeList && "handleInput" in activeList && activeList.handleInput) { + activeList.handleInput("\r"); + } + tui.requestRender(); + return; + } + if (activeList && "handleInput" in activeList && activeList.handleInput) { + activeList.handleInput(data); + } + tui.requestRender(); + }, + }; + }).then(resolve); + }); +} diff --git a/pi/files/agent/extensions/sub-bar/src/share.ts b/pi/files/agent/extensions/sub-bar/src/share.ts new file mode 100644 index 0000000..06bd157 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/share.ts @@ -0,0 +1,75 @@ +/** + * Display theme share helpers. + */ + +import type { Settings } from "./settings-types.js"; +import { mergeSettings } from "./settings-types.js"; + +const SHARE_SEPARATOR = ":"; +const DISPLAY_SHARE_VERSION = 1; + +export interface DisplaySharePayload { + v: number; + display: Settings["display"]; +} + +export interface DecodedDisplayShare { + name: string; + display: Settings["display"]; + version: number; + isNewerVersion: boolean; + hasName: boolean; +} + +function encodeDisplaySharePayload(display: Settings["display"]): string { + const payload: DisplaySharePayload = { v: DISPLAY_SHARE_VERSION, display }; + return Buffer.from(JSON.stringify(payload)).toString("base64url"); +} + +export function buildDisplayShareString(name: string, display: Settings["display"]): string { + const encoded = encodeDisplaySharePayload(display); + const trimmedName = name.trim() || "custom"; + return `${trimmedName}${SHARE_SEPARATOR}${encoded}`; +} + +export function buildDisplayShareStringWithoutName(display: Settings["display"]): string { + return encodeDisplaySharePayload(display); +} + +export function decodeDisplayShareString(input: string): DecodedDisplayShare | null { + const trimmed = input.trim(); + if (!trimmed) return null; + let name = "custom"; + let hasName = false; + let payload = trimmed; + const separatorIndex = trimmed.indexOf(SHARE_SEPARATOR); + if (separatorIndex >= 0) { + const candidateName = trimmed.slice(0, separatorIndex).trim(); + payload = trimmed.slice(separatorIndex + 1).trim(); + if (candidateName) { + name = candidateName; + hasName = true; + } + } + if (!payload) return null; + try { + const decoded = Buffer.from(payload, "base64url").toString("utf-8"); + const parsed = JSON.parse(decoded) as unknown; + if (!parsed || typeof parsed !== "object") return null; + const displayCandidate = (parsed as DisplaySharePayload).display ?? parsed; + if (!displayCandidate || typeof displayCandidate !== "object" || Array.isArray(displayCandidate)) { + return null; + } + const merged = mergeSettings({ display: displayCandidate } as Partial).display; + const version = typeof (parsed as DisplaySharePayload).v === "number" ? (parsed as DisplaySharePayload).v : 0; + return { + name, + display: merged, + version, + isNewerVersion: version > DISPLAY_SHARE_VERSION, + hasName, + }; + } catch { + return null; + } +} diff --git a/pi/files/agent/extensions/sub-bar/src/shared.ts b/pi/files/agent/extensions/sub-bar/src/shared.ts new file mode 100644 index 0000000..604a859 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/shared.ts @@ -0,0 +1,229 @@ +/** + * Shared types and metadata for sub-* extensions. + */ + +export const PROVIDERS = ["anthropic", "copilot", "gemini", "antigravity", "codex", "kiro", "zai", "opencode-go"] as const; + +export type ProviderName = (typeof PROVIDERS)[number]; + +export type StatusIndicator = "none" | "minor" | "major" | "critical" | "maintenance" | "unknown"; + +export interface ProviderStatus { + indicator: StatusIndicator; + description?: string; +} + +export interface RateWindow { + label: string; + usedPercent: number; + resetDescription?: string; + resetAt?: string; +} + +export interface UsageSnapshot { + provider: ProviderName; + displayName: string; + windows: RateWindow[]; + extraUsageEnabled?: boolean; + fiveHourUsage?: number; + lastSuccessAt?: number; + error?: UsageError; + status?: ProviderStatus; + requestsSummary?: string; + requestsRemaining?: number; + requestsEntitlement?: number; +} + +export type UsageErrorCode = + | "NO_CREDENTIALS" + | "NO_CLI" + | "NOT_LOGGED_IN" + | "FETCH_FAILED" + | "HTTP_ERROR" + | "API_ERROR" + | "TIMEOUT" + | "UNKNOWN"; + +export interface UsageError { + code: UsageErrorCode; + message: string; + httpStatus?: number; +} + +export interface ProviderUsageEntry { + provider: ProviderName; + usage?: UsageSnapshot; +} + +export type ProviderEnabledSetting = "auto" | "on" | "off" | boolean; + +export interface CoreProviderSettings { + enabled: ProviderEnabledSetting; + displayName?: string; + fetchStatus: boolean; + extraUsageCurrencySymbol?: string; + extraUsageDecimalSeparator?: "." | ","; +} + +export interface CoreProviderSettingsMap { + anthropic: CoreProviderSettings; + copilot: CoreProviderSettings; + gemini: CoreProviderSettings; + antigravity: CoreProviderSettings; + codex: CoreProviderSettings; + kiro: CoreProviderSettings; + zai: CoreProviderSettings; + "opencode-go": CoreProviderSettings; +} + +export interface BehaviorSettings { + refreshInterval: number; + minRefreshInterval: number; + refreshOnTurnStart: boolean; + refreshOnToolResult: boolean; +} + +export const DEFAULT_BEHAVIOR_SETTINGS: BehaviorSettings = { + refreshInterval: 60, + minRefreshInterval: 10, + refreshOnTurnStart: false, + refreshOnToolResult: false, +}; + +export function getDefaultCoreProviderSettings(): CoreProviderSettingsMap { + const defaults = {} as CoreProviderSettingsMap; + for (const provider of PROVIDERS) { + defaults[provider] = { + enabled: "auto" as ProviderEnabledSetting, + fetchStatus: Boolean(PROVIDER_METADATA[provider]?.status), + }; + } + return defaults; +} + +export function getDefaultCoreSettings(): CoreSettings { + return { + providers: getDefaultCoreProviderSettings(), + behavior: { ...DEFAULT_BEHAVIOR_SETTINGS }, + statusRefresh: { ...DEFAULT_BEHAVIOR_SETTINGS }, + providerOrder: [...PROVIDERS], + defaultProvider: null, + }; +} + +export interface CoreSettings { + providers: CoreProviderSettingsMap; + behavior: BehaviorSettings; + statusRefresh: BehaviorSettings; + providerOrder: ProviderName[]; + defaultProvider: ProviderName | null; +} + +export type SubCoreState = { + provider?: ProviderName; + usage?: UsageSnapshot; +}; + +export type SubCoreAllState = { + provider?: ProviderName; + entries: ProviderUsageEntry[]; +}; + +export type SubCoreEvents = + | { type: "sub-core:ready"; state: SubCoreState } + | { type: "sub-core:update-current"; state: SubCoreState } + | { type: "sub-core:update-all"; state: SubCoreAllState }; + +export interface StatusPageComponentMatch { + id?: string; + name?: string; +} + +export type ProviderStatusConfig = + | { type: "statuspage"; url: string; component?: StatusPageComponentMatch } + | { type: "google-workspace" }; + +export interface ProviderDetectionConfig { + providerTokens: string[]; + modelTokens: string[]; +} + +export interface ProviderMetadata { + displayName: string; + detection?: ProviderDetectionConfig; + status?: ProviderStatusConfig; +} + +export const PROVIDER_METADATA: Record = { + anthropic: { + displayName: "Anthropic (Claude)", + status: { type: "statuspage", url: "https://status.anthropic.com/api/v2/status.json" }, + detection: { providerTokens: ["anthropic"], modelTokens: ["claude"] }, + }, + copilot: { + displayName: "GitHub Copilot", + status: { type: "statuspage", url: "https://www.githubstatus.com/api/v2/status.json" }, + detection: { providerTokens: ["copilot", "github"], modelTokens: [] }, + }, + gemini: { + displayName: "Google Gemini", + status: { type: "google-workspace" }, + detection: { providerTokens: ["google", "gemini"], modelTokens: ["gemini"] }, + }, + antigravity: { + displayName: "Antigravity", + status: { type: "google-workspace" }, + detection: { providerTokens: ["antigravity"], modelTokens: ["antigravity"] }, + }, + codex: { + displayName: "OpenAI Codex", + status: { + type: "statuspage", + url: "https://status.openai.com/api/v2/status.json", + component: { + id: "01JVCV8YSWZFRSM1G5CVP253SK", + name: "Codex", + }, + }, + detection: { providerTokens: ["openai", "codex"], modelTokens: ["gpt", "o1", "o3"] }, + }, + kiro: { + displayName: "AWS Kiro", + detection: { providerTokens: ["kiro", "aws"], modelTokens: [] }, + }, + zai: { + displayName: "z.ai", + detection: { providerTokens: ["zai", "z.ai", "xai"], modelTokens: [] }, + }, + "opencode-go": { + displayName: "OpenCode (MiniMax)", + detection: { providerTokens: ["opencode-go", "opencode", "minimax"], modelTokens: ["kimi", "minimax", "m2.5"] }, + }, +}; + +export const PROVIDER_DISPLAY_NAMES = Object.fromEntries( + PROVIDERS.map((provider) => [provider, PROVIDER_METADATA[provider].displayName]) +) as Record; + +export const MODEL_MULTIPLIERS: Record = { + "Claude Haiku 4.5": 0.33, + "Claude Opus 4.1": 10, + "Claude Opus 4.5": 3, + "Claude Sonnet 4": 1, + "Claude Sonnet 4.5": 1, + "Gemini 2.5 Pro": 1, + "Gemini 3 Flash": 0.33, + "Gemini 3 Pro": 1, + "GPT-4.1": 0, + "GPT-4o": 0, + "GPT-5": 1, + "GPT-5 mini": 0, + "GPT-5-Codex": 1, + "GPT-5.1": 1, + "GPT-5.1-Codex": 1, + "GPT-5.1-Codex-Mini": 0.33, + "GPT-5.1-Codex-Max": 1, + "GPT-5.2": 1, + "Grok Code Fast 1": 0.25, + "Raptor mini": 0, +}; diff --git a/pi/files/agent/extensions/sub-bar/src/status.ts b/pi/files/agent/extensions/sub-bar/src/status.ts new file mode 100644 index 0000000..1d762cf --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/status.ts @@ -0,0 +1,103 @@ +/** + * Status indicator helpers. + */ + +import type { ProviderStatus } from "./types.js"; +import type { StatusIconPack } from "./settings-types.js"; + +const STATUS_ICON_PACKS: Record, Record> = { + minimal: { + none: "✓", + minor: "⚠", + major: "⚠", + critical: "×", + maintenance: "~", + unknown: "?", + }, + emoji: { + none: "✅", + minor: "⚠️", + major: "🟠", + critical: "🔴", + maintenance: "🔧", + unknown: "❓", + }, +}; + +const DEFAULT_CUSTOM_ICONS = ["✓", "⚠", "×", "?"]; +const CUSTOM_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +function parseCustomIcons(value?: string): [string, string, string, string] { + if (!value) return DEFAULT_CUSTOM_ICONS as [string, string, string, string]; + const segments = Array.from(CUSTOM_SEGMENTER.segment(value), (entry) => entry.segment) + .map((segment) => segment.trim()) + .filter(Boolean); + if (segments.length < 3) return DEFAULT_CUSTOM_ICONS as [string, string, string, string]; + if (segments.length === 3) { + return [segments[0], segments[1], segments[2], DEFAULT_CUSTOM_ICONS[3]] as [string, string, string, string]; + } + return [segments[0], segments[1], segments[2], segments[3]] as [string, string, string, string]; +} + +function buildCustomPack(custom?: string): Record { + const [ok, warn, error, unknown] = parseCustomIcons(custom); + return { + none: ok, + minor: warn, + major: error, + critical: error, + maintenance: warn, + unknown, + }; +} + +export function getStatusIcon( + status: ProviderStatus | undefined, + pack: StatusIconPack, + custom?: string, +): string { + if (!status) return ""; + if (pack === "custom") { + return buildCustomPack(custom)[status.indicator] ?? ""; + } + return STATUS_ICON_PACKS[pack][status.indicator] ?? ""; +} + +export function getStatusLabel( + status: ProviderStatus | undefined, + useAbbreviated = false, +): string { + if (!status) return ""; + if (useAbbreviated) { + switch (status.indicator) { + case "none": + return "Status OK"; + case "minor": + return "Status Degr."; + case "major": + case "critical": + return "Status Crit."; + case "maintenance": + return "Status Maint."; + case "unknown": + default: + return "Status Unk."; + } + } + if (status.description) return status.description; + switch (status.indicator) { + case "none": + return "Operational"; + case "minor": + return "Degraded"; + case "major": + return "Outage"; + case "critical": + return "Outage"; + case "maintenance": + return "Maintenance"; + case "unknown": + default: + return "Status Unknown"; + } +} diff --git a/pi/files/agent/extensions/sub-bar/src/storage.ts b/pi/files/agent/extensions/sub-bar/src/storage.ts new file mode 100644 index 0000000..d762d8d --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/storage.ts @@ -0,0 +1,61 @@ +/** + * Storage abstraction for settings persistence. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; + +export interface StorageAdapter { + readFile(path: string): string | undefined; + writeFile(path: string, contents: string): void; + writeFileExclusive(path: string, contents: string): boolean; + exists(path: string): boolean; + removeFile(path: string): void; + ensureDir(path: string): void; +} + +export function createFsStorage(): StorageAdapter { + return { + readFile(filePath: string): string | undefined { + try { + return fs.readFileSync(filePath, "utf-8"); + } catch { + return undefined; + } + }, + writeFile(filePath: string, contents: string): void { + fs.writeFileSync(filePath, contents, "utf-8"); + }, + writeFileExclusive(filePath: string, contents: string): boolean { + try { + fs.writeFileSync(filePath, contents, { flag: "wx" }); + return true; + } catch { + return false; + } + }, + exists(filePath: string): boolean { + return fs.existsSync(filePath); + }, + removeFile(filePath: string): void { + try { + fs.unlinkSync(filePath); + } catch { + // Ignore remove errors + } + }, + ensureDir(dirPath: string): void { + fs.mkdirSync(path.resolve(dirPath), { recursive: true }); + }, + }; +} + +let activeStorage: StorageAdapter = createFsStorage(); + +export function getStorage(): StorageAdapter { + return activeStorage; +} + +export function setStorage(storage: StorageAdapter): void { + activeStorage = storage; +} diff --git a/pi/files/agent/extensions/sub-bar/src/types.ts b/pi/files/agent/extensions/sub-bar/src/types.ts new file mode 100644 index 0000000..02047d0 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/types.ts @@ -0,0 +1,25 @@ +/** + * Core types for the sub-bar extension + */ + +export type { + ProviderName, + StatusIndicator, + ProviderStatus, + RateWindow, + UsageSnapshot, + UsageError, + UsageErrorCode, + ProviderUsageEntry, + SubCoreState, + SubCoreAllState, + SubCoreEvents, +} from "./shared.js"; + +export { PROVIDERS } from "./shared.js"; + +export type ModelInfo = { + provider?: string; + id?: string; + scopedModelPatterns?: string[]; +}; diff --git a/pi/files/agent/extensions/sub-bar/src/ui/settings-list.ts b/pi/files/agent/extensions/sub-bar/src/ui/settings-list.ts new file mode 100644 index 0000000..420fd44 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/ui/settings-list.ts @@ -0,0 +1,304 @@ +import type { Component, SettingItem, SettingsListTheme } from "@mariozechner/pi-tui"; +import { + Input, + fuzzyFilter, + getEditorKeybindings, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, +} from "@mariozechner/pi-tui"; + +export interface SettingsListOptions { + enableSearch?: boolean; +} + +export const CUSTOM_OPTION = "__custom__"; +export const CUSTOM_LABEL = "custom"; + +export type { SettingItem, SettingsListTheme }; + +export class SettingsList implements Component { + private items: SettingItem[]; + private filteredItems: SettingItem[]; + private theme: SettingsListTheme; + private selectedIndex = 0; + private maxVisible: number; + private onChange: (id: string, newValue: string) => void; + private onCancel: () => void; + private searchInput?: Input; + private searchEnabled: boolean; + private submenuComponent: Component | null = null; + private submenuItemIndex: number | null = null; + + constructor( + items: SettingItem[], + maxVisible: number, + theme: SettingsListTheme, + onChange: (id: string, newValue: string) => void, + onCancel: () => void, + options: SettingsListOptions = {}, + ) { + this.items = items; + this.filteredItems = items; + this.maxVisible = maxVisible; + this.theme = theme; + this.onChange = onChange; + this.onCancel = onCancel; + this.searchEnabled = options.enableSearch ?? false; + + if (this.searchEnabled) { + this.searchInput = new Input(); + } + } + + /** Update an item's currentValue */ + updateValue(id: string, newValue: string): void { + const item = this.items.find((i) => i.id === id); + if (item) { + item.currentValue = newValue; + } + } + + getSelectedId(): string | null { + const displayItems = this.searchEnabled ? this.filteredItems : this.items; + const item = displayItems[this.selectedIndex]; + return item?.id ?? null; + } + + setSelectedId(id: string): void { + const displayItems = this.searchEnabled ? this.filteredItems : this.items; + const index = displayItems.findIndex((item) => item.id === id); + if (index >= 0) { + this.selectedIndex = index; + } + } + + invalidate(): void { + this.submenuComponent?.invalidate?.(); + } + + render(width: number): string[] { + // If submenu is active, render it instead + if (this.submenuComponent) { + return this.submenuComponent.render(width); + } + return this.renderMainList(width); + } + + private renderMainList(width: number): string[] { + const lines: string[] = []; + if (this.searchEnabled && this.searchInput) { + lines.push(...this.searchInput.render(width)); + lines.push(""); + } + + if (this.items.length === 0) { + lines.push(this.theme.hint(" No settings available")); + if (this.searchEnabled) { + this.addHintLine(lines); + } + return lines; + } + + const displayItems = this.searchEnabled ? this.filteredItems : this.items; + if (displayItems.length === 0) { + lines.push(this.theme.hint(" No matching settings")); + this.addHintLine(lines); + return lines; + } + + // Calculate visible range with scrolling + const startIndex = Math.max( + 0, + Math.min( + this.selectedIndex - Math.floor(this.maxVisible / 2), + displayItems.length - this.maxVisible, + ), + ); + const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length); + + // Calculate max label width for alignment + const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label)))); + + // Render visible items + for (let i = startIndex; i < endIndex; i++) { + const item = displayItems[i]; + if (!item) continue; + const isSelected = i === this.selectedIndex; + const prefix = isSelected ? this.theme.cursor : " "; + const prefixWidth = visibleWidth(prefix); + + // Pad label to align values + const labelPadded = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label))); + const labelText = this.theme.label(labelPadded, isSelected); + + // Calculate space for value + const separator = " "; + const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator); + const valueMaxWidth = Math.max(1, width - usedWidth - 2); + const optionLines = isSelected && item.values && item.values.length > 0 + ? wrapTextWithAnsi(this.formatOptionsInline(item, item.values), valueMaxWidth) + : null; + const valueText = optionLines + ? optionLines[0] ?? "" + : this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, ""), isSelected); + const line = prefix + labelText + separator + valueText; + lines.push(truncateToWidth(line, width, "")); + if (optionLines && optionLines.length > 1) { + const indent = " ".repeat(prefixWidth + maxLabelWidth + visibleWidth(separator)); + for (const continuation of optionLines.slice(1)) { + lines.push(truncateToWidth(indent + continuation, width, "")); + } + } + } + + // Add scroll indicator if needed + if (startIndex > 0 || endIndex < displayItems.length) { + const scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`; + lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, ""))); + } + + // Add description for selected item + const selectedItem = displayItems[this.selectedIndex]; + if (selectedItem?.description) { + lines.push(""); + const wrapWidth = Math.max(1, width - 4); + const wrappedDesc = wrapTextWithAnsi(selectedItem.description, wrapWidth); + for (const line of wrappedDesc) { + const prefixed = ` ${line}`; + lines.push(this.theme.description(truncateToWidth(prefixed, width, ""))); + } + } + + + // Add hint + this.addHintLine(lines); + return lines; + } + + handleInput(data: string): void { + // If submenu is active, delegate all input to it + // The submenu's onCancel (triggered by escape) will call done() which closes it + if (this.submenuComponent) { + this.submenuComponent.handleInput?.(data); + return; + } + + const kb = getEditorKeybindings(); + const displayItems = this.searchEnabled ? this.filteredItems : this.items; + + if (kb.matches(data, "selectUp")) { + if (displayItems.length === 0) return; + this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1; + } else if (kb.matches(data, "selectDown")) { + if (displayItems.length === 0) return; + this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1; + } else if (kb.matches(data, "cursorLeft")) { + this.stepValue(-1); + } else if (kb.matches(data, "cursorRight")) { + this.stepValue(1); + } else if (kb.matches(data, "selectConfirm") || data === " ") { + this.activateItem(); + } else if (kb.matches(data, "selectCancel")) { + this.onCancel(); + } else if (this.searchEnabled && this.searchInput) { + const sanitized = data.replace(/ /g, ""); + if (!sanitized) { + return; + } + this.searchInput.handleInput(sanitized); + this.applyFilter(this.searchInput.getValue()); + } + } + + private stepValue(direction: -1 | 1): void { + const displayItems = this.searchEnabled ? this.filteredItems : this.items; + const item = displayItems[this.selectedIndex]; + if (!item || !item.values || item.values.length === 0) return; + const values = item.values; + let currentIndex = values.indexOf(item.currentValue); + if (currentIndex === -1) { + currentIndex = direction > 0 ? 0 : values.length - 1; + } + const nextIndex = (currentIndex + direction + values.length) % values.length; + const newValue = values[nextIndex]; + if (newValue === CUSTOM_OPTION) { + item.currentValue = newValue; + this.onChange(item.id, newValue); + return; + } + item.currentValue = newValue; + this.onChange(item.id, newValue); + } + + private activateItem(): void { + const item = this.searchEnabled ? this.filteredItems[this.selectedIndex] : this.items[this.selectedIndex]; + if (!item) return; + + const hasCustom = Boolean(item.values && item.values.includes(CUSTOM_OPTION)); + const currentIsCustom = hasCustom && item.values && !item.values.includes(item.currentValue); + + if (item.submenu && hasCustom) { + if (currentIsCustom || item.currentValue === CUSTOM_OPTION) { + this.openSubmenu(item); + } + return; + } + + if (item.submenu) { + this.openSubmenu(item); + } + } + + private closeSubmenu(): void { + this.submenuComponent = null; + // Restore selection to the item that opened the submenu + if (this.submenuItemIndex !== null) { + this.selectedIndex = this.submenuItemIndex; + this.submenuItemIndex = null; + } + } + + private applyFilter(query: string): void { + this.filteredItems = fuzzyFilter(this.items, query, (item) => item.label); + this.selectedIndex = 0; + } + + private formatOptionsInline(item: SettingItem, values: string[]): string { + const separator = this.theme.description(" • "); + const hasCustom = values.includes(CUSTOM_OPTION); + const currentIsCustom = hasCustom && !values.includes(item.currentValue); + return values + .map((value) => { + const label = value === CUSTOM_OPTION + ? (currentIsCustom ? `${CUSTOM_LABEL} (${item.currentValue})` : CUSTOM_LABEL) + : value; + const selected = value === item.currentValue || (currentIsCustom && value === CUSTOM_OPTION); + return this.theme.value(label, selected); + }) + .join(separator); + } + + private openSubmenu(item: SettingItem): void { + if (!item.submenu) return; + this.submenuItemIndex = this.selectedIndex; + this.submenuComponent = item.submenu(item.currentValue, (selectedValue) => { + if (selectedValue !== undefined) { + item.currentValue = selectedValue; + this.onChange(item.id, selectedValue); + } + this.closeSubmenu(); + }); + } + + private addHintLine(lines: string[]): void { + lines.push(""); + lines.push( + this.theme.hint( + this.searchEnabled + ? " Type to search · ←/→ change · Enter/Space edit custom · Esc to cancel" + : " ←/→ change · Enter/Space edit custom · Esc to cancel", + ), + ); + } +} diff --git a/pi/files/agent/extensions/sub-bar/src/usage/types.ts b/pi/files/agent/extensions/sub-bar/src/usage/types.ts new file mode 100644 index 0000000..eaf4a46 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/usage/types.ts @@ -0,0 +1,5 @@ +/** + * Usage data types shared across modules. + */ + +export type { ProviderUsageEntry } from "../../shared.js"; diff --git a/pi/files/agent/extensions/sub-bar/src/utils.ts b/pi/files/agent/extensions/sub-bar/src/utils.ts new file mode 100644 index 0000000..3e7fdd2 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/src/utils.ts @@ -0,0 +1,42 @@ +/** + * Utility functions for the sub-bar display layer. + */ + +import { MODEL_MULTIPLIERS } from "./shared.js"; + +export function normalizeTokens(value: string): string[] { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim() + .split(" ") + .filter(Boolean); +} + +const MODEL_MULTIPLIER_TOKENS = Object.entries(MODEL_MULTIPLIERS).map(([label, multiplier]) => ({ + label, + multiplier, + tokens: normalizeTokens(label), +})); + +/** + * Get the request multiplier for a model ID + * Uses fuzzy matching against known model names + */ +export function getModelMultiplier(modelId: string | undefined): number | undefined { + if (!modelId) return undefined; + const modelTokens = normalizeTokens(modelId); + if (modelTokens.length === 0) return undefined; + + let bestMatch: { multiplier: number; tokenCount: number } | undefined; + for (const entry of MODEL_MULTIPLIER_TOKENS) { + const isMatch = entry.tokens.every((token) => modelTokens.includes(token)); + if (!isMatch) continue; + const tokenCount = entry.tokens.length; + if (!bestMatch || tokenCount > bestMatch.tokenCount) { + bestMatch = { multiplier: entry.multiplier, tokenCount }; + } + } + + return bestMatch?.multiplier; +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/index.ts b/pi/files/agent/extensions/sub-bar/sub-core/index.ts new file mode 100644 index 0000000..ea77e8c --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/index.ts @@ -0,0 +1,535 @@ +/** + * sub-core - Shared usage data core for sub-* extensions. + */ + +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import * as fs from "node:fs"; +import type { Dependencies, ProviderName, SubCoreState, UsageSnapshot } from "./src/types.js"; +import { getDefaultSettings, type Settings } from "./src/settings-types.js"; +import type { ProviderUsageEntry } from "./src/usage/types.js"; +import { createDefaultDependencies } from "./src/dependencies.js"; +import { createUsageController, type UsageUpdate } from "./src/usage/controller.js"; +import { fetchUsageEntries, getCachedUsageEntries } from "./src/usage/fetch.js"; +import { onCacheSnapshot, onCacheUpdate, watchCacheUpdates, type Cache } from "./src/cache.js"; +import { isExpectedMissingData } from "./src/errors.js"; +import { getStorage } from "./src/storage.js"; +import { clearSettingsCache, loadSettings, saveSettings, SETTINGS_PATH } from "./src/settings.js"; +import { showSettingsUI } from "./src/settings-ui.js"; + +type SubCoreRequest = + | { + type?: "current"; + includeSettings?: boolean; + reply: (payload: { state: SubCoreState; settings?: Settings }) => void; + } + | { + type: "entries"; + force?: boolean; + reply: (payload: { entries: ProviderUsageEntry[] }) => void; + }; + +type SubCoreAction = { + type: "refresh" | "cycleProvider"; + force?: boolean; +}; + +const TOOL_NAMES = { + usage: ["sub_get_usage", "get_current_usage"], + allUsage: ["sub_get_all_usage", "get_all_usage"], +} as const; + +type ToolName = (typeof TOOL_NAMES)[keyof typeof TOOL_NAMES][number]; + +type SubCoreGlobalState = { active: boolean }; +const subCoreGlobal = globalThis as typeof globalThis & { __piSubCore?: SubCoreGlobalState }; + +function deepMerge(target: T, source: Partial): T { + const result = { ...target } as T; + for (const key of Object.keys(source) as (keyof T)[]) { + const sourceValue = source[key]; + const targetValue = result[key]; + if ( + sourceValue !== undefined && + typeof sourceValue === "object" && + sourceValue !== null && + !Array.isArray(sourceValue) && + typeof targetValue === "object" && + targetValue !== null && + !Array.isArray(targetValue) + ) { + result[key] = deepMerge(targetValue as object, sourceValue as object) as T[keyof T]; + } else if (sourceValue !== undefined) { + result[key] = sourceValue as T[keyof T]; + } + } + return result; +} + +function stripUsageProvider(usage?: UsageSnapshot): Omit | undefined { + if (!usage) return undefined; + const { provider: _provider, ...rest } = usage; + return rest; +} + +/** + * Create the extension + */ +export default function createExtension(pi: ExtensionAPI, deps: Dependencies = createDefaultDependencies()): void { + if (subCoreGlobal.__piSubCore?.active) { + return; + } + subCoreGlobal.__piSubCore = { active: true }; + + let usageRefreshInterval: ReturnType | undefined; + let statusRefreshInterval: ReturnType | undefined; + let lastContext: ExtensionContext | undefined; + let lastUsageRefreshAt = 0; + let lastStatusRefreshAt = 0; + let settings: Settings = getDefaultSettings(); + let settingsLoaded = false; + let toolsRegistered = false; + let lastState: SubCoreState = {}; + let settingsSnapshot = ""; + let settingsMtimeMs = 0; + let settingsDebounce: NodeJS.Timeout | undefined; + let settingsWatcher: fs.FSWatcher | undefined; + let settingsPoll: NodeJS.Timeout | undefined; + let settingsWatchStarted = false; + + const controller = createUsageController(deps); + const controllerState = { + currentProvider: undefined as ProviderName | undefined, + cachedUsage: undefined as UsageSnapshot | undefined, + providerCycleIndex: 0, + }; + + let lastAllSnapshot = ""; + let lastCurrentSnapshot = ""; + + const emitCurrentUpdate = (provider?: ProviderName, usage?: UsageSnapshot): void => { + lastState = { provider, usage }; + const payload = JSON.stringify(lastState); + if (payload === lastCurrentSnapshot) return; + lastCurrentSnapshot = payload; + pi.events.emit("sub-core:update-current", { state: lastState }); + }; + + const unsubscribeCacheSnapshot = onCacheSnapshot((cache: Cache) => { + const ttlMs = settings.behavior.refreshInterval * 1000; + const now = Date.now(); + const entries: ProviderUsageEntry[] = []; + for (const provider of settings.providerOrder) { + const entry = cache[provider]; + if (!entry || !entry.usage) continue; + if (now - entry.fetchedAt >= ttlMs) continue; + const usage = { ...entry.usage, status: entry.status }; + if (usage.error && isExpectedMissingData(usage.error)) continue; + entries.push({ provider, usage }); + } + const payload = JSON.stringify({ provider: controllerState.currentProvider, entries }); + if (payload === lastAllSnapshot) return; + lastAllSnapshot = payload; + pi.events.emit("sub-core:update-all", { + state: { provider: controllerState.currentProvider, entries }, + }); + }); + + const unsubscribeCache = onCacheUpdate((provider, entry) => { + if (!controllerState.currentProvider || provider !== controllerState.currentProvider) return; + const usage = entry?.usage ? { ...entry.usage, status: entry.status } : undefined; + controllerState.cachedUsage = usage; + emitCurrentUpdate(controllerState.currentProvider, usage); + }); + + let stopCacheWatch: (() => void) | undefined; + let cacheWatchStarted = false; + + const startCacheWatch = (): void => { + if (cacheWatchStarted) return; + cacheWatchStarted = true; + stopCacheWatch = watchCacheUpdates(); + }; + + function emitUpdate(update: UsageUpdate): void { + emitCurrentUpdate(update.provider, update.usage); + } + + async function refresh( + ctx: ExtensionContext, + options?: { force?: boolean; allowStaleCache?: boolean; skipFetch?: boolean } + ) { + lastContext = ctx; + ensureSettingsLoaded(); + try { + await controller.refresh(ctx, settings, controllerState, emitUpdate, options); + } finally { + if (!options?.skipFetch) { + lastUsageRefreshAt = Date.now(); + } + } + } + + async function refreshStatus( + ctx: ExtensionContext, + options?: { force?: boolean; allowStaleCache?: boolean; skipFetch?: boolean } + ) { + lastContext = ctx; + ensureSettingsLoaded(); + try { + await controller.refreshStatus(ctx, settings, controllerState, emitUpdate, options); + } finally { + if (!options?.skipFetch) { + lastStatusRefreshAt = Date.now(); + } + } + } + + async function cycleProvider(ctx: ExtensionContext): Promise { + ensureSettingsLoaded(); + await controller.cycleProvider(ctx, settings, controllerState, emitUpdate); + } + + function setupRefreshInterval(): void { + if (usageRefreshInterval) { + clearInterval(usageRefreshInterval); + usageRefreshInterval = undefined; + } + if (statusRefreshInterval) { + clearInterval(statusRefreshInterval); + statusRefreshInterval = undefined; + } + + const usageIntervalMs = settings.behavior.refreshInterval * 1000; + if (usageIntervalMs > 0) { + const usageTickMs = Math.min(usageIntervalMs, 10000); + usageRefreshInterval = setInterval(() => { + if (!lastContext) return; + const elapsed = lastUsageRefreshAt ? Date.now() - lastUsageRefreshAt : usageIntervalMs + 1; + if (elapsed >= usageIntervalMs) { + void refresh(lastContext); + } + }, usageTickMs); + usageRefreshInterval.unref?.(); + } + + const statusIntervalMs = settings.statusRefresh.refreshInterval * 1000; + if (statusIntervalMs > 0) { + const statusTickMs = Math.min(statusIntervalMs, 10000); + statusRefreshInterval = setInterval(() => { + if (!lastContext) return; + const elapsed = lastStatusRefreshAt ? Date.now() - lastStatusRefreshAt : statusIntervalMs + 1; + if (elapsed >= statusIntervalMs) { + void refreshStatus(lastContext); + } + }, statusTickMs); + statusRefreshInterval.unref?.(); + } + } + + function applySettingsPatch(patch: Partial): void { + ensureSettingsLoaded(); + settings = deepMerge(settings, patch); + saveSettings(settings); + setupRefreshInterval(); + pi.events.emit("sub-core:settings:updated", { settings }); + } + + function readSettingsFile(): string | undefined { + try { + return fs.readFileSync(SETTINGS_PATH, "utf-8"); + } catch { + return undefined; + } + } + + function applySettingsFromDisk(): void { + clearSettingsCache(); + settings = loadSettings(); + registerToolsFromSettings(settings); + setupRefreshInterval(); + pi.events.emit("sub-core:settings:updated", { settings }); + if (lastContext) { + void refresh(lastContext, { allowStaleCache: true, skipFetch: true }); + void refreshStatus(lastContext, { allowStaleCache: true, skipFetch: true }); + } + } + + function refreshSettingsSnapshot(): void { + const content = readSettingsFile(); + if (!content || content === settingsSnapshot) return; + try { + JSON.parse(content); + } catch { + return; + } + settingsSnapshot = content; + applySettingsFromDisk(); + } + + function checkSettingsFile(): void { + try { + const stat = fs.statSync(SETTINGS_PATH, { throwIfNoEntry: false }); + if (!stat || !stat.mtimeMs) return; + if (stat.mtimeMs === settingsMtimeMs) return; + settingsMtimeMs = stat.mtimeMs; + refreshSettingsSnapshot(); + } catch { + // Ignore missing files + } + } + + function scheduleSettingsRefresh(): void { + if (settingsDebounce) clearTimeout(settingsDebounce); + settingsDebounce = setTimeout(() => checkSettingsFile(), 200); + } + + function startSettingsWatch(): void { + if (settingsWatchStarted) return; + settingsWatchStarted = true; + if (!settingsSnapshot) { + const content = readSettingsFile(); + if (content) { + settingsSnapshot = content; + try { + const stat = fs.statSync(SETTINGS_PATH, { throwIfNoEntry: false }); + if (stat?.mtimeMs) settingsMtimeMs = stat.mtimeMs; + } catch { + // Ignore + } + } + } + try { + settingsWatcher = fs.watch(SETTINGS_PATH, scheduleSettingsRefresh); + settingsWatcher.unref?.(); + } catch { + settingsWatcher = undefined; + } + settingsPoll = setInterval(() => checkSettingsFile(), 2000); + settingsPoll.unref?.(); + } + + async function getEntries(force?: boolean): Promise { + ensureSettingsLoaded(); + const enabledProviders = controller.getEnabledProviders(settings); + if (enabledProviders.length === 0) return []; + if (force) { + return fetchUsageEntries(deps, settings, enabledProviders, { force: true }); + } + return getCachedUsageEntries(enabledProviders, settings); + } + + const registerUsageTool = (name: ToolName): void => { + pi.registerTool({ + name, + label: "Sub Usage", + description: "Refresh and return the latest subscription usage snapshot.", + parameters: Type.Object({ + force: Type.Optional(Type.Boolean({ description: "Force refresh" })), + }), + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const { force } = params as { force?: boolean }; + await refresh(ctx, { force: force ?? true }); + const payload = { provider: lastState.provider, usage: stripUsageProvider(lastState.usage) }; + return { + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + details: payload, + }; + }, + }); + }; + + const registerAllUsageTool = (name: ToolName): void => { + pi.registerTool({ + name, + label: "Sub All Usage", + description: "Refresh and return usage snapshots for all enabled providers.", + parameters: Type.Object({ + force: Type.Optional(Type.Boolean({ description: "Force refresh" })), + }), + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + const { force } = params as { force?: boolean }; + const entries = await getEntries(force ?? true); + const payload = entries.map((entry) => ({ + provider: entry.provider, + usage: stripUsageProvider(entry.usage), + })); + return { + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + details: { entries: payload }, + }; + }, + }); + }; + + function registerToolsFromSettings(nextSettings: Settings): void { + if (toolsRegistered) return; + const usageToolEnabled = nextSettings.tools?.usageTool ?? false; + const allUsageToolEnabled = nextSettings.tools?.allUsageTool ?? false; + + if (usageToolEnabled) { + for (const name of TOOL_NAMES.usage) { + registerUsageTool(name); + } + } + if (allUsageToolEnabled) { + for (const name of TOOL_NAMES.allUsage) { + registerAllUsageTool(name); + } + } + toolsRegistered = true; + } + + function ensureSettingsLoaded(): void { + if (settingsLoaded) return; + settings = loadSettings(); + settingsLoaded = true; + registerToolsFromSettings(settings); + setupRefreshInterval(); + const watchTimer = setTimeout(() => { + startCacheWatch(); + startSettingsWatch(); + }, 0); + watchTimer.unref?.(); + } + pi.registerCommand("sub-core:settings", { + description: "Open sub-core settings", + handler: async (_args, ctx) => { + ensureSettingsLoaded(); + const handleSettingsChange = async (updatedSettings: Settings) => { + applySettingsPatch(updatedSettings); + if (lastContext) { + await refresh(lastContext); + } + }; + + const newSettings = await showSettingsUI(ctx, handleSettingsChange); + settings = newSettings; + applySettingsPatch(newSettings); + if (lastContext) { + await refresh(lastContext); + } + }, + }); + + pi.events.on("sub-core:request", async (payload) => { + ensureSettingsLoaded(); + const request = payload as SubCoreRequest; + if (request.type === "entries") { + const entries = await getEntries(request.force); + if (lastContext && settings.statusRefresh.refreshInterval > 0) { + await refreshStatus(lastContext, { force: request.force }); + } + request.reply({ entries }); + return; + } + request.reply({ + state: lastState, + settings: request.includeSettings ? settings : undefined, + }); + }); + + pi.events.on("sub-core:settings:patch", (payload) => { + const patch = (payload as { patch?: Partial }).patch; + if (!patch) return; + applySettingsPatch(patch); + if (lastContext) { + void refresh(lastContext); + } + }); + + pi.events.on("sub-core:action", (payload) => { + const action = payload as SubCoreAction; + if (!lastContext) return; + switch (action.type) { + case "refresh": + void refresh(lastContext, { force: action.force }); + break; + case "cycleProvider": + void cycleProvider(lastContext); + break; + } + }); + + pi.on("session_start", async (_event, ctx) => { + lastContext = ctx; + ensureSettingsLoaded(); + void refresh(ctx, { allowStaleCache: true, skipFetch: true }); + void refreshStatus(ctx, { allowStaleCache: true, skipFetch: true }); + pi.events.emit("sub-core:ready", { state: lastState, settings }); + }); + + pi.on("turn_start", async (_event, ctx) => { + if (settings.behavior.refreshOnTurnStart) { + await refresh(ctx); + } + if (settings.statusRefresh.refreshOnTurnStart) { + await refreshStatus(ctx); + } + }); + + pi.on("tool_result", async (_event, ctx) => { + if (settings.behavior.refreshOnToolResult) { + await refresh(ctx, { force: true }); + } + if (settings.statusRefresh.refreshOnToolResult) { + await refreshStatus(ctx, { force: true }); + } + }); + + pi.on("turn_end", async (_event, ctx) => { + await refresh(ctx, { force: true }); + }); + + pi.on("session_switch", async (_event, ctx) => { + controllerState.currentProvider = undefined; + controllerState.cachedUsage = undefined; + await refresh(ctx); + await refreshStatus(ctx); + }); + + pi.on("session_branch" as unknown as "session_start", async (_event: unknown, ctx: ExtensionContext) => { + controllerState.currentProvider = undefined; + controllerState.cachedUsage = undefined; + await refresh(ctx); + await refreshStatus(ctx); + }); + + pi.on("model_select" as unknown as "session_start", async (_event: unknown, ctx: ExtensionContext) => { + controllerState.currentProvider = undefined; + controllerState.cachedUsage = undefined; + void refresh(ctx, { force: true, allowStaleCache: true }); + void refreshStatus(ctx, { force: true, allowStaleCache: true }); + }); + + pi.on("session_shutdown", async () => { + if (usageRefreshInterval) { + clearInterval(usageRefreshInterval); + usageRefreshInterval = undefined; + } + if (statusRefreshInterval) { + clearInterval(statusRefreshInterval); + statusRefreshInterval = undefined; + } + if (settingsDebounce) { + clearTimeout(settingsDebounce); + settingsDebounce = undefined; + } + if (settingsPoll) { + clearInterval(settingsPoll); + settingsPoll = undefined; + } + settingsWatcher?.close(); + settingsWatcher = undefined; + settingsWatchStarted = false; + settingsSnapshot = ""; + settingsMtimeMs = 0; + unsubscribeCache(); + unsubscribeCacheSnapshot(); + stopCacheWatch?.(); + stopCacheWatch = undefined; + cacheWatchStarted = false; + lastContext = undefined; + subCoreGlobal.__piSubCore = undefined; + }); +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/package.json b/pi/files/agent/extensions/sub-bar/sub-core/package.json new file mode 100644 index 0000000..7de2763 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/package.json @@ -0,0 +1,35 @@ +{ + "name": "@marckrenn/pi-sub-core", + "version": "1.3.0", + "description": "Shared usage data core for pi extensions", + "keywords": [ + "pi-package" + ], + "type": "module", + "license": "MIT", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "pi": { + "extensions": [ + "./index.ts" + ] + }, + "scripts": { + "check": "tsc --noEmit", + "check:watch": "tsc --noEmit --watch", + "test": "tsx test/all.test.ts" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.2", + "typescript": "^5.8.0" + }, + "dependencies": { + "@marckrenn/pi-sub-shared": "^1.3.0" + }, + "peerDependencies": { + "@mariozechner/pi-coding-agent": "*" + } +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/cache.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/cache.ts new file mode 100644 index 0000000..e55e442 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/cache.ts @@ -0,0 +1,489 @@ +/** + * Cache management for sub-bar + * Shared cache across all pi instances to avoid redundant API calls + */ + +import * as path from "node:path"; +import * as fs from "node:fs"; +import type { ProviderName, ProviderStatus, UsageSnapshot } from "./types.js"; +import { isExpectedMissingData } from "./errors.js"; +import { getStorage } from "./storage.js"; +import { + getCachePath, + getCacheLockPath, + getLegacyAgentCacheLockPath, + getLegacyAgentCachePath, + getLegacyCacheLockPath, + getLegacyCachePath, +} from "./paths.js"; +import { tryAcquireFileLock, releaseFileLock, waitForLockRelease } from "./storage/lock.js"; + +/** + * Cache entry for a provider + */ +export interface CacheEntry { + fetchedAt: number; + statusFetchedAt?: number; + usage?: UsageSnapshot; + status?: ProviderStatus; +} + +/** + * Cache structure + */ +export interface Cache { + [provider: string]: CacheEntry; +} + +export type CacheUpdateListener = (provider: ProviderName, entry?: CacheEntry) => void; +export type CacheSnapshotListener = (cache: Cache) => void; + +const cacheUpdateListeners = new Set(); +const cacheSnapshotListeners = new Set(); + +let lastCacheSnapshot: Cache | null = null; +let lastCacheContent = ""; +let lastCacheMtimeMs = 0; +let legacyCacheMigrated = false; + +function updateCacheSnapshot(cache: Cache, content: string, mtimeMs: number): void { + lastCacheSnapshot = cache; + lastCacheContent = content; + lastCacheMtimeMs = mtimeMs; +} + +function resetCacheSnapshot(): void { + lastCacheSnapshot = {}; + lastCacheContent = ""; + lastCacheMtimeMs = 0; +} + +function migrateLegacyCache(): void { + if (legacyCacheMigrated) return; + legacyCacheMigrated = true; + const storage = getStorage(); + try { + const legacyCachePaths = [LEGACY_AGENT_CACHE_PATH, LEGACY_CACHE_PATH]; + if (!storage.exists(CACHE_PATH)) { + const legacyPath = legacyCachePaths.find((path) => storage.exists(path)); + if (legacyPath) { + const content = storage.readFile(legacyPath); + if (content) { + ensureCacheDir(); + storage.writeFile(CACHE_PATH, content); + } + } + } + for (const legacyPath of legacyCachePaths) { + if (storage.exists(legacyPath)) { + storage.removeFile(legacyPath); + } + } + for (const legacyLockPath of [LEGACY_AGENT_LOCK_PATH, LEGACY_LOCK_PATH]) { + if (storage.exists(legacyLockPath)) { + storage.removeFile(legacyLockPath); + } + } + } catch (error) { + console.error("Failed to migrate cache:", error); + } +} + +export function onCacheUpdate(listener: CacheUpdateListener): () => void { + cacheUpdateListeners.add(listener); + return () => { + cacheUpdateListeners.delete(listener); + }; +} + +export function onCacheSnapshot(listener: CacheSnapshotListener): () => void { + cacheSnapshotListeners.add(listener); + return () => { + cacheSnapshotListeners.delete(listener); + }; +} + +function emitCacheUpdate(provider: ProviderName, entry?: CacheEntry): void { + for (const listener of cacheUpdateListeners) { + try { + listener(provider, entry); + } catch (error) { + console.error("Failed to notify cache update:", error); + } + } +} + +function emitCacheSnapshot(cache: Cache): void { + for (const listener of cacheSnapshotListeners) { + try { + listener(cache); + } catch (error) { + console.error("Failed to notify cache snapshot:", error); + } + } +} + +/** + * Cache file path + */ +export const CACHE_PATH = getCachePath(); +const LEGACY_CACHE_PATH = getLegacyCachePath(); +const LEGACY_AGENT_CACHE_PATH = getLegacyAgentCachePath(); + +/** + * Lock file path + */ +const LOCK_PATH = getCacheLockPath(); +const LEGACY_LOCK_PATH = getLegacyCacheLockPath(); +const LEGACY_AGENT_LOCK_PATH = getLegacyAgentCacheLockPath(); + +/** + * Lock timeout in milliseconds + */ +const LOCK_TIMEOUT_MS = 5000; + +/** + * Ensure cache directory exists + */ +function ensureCacheDir(): void { + const storage = getStorage(); + const dir = path.dirname(CACHE_PATH); + storage.ensureDir(dir); +} + +/** + * Read cache from disk + */ +export function readCache(): Cache { + migrateLegacyCache(); + const storage = getStorage(); + try { + const cacheExists = storage.exists(CACHE_PATH); + if (!cacheExists) { + if (lastCacheMtimeMs !== 0 || lastCacheContent) { + resetCacheSnapshot(); + } + return lastCacheSnapshot ?? {}; + } + + const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false }); + if (stat && stat.mtimeMs === lastCacheMtimeMs && lastCacheSnapshot) { + return lastCacheSnapshot; + } + + const content = storage.readFile(CACHE_PATH); + if (!content) { + updateCacheSnapshot({}, "", stat?.mtimeMs ?? 0); + return {}; + } + if (!stat && content === lastCacheContent && lastCacheSnapshot) { + return lastCacheSnapshot; + } + + try { + const parsed = JSON.parse(content) as Cache; + updateCacheSnapshot(parsed, content, stat?.mtimeMs ?? Date.now()); + return parsed; + } catch (error) { + const lastBrace = content.lastIndexOf("}"); + if (lastBrace > 0) { + const trimmed = content.slice(0, lastBrace + 1); + try { + const parsed = JSON.parse(trimmed) as Cache; + if (stat) { + writeCache(parsed); + } else { + updateCacheSnapshot(parsed, trimmed, Date.now()); + } + return parsed; + } catch { + // fall through to log below + } + } + console.error("Failed to read cache:", error); + } + } catch (error) { + console.error("Failed to read cache:", error); + } + return {}; +} + +/** + * Write cache to disk + */ +function writeCache(cache: Cache): void { + migrateLegacyCache(); + const storage = getStorage(); + try { + ensureCacheDir(); + const content = JSON.stringify(cache, null, 2); + const cacheExists = storage.exists(CACHE_PATH); + if (cacheExists && content === lastCacheContent) { + const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false }); + updateCacheSnapshot(cache, content, stat?.mtimeMs ?? lastCacheMtimeMs); + return; + } + const tempPath = `${CACHE_PATH}.${process.pid}.tmp`; + fs.writeFileSync(tempPath, content, "utf-8"); + fs.renameSync(tempPath, CACHE_PATH); + const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false }); + updateCacheSnapshot(cache, content, stat?.mtimeMs ?? Date.now()); + } catch (error) { + console.error("Failed to write cache:", error); + } +} + +export interface CacheWatchOptions { + debounceMs?: number; + pollIntervalMs?: number; + lockRetryMs?: number; +} + +export function watchCacheUpdates(options?: CacheWatchOptions): () => void { + migrateLegacyCache(); + const debounceMs = options?.debounceMs ?? 250; + const pollIntervalMs = options?.pollIntervalMs ?? 5000; + const lockRetryMs = options?.lockRetryMs ?? 1000; + let debounceTimer: NodeJS.Timeout | undefined; + let pollTimer: NodeJS.Timeout | undefined; + let lockRetryPending = false; + let lastSnapshot = ""; + let lastMtimeMs = 0; + let stopped = false; + + const scheduleLockRetry = () => { + if (lockRetryPending || stopped) return; + lockRetryPending = true; + void waitForLockRelease(LOCK_PATH, lockRetryMs).then((released) => { + lockRetryPending = false; + if (released) { + emitFromCache(); + } + }); + }; + + const emitFromCache = () => { + try { + if (fs.existsSync(LOCK_PATH)) { + scheduleLockRetry(); + return; + } + const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false }); + if (!stat || !stat.mtimeMs) return; + if (stat.mtimeMs === lastMtimeMs) return; + lastMtimeMs = stat.mtimeMs; + const content = fs.readFileSync(CACHE_PATH, "utf-8"); + if (content === lastSnapshot) return; + lastSnapshot = content; + const cache = JSON.parse(content) as Cache; + updateCacheSnapshot(cache, content, stat.mtimeMs); + emitCacheSnapshot(cache); + for (const [provider, entry] of Object.entries(cache)) { + emitCacheUpdate(provider as ProviderName, entry); + } + } catch { + // Ignore parse or read errors (likely mid-write) + } + }; + + const scheduleEmit = () => { + if (stopped) return; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => emitFromCache(), debounceMs); + }; + + let watcher: fs.FSWatcher | undefined; + try { + watcher = fs.watch(CACHE_PATH, scheduleEmit); + watcher.unref?.(); + } catch { + watcher = undefined; + } + + pollTimer = setInterval(() => emitFromCache(), pollIntervalMs); + pollTimer.unref?.(); + + return () => { + stopped = true; + if (debounceTimer) clearTimeout(debounceTimer); + if (pollTimer) clearInterval(pollTimer); + watcher?.close(); + }; +} + +/** + * Wait for lock to be released and re-check cache + * Returns the cache entry if it became fresh while waiting + */ +async function waitForLockAndRecheck( + provider: ProviderName, + ttlMs: number, + maxWaitMs: number = 3000 +): Promise { + const released = await waitForLockRelease(LOCK_PATH, maxWaitMs); + if (!released) { + return null; + } + + const cache = readCache(); + const entry = cache[provider]; + if (entry && entry.usage?.error && !isExpectedMissingData(entry.usage.error)) { + return null; + } + if (entry && Date.now() - entry.fetchedAt < ttlMs) { + return entry; + } + return null; +} + +/** + * Get cached data for a provider if fresh, or null if stale/missing + */ +export async function getCachedData( + provider: ProviderName, + ttlMs: number, + cacheSnapshot?: Cache +): Promise { + const cache = cacheSnapshot ?? readCache(); + const entry = cache[provider]; + + if (!entry) { + return null; + } + + if (entry.usage?.error && !isExpectedMissingData(entry.usage.error)) { + return null; + } + + const age = Date.now() - entry.fetchedAt; + if (age < ttlMs) { + return entry; + } + + return null; +} + +/** + * Fetch data with lock coordination + * Returns cached data if fresh, or executes fetchFn if cache is stale + */ +export async function fetchWithCache( + provider: ProviderName, + ttlMs: number, + fetchFn: () => Promise, + options?: { force?: boolean } +): Promise { + const forceRefresh = options?.force === true; + + if (!forceRefresh) { + // Check cache first + const cached = await getCachedData(provider, ttlMs); + if (cached) { + return { usage: cached.usage, status: cached.status } as T; + } + } + + // Cache is stale or forced refresh, try to acquire lock + const lockAcquired = tryAcquireFileLock(LOCK_PATH, LOCK_TIMEOUT_MS); + + if (!lockAcquired) { + // Another process is fetching, wait and re-check cache + const freshEntry = await waitForLockAndRecheck(provider, ttlMs); + if (freshEntry) { + return { usage: freshEntry.usage, status: freshEntry.status } as T; + } + // Timeout or cache still stale, fetch anyway + } + + try { + // Fetch fresh data + const result = await fetchFn(); + + // Only cache if we got valid usage data (not just no-credentials/errors) + const hasCredentialError = result.usage?.error && isExpectedMissingData(result.usage.error); + const hasError = Boolean(result.usage?.error); + const shouldCache = result.usage && !hasCredentialError && !hasError; + + const cache = readCache(); + + if (shouldCache) { + // Update cache with valid data + const fetchedAt = Date.now(); + const previous = cache[provider]; + const statusFetchedAt = result.statusFetchedAt ?? (result.status ? fetchedAt : previous?.statusFetchedAt); + cache[provider] = { + fetchedAt, + statusFetchedAt, + usage: result.usage, + status: result.status, + }; + writeCache(cache); + emitCacheUpdate(provider, cache[provider]); + emitCacheSnapshot(cache); + } else if (hasCredentialError) { + // Remove from cache if no credentials + if (cache[provider]) { + delete cache[provider]; + writeCache(cache); + emitCacheUpdate(provider, undefined); + emitCacheSnapshot(cache); + } + } + + return result; + } finally { + if (lockAcquired) { + releaseFileLock(LOCK_PATH); + } + } +} + +export async function updateCacheStatus( + provider: ProviderName, + status: ProviderStatus, + options?: { statusFetchedAt?: number } +): Promise { + const lockAcquired = tryAcquireFileLock(LOCK_PATH, LOCK_TIMEOUT_MS); + if (!lockAcquired) { + await waitForLockRelease(LOCK_PATH, 3000); + } + try { + const cache = readCache(); + const entry = cache[provider]; + const statusFetchedAt = options?.statusFetchedAt ?? Date.now(); + cache[provider] = { + fetchedAt: entry?.fetchedAt ?? 0, + statusFetchedAt, + usage: entry?.usage, + status, + }; + writeCache(cache); + emitCacheUpdate(provider, cache[provider]); + emitCacheSnapshot(cache); + } finally { + if (lockAcquired) { + releaseFileLock(LOCK_PATH); + } + } +} + +/** + * Clear cache for a specific provider or all providers + */ +export function clearCache(provider?: ProviderName): void { + const storage = getStorage(); + if (provider) { + const cache = readCache(); + delete cache[provider]; + writeCache(cache); + } else { + try { + if (storage.exists(CACHE_PATH)) { + storage.removeFile(CACHE_PATH); + } + resetCacheSnapshot(); + } catch (error) { + console.error("Failed to clear cache:", error); + } + } +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/config.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/config.ts new file mode 100644 index 0000000..d48a408 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/config.ts @@ -0,0 +1,35 @@ +/** + * Configuration constants for the sub-bar extension + */ + +/** + * Google Workspace status API endpoint + */ +export const GOOGLE_STATUS_URL = "https://www.google.com/appsstatus/dashboard/incidents.json"; + +/** + * Google product ID for Gemini in the status API + */ +export const GEMINI_PRODUCT_ID = "npdyhgECDJ6tB66MxXyo"; + +/** + * Model multipliers for Copilot request counting + * Maps model display names to their request multiplier + */ +export { MODEL_MULTIPLIERS } from "../../src/shared.js"; + +/** + * Timeout for API requests in milliseconds + */ +export const API_TIMEOUT_MS = 5000; + +/** + * Timeout for CLI commands in milliseconds + */ +export const CLI_TIMEOUT_MS = 10000; + +/** + * Interval for automatic usage refresh in milliseconds + */ +export const REFRESH_INTERVAL_MS = 60_000; + diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/dependencies.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/dependencies.ts new file mode 100644 index 0000000..233f219 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/dependencies.ts @@ -0,0 +1,37 @@ +/** + * Default dependencies using real implementations + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import { execFileSync } from "node:child_process"; +import type { ExecFileSyncOptionsWithStringEncoding } from "node:child_process"; +import type { Dependencies } from "./types.js"; + +/** + * Create default dependencies using Node.js APIs + */ +export function createDefaultDependencies(): Dependencies { + return { + fetch: globalThis.fetch, + readFile: (path: string) => { + try { + return fs.readFileSync(path, "utf-8"); + } catch { + return undefined; + } + }, + fileExists: (path: string) => { + try { + return fs.existsSync(path); + } catch { + return false; + } + }, + execFileSync: (file: string, args: string[], options?: ExecFileSyncOptionsWithStringEncoding) => { + return execFileSync(file, args, options) as string; + }, + homedir: () => os.homedir(), + env: process.env, + }; +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/errors.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/errors.ts new file mode 100644 index 0000000..b7487d9 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/errors.ts @@ -0,0 +1,71 @@ +/** + * Error utilities for the sub-bar extension + */ + +import type { UsageError, UsageErrorCode } from "./types.js"; + +export function createError(code: UsageErrorCode, message: string, httpStatus?: number): UsageError { + return { code, message, httpStatus }; +} + +export function noCredentials(): UsageError { + return createError("NO_CREDENTIALS", "No credentials found"); +} + +export function noCli(cliName: string): UsageError { + return createError("NO_CLI", `${cliName} CLI not found`); +} + +export function notLoggedIn(): UsageError { + return createError("NOT_LOGGED_IN", "Not logged in"); +} + +export function fetchFailed(reason?: string): UsageError { + return createError("FETCH_FAILED", reason ?? "Fetch failed"); +} + +export function httpError(status: number): UsageError { + return createError("HTTP_ERROR", `HTTP ${status}`, status); +} + +export function apiError(message: string): UsageError { + return createError("API_ERROR", message); +} + +export function timeout(): UsageError { + return createError("TIMEOUT", "Request timed out"); +} + +/** + * Check if an error should be considered "no data available" vs actual error + * These are expected states when provider isn't configured + */ +export function isExpectedMissingData(error: UsageError): boolean { + const ignoreCodes = new Set(["NO_CREDENTIALS", "NO_CLI", "NOT_LOGGED_IN"]); + return ignoreCodes.has(error.code); +} + +/** + * Format error for display in the usage widget + */ +export function formatErrorForDisplay(error: UsageError): string { + switch (error.code) { + case "NO_CREDENTIALS": + return "No creds"; + case "NO_CLI": + return "No CLI"; + case "NOT_LOGGED_IN": + return "Not logged in"; + case "HTTP_ERROR": + if (error.httpStatus === 401) { + return "token no longer valid – please /login again"; + } + return `${error.httpStatus}`; + case "FETCH_FAILED": + case "API_ERROR": + case "TIMEOUT": + case "UNKNOWN": + default: + return "Fetch failed"; + } +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/paths.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/paths.ts new file mode 100644 index 0000000..c382e9c --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/paths.ts @@ -0,0 +1,55 @@ +/** + * Shared path helpers for sub-core storage. + */ + +import { getAgentDir } from "@mariozechner/pi-coding-agent"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SETTINGS_FILE_NAME = "pi-sub-core-settings.json"; +const CACHE_DIR_NAME = "cache"; +const CACHE_NAMESPACE_DIR = "sub-core"; +const CACHE_FILE_NAME = "cache.json"; +const CACHE_LOCK_FILE_NAME = "cache.lock"; +const LEGACY_AGENT_CACHE_FILE_NAME = "pi-sub-core-cache.json"; +const LEGACY_AGENT_LOCK_FILE_NAME = "pi-sub-core-cache.lock"; + +export function getExtensionDir(): string { + return join(dirname(fileURLToPath(import.meta.url)), ".."); +} + +export function getCacheDir(): string { + return join(getAgentDir(), CACHE_DIR_NAME, CACHE_NAMESPACE_DIR); +} + +export function getCachePath(): string { + return join(getCacheDir(), CACHE_FILE_NAME); +} + +export function getCacheLockPath(): string { + return join(getCacheDir(), CACHE_LOCK_FILE_NAME); +} + +export function getLegacyCachePath(): string { + return join(getExtensionDir(), "cache.json"); +} + +export function getLegacyCacheLockPath(): string { + return join(getExtensionDir(), "cache.lock"); +} + +export function getLegacyAgentCachePath(): string { + return join(getAgentDir(), LEGACY_AGENT_CACHE_FILE_NAME); +} + +export function getLegacyAgentCacheLockPath(): string { + return join(getAgentDir(), LEGACY_AGENT_LOCK_FILE_NAME); +} + +export function getSettingsPath(): string { + return join(getAgentDir(), SETTINGS_FILE_NAME); +} + +export function getLegacySettingsPath(): string { + return join(getExtensionDir(), "settings.json"); +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/provider.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/provider.ts new file mode 100644 index 0000000..1d2a6cc --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/provider.ts @@ -0,0 +1,66 @@ +/** + * Provider interface and registry + */ + +import type { Dependencies, ProviderName, ProviderStatus, UsageSnapshot } from "./types.js"; + +/** + * Interface for a usage provider + */ +export interface UsageProvider { + readonly name: ProviderName; + readonly displayName: string; + + /** + * Fetch current usage data for this provider + */ + fetchUsage(deps: Dependencies): Promise; + + /** + * Fetch current status for this provider (optional) + */ + fetchStatus?(deps: Dependencies): Promise; + + /** + * Check if credentials are available (optional) + */ + hasCredentials?(deps: Dependencies): boolean; +} + +/** + * Base class for providers with common functionality + */ +export abstract class BaseProvider implements UsageProvider { + abstract readonly name: ProviderName; + abstract readonly displayName: string; + + abstract fetchUsage(deps: Dependencies): Promise; + + hasCredentials(_deps: Dependencies): boolean { + return true; + } + + /** + * Create an empty snapshot with an error + */ + protected emptySnapshot(error?: import("./types.js").UsageError): UsageSnapshot { + return { + provider: this.name, + displayName: this.displayName, + windows: [], + error, + }; + } + + /** + * Create a snapshot with usage data + */ + protected snapshot(data: Partial>): UsageSnapshot { + return { + provider: this.name, + displayName: this.displayName, + windows: [], + ...data, + }; + } +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/providers/detection.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/detection.ts new file mode 100644 index 0000000..f7ec476 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/detection.ts @@ -0,0 +1,51 @@ +/** + * Provider detection helpers. + */ + +import type { ProviderName } from "../types.js"; +import { PROVIDERS } from "../types.js"; +import { PROVIDER_METADATA } from "./metadata.js"; + +interface ProviderDetectionHint { + provider: ProviderName; + providerTokens: string[]; + modelTokens: string[]; +} + +const PROVIDER_DETECTION_HINTS: ProviderDetectionHint[] = PROVIDERS.map((provider) => { + const detection = PROVIDER_METADATA[provider].detection ?? { providerTokens: [], modelTokens: [] }; + return { + provider, + providerTokens: detection.providerTokens, + modelTokens: detection.modelTokens, + }; +}); + +/** + * Detect the provider from model metadata. + */ +export function detectProviderFromModel( + model: { provider?: string; id?: string } | undefined +): ProviderName | undefined { + if (!model) return undefined; + const providerValue = model.provider?.toLowerCase() || ""; + const idValue = model.id?.toLowerCase() || ""; + + if (providerValue.includes("antigravity") || idValue.includes("antigravity")) { + return "antigravity"; + } + + for (const hint of PROVIDER_DETECTION_HINTS) { + if (hint.providerTokens.some((token) => providerValue.includes(token))) { + return hint.provider; + } + } + + for (const hint of PROVIDER_DETECTION_HINTS) { + if (hint.modelTokens.some((token) => idValue.includes(token))) { + return hint.provider; + } + } + + return undefined; +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/anthropic.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/anthropic.ts new file mode 100644 index 0000000..aa29ab6 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/anthropic.ts @@ -0,0 +1,174 @@ +/** + * Anthropic/Claude usage provider + */ + +import * as path from "node:path"; +import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js"; +import { BaseProvider } from "../../provider.js"; +import { noCredentials, fetchFailed, httpError } from "../../errors.js"; +import { formatReset, createTimeoutController } from "../../utils.js"; +import { API_TIMEOUT_MS } from "../../config.js"; +import { getSettings } from "../../settings.js"; + +/** + * Load Claude API token from various sources + */ +function loadClaudeToken(deps: Dependencies): string | undefined { + // Explicit override via env var (useful in CI / menu bar apps) + const envToken = deps.env.ANTHROPIC_OAUTH_TOKEN?.trim(); + if (envToken) return envToken; + + // Try pi auth.json next + const piAuthPath = path.join(deps.homedir(), ".pi", "agent", "auth.json"); + try { + if (deps.fileExists(piAuthPath)) { + const data = JSON.parse(deps.readFile(piAuthPath) ?? "{}"); + if (data.anthropic?.access) return data.anthropic.access; + } + } catch { + // Ignore parse errors + } + + // Try macOS Keychain (Claude Code credentials) + try { + const keychainData = deps.execFileSync( + "security", + ["find-generic-password", "-s", "Claude Code-credentials", "-w"], + { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] } + ).trim(); + if (keychainData) { + const parsed = JSON.parse(keychainData); + const scopes = parsed.claudeAiOauth?.scopes || []; + if (scopes.includes("user:profile") && parsed.claudeAiOauth?.accessToken) { + return parsed.claudeAiOauth.accessToken; + } + } + } catch { + // Keychain access failed + } + + return undefined; +} + +type ExtraUsageFormat = { + symbol: string; + decimalSeparator: "." | ","; +}; + +function getExtraUsageFormat(): ExtraUsageFormat { + const settings = getSettings(); + const providerSettings = settings.providers.anthropic; + return { + symbol: providerSettings.extraUsageCurrencySymbol?.trim() ?? "", + decimalSeparator: providerSettings.extraUsageDecimalSeparator === "," ? "," : ".", + }; +} + +function formatExtraUsageCredits(credits: number, format: ExtraUsageFormat): string { + const amount = (credits / 100).toFixed(2); + const formatted = format.decimalSeparator === "," ? amount.replace(".", ",") : amount; + return format.symbol ? `${format.symbol}${formatted}` : formatted; +} + + +export class AnthropicProvider extends BaseProvider { + readonly name = "anthropic" as const; + readonly displayName = "Claude Plan"; + + hasCredentials(deps: Dependencies): boolean { + return Boolean(loadClaudeToken(deps)); + } + + async fetchUsage(deps: Dependencies): Promise { + const token = loadClaudeToken(deps); + if (!token) { + return this.emptySnapshot(noCredentials()); + } + + const { controller, clear } = createTimeoutController(API_TIMEOUT_MS); + + try { + const res = await deps.fetch("https://api.anthropic.com/api/oauth/usage", { + headers: { + Authorization: `Bearer ${token}`, + "anthropic-beta": "oauth-2025-04-20", + }, + signal: controller.signal, + }); + clear(); + + if (!res.ok) { + return this.emptySnapshot(httpError(res.status)); + } + + const data = (await res.json()) as { + five_hour?: { utilization?: number; resets_at?: string }; + seven_day?: { utilization?: number; resets_at?: string }; + extra_usage?: { + is_enabled?: boolean; + used_credits?: number; + monthly_limit?: number; + utilization?: number; + }; + }; + + const windows: RateWindow[] = []; + + if (data.five_hour?.utilization !== undefined) { + const resetAt = data.five_hour.resets_at ? new Date(data.five_hour.resets_at) : undefined; + windows.push({ + label: "5h", + usedPercent: data.five_hour.utilization, + resetDescription: resetAt ? formatReset(resetAt) : undefined, + resetAt: resetAt?.toISOString(), + }); + } + + if (data.seven_day?.utilization !== undefined) { + const resetAt = data.seven_day.resets_at ? new Date(data.seven_day.resets_at) : undefined; + windows.push({ + label: "Week", + usedPercent: data.seven_day.utilization, + resetDescription: resetAt ? formatReset(resetAt) : undefined, + resetAt: resetAt?.toISOString(), + }); + } + + // Extra usage + const extraUsageEnabled = data.extra_usage?.is_enabled === true; + const fiveHourUsage = data.five_hour?.utilization ?? 0; + + if (extraUsageEnabled) { + const extra = data.extra_usage!; + const usedCredits = extra.used_credits || 0; + const monthlyLimit = extra.monthly_limit; + const utilization = extra.utilization || 0; + const format = getExtraUsageFormat(); + // "active" when 5h >= 99%, otherwise "on" + const extraStatus = fiveHourUsage >= 99 ? "active" : "on"; + let label: string; + if (monthlyLimit && monthlyLimit > 0) { + label = `Extra [${extraStatus}] ${formatExtraUsageCredits(usedCredits, format)}/${formatExtraUsageCredits(monthlyLimit, format)}`; + } else { + label = `Extra [${extraStatus}] ${formatExtraUsageCredits(usedCredits, format)}`; + } + + windows.push({ + label, + usedPercent: utilization, + resetDescription: extraStatus === "active" ? "__ACTIVE__" : undefined, + }); + } + + return this.snapshot({ + windows, + extraUsageEnabled, + fiveHourUsage, + }); + } catch { + clear(); + return this.emptySnapshot(fetchFailed()); + } + } + +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/antigravity.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/antigravity.ts new file mode 100644 index 0000000..4d63208 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/antigravity.ts @@ -0,0 +1,226 @@ +/** + * Google Antigravity usage provider + */ + +import * as path from "node:path"; +import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js"; +import { BaseProvider } from "../../provider.js"; +import { noCredentials, fetchFailed, httpError } from "../../errors.js"; +import { createTimeoutController, formatReset } from "../../utils.js"; +import { API_TIMEOUT_MS } from "../../config.js"; + +const ANTIGRAVITY_ENDPOINTS = [ + "https://daily-cloudcode-pa.sandbox.googleapis.com", + "https://cloudcode-pa.googleapis.com", +] as const; + +const ANTIGRAVITY_HEADERS = { + "User-Agent": "antigravity/1.11.5 darwin/arm64", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "Client-Metadata": JSON.stringify({ + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }), +}; + +const ANTIGRAVITY_HIDDEN_MODELS = new Set(["tab_flash_lite_preview"]); + +interface AntigravityAuth { + access?: string; + accessToken?: string; + token?: string; + key?: string; + projectId?: string; + project?: string; +} + +interface CloudCodeQuotaResponse { + models?: Record; +} + +interface ParsedModelQuota { + name: string; + remainingFraction: number; + resetAt?: Date; +} + +/** + * Load Antigravity access token from auth.json + */ +function loadAntigravityAuth(deps: Dependencies): AntigravityAuth | undefined { + // Explicit override via env var + const envProjectId = (deps.env.GOOGLE_ANTIGRAVITY_PROJECT_ID || deps.env.GOOGLE_ANTIGRAVITY_PROJECT)?.trim(); + const envToken = (deps.env.GOOGLE_ANTIGRAVITY_OAUTH_TOKEN || deps.env.ANTIGRAVITY_OAUTH_TOKEN)?.trim(); + if (envToken) { + return { token: envToken, projectId: envProjectId || undefined }; + } + + // Also support passing pi-ai style JSON api key: { token, projectId } + const envApiKey = (deps.env.GOOGLE_ANTIGRAVITY_API_KEY || deps.env.ANTIGRAVITY_API_KEY)?.trim(); + if (envApiKey) { + try { + const parsed = JSON.parse(envApiKey) as { token?: string; projectId?: string }; + if (parsed?.token) { + return { token: parsed.token, projectId: parsed.projectId || envProjectId || undefined }; + } + } catch { + // not JSON + } + return { token: envApiKey, projectId: envProjectId || undefined }; + } + + const piAuthPath = path.join(deps.homedir(), ".pi", "agent", "auth.json"); + try { + if (deps.fileExists(piAuthPath)) { + const data = JSON.parse(deps.readFile(piAuthPath) ?? "{}"); + const entry = data["google-antigravity"]; + if (!entry) return undefined; + if (typeof entry === "string") { + return { token: entry }; + } + return { + access: entry.access, + accessToken: entry.accessToken, + token: entry.token, + key: entry.key, + projectId: entry.projectId ?? entry.project, + }; + } + } catch { + // Ignore parse errors + } + + return undefined; +} + +function resolveAntigravityToken(auth: AntigravityAuth | undefined): string | undefined { + return auth?.access ?? auth?.accessToken ?? auth?.token ?? auth?.key; +} + +function parseResetTime(value?: string): Date | undefined { + if (!value) return undefined; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return undefined; + return date; +} + +function toUsedPercent(remainingFraction: number): number { + const fraction = Number.isFinite(remainingFraction) ? remainingFraction : 1; + const used = (1 - fraction) * 100; + return Math.max(0, Math.min(100, used)); +} + +async function fetchAntigravityQuota( + deps: Dependencies, + endpoint: string, + token: string, + projectId?: string +): Promise<{ data?: CloudCodeQuotaResponse; status?: number }> { + const { controller, clear } = createTimeoutController(API_TIMEOUT_MS); + try { + const payload = projectId ? { project: projectId } : {}; + const res = await deps.fetch(`${endpoint}/v1internal:fetchAvailableModels`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + ...ANTIGRAVITY_HEADERS, + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + clear(); + if (!res.ok) return { status: res.status }; + const data = (await res.json()) as CloudCodeQuotaResponse; + return { data }; + } catch { + clear(); + return {}; + } +} + +export class AntigravityProvider extends BaseProvider { + readonly name = "antigravity" as const; + readonly displayName = "Antigravity"; + + hasCredentials(deps: Dependencies): boolean { + return Boolean(resolveAntigravityToken(loadAntigravityAuth(deps))); + } + + async fetchUsage(deps: Dependencies): Promise { + const auth = loadAntigravityAuth(deps); + const token = resolveAntigravityToken(auth); + if (!token) { + return this.emptySnapshot(noCredentials()); + } + + let data: CloudCodeQuotaResponse | undefined; + let lastStatus: number | undefined; + for (const endpoint of ANTIGRAVITY_ENDPOINTS) { + const result = await fetchAntigravityQuota(deps, endpoint, token, auth?.projectId); + if (result.data) { + data = result.data; + break; + } + if (result.status) { + lastStatus = result.status; + } + } + + if (!data) { + return lastStatus ? this.emptySnapshot(httpError(lastStatus)) : this.emptySnapshot(fetchFailed()); + } + + const modelByName = new Map(); + for (const [modelId, model] of Object.entries(data.models ?? {})) { + if (model.isInternal) continue; + if (modelId && ANTIGRAVITY_HIDDEN_MODELS.has(modelId.toLowerCase())) continue; + const name = model.displayName ?? modelId ?? model.model ?? "unknown"; + if (!name) continue; + if (ANTIGRAVITY_HIDDEN_MODELS.has(name.toLowerCase())) continue; + const remainingFraction = model.quotaInfo?.remainingFraction ?? 1; + const resetAt = parseResetTime(model.quotaInfo?.resetTime); + const existing = modelByName.get(name); + if (!existing) { + modelByName.set(name, { name, remainingFraction, resetAt }); + continue; + } + let next = existing; + if (remainingFraction < existing.remainingFraction) { + next = { name, remainingFraction, resetAt }; + } else if (remainingFraction === existing.remainingFraction && resetAt) { + if (!existing.resetAt || resetAt.getTime() < existing.resetAt.getTime()) { + next = { ...existing, resetAt }; + } + } else if (!existing.resetAt && resetAt) { + next = { ...existing, resetAt }; + } + if (next !== existing) { + modelByName.set(name, next); + } + } + + const parsedModels = Array.from(modelByName.values()).sort((a, b) => a.name.localeCompare(b.name)); + + const buildWindow = (label: string, remainingFraction: number, resetAt?: Date): RateWindow => ({ + label, + usedPercent: toUsedPercent(remainingFraction), + resetDescription: resetAt ? formatReset(resetAt) : undefined, + resetAt: resetAt?.toISOString(), + }); + + const windows = parsedModels.map((model) => buildWindow(model.name, model.remainingFraction, model.resetAt)); + + return this.snapshot({ windows }); + } +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/codex.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/codex.ts new file mode 100644 index 0000000..1bc4816 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/codex.ts @@ -0,0 +1,186 @@ +/** + * OpenAI Codex usage provider + */ + +import * as path from "node:path"; +import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js"; +import { BaseProvider } from "../../provider.js"; +import { noCredentials, fetchFailed, httpError } from "../../errors.js"; +import { formatReset, createTimeoutController } from "../../utils.js"; +import { API_TIMEOUT_MS } from "../../config.js"; + +interface CodexRateWindow { + reset_at?: number; + limit_window_seconds?: number; + used_percent?: number; +} + +interface CodexRateLimit { + primary_window?: CodexRateWindow; + secondary_window?: CodexRateWindow; +} + +interface CodexAdditionalRateLimit { + limit_name?: string; + metered_feature?: string; + rate_limit?: CodexRateLimit; +} + +/** + * Load Codex credentials from auth.json + * First tries pi's auth.json, then falls back to legacy codex location + */ +function loadCodexCredentials(deps: Dependencies): { accessToken?: string; accountId?: string } { + // Explicit override via env var + const envAccessToken = ( + deps.env.OPENAI_CODEX_OAUTH_TOKEN || + deps.env.OPENAI_CODEX_ACCESS_TOKEN || + deps.env.CODEX_OAUTH_TOKEN || + deps.env.CODEX_ACCESS_TOKEN + )?.trim(); + const envAccountId = (deps.env.OPENAI_CODEX_ACCOUNT_ID || deps.env.CHATGPT_ACCOUNT_ID)?.trim(); + if (envAccessToken) { + return { accessToken: envAccessToken, accountId: envAccountId || undefined }; + } + + // Try pi's auth.json first + const piAuthPath = path.join(deps.homedir(), ".pi", "agent", "auth.json"); + try { + if (deps.fileExists(piAuthPath)) { + const data = JSON.parse(deps.readFile(piAuthPath) ?? "{}"); + if (data["openai-codex"]?.access) { + return { + accessToken: data["openai-codex"].access, + accountId: data["openai-codex"].accountId, + }; + } + } + } catch { + // Ignore parse errors, try legacy location + } + + // Fall back to legacy codex location + const codexHome = deps.env.CODEX_HOME || path.join(deps.homedir(), ".codex"); + const authPath = path.join(codexHome, "auth.json"); + try { + if (deps.fileExists(authPath)) { + const data = JSON.parse(deps.readFile(authPath) ?? "{}"); + if (data.OPENAI_API_KEY) { + return { accessToken: data.OPENAI_API_KEY }; + } else if (data.tokens?.access_token) { + return { + accessToken: data.tokens.access_token, + accountId: data.tokens.account_id, + }; + } + } + } catch { + // Ignore parse errors + } + + return {}; +} + +function getWindowLabel(windowSeconds?: number, fallbackWindowSeconds?: number): string { + const safeWindowSeconds = + typeof windowSeconds === "number" && windowSeconds > 0 + ? windowSeconds + : typeof fallbackWindowSeconds === "number" && fallbackWindowSeconds > 0 + ? fallbackWindowSeconds + : 0; + if (!safeWindowSeconds) { + return "0h"; + } + const windowHours = Math.round(safeWindowSeconds / 3600); + if (windowHours >= 144) return "Week"; + if (windowHours >= 24) return "Day"; + return `${windowHours}h`; +} + +function pushWindow( + windows: RateWindow[], + prefix: string | undefined, + window: CodexRateWindow | undefined, + fallbackWindowSeconds?: number +): void { + if (!window) return; + const resetDate = window.reset_at ? new Date(window.reset_at * 1000) : undefined; + const label = getWindowLabel(window.limit_window_seconds, fallbackWindowSeconds); + const windowLabel = prefix ? `${prefix} ${label}` : label; + windows.push({ + label: windowLabel, + usedPercent: window.used_percent || 0, + resetDescription: resetDate ? formatReset(resetDate) : undefined, + resetAt: resetDate?.toISOString(), + }); +} + +function addRateWindows(windows: RateWindow[], rateLimit: CodexRateLimit | undefined, prefix?: string): void { + pushWindow(windows, prefix, rateLimit?.primary_window, 10800); + pushWindow(windows, prefix, rateLimit?.secondary_window, 86400); +} + +export class CodexProvider extends BaseProvider { + readonly name = "codex" as const; + readonly displayName = "Codex Plan"; + + hasCredentials(deps: Dependencies): boolean { + return Boolean(loadCodexCredentials(deps).accessToken); + } + + async fetchUsage(deps: Dependencies): Promise { + const { accessToken, accountId } = loadCodexCredentials(deps); + if (!accessToken) { + return this.emptySnapshot(noCredentials()); + } + + const { controller, clear } = createTimeoutController(API_TIMEOUT_MS); + + try { + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }; + if (accountId) { + headers["ChatGPT-Account-Id"] = accountId; + } + + const res = await deps.fetch("https://chatgpt.com/backend-api/wham/usage", { + headers, + signal: controller.signal, + }); + clear(); + + if (!res.ok) { + return this.emptySnapshot(httpError(res.status)); + } + + const data = (await res.json()) as { + rate_limit?: CodexRateLimit; + additional_rate_limits?: CodexAdditionalRateLimit[]; + }; + + const windows: RateWindow[] = []; + addRateWindows(windows, data.rate_limit); + + if (Array.isArray(data.additional_rate_limits)) { + for (const entry of data.additional_rate_limits) { + if (!entry || typeof entry !== "object") continue; + const prefix = + typeof entry.limit_name === "string" && entry.limit_name.trim().length > 0 + ? entry.limit_name.trim() + : typeof entry.metered_feature === "string" && entry.metered_feature.trim().length > 0 + ? entry.metered_feature.trim() + : "Additional"; + addRateWindows(windows, entry.rate_limit, prefix); + } + } + + return this.snapshot({ windows }); + } catch { + clear(); + return this.emptySnapshot(fetchFailed()); + } + } + +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/copilot.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/copilot.ts new file mode 100644 index 0000000..d6d55d7 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/copilot.ts @@ -0,0 +1,176 @@ +/** + * GitHub Copilot usage provider + */ + +import * as path from "node:path"; +import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js"; +import { BaseProvider } from "../../provider.js"; +import { noCredentials, fetchFailed, httpError } from "../../errors.js"; +import { formatReset, createTimeoutController } from "../../utils.js"; +import { API_TIMEOUT_MS } from "../../config.js"; + +/** + * Copilot token entries stored by legacy GitHub Copilot CLI + */ +type CopilotHostEntry = { + oauth_token?: string; + user_token?: string; + github_token?: string; + token?: string; +}; + +const COPILOT_TOKEN_KEYS: Array = [ + "oauth_token", + "user_token", + "github_token", + "token", +]; + +function getTokenFromHostEntry(entry: CopilotHostEntry | undefined): string | undefined { + if (!entry) return undefined; + for (const key of COPILOT_TOKEN_KEYS) { + const value = entry[key]; + if (typeof value === "string" && value.length > 0) { + return value; + } + } + return undefined; +} + +function loadLegacyCopilotToken(deps: Dependencies): string | undefined { + const configHome = deps.env.XDG_CONFIG_HOME || path.join(deps.homedir(), ".config"); + const legacyPaths = [ + path.join(configHome, "github-copilot", "hosts.json"), + path.join(deps.homedir(), ".github-copilot", "hosts.json"), + ]; + + for (const hostsPath of legacyPaths) { + try { + if (!deps.fileExists(hostsPath)) continue; + const data = JSON.parse(deps.readFile(hostsPath) ?? "{}"); + if (!data || typeof data !== "object") continue; + + const normalizedHosts: Record = {}; + for (const [host, entry] of Object.entries(data as Record)) { + normalizedHosts[host.toLowerCase()] = entry; + } + + const preferredToken = + getTokenFromHostEntry(normalizedHosts["github.com"]) || + getTokenFromHostEntry(normalizedHosts["api.github.com"]); + if (preferredToken) return preferredToken; + + for (const entry of Object.values(normalizedHosts)) { + const token = getTokenFromHostEntry(entry); + if (token) return token; + } + } catch { + // Ignore parse errors + } + } + + return undefined; +} + +/** + * Load Copilot token from pi auth.json first, then fallback to legacy locations. + */ +function loadCopilotToken(deps: Dependencies): string | undefined { + // Explicit override via env var + const envToken = (deps.env.COPILOT_GITHUB_TOKEN || deps.env.GH_TOKEN || deps.env.GITHUB_TOKEN || deps.env.COPILOT_TOKEN)?.trim(); + if (envToken) return envToken; + + const authPath = path.join(deps.homedir(), ".pi", "agent", "auth.json"); + try { + if (deps.fileExists(authPath)) { + const data = JSON.parse(deps.readFile(authPath) ?? "{}"); + // Prefer refresh token (GitHub access token) for GitHub API endpoints. + const piToken = data["github-copilot"]?.refresh || data["github-copilot"]?.access; + if (typeof piToken === "string" && piToken.length > 0) return piToken; + } + } catch { + // Ignore parse errors + } + + return loadLegacyCopilotToken(deps); +} + +export class CopilotProvider extends BaseProvider { + readonly name = "copilot" as const; + readonly displayName = "Copilot Plan"; + + hasCredentials(deps: Dependencies): boolean { + return Boolean(loadCopilotToken(deps)); + } + + async fetchUsage(deps: Dependencies): Promise { + const token = loadCopilotToken(deps); + if (!token) { + return this.emptySnapshot(noCredentials()); + } + + const { controller, clear } = createTimeoutController(API_TIMEOUT_MS); + + try { + const res = await deps.fetch("https://api.github.com/copilot_internal/user", { + headers: { + "Editor-Version": "vscode/1.96.2", + "User-Agent": "GitHubCopilotChat/0.26.7", + "X-Github-Api-Version": "2025-04-01", + Accept: "application/json", + Authorization: `token ${token}`, + }, + signal: controller.signal, + }); + clear(); + + if (!res.ok) { + return this.emptySnapshot(httpError(res.status)); + } + + const data = (await res.json()) as { + quota_reset_date_utc?: string; + quota_snapshots?: { + premium_interactions?: { + percent_remaining?: number; + remaining?: number; + entitlement?: number; + }; + }; + }; + + const windows: RateWindow[] = []; + const resetDate = data.quota_reset_date_utc ? new Date(data.quota_reset_date_utc) : undefined; + const resetDesc = resetDate ? formatReset(resetDate) : undefined; + + let requestsRemaining: number | undefined; + let requestsEntitlement: number | undefined; + + if (data.quota_snapshots?.premium_interactions) { + const pi = data.quota_snapshots.premium_interactions; + const monthUsedPercent = Math.max(0, 100 - (pi.percent_remaining || 0)); + windows.push({ + label: "Month", + usedPercent: monthUsedPercent, + resetDescription: resetDesc, + resetAt: resetDate?.toISOString(), + }); + + const remaining = pi.remaining ?? 0; + const entitlement = pi.entitlement ?? 0; + requestsRemaining = remaining; + requestsEntitlement = entitlement; + } + + return this.snapshot({ + windows, + requestsRemaining, + requestsEntitlement, + }); + } catch { + clear(); + return this.emptySnapshot(fetchFailed()); + } + } + +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/gemini.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/gemini.ts new file mode 100644 index 0000000..033194a --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/gemini.ts @@ -0,0 +1,130 @@ +/** + * Google Gemini usage provider + */ + +import * as path from "node:path"; +import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js"; +import { BaseProvider } from "../../provider.js"; +import { noCredentials, fetchFailed, httpError } from "../../errors.js"; +import { createTimeoutController } from "../../utils.js"; +import { API_TIMEOUT_MS } from "../../config.js"; + +/** + * Load Gemini access token from various sources + */ +function loadGeminiToken(deps: Dependencies): string | undefined { + // Explicit override via env var + const envToken = ( + deps.env.GOOGLE_GEMINI_CLI_OAUTH_TOKEN || + deps.env.GOOGLE_GEMINI_CLI_ACCESS_TOKEN || + deps.env.GEMINI_OAUTH_TOKEN || + deps.env.GOOGLE_GEMINI_OAUTH_TOKEN + )?.trim(); + if (envToken) return envToken; + + // Try pi auth.json first + const piAuthPath = path.join(deps.homedir(), ".pi", "agent", "auth.json"); + try { + if (deps.fileExists(piAuthPath)) { + const data = JSON.parse(deps.readFile(piAuthPath) ?? "{}"); + if (data["google-gemini-cli"]?.access) return data["google-gemini-cli"].access; + } + } catch { + // Ignore parse errors + } + + // Try ~/.gemini/oauth_creds.json + const credPath = path.join(deps.homedir(), ".gemini", "oauth_creds.json"); + try { + if (deps.fileExists(credPath)) { + const data = JSON.parse(deps.readFile(credPath) ?? "{}"); + if (data.access_token) return data.access_token; + } + } catch { + // Ignore parse errors + } + + return undefined; +} + +export class GeminiProvider extends BaseProvider { + readonly name = "gemini" as const; + readonly displayName = "Gemini Plan"; + + hasCredentials(deps: Dependencies): boolean { + return Boolean(loadGeminiToken(deps)); + } + + async fetchUsage(deps: Dependencies): Promise { + const token = loadGeminiToken(deps); + if (!token) { + return this.emptySnapshot(noCredentials()); + } + + const { controller, clear } = createTimeoutController(API_TIMEOUT_MS); + + try { + const res = await deps.fetch("https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: "{}", + signal: controller.signal, + }); + clear(); + + if (!res.ok) { + return this.emptySnapshot(httpError(res.status)); + } + + const data = (await res.json()) as { + buckets?: Array<{ + modelId?: string; + remainingFraction?: number; + }>; + }; + + // Aggregate quotas by model type + const quotas: Record = {}; + for (const bucket of data.buckets || []) { + const model = bucket.modelId || "unknown"; + const frac = bucket.remainingFraction ?? 1; + if (!quotas[model] || frac < quotas[model]) { + quotas[model] = frac; + } + } + + const windows: RateWindow[] = []; + let proMin = 1; + let flashMin = 1; + let hasProModel = false; + let hasFlashModel = false; + + for (const [model, frac] of Object.entries(quotas)) { + if (model.toLowerCase().includes("pro")) { + hasProModel = true; + if (frac < proMin) proMin = frac; + } + if (model.toLowerCase().includes("flash")) { + hasFlashModel = true; + if (frac < flashMin) flashMin = frac; + } + } + + if (hasProModel) { + windows.push({ label: "Pro", usedPercent: (1 - proMin) * 100 }); + } + if (hasFlashModel) { + windows.push({ label: "Flash", usedPercent: (1 - flashMin) * 100 }); + } + + return this.snapshot({ windows }); + } catch { + clear(); + return this.emptySnapshot(fetchFailed()); + } + } + +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/kiro.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/kiro.ts new file mode 100644 index 0000000..8645dab --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/kiro.ts @@ -0,0 +1,92 @@ +/** + * AWS Kiro usage provider + */ + +import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js"; +import { BaseProvider } from "../../provider.js"; +import { noCli, notLoggedIn, fetchFailed } from "../../errors.js"; +import { formatReset, stripAnsi, whichSync } from "../../utils.js"; +import { CLI_TIMEOUT_MS } from "../../config.js"; + +export class KiroProvider extends BaseProvider { + readonly name = "kiro" as const; + readonly displayName = "Kiro Plan"; + + hasCredentials(deps: Dependencies): boolean { + return Boolean(whichSync("kiro-cli", deps)); + } + + async fetchUsage(deps: Dependencies): Promise { + const kiroBinary = whichSync("kiro-cli", deps); + if (!kiroBinary) { + return this.emptySnapshot(noCli("kiro-cli")); + } + + try { + // Check if logged in + try { + deps.execFileSync(kiroBinary, ["whoami"], { + encoding: "utf-8", + timeout: API_TIMEOUT_MS, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + return this.emptySnapshot(notLoggedIn()); + } + + // Get usage + const output = deps.execFileSync(kiroBinary, ["chat", "--no-interactive", "/usage"], { + encoding: "utf-8", + timeout: CLI_TIMEOUT_MS, + env: { ...deps.env, TERM: "xterm-256color" }, + stdio: ["ignore", "pipe", "pipe"], + }); + + const stripped = stripAnsi(output); + const windows: RateWindow[] = []; + + // Parse credits percentage from "████...█ X%" + let creditsPercent = 0; + const percentMatch = stripped.match(/█+\s*(\d+)%/); + if (percentMatch) { + creditsPercent = parseInt(percentMatch[1], 10); + } + + // Parse credits used/total from "(X.XX of Y covered in plan)" + const creditsMatch = stripped.match(/\((\d+\.?\d*)\s+of\s+(\d+)\s+covered/); + if (creditsMatch && !percentMatch) { + const creditsUsed = parseFloat(creditsMatch[1]); + const creditsTotal = parseFloat(creditsMatch[2]); + if (creditsTotal > 0) { + creditsPercent = (creditsUsed / creditsTotal) * 100; + } + } + + // Parse reset date from "resets on 01/01" + let resetsAt: Date | undefined; + const resetMatch = stripped.match(/resets on (\d{2}\/\d{2})/); + if (resetMatch) { + const [month, day] = resetMatch[1].split("/").map(Number); + const now = new Date(); + const year = now.getFullYear(); + resetsAt = new Date(year, month - 1, day); + if (resetsAt < now) resetsAt.setFullYear(year + 1); + } + + windows.push({ + label: "Credits", + usedPercent: creditsPercent, + resetDescription: resetsAt ? formatReset(resetsAt) : undefined, + resetAt: resetsAt?.toISOString(), + }); + + return this.snapshot({ windows }); + } catch { + return this.emptySnapshot(fetchFailed()); + } + } + + // Kiro doesn't have a public status page +} + +const API_TIMEOUT_MS = 5000; diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/opencode-go.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/opencode-go.ts new file mode 100644 index 0000000..0e54a63 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/opencode-go.ts @@ -0,0 +1,191 @@ +/** + * OpenCode Go usage provider + * + * OpenCode Go is a subscription plan ($10/mo) from the OpenCode team + * that provides access to open coding models (GLM-5, Kimi K2.5, MiniMax M2.5) + * through opencode.ai/zen/go/v1 endpoints. + * + * Credentials are discovered from: + * 1. Config file (~/.config/opencode/opencode-go-usage.json) + * 2. Environment variables (OPENCODE_GO_WORKSPACE_ID, OPENCODE_GO_AUTH_COOKIE) + * 3. Environment variable (OPENCODE_API_KEY) - for API key auth + * + * Usage limits: rolling 5h ($12), weekly ($30), monthly ($60) — tracked in dollar value. + * + * Usage is fetched by scraping the HTML dashboard at opencode.ai/workspace/{id}/go + * using the auth cookie from the browser. + */ + +import * as path from "node:path"; +import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js"; +import { BaseProvider } from "../../provider.js"; +import { noCredentials, fetchFailed, apiError } from "../../errors.js"; +import { formatReset, createTimeoutController } from "../../utils.js"; +import { API_TIMEOUT_MS } from "../../config.js"; + +interface OpenCodeGoConfig { + workspaceId?: string; + authCookie?: string; +} + +/** + * Load config from file or environment + */ +function loadOpenCodeGoConfig(deps: Dependencies): OpenCodeGoConfig | undefined { + // 1. Config file (~/.config/opencode/opencode-go-usage.json) + const configPath = path.join(deps.homedir(), ".config", "opencode", "opencode-go-usage.json"); + try { + if (deps.fileExists(configPath)) { + const content = deps.readFile(configPath); + if (content) { + const parsed = JSON.parse(content) as OpenCodeGoConfig; + if (parsed.workspaceId && parsed.authCookie) { + return parsed; + } + } + } + } catch { + // Ignore parse errors + } + + // 2. Environment variables + const workspaceId = deps.env.OPENCODE_GO_WORKSPACE_ID?.trim(); + const authCookie = deps.env.OPENCODE_GO_AUTH_COOKIE?.trim(); + if (workspaceId && authCookie) { + return { workspaceId, authCookie }; + } + + return undefined; +} + +interface UsageData { + usagePercent: number; + resetInSec: number; +} + +/** + * Fetch usage by scraping the HTML dashboard + */ +async function fetchOpenCodeGoUsage( + workspaceId: string, + authCookie: string, + fetch: typeof globalThis.fetch, + signal?: AbortSignal +): Promise<{ rolling?: UsageData; weekly?: UsageData; monthly?: UsageData }> { + const url = `https://opencode.ai/workspace/${encodeURIComponent(workspaceId)}/go`; + + const response = await fetch(url, { + headers: { + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + Cookie: `auth=${authCookie}`, + }, + signal, + }); + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw new Error("Authentication failed. Please refresh your auth cookie."); + } + throw new Error(`HTTP ${response.status}: Request failed`); + } + + const html = await response.text(); + + const usage: { rolling?: UsageData; weekly?: UsageData; monthly?: UsageData } = {}; + const patterns = { + rolling: /rollingUsage:\$R\[\d+\]=(\{[^}]+\})/, + weekly: /weeklyUsage:\$R\[\d+\]=(\{[^}]+\})/, + monthly: /monthlyUsage:\$R\[\d+\]=(\{[^}]+\})/, + }; + + for (const [key, pattern] of Object.entries(patterns)) { + const match = html.match(pattern); + if (match) { + try { + // Fix the JavaScript object syntax to valid JSON + const jsonStr = match[1].replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)(\s*:)/g, '$1"$2"$3'); + usage[key as keyof typeof usage] = JSON.parse(jsonStr); + } catch { + // Ignore parse errors + } + } + } + + return usage; +} + +export class OpenCodeGoProvider extends BaseProvider { + readonly name = "opencode-go" as const; + readonly displayName = "OpenCode Go"; + + hasCredentials(deps: Dependencies): boolean { + return Boolean(loadOpenCodeGoConfig(deps)); + } + + async fetchUsage(deps: Dependencies): Promise { + const config = loadOpenCodeGoConfig(deps); + if (!config || !config.workspaceId || !config.authCookie) { + return this.emptySnapshot(noCredentials()); + } + + const { controller, clear } = createTimeoutController(API_TIMEOUT_MS); + + try { + const usage = await fetchOpenCodeGoUsage( + config.workspaceId, + config.authCookie, + deps.fetch, + controller.signal + ); + clear(); + + const windows: RateWindow[] = []; + + if (usage.rolling) { + const resetDate = usage.rolling.resetInSec > 0 + ? new Date(Date.now() + usage.rolling.resetInSec * 1000) + : undefined; + windows.push({ + label: "5h", + usedPercent: usage.rolling.usagePercent, + resetDescription: resetDate ? formatReset(resetDate) : undefined, + resetAt: resetDate?.toISOString(), + }); + } + + if (usage.weekly) { + const resetDate = usage.weekly.resetInSec > 0 + ? new Date(Date.now() + usage.weekly.resetInSec * 1000) + : undefined; + windows.push({ + label: "Week", + usedPercent: usage.weekly.usagePercent, + resetDescription: resetDate ? formatReset(resetDate) : undefined, + resetAt: resetDate?.toISOString(), + }); + } + + if (usage.monthly) { + const resetDate = usage.monthly.resetInSec > 0 + ? new Date(Date.now() + usage.monthly.resetInSec * 1000) + : undefined; + windows.push({ + label: "Month", + usedPercent: usage.monthly.usagePercent, + resetDescription: resetDate ? formatReset(resetDate) : undefined, + resetAt: resetDate?.toISOString(), + }); + } + + return this.snapshot({ windows }); + } catch (err) { + clear(); + const error = err instanceof Error ? err.message : "Unknown error"; + if (error.includes("Authentication failed")) { + return this.emptySnapshot(apiError(error)); + } + return this.emptySnapshot(fetchFailed(error)); + } + } +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/zai.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/zai.ts new file mode 100644 index 0000000..2db8206 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/impl/zai.ts @@ -0,0 +1,120 @@ +/** + * z.ai usage provider + */ + +import * as path from "node:path"; +import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js"; +import { BaseProvider } from "../../provider.js"; +import { noCredentials, fetchFailed, httpError, apiError } from "../../errors.js"; +import { formatReset, createTimeoutController } from "../../utils.js"; +import { API_TIMEOUT_MS } from "../../config.js"; + +/** + * Load z.ai API key from environment or auth.json + */ +function loadZaiApiKey(deps: Dependencies): string | undefined { + // Try environment variable first + if (deps.env.ZAI_API_KEY) { + return deps.env.ZAI_API_KEY; + } + if (deps.env.Z_AI_API_KEY) { + return deps.env.Z_AI_API_KEY; + } + + // Try pi auth.json + const authPath = path.join(deps.homedir(), ".pi", "agent", "auth.json"); + try { + if (deps.fileExists(authPath)) { + const auth = JSON.parse(deps.readFile(authPath) ?? "{}"); + return auth["z-ai"]?.access || auth["z-ai"]?.key || auth["zai"]?.access || auth["zai"]?.key; + } + } catch { + // Ignore parse errors + } + + return undefined; +} + +export class ZaiProvider extends BaseProvider { + readonly name = "zai" as const; + readonly displayName = "z.ai Plan"; + + hasCredentials(deps: Dependencies): boolean { + return Boolean(loadZaiApiKey(deps)); + } + + async fetchUsage(deps: Dependencies): Promise { + const apiKey = loadZaiApiKey(deps); + if (!apiKey) { + return this.emptySnapshot(noCredentials()); + } + + const { controller, clear } = createTimeoutController(API_TIMEOUT_MS); + + try { + const res = await deps.fetch("https://api.z.ai/api/monitor/usage/quota/limit", { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + signal: controller.signal, + }); + clear(); + + if (!res.ok) { + return this.emptySnapshot(httpError(res.status)); + } + + const data = (await res.json()) as { + success?: boolean; + code?: number; + msg?: string; + data?: { + limits?: Array<{ + type?: string; + unit?: number; + number?: number; + percentage?: number; + nextResetTime?: string; + }>; + }; + }; + + if (!data.success || data.code !== 200) { + return this.emptySnapshot(apiError(data.msg || "API error")); + } + + const windows: RateWindow[] = []; + const limits = data.data?.limits || []; + + for (const limit of limits) { + const percent = limit.percentage || 0; + const nextReset = limit.nextResetTime ? new Date(limit.nextResetTime) : undefined; + + if (limit.type === "TOKENS_LIMIT") { + windows.push({ + label: "Tokens", + usedPercent: percent, + resetDescription: nextReset ? formatReset(nextReset) : undefined, + resetAt: nextReset?.toISOString(), + }); + } else if (limit.type === "TIME_LIMIT") { + windows.push({ + label: "Monthly", + usedPercent: percent, + resetDescription: nextReset ? formatReset(nextReset) : undefined, + resetAt: nextReset?.toISOString(), + }); + } + } + + return this.snapshot({ windows }); + } catch { + clear(); + return this.emptySnapshot(fetchFailed()); + } + } + + // z.ai doesn't have a public status page +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/providers/index.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/index.ts new file mode 100644 index 0000000..afadbd9 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/index.ts @@ -0,0 +1,5 @@ +/** + * Provider registry exports. + */ + +export * from "./registry.js"; diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/providers/metadata.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/metadata.ts new file mode 100644 index 0000000..6550a97 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/metadata.ts @@ -0,0 +1,16 @@ +/** + * Provider metadata shared across the core. + */ + +export { + PROVIDERS, + PROVIDER_METADATA, + PROVIDER_DISPLAY_NAMES, +} from "../../../src/shared.js"; + +export type { + ProviderName, + ProviderMetadata, + ProviderStatusConfig, + ProviderDetectionConfig, +} from "../../../src/shared.js"; diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/providers/registry.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/registry.ts new file mode 100644 index 0000000..d24fb39 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/registry.ts @@ -0,0 +1,57 @@ +/** + * Provider registry - exports all providers + */ + +export { AnthropicProvider } from "./impl/anthropic.js"; +export { CopilotProvider } from "./impl/copilot.js"; +export { GeminiProvider } from "./impl/gemini.js"; +export { AntigravityProvider } from "./impl/antigravity.js"; +export { CodexProvider } from "./impl/codex.js"; +export { KiroProvider } from "./impl/kiro.js"; +export { ZaiProvider } from "./impl/zai.js"; +export { OpenCodeGoProvider } from "./impl/opencode-go.js"; + +import type { Dependencies, ProviderName } from "../types.js"; +import type { UsageProvider } from "../provider.js"; +import { PROVIDERS } from "./metadata.js"; +import { AnthropicProvider } from "./impl/anthropic.js"; +import { CopilotProvider } from "./impl/copilot.js"; +import { GeminiProvider } from "./impl/gemini.js"; +import { AntigravityProvider } from "./impl/antigravity.js"; +import { CodexProvider } from "./impl/codex.js"; +import { KiroProvider } from "./impl/kiro.js"; +import { ZaiProvider } from "./impl/zai.js"; +import { OpenCodeGoProvider } from "./impl/opencode-go.js"; + +const PROVIDER_FACTORIES: Record UsageProvider> = { + anthropic: () => new AnthropicProvider(), + copilot: () => new CopilotProvider(), + gemini: () => new GeminiProvider(), + antigravity: () => new AntigravityProvider(), + codex: () => new CodexProvider(), + kiro: () => new KiroProvider(), + zai: () => new ZaiProvider(), + "opencode-go": () => new OpenCodeGoProvider(), +}; + +/** + * Create a provider instance by name + */ +export function createProvider(name: ProviderName): UsageProvider { + return PROVIDER_FACTORIES[name](); +} + +/** + * Get all provider instances + */ +export function getAllProviders(): UsageProvider[] { + return PROVIDERS.map((name) => PROVIDER_FACTORIES[name]()); +} + +export function hasProviderCredentials(name: ProviderName, deps: Dependencies): boolean { + const provider = createProvider(name); + if (provider.hasCredentials) { + return provider.hasCredentials(deps); + } + return true; +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/providers/settings.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/settings.ts new file mode 100644 index 0000000..25c68cd --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/settings.ts @@ -0,0 +1,109 @@ +/** + * Provider-specific settings helpers. + */ + +import type { SettingItem } from "@mariozechner/pi-tui"; +import type { ProviderName } from "../types.js"; +import type { Settings, CoreProviderSettings } from "../settings-types.js"; +import { CUSTOM_OPTION } from "../ui/settings-list.js"; + +function buildBaseProviderItems(ps: CoreProviderSettings): SettingItem[] { + const enabledValue = ps.enabled === "auto" ? "auto" : ps.enabled === true || ps.enabled === "on" ? "on" : "off"; + return [ + { + id: "enabled", + label: "Enabled", + currentValue: enabledValue, + values: ["auto", "on", "off"], + description: "Auto enables if credentials are detected.", + }, + { + id: "fetchStatus", + label: "Fetch Status", + currentValue: ps.fetchStatus ? "on" : "off", + values: ["on", "off"], + description: "Fetch status page indicator for this provider.", + }, + ]; +} + +function resolveEnabledValue(value: string): CoreProviderSettings["enabled"] { + if (value === "auto") return "auto"; + return value === "on"; +} + +function applyBaseProviderSetting(ps: CoreProviderSettings, id: string, value: string): boolean { + switch (id) { + case "enabled": + ps.enabled = resolveEnabledValue(value); + return true; + case "fetchStatus": + ps.fetchStatus = value === "on"; + return true; + default: + return false; + } +} + +/** + * Build settings items for a specific provider. + */ +export function buildProviderSettingsItems(settings: Settings, provider: ProviderName): SettingItem[] { + const ps = settings.providers[provider]; + const items = buildBaseProviderItems(ps); + + if (provider === "anthropic") { + const currencySymbol = ps.extraUsageCurrencySymbol?.trim(); + items.push( + { + id: "extraUsageCurrencySymbol", + label: "Extra Usage Currency Symbol", + currentValue: currencySymbol ? currencySymbol : "none", + values: ["none", CUSTOM_OPTION], + description: "Prefix symbol for Extra usage amounts.", + }, + { + id: "extraUsageDecimalSeparator", + label: "Extra Usage Decimal Separator", + currentValue: ps.extraUsageDecimalSeparator === "," ? "," : ".", + values: [".", ","], + description: "Decimal separator for Extra usage amounts.", + }, + ); + } + + return items; +} + +/** + * Apply a provider settings change in-place. + */ +export function applyProviderSettingsChange( + settings: Settings, + provider: ProviderName, + id: string, + value: string +): Settings { + const ps = settings.providers[provider]; + if (applyBaseProviderSetting(ps, id, value)) { + return settings; + } + + switch (id) { + case "extraUsageCurrencySymbol": + if (value === CUSTOM_OPTION) { + return settings; + } + if (value === "none") { + delete ps.extraUsageCurrencySymbol; + return settings; + } + ps.extraUsageCurrencySymbol = value; + return settings; + case "extraUsageDecimalSeparator": + ps.extraUsageDecimalSeparator = value === "," ? "," : "."; + return settings; + default: + return settings; + } +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/providers/status.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/status.ts new file mode 100644 index 0000000..15da024 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/providers/status.ts @@ -0,0 +1,25 @@ +/** + * Provider status handling helpers. + */ + +import type { Dependencies, ProviderName, ProviderStatus } from "../types.js"; +import { fetchProviderStatus } from "../status.js"; +import { PROVIDER_METADATA } from "./metadata.js"; + +export function providerHasStatus( + provider: ProviderName, + providerInstance?: { fetchStatus?: (deps: Dependencies) => Promise } +): boolean { + return Boolean(providerInstance?.fetchStatus) || Boolean(PROVIDER_METADATA[provider]?.status); +} + +export async function fetchProviderStatusWithFallback( + provider: ProviderName, + providerInstance: { fetchStatus?: (deps: Dependencies) => Promise }, + deps: Dependencies +): Promise { + if (providerInstance.fetchStatus) { + return providerInstance.fetchStatus(deps); + } + return fetchProviderStatus(provider, deps); +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/settings-types.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/settings-types.ts new file mode 100644 index 0000000..99a2962 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/settings-types.ts @@ -0,0 +1,95 @@ +/** + * Settings types and defaults for sub-core + */ + +import type { + CoreSettings, + CoreProviderSettingsMap, + CoreProviderSettings, + BehaviorSettings, + ProviderName, + ProviderEnabledSetting, +} from "../../src/shared.js"; +import { PROVIDERS, getDefaultCoreSettings } from "../../src/shared.js"; + +export type { + CoreProviderSettings, + CoreProviderSettingsMap, + BehaviorSettings, + CoreSettings, + ProviderEnabledSetting, +} from "../../src/shared.js"; + +/** + * Tool registration settings + */ +export interface ToolSettings { + usageTool: boolean; + allUsageTool: boolean; +} + +/** + * All settings + */ +export interface Settings extends CoreSettings { + /** Version for migration */ + version: number; + /** Tool registration settings */ + tools: ToolSettings; +} + +/** + * Current settings version + */ +export const SETTINGS_VERSION = 3; + +/** + * Default settings + */ +export function getDefaultSettings(): Settings { + const coreDefaults = getDefaultCoreSettings(); + return { + version: SETTINGS_VERSION, + tools: { + usageTool: false, + allUsageTool: false, + }, + providers: coreDefaults.providers, + behavior: coreDefaults.behavior, + statusRefresh: coreDefaults.statusRefresh, + providerOrder: coreDefaults.providerOrder, + defaultProvider: coreDefaults.defaultProvider, + }; +} + +/** + * Deep merge two objects + */ +function deepMerge(target: T, source: Partial): T { + const result = { ...target } as T; + for (const key of Object.keys(source) as (keyof T)[]) { + const sourceValue = source[key]; + const targetValue = result[key]; + if ( + sourceValue !== undefined && + typeof sourceValue === "object" && + sourceValue !== null && + !Array.isArray(sourceValue) && + typeof targetValue === "object" && + targetValue !== null && + !Array.isArray(targetValue) + ) { + result[key] = deepMerge(targetValue as object, sourceValue as object) as T[keyof T]; + } else if (sourceValue !== undefined) { + result[key] = sourceValue as T[keyof T]; + } + } + return result; +} + +/** + * Merge settings with defaults (no legacy migrations). + */ +export function mergeSettings(loaded: Partial): Settings { + return deepMerge(getDefaultSettings(), loaded); +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/settings-ui.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/settings-ui.ts new file mode 100644 index 0000000..ed1e4fb --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/settings-ui.ts @@ -0,0 +1 @@ +export { showSettingsUI } from "./settings/ui.js"; diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/settings.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/settings.ts new file mode 100644 index 0000000..d0ee7e2 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/settings.ts @@ -0,0 +1,137 @@ +/** + * Settings persistence for sub-core + */ + +import * as path from "node:path"; +import type { Settings } from "./settings-types.js"; +import { getDefaultSettings, mergeSettings, SETTINGS_VERSION } from "./settings-types.js"; +import { getStorage } from "./storage.js"; +import { getLegacySettingsPath, getSettingsPath } from "./paths.js"; +import { clearCache } from "./cache.js"; + +/** + * Settings file path + */ +export const SETTINGS_PATH = getSettingsPath(); +const LEGACY_SETTINGS_PATH = getLegacySettingsPath(); + +/** + * In-memory settings cache + */ +let cachedSettings: Settings | undefined; + +type LoadedSettings = { + settings: Settings; + loadedVersion: number; +}; + +/** + * Ensure the settings directory exists + */ +function ensureSettingsDir(): void { + const storage = getStorage(); + const dir = path.dirname(SETTINGS_PATH); + storage.ensureDir(dir); +} + +function loadSettingsFromDisk(settingsPath: string): LoadedSettings | null { + const storage = getStorage(); + if (!storage.exists(settingsPath)) return null; + const content = storage.readFile(settingsPath); + if (!content) return null; + const loaded = JSON.parse(content) as Partial; + const loadedVersion = typeof loaded.version === "number" ? loaded.version : 0; + const merged = mergeSettings(loaded); + return { settings: merged, loadedVersion }; +} + +function applyVersionMigration(settings: Settings, loadedVersion: number): { settings: Settings; needsSave: boolean } { + if (loadedVersion < SETTINGS_VERSION) { + clearCache(); + return { settings: { ...settings, version: SETTINGS_VERSION }, needsSave: true }; + } + return { settings, needsSave: false }; +} + +function tryLoadSettings(settingsPath: string): LoadedSettings | null { + try { + return loadSettingsFromDisk(settingsPath); + } catch (error) { + console.error(`Failed to load settings from ${settingsPath}:`, error); + return null; + } +} + +/** + * Load settings from disk + */ +export function loadSettings(): Settings { + if (cachedSettings) { + return cachedSettings; + } + + const diskSettings = tryLoadSettings(SETTINGS_PATH); + if (diskSettings) { + const { settings: next, needsSave } = applyVersionMigration(diskSettings.settings, diskSettings.loadedVersion); + if (needsSave) { + saveSettings(next); + } + cachedSettings = next; + return cachedSettings; + } + + const legacySettings = tryLoadSettings(LEGACY_SETTINGS_PATH); + if (legacySettings) { + const { settings: next } = applyVersionMigration(legacySettings.settings, legacySettings.loadedVersion); + const saved = saveSettings(next); + if (saved) { + getStorage().removeFile(LEGACY_SETTINGS_PATH); + } + cachedSettings = next; + return cachedSettings; + } + + // Return defaults if file doesn't exist or failed to load + cachedSettings = getDefaultSettings(); + return cachedSettings; +} + +/** + * Save settings to disk + */ +export function saveSettings(settings: Settings): boolean { + const storage = getStorage(); + try { + ensureSettingsDir(); + const content = JSON.stringify(settings, null, 2); + storage.writeFile(SETTINGS_PATH, content); + cachedSettings = settings; + return true; + } catch (error) { + console.error(`Failed to save settings to ${SETTINGS_PATH}:`, error); + return false; + } +} + +/** + * Reset settings to defaults + */ +export function resetSettings(): Settings { + const defaults = getDefaultSettings(); + saveSettings(defaults); + return defaults; +} + +/** + * Get current settings (cached) + */ +export function getSettings(): Settings { + return loadSettings(); +} + +/** + * Clear the settings cache (force reload on next access) + */ +export function clearSettingsCache(): void { + cachedSettings = undefined; +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/settings/behavior.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/settings/behavior.ts new file mode 100644 index 0000000..cc90aa7 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/settings/behavior.ts @@ -0,0 +1,58 @@ +/** + * Behavior settings UI helpers. + */ + +import type { SettingItem } from "@mariozechner/pi-tui"; +import type { BehaviorSettings } from "../settings-types.js"; +import { CUSTOM_OPTION } from "../ui/settings-list.js"; + +export function buildRefreshItems(settings: BehaviorSettings): SettingItem[] { + return [ + { + id: "refreshInterval", + label: "Auto-refresh Interval", + currentValue: settings.refreshInterval === 0 ? "off" : `${settings.refreshInterval}s`, + values: ["off", "15s", "30s", "60s", "120s", "300s", CUSTOM_OPTION], + description: "How often to refresh automatically.", + }, + { + id: "minRefreshInterval", + label: "Minimum Refresh Interval", + currentValue: settings.minRefreshInterval === 0 ? "off" : `${settings.minRefreshInterval}s`, + values: ["off", "5s", "10s", "15s", "30s", "60s", "120s", CUSTOM_OPTION], + description: "Cap refreshes even when triggered each turn.", + }, + { + id: "refreshOnTurnStart", + label: "Refresh on Turn Start", + currentValue: settings.refreshOnTurnStart ? "on" : "off", + values: ["on", "off"], + description: "Refresh when a new turn starts.", + }, + { + id: "refreshOnToolResult", + label: "Refresh on Tool Result", + currentValue: settings.refreshOnToolResult ? "on" : "off", + values: ["on", "off"], + description: "Refresh after tool executions.", + }, + ]; +} + +export function applyRefreshChange(settings: BehaviorSettings, id: string, value: string): BehaviorSettings { + switch (id) { + case "refreshInterval": + settings.refreshInterval = value === "off" ? 0 : parseInt(value, 10); + break; + case "minRefreshInterval": + settings.minRefreshInterval = value === "off" ? 0 : parseInt(value, 10); + break; + case "refreshOnTurnStart": + settings.refreshOnTurnStart = value === "on"; + break; + case "refreshOnToolResult": + settings.refreshOnToolResult = value === "on"; + break; + } + return settings; +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/settings/menu.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/settings/menu.ts new file mode 100644 index 0000000..975f26a --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/settings/menu.ts @@ -0,0 +1,83 @@ +/** + * Settings menu item builders. + */ + +import type { SelectItem } from "@mariozechner/pi-tui"; +import type { Settings } from "../settings-types.js"; +import type { ProviderName } from "../types.js"; +import { PROVIDER_DISPLAY_NAMES } from "../providers/metadata.js"; + +export type TooltipSelectItem = SelectItem & { tooltip?: string }; + +export function buildMainMenuItems(settings: Settings): TooltipSelectItem[] { + const enabledCount = Object.values(settings.providers).filter((p) => p.enabled !== "off" && p.enabled !== false).length; + const totalCount = Object.keys(settings.providers).length; + const toolEnabledCount = [settings.tools.usageTool, settings.tools.allUsageTool].filter(Boolean).length; + const toolTotalCount = 2; + + return [ + { + value: "providers", + label: "Provider Settings", + description: `${enabledCount}/${totalCount} enabled`, + tooltip: "Enable providers, toggle status fetch, and adjust provider settings.", + }, + { + value: "behavior", + label: "Usage Refresh Settings", + description: `refresh ${settings.behavior.refreshInterval}s`, + tooltip: "Control usage refresh interval and triggers.", + }, + { + value: "status-refresh", + label: "Status Refresh Settings", + description: `refresh ${settings.statusRefresh.refreshInterval}s`, + tooltip: "Control status refresh interval and triggers.", + }, + { + value: "tools", + label: "Tool Settings", + description: `${toolEnabledCount}/${toolTotalCount} enabled`, + tooltip: "Enable sub-core tools (requires /reload to take effect).", + }, + { + value: "provider-order", + label: "Provider Order", + description: settings.providerOrder.slice(0, 3).join(", ") + "...", + tooltip: "Reorder providers for cycling and auto-selection.", + }, + { + value: "reset", + label: "Reset to Defaults", + description: "restore all settings", + tooltip: "Restore all sub-core settings to defaults.", + }, + ]; +} + +export function buildProviderListItems(settings: Settings): TooltipSelectItem[] { + return settings.providerOrder.map((provider) => { + const ps = settings.providers[provider]; + const enabledValue = ps.enabled === "auto" ? "auto" : ps.enabled === true || ps.enabled === "on" ? "on" : "off"; + const statusIcon = ps.fetchStatus ? ", status fetch on" : ""; + return { + value: `provider-${provider}`, + label: PROVIDER_DISPLAY_NAMES[provider], + description: `enabled ${enabledValue}${statusIcon}`, + tooltip: `Enable ${PROVIDER_DISPLAY_NAMES[provider]} and configure status fetching.`, + }; + }); +} + +export function buildProviderOrderItems(settings: Settings): TooltipSelectItem[] { + const activeProviders = settings.providerOrder.filter((provider) => { + const enabled = settings.providers[provider].enabled; + return enabled !== "off" && enabled !== false; + }); + return activeProviders.map((provider, index) => ({ + value: provider, + label: `${index + 1}. ${PROVIDER_DISPLAY_NAMES[provider]}`, + tooltip: "Reorder enabled providers (Space to toggle move mode).", + })); +} + diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/settings/tools.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/settings/tools.ts new file mode 100644 index 0000000..0349dce --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/settings/tools.ts @@ -0,0 +1,38 @@ +/** + * Tool settings UI helpers. + */ + +import type { SettingItem } from "@mariozechner/pi-tui"; +import type { Settings, ToolSettings } from "../settings-types.js"; + +export function buildToolItems(settings: ToolSettings): SettingItem[] { + return [ + { + id: "usageTool", + label: "Usage Tool", + currentValue: settings.usageTool ? "on" : "off", + values: ["on", "off"], + description: "Expose sub_get_usage/get_current_usage (requires /reload).", + }, + { + id: "allUsageTool", + label: "All Usage Tool", + currentValue: settings.allUsageTool ? "on" : "off", + values: ["on", "off"], + description: "Expose sub_get_all_usage/get_all_usage (requires /reload).", + }, + ]; +} + +export function applyToolChange(settings: Settings, id: string, value: string): Settings { + const enabled = value === "on"; + switch (id) { + case "usageTool": + settings.tools.usageTool = enabled; + break; + case "allUsageTool": + settings.tools.allUsageTool = enabled; + break; + } + return settings; +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/settings/ui.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/settings/ui.ts new file mode 100644 index 0000000..6eea461 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/settings/ui.ts @@ -0,0 +1,450 @@ +/** + * Settings UI for sub-core + */ + +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { DynamicBorder, getSettingsListTheme } from "@mariozechner/pi-coding-agent"; +import { Container, Input, type SelectItem, SelectList, Spacer, Text } from "@mariozechner/pi-tui"; +import { SettingsList, type SettingItem, CUSTOM_OPTION } from "../ui/settings-list.js"; +import type { ProviderName } from "../types.js"; +import type { Settings } from "../settings-types.js"; +import { getDefaultSettings } from "../settings-types.js"; +import { getSettings, saveSettings, resetSettings } from "../settings.js"; +import { PROVIDER_DISPLAY_NAMES } from "../providers/metadata.js"; +import { buildProviderSettingsItems, applyProviderSettingsChange } from "../providers/settings.js"; +import { buildRefreshItems, applyRefreshChange } from "./behavior.js"; +import { buildToolItems, applyToolChange } from "./tools.js"; +import { buildMainMenuItems, buildProviderListItems, buildProviderOrderItems, type TooltipSelectItem } from "./menu.js"; + +/** + * Settings category + */ +type ProviderCategory = `provider-${ProviderName}`; + +type SettingsCategory = + | "main" + | "providers" + | ProviderCategory + | "behavior" + | "status-refresh" + | "tools" + | "provider-order"; + +/** + * Extract provider name from category + */ +function getProviderFromCategory(category: SettingsCategory): ProviderName | null { + const match = category.match(/^provider-(\w+)$/); + if (match && match[1] !== "order") { + return match[1] as ProviderName; + } + return null; +} + +/** + * Show the settings UI + */ +export async function showSettingsUI( + ctx: ExtensionContext, + onSettingsChange?: (settings: Settings) => void | Promise +): Promise { + let settings = getSettings(); + let currentCategory: SettingsCategory = "main"; + let providerOrderSelectedIndex = 0; + let providerOrderReordering = false; + let suppressProviderOrderChange = false; + + return new Promise((resolve) => { + ctx.ui.custom((tui, theme, _kb, done) => { + let container = new Container(); + let activeList: SelectList | SettingsList | null = null; + const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)); + + const buildInputSubmenu = ( + label: string, + parseValue: (value: string) => string | null, + formatInitial?: (value: string) => string, + ) => { + return (currentValue: string, done: (selectedValue?: string) => void) => { + const input = new Input(); + input.focused = true; + input.setValue(formatInitial ? formatInitial("") : ""); + input.onSubmit = (value) => { + const parsed = parseValue(value); + if (!parsed) return; + done(parsed); + }; + input.onEscape = () => { + done(); + }; + + const inputContainer = new Container(); + inputContainer.addChild(new Text(theme.fg("muted", label), 1, 0)); + inputContainer.addChild(new Spacer(1)); + inputContainer.addChild(input); + + return { + render: (width: number) => inputContainer.render(width), + invalidate: () => inputContainer.invalidate(), + handleInput: (data: string) => input.handleInput(data), + }; + }; + }; + + const parseRefreshInterval = (raw: string): string | null => { + const trimmed = raw.trim().toLowerCase(); + if (!trimmed) { + ctx.ui.notify("Enter a value", "warning"); + return null; + } + if (trimmed === "off") return "off"; + const cleaned = trimmed.replace(/s$/, ""); + const parsed = Number.parseInt(cleaned, 10); + if (Number.isNaN(parsed)) { + ctx.ui.notify("Enter seconds", "warning"); + return null; + } + const clamped = parsed <= 0 ? 0 : clamp(parsed, 5, 3600); + return clamped === 0 ? "off" : `${clamped}s`; + }; + + const parseMinRefreshInterval = (raw: string): string | null => { + const trimmed = raw.trim().toLowerCase(); + if (!trimmed) { + ctx.ui.notify("Enter a value", "warning"); + return null; + } + if (trimmed === "off") return "off"; + const cleaned = trimmed.replace(/s$/, ""); + const parsed = Number.parseInt(cleaned, 10); + if (Number.isNaN(parsed)) { + ctx.ui.notify("Enter seconds", "warning"); + return null; + } + const clamped = parsed <= 0 ? 0 : clamp(parsed, 5, 3600); + return clamped === 0 ? "off" : `${clamped}s`; + }; + + const parseCurrencySymbol = (raw: string): string | null => { + const trimmed = raw.trim(); + if (!trimmed) { + ctx.ui.notify("Enter a symbol or 'none'", "warning"); + return null; + } + if (trimmed.toLowerCase() === "none") return "none"; + return trimmed; + }; + + function rebuild(): void { + container = new Container(); + let tooltipText: Text | null = null; + + const attachTooltip = (items: TooltipSelectItem[], selectList: SelectList): void => { + if (!items.some((item) => item.tooltip)) return; + const tooltipComponent = new Text("", 1, 0); + const setTooltip = (item?: TooltipSelectItem | null) => { + const tooltip = item?.tooltip?.trim(); + tooltipComponent.setText(tooltip ? theme.fg("dim", tooltip) : ""); + }; + setTooltip(selectList.getSelectedItem() as TooltipSelectItem | null); + const existingHandler = selectList.onSelectionChange; + selectList.onSelectionChange = (item) => { + if (existingHandler) existingHandler(item); + setTooltip(item as TooltipSelectItem); + tui.requestRender(); + }; + tooltipText = tooltipComponent; + }; + + // Top border + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + // Title + const titles: Record = { + main: "sub-core Settings", + providers: "Provider Settings", + behavior: "Usage Refresh Settings", + "status-refresh": "Status Refresh Settings", + tools: "Tool Settings", + "provider-order": "Provider Order", + }; + const providerCategory = getProviderFromCategory(currentCategory); + const title = providerCategory + ? `${PROVIDER_DISPLAY_NAMES[providerCategory]} Settings` + : titles[currentCategory] ?? "sub-core Settings"; + container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0)); + container.addChild(new Spacer(1)); + + if (currentCategory === "main") { + const items = buildMainMenuItems(settings); + const selectList = new SelectList(items, Math.min(items.length, 10), { + selectedPrefix: (t: string) => theme.fg("accent", t), + selectedText: (t: string) => theme.fg("accent", t), + description: (t: string) => theme.fg("muted", t), + scrollInfo: (t: string) => theme.fg("dim", t), + noMatch: (t: string) => theme.fg("warning", t), + }); + attachTooltip(items, selectList); + selectList.onSelect = (item) => { + if (item.value === "reset") { + settings = resetSettings(); + if (onSettingsChange) void onSettingsChange(settings); + ctx.ui.notify("Settings reset to defaults", "info"); + rebuild(); + tui.requestRender(); + } else { + currentCategory = item.value as SettingsCategory; + rebuild(); + tui.requestRender(); + } + }; + selectList.onCancel = () => { + saveSettings(settings); + done(settings); + }; + activeList = selectList; + container.addChild(selectList); + } else if (currentCategory === "providers") { + const items = buildProviderListItems(settings); + const selectList = new SelectList(items, Math.min(items.length, 10), { + selectedPrefix: (t: string) => theme.fg("accent", t), + selectedText: (t: string) => theme.fg("accent", t), + description: (t: string) => theme.fg("muted", t), + scrollInfo: (t: string) => theme.fg("dim", t), + noMatch: (t: string) => theme.fg("warning", t), + }); + attachTooltip(items, selectList); + selectList.onSelect = (item) => { + currentCategory = item.value as SettingsCategory; + rebuild(); + tui.requestRender(); + }; + selectList.onCancel = () => { + currentCategory = "main"; + rebuild(); + tui.requestRender(); + }; + activeList = selectList; + container.addChild(selectList); + } else if (currentCategory === "provider-order") { + const items = buildProviderOrderItems(settings); + const isReordering = providerOrderReordering; + const selectList = new SelectList(items, Math.min(items.length, 10), { + selectedPrefix: (t: string) => isReordering ? theme.fg("warning", t) : theme.fg("accent", t), + selectedText: (t: string) => isReordering ? theme.fg("warning", t) : theme.fg("accent", t), + description: (t: string) => theme.fg("muted", t), + scrollInfo: (t: string) => theme.fg("dim", t), + noMatch: (t: string) => theme.fg("warning", t), + }); + + if (items.length > 0) { + suppressProviderOrderChange = true; + providerOrderSelectedIndex = Math.min(providerOrderSelectedIndex, items.length - 1); + selectList.setSelectedIndex(providerOrderSelectedIndex); + suppressProviderOrderChange = false; + } + + selectList.onSelectionChange = (item) => { + if (suppressProviderOrderChange) return; + + const newIndex = items.findIndex((listItem) => listItem.value === item.value); + if (newIndex === -1) return; + + if (!providerOrderReordering) { + providerOrderSelectedIndex = newIndex; + return; + } + + const activeProviders = settings.providerOrder.filter((provider) => { + const enabled = settings.providers[provider].enabled; + return enabled !== "off" && enabled !== false; + }); + const oldIndex = providerOrderSelectedIndex; + if (newIndex === oldIndex) return; + if (oldIndex < 0 || oldIndex >= activeProviders.length) return; + + const provider = activeProviders[oldIndex]; + const updatedActive = [...activeProviders]; + updatedActive.splice(oldIndex, 1); + updatedActive.splice(newIndex, 0, provider); + + let activeIndex = 0; + settings.providerOrder = settings.providerOrder.map((existing) => { + const enabled = settings.providers[existing].enabled; + if (enabled === "off" || enabled === false) return existing; + const next = updatedActive[activeIndex]; + activeIndex += 1; + return next; + }); + + providerOrderSelectedIndex = newIndex; + saveSettings(settings); + if (onSettingsChange) void onSettingsChange(settings); + rebuild(); + tui.requestRender(); + }; + + attachTooltip(items, selectList); + + selectList.onSelect = () => { + if (items.length === 0) return; + providerOrderReordering = !providerOrderReordering; + rebuild(); + tui.requestRender(); + }; + + selectList.onCancel = () => { + if (providerOrderReordering) { + providerOrderReordering = false; + rebuild(); + tui.requestRender(); + return; + } + currentCategory = "main"; + rebuild(); + tui.requestRender(); + }; + + activeList = selectList; + container.addChild(selectList); + } else { + let items: SettingItem[]; + let handleChange: (id: string, value: string) => void; + let backCategory: SettingsCategory = "main"; + + const provider = getProviderFromCategory(currentCategory); + if (provider) { + items = buildProviderSettingsItems(settings, provider); + const customHandlers: Record> = {}; + if (provider === "anthropic") { + customHandlers.extraUsageCurrencySymbol = buildInputSubmenu( + "Extra Usage Currency Symbol", + parseCurrencySymbol, + undefined, + ); + } + for (const item of items) { + if (item.values?.includes(CUSTOM_OPTION) && customHandlers[item.id]) { + item.submenu = customHandlers[item.id]; + } + } + handleChange = (id, value) => { + settings = applyProviderSettingsChange(settings, provider, id, value); + saveSettings(settings); + if (onSettingsChange) void onSettingsChange(settings); + }; + backCategory = "providers"; + } else if (currentCategory === "tools") { + items = buildToolItems(settings.tools); + handleChange = (id, value) => { + settings = applyToolChange(settings, id, value); + saveSettings(settings); + if (onSettingsChange) void onSettingsChange(settings); + }; + backCategory = "main"; + } else { + const refreshTarget = currentCategory === "status-refresh" ? settings.statusRefresh : settings.behavior; + items = buildRefreshItems(refreshTarget); + const customHandlers: Record> = { + refreshInterval: buildInputSubmenu("Auto-refresh Interval (seconds)", parseRefreshInterval), + minRefreshInterval: buildInputSubmenu("Minimum Refresh Interval (seconds)", parseMinRefreshInterval), + }; + for (const item of items) { + if (item.values?.includes(CUSTOM_OPTION) && customHandlers[item.id]) { + item.submenu = customHandlers[item.id]; + } + } + handleChange = (id, value) => { + applyRefreshChange(refreshTarget, id, value); + saveSettings(settings); + if (onSettingsChange) void onSettingsChange(settings); + }; + backCategory = "main"; + } + + const settingsHintText = "↓ navigate • ←/→ change • Enter/Space edit custom • Esc to cancel"; + const customTheme = { + ...getSettingsListTheme(), + hint: (text: string) => { + if (text.includes("Enter/Space")) { + return theme.fg("dim", settingsHintText); + } + return theme.fg("dim", text); + }, + }; + const settingsList = new SettingsList( + items, + Math.min(items.length + 2, 15), + customTheme, + handleChange, + () => { + currentCategory = backCategory; + rebuild(); + tui.requestRender(); + } + ); + activeList = settingsList; + container.addChild(settingsList); + } + + const usesSettingsList = + currentCategory === "behavior" || + currentCategory === "status-refresh" || + currentCategory === "tools" || + getProviderFromCategory(currentCategory) !== null; + if (!usesSettingsList) { + let helpText: string; + if (currentCategory === "main" || currentCategory === "providers") { + helpText = "↑↓ navigate • Enter/Space select • Esc back"; + } else if (currentCategory === "provider-order") { + helpText = providerOrderReordering + ? "↑↓ move provider • Esc back" + : "↑↓ navigate • Enter/Space select • Esc back"; + } else { + helpText = "↑↓ navigate • Enter/Space to change • Esc to cancel"; + } + if (tooltipText) { + container.addChild(new Spacer(1)); + container.addChild(tooltipText); + } + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("dim", helpText), 1, 0)); + } + + // Bottom border + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + } + + + rebuild(); + + return { + render(width: number) { + return container.render(width); + }, + invalidate() { + container.invalidate(); + }, + handleInput(data: string) { + if (data === " ") { + if (currentCategory === "provider-order") { + providerOrderReordering = !providerOrderReordering; + rebuild(); + tui.requestRender(); + return; + } + if (activeList && "handleInput" in activeList && activeList.handleInput) { + activeList.handleInput("\r"); + } + tui.requestRender(); + return; + } + if (activeList && "handleInput" in activeList && activeList.handleInput) { + activeList.handleInput(data); + } + tui.requestRender(); + }, + }; + }).then(resolve); + }); +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/status.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/status.ts new file mode 100644 index 0000000..d59a7e4 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/status.ts @@ -0,0 +1,245 @@ +/** + * Status polling for providers + */ + +import type { Dependencies, ProviderName, ProviderStatus, StatusIndicator } from "./types.js"; +import type { ProviderStatusConfig } from "./providers/metadata.js"; +import { GOOGLE_STATUS_URL, GEMINI_PRODUCT_ID, API_TIMEOUT_MS } from "./config.js"; +import { PROVIDER_METADATA } from "./providers/metadata.js"; +import { createTimeoutController } from "./utils.js"; + +type StatusPageStatusConfig = Extract; + +interface StatusPageSummary { + status?: { + indicator?: string; + description?: string; + }; + components?: Array<{ + id?: string; + name?: string; + status?: string; + }>; +} + +interface StatusPageStatus { + indicator?: string; + description?: string; +} + +interface StatusPageResponse { + status?: StatusPageStatus; +} + +function toSummaryUrl(url: string): string { + if (url.endsWith("/summary.json")) return url; + if (url.endsWith("/status.json")) { + return `${url.slice(0, -"/status.json".length)}/summary.json`; + } + if (!url.endsWith("/")) return `${url}/summary.json`; + return `${url}summary.json`; +} + +function isComponentMatch(component: { id?: string; name?: string }, config?: StatusPageStatusConfig): boolean { + if (!config?.component) return false; + + if (config.component.id && component.id) { + return component.id === config.component.id; + } + + if (config.component.name && component.name) { + return component.name.trim().toLowerCase() === config.component.name.trim().toLowerCase(); + } + + return false; +} + +function mapStatusIndicator(indicator?: string): StatusIndicator { + switch (indicator) { + case "none": + return "none"; + case "minor": + return "minor"; + case "major": + return "major"; + case "critical": + return "critical"; + case "maintenance": + return "maintenance"; + default: + return "unknown"; + } +} + +function mapComponentStatus(indicator?: string): StatusIndicator { + switch ((indicator || "").toLowerCase()) { + case "operational": + return "none"; + case "under_maintenance": + return "maintenance"; + case "degraded_performance": + return "minor"; + case "partial_outage": + return "major"; + case "major_outage": + return "critical"; + default: + return "unknown"; + } +} + +function formatComponentLabel(rawStatus?: string): string { + switch ((rawStatus || "").toLowerCase()) { + case "operational": + return "Operational"; + case "under_maintenance": + return "Under maintenance"; + case "degraded_performance": + return "Degraded performance"; + case "partial_outage": + return "Partial outage"; + case "major_outage": + return "Major outage"; + default: + return rawStatus ? rawStatus.replace(/_/g, " ") : "Unknown"; + } +} + +/** + * Fetch status from a standard statuspage.io API + */ +async function fetchStatuspageStatus( + url: string, + deps: Dependencies, + config?: StatusPageStatusConfig +): Promise { + const { controller, clear } = createTimeoutController(API_TIMEOUT_MS); + + try { + const fetchUrl = config?.component ? toSummaryUrl(url) : url; + const res = await deps.fetch(fetchUrl, { signal: controller.signal }); + clear(); + + if (!res.ok) { + return { indicator: "unknown" }; + } + + if (!config?.component) { + const data = (await res.json()) as StatusPageResponse; + const indicator = mapStatusIndicator(data.status?.indicator); + return { indicator, description: data.status?.description }; + } + + const data = (await res.json()) as StatusPageSummary; + const summaryIndicator = mapStatusIndicator(data.status?.indicator); + const component = (data.components ?? []).find((entry) => isComponentMatch(entry, config)); + if (component) { + const componentIndicator = mapComponentStatus(component.status); + const componentDescription = + componentIndicator === "none" + ? undefined + : `${component.name ?? "Component"}: ${formatComponentLabel(component.status)}`; + return { + indicator: componentIndicator, + description: componentDescription, + }; + } + + return { + indicator: summaryIndicator, + description: data.status?.description, + }; + } catch { + clear(); + return { indicator: "unknown" }; + } +} + +/** + * Fetch Gemini status from Google Workspace status API + */ +async function fetchGeminiStatus(deps: Dependencies): Promise { + const { controller, clear } = createTimeoutController(API_TIMEOUT_MS); + + try { + const res = await deps.fetch(GOOGLE_STATUS_URL, { signal: controller.signal }); + clear(); + + if (!res.ok) return { indicator: "unknown" }; + + const incidents = (await res.json()) as Array<{ + end?: string; + currently_affected_products?: Array<{ id: string }>; + affected_products?: Array<{ id: string }>; + most_recent_update?: { status?: string }; + status_impact?: string; + external_desc?: string; + }>; + + const activeIncidents = incidents.filter((inc) => { + if (inc.end) return false; + const affected = inc.currently_affected_products || inc.affected_products || []; + return affected.some((p) => p.id === GEMINI_PRODUCT_ID); + }); + + if (activeIncidents.length === 0) { + return { indicator: "none" }; + } + + let worstIndicator: StatusIndicator = "minor"; + let description: string | undefined; + + for (const inc of activeIncidents) { + const status = inc.most_recent_update?.status || inc.status_impact; + if (status === "SERVICE_OUTAGE") { + worstIndicator = "critical"; + description = inc.external_desc; + } else if (status === "SERVICE_DISRUPTION" && worstIndicator !== "critical") { + worstIndicator = "major"; + description = inc.external_desc; + } + } + + return { indicator: worstIndicator, description }; + } catch { + clear(); + return { indicator: "unknown" }; + } +} + +/** + * Fetch status for a provider + */ +export async function fetchProviderStatus(provider: ProviderName, deps: Dependencies): Promise { + const statusConfig = PROVIDER_METADATA[provider]?.status; + if (!statusConfig) { + return { indicator: "none" }; + } + + if (statusConfig.type === "google-workspace") { + return fetchGeminiStatus(deps); + } + + return fetchStatuspageStatus(statusConfig.url, deps, statusConfig); +} + +/** + * Get emoji for a status indicator + */ +export function getStatusEmoji(status?: ProviderStatus): string { + if (!status) return ""; + switch (status.indicator) { + case "none": + return "✅"; + case "minor": + return "⚠️"; + case "major": + return "🟠"; + case "critical": + return "🔴"; + case "maintenance": + return "🔧"; + default: + return ""; + } +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/storage.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/storage.ts new file mode 100644 index 0000000..f26fb07 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/storage.ts @@ -0,0 +1,61 @@ +/** + * Storage abstraction for settings and cache persistence. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; + +export interface StorageAdapter { + readFile(path: string): string | undefined; + writeFile(path: string, contents: string): void; + writeFileExclusive(path: string, contents: string): boolean; + exists(path: string): boolean; + removeFile(path: string): void; + ensureDir(path: string): void; +} + +export function createFsStorage(): StorageAdapter { + return { + readFile(filePath: string): string | undefined { + try { + return fs.readFileSync(filePath, "utf-8"); + } catch { + return undefined; + } + }, + writeFile(filePath: string, contents: string): void { + fs.writeFileSync(filePath, contents, "utf-8"); + }, + writeFileExclusive(filePath: string, contents: string): boolean { + try { + fs.writeFileSync(filePath, contents, { flag: "wx" }); + return true; + } catch { + return false; + } + }, + exists(filePath: string): boolean { + return fs.existsSync(filePath); + }, + removeFile(filePath: string): void { + try { + fs.unlinkSync(filePath); + } catch { + // Ignore remove errors + } + }, + ensureDir(dirPath: string): void { + fs.mkdirSync(path.resolve(dirPath), { recursive: true }); + }, + }; +} + +let activeStorage: StorageAdapter = createFsStorage(); + +export function getStorage(): StorageAdapter { + return activeStorage; +} + +export function setStorage(storage: StorageAdapter): void { + activeStorage = storage; +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/storage/lock.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/storage/lock.ts new file mode 100644 index 0000000..e9c965a --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/storage/lock.ts @@ -0,0 +1,60 @@ +/** + * File lock helpers for storage-backed locks. + */ + +import { getStorage } from "../storage.js"; + +export function tryAcquireFileLock(lockPath: string, staleAfterMs: number): boolean { + const storage = getStorage(); + try { + if (storage.writeFileExclusive(lockPath, String(Date.now()))) { + return true; + } + } catch { + // ignore + } + + try { + if (storage.exists(lockPath)) { + const lockContent = storage.readFile(lockPath) ?? ""; + const lockTime = parseInt(lockContent, 10); + if (Date.now() - lockTime > staleAfterMs) { + storage.writeFile(lockPath, String(Date.now())); + return true; + } + } + } catch { + // Ignore, lock is held by another process + } + + return false; +} + +export function releaseFileLock(lockPath: string): void { + const storage = getStorage(); + try { + if (storage.exists(lockPath)) { + storage.removeFile(lockPath); + } + } catch { + // Ignore + } +} + +export async function waitForLockRelease( + lockPath: string, + maxWaitMs: number, + pollMs: number = 100 +): Promise { + const storage = getStorage(); + const startTime = Date.now(); + + while (Date.now() - startTime < maxWaitMs) { + await new Promise((resolve) => setTimeout(resolve, pollMs)); + if (!storage.exists(lockPath)) { + return true; + } + } + + return false; +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/types.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/types.ts new file mode 100644 index 0000000..8fe6505 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/types.ts @@ -0,0 +1,33 @@ +/** + * Core types for the sub-bar extension + */ + +import type { ExecFileSyncOptionsWithStringEncoding } from "node:child_process"; + +export type { + ProviderName, + StatusIndicator, + ProviderStatus, + RateWindow, + UsageSnapshot, + UsageError, + UsageErrorCode, + ProviderUsageEntry, + SubCoreState, + SubCoreEvents, +} from "../../src/shared.js"; + +export { PROVIDERS } from "../../src/shared.js"; + +/** + * Dependencies that can be injected for testing + */ +export interface Dependencies { + fetch: typeof globalThis.fetch; + readFile: (path: string) => string | undefined; + fileExists: (path: string) => boolean; + // Use static commands/args only (no user-controlled input). + execFileSync: (file: string, args: string[], options?: ExecFileSyncOptionsWithStringEncoding) => string; + homedir: () => string; + env: NodeJS.ProcessEnv; +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/ui/settings-list.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/ui/settings-list.ts new file mode 100644 index 0000000..49c1ffb --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/ui/settings-list.ts @@ -0,0 +1,290 @@ +import type { Component, SettingItem, SettingsListTheme } from "@mariozechner/pi-tui"; +import { + Input, + fuzzyFilter, + getEditorKeybindings, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, +} from "@mariozechner/pi-tui"; + +export interface SettingsListOptions { + enableSearch?: boolean; +} + +export const CUSTOM_OPTION = "__custom__"; +export const CUSTOM_LABEL = "custom"; + +export type { SettingItem, SettingsListTheme }; + +export class SettingsList implements Component { + private items: SettingItem[]; + private filteredItems: SettingItem[]; + private theme: SettingsListTheme; + private selectedIndex = 0; + private maxVisible: number; + private onChange: (id: string, newValue: string) => void; + private onCancel: () => void; + private searchInput?: Input; + private searchEnabled: boolean; + private submenuComponent: Component | null = null; + private submenuItemIndex: number | null = null; + + constructor( + items: SettingItem[], + maxVisible: number, + theme: SettingsListTheme, + onChange: (id: string, newValue: string) => void, + onCancel: () => void, + options: SettingsListOptions = {}, + ) { + this.items = items; + this.filteredItems = items; + this.maxVisible = maxVisible; + this.theme = theme; + this.onChange = onChange; + this.onCancel = onCancel; + this.searchEnabled = options.enableSearch ?? false; + + if (this.searchEnabled) { + this.searchInput = new Input(); + } + } + + /** Update an item's currentValue */ + updateValue(id: string, newValue: string): void { + const item = this.items.find((i) => i.id === id); + if (item) { + item.currentValue = newValue; + } + } + + invalidate(): void { + this.submenuComponent?.invalidate?.(); + } + + render(width: number): string[] { + // If submenu is active, render it instead + if (this.submenuComponent) { + return this.submenuComponent.render(width); + } + return this.renderMainList(width); + } + + private renderMainList(width: number): string[] { + const lines: string[] = []; + if (this.searchEnabled && this.searchInput) { + lines.push(...this.searchInput.render(width)); + lines.push(""); + } + + if (this.items.length === 0) { + lines.push(this.theme.hint(" No settings available")); + if (this.searchEnabled) { + this.addHintLine(lines); + } + return lines; + } + + const displayItems = this.searchEnabled ? this.filteredItems : this.items; + if (displayItems.length === 0) { + lines.push(this.theme.hint(" No matching settings")); + this.addHintLine(lines); + return lines; + } + + // Calculate visible range with scrolling + const startIndex = Math.max( + 0, + Math.min( + this.selectedIndex - Math.floor(this.maxVisible / 2), + displayItems.length - this.maxVisible, + ), + ); + const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length); + + // Calculate max label width for alignment + const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label)))); + + // Render visible items + for (let i = startIndex; i < endIndex; i++) { + const item = displayItems[i]; + if (!item) continue; + const isSelected = i === this.selectedIndex; + const prefix = isSelected ? this.theme.cursor : " "; + const prefixWidth = visibleWidth(prefix); + + // Pad label to align values + const labelPadded = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label))); + const labelText = this.theme.label(labelPadded, isSelected); + + // Calculate space for value + const separator = " "; + const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator); + const valueMaxWidth = Math.max(1, width - usedWidth - 2); + const optionLines = isSelected && item.values && item.values.length > 0 + ? wrapTextWithAnsi(this.formatOptionsInline(item, item.values), valueMaxWidth) + : null; + const valueText = optionLines + ? optionLines[0] ?? "" + : this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, ""), isSelected); + const line = prefix + labelText + separator + valueText; + lines.push(truncateToWidth(line, width, "")); + if (optionLines && optionLines.length > 1) { + const indent = " ".repeat(prefixWidth + maxLabelWidth + visibleWidth(separator)); + for (const continuation of optionLines.slice(1)) { + lines.push(truncateToWidth(indent + continuation, width, "")); + } + } + } + + // Add scroll indicator if needed + if (startIndex > 0 || endIndex < displayItems.length) { + const scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`; + lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, ""))); + } + + // Add description for selected item + const selectedItem = displayItems[this.selectedIndex]; + if (selectedItem?.description) { + lines.push(""); + const wrapWidth = Math.max(1, width - 4); + const wrappedDesc = wrapTextWithAnsi(selectedItem.description, wrapWidth); + for (const line of wrappedDesc) { + const prefixed = ` ${line}`; + lines.push(this.theme.description(truncateToWidth(prefixed, width, ""))); + } + } + + + // Add hint + this.addHintLine(lines); + return lines; + } + + handleInput(data: string): void { + // If submenu is active, delegate all input to it + // The submenu's onCancel (triggered by escape) will call done() which closes it + if (this.submenuComponent) { + this.submenuComponent.handleInput?.(data); + return; + } + + const kb = getEditorKeybindings(); + const displayItems = this.searchEnabled ? this.filteredItems : this.items; + + if (kb.matches(data, "selectUp")) { + if (displayItems.length === 0) return; + this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1; + } else if (kb.matches(data, "selectDown")) { + if (displayItems.length === 0) return; + this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1; + } else if (kb.matches(data, "cursorLeft")) { + this.stepValue(-1); + } else if (kb.matches(data, "cursorRight")) { + this.stepValue(1); + } else if (kb.matches(data, "selectConfirm") || data === " ") { + this.activateItem(); + } else if (kb.matches(data, "selectCancel")) { + this.onCancel(); + } else if (this.searchEnabled && this.searchInput) { + const sanitized = data.replace(/ /g, ""); + if (!sanitized) { + return; + } + this.searchInput.handleInput(sanitized); + this.applyFilter(this.searchInput.getValue()); + } + } + + private stepValue(direction: -1 | 1): void { + const displayItems = this.searchEnabled ? this.filteredItems : this.items; + const item = displayItems[this.selectedIndex]; + if (!item || !item.values || item.values.length === 0) return; + const values = item.values; + let currentIndex = values.indexOf(item.currentValue); + if (currentIndex === -1) { + currentIndex = direction > 0 ? 0 : values.length - 1; + } + const nextIndex = (currentIndex + direction + values.length) % values.length; + const newValue = values[nextIndex]; + if (newValue === CUSTOM_OPTION) { + item.currentValue = newValue; + this.onChange(item.id, newValue); + return; + } + item.currentValue = newValue; + this.onChange(item.id, newValue); + } + + private activateItem(): void { + const item = this.searchEnabled ? this.filteredItems[this.selectedIndex] : this.items[this.selectedIndex]; + if (!item) return; + + const hasCustom = Boolean(item.values && item.values.includes(CUSTOM_OPTION)); + const currentIsCustom = hasCustom && item.values && !item.values.includes(item.currentValue); + + if (item.submenu && hasCustom) { + if (currentIsCustom || item.currentValue === CUSTOM_OPTION) { + this.openSubmenu(item); + } + return; + } + + if (item.submenu) { + this.openSubmenu(item); + } + } + + private closeSubmenu(): void { + this.submenuComponent = null; + // Restore selection to the item that opened the submenu + if (this.submenuItemIndex !== null) { + this.selectedIndex = this.submenuItemIndex; + this.submenuItemIndex = null; + } + } + + private applyFilter(query: string): void { + this.filteredItems = fuzzyFilter(this.items, query, (item) => item.label); + this.selectedIndex = 0; + } + + private formatOptionsInline(item: SettingItem, values: string[]): string { + const separator = this.theme.description(" • "); + const hasCustom = values.includes(CUSTOM_OPTION); + const currentIsCustom = hasCustom && !values.includes(item.currentValue); + return values + .map((value) => { + const label = value === CUSTOM_OPTION + ? (currentIsCustom ? `${CUSTOM_LABEL} (${item.currentValue})` : CUSTOM_LABEL) + : value; + const selected = value === item.currentValue || (currentIsCustom && value === CUSTOM_OPTION); + return this.theme.value(label, selected); + }) + .join(separator); + } + + private openSubmenu(item: SettingItem): void { + if (!item.submenu) return; + this.submenuItemIndex = this.selectedIndex; + this.submenuComponent = item.submenu(item.currentValue, (selectedValue) => { + if (selectedValue !== undefined) { + item.currentValue = selectedValue; + this.onChange(item.id, selectedValue); + } + this.closeSubmenu(); + }); + } + + private addHintLine(lines: string[]): void { + lines.push(""); + lines.push( + this.theme.hint( + this.searchEnabled + ? " Type to search · ←/→ change · Enter/Space edit custom · Esc to cancel" + : " ←/→ change · Enter/Space edit custom · Esc to cancel", + ), + ); + } +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/usage/controller.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/usage/controller.ts new file mode 100644 index 0000000..38588ef --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/usage/controller.ts @@ -0,0 +1,250 @@ +/** + * Usage refresh and provider selection controller. + */ + +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; +import type { ProviderName, UsageSnapshot } from "../types.js"; +import type { Settings } from "../settings-types.js"; +import { detectProviderFromModel } from "../providers/detection.js"; +import { isExpectedMissingData } from "../errors.js"; +import { formatElapsedSince } from "../utils.js"; +import { fetchUsageForProvider, refreshStatusForProvider } from "./fetch.js"; +import type { Dependencies } from "../types.js"; +import { getCachedData, readCache } from "../cache.js"; +import { hasProviderCredentials } from "../providers/registry.js"; + +export interface UsageControllerState { + currentProvider?: ProviderName; + cachedUsage?: UsageSnapshot; + lastSuccessAt?: number; + providerCycleIndex: number; +} + +export interface UsageUpdate { + provider?: ProviderName; + usage?: UsageSnapshot; +} + +export type UsageUpdateHandler = (update: UsageUpdate) => void; + +export function createUsageController(deps: Dependencies) { + function isProviderAvailable( + settings: Settings, + provider: ProviderName, + options?: { skipCredentials?: boolean } + ): boolean { + const setting = settings.providers[provider]; + if (setting.enabled === "off" || setting.enabled === false) return false; + if (setting.enabled === "on" || setting.enabled === true) return true; + if (options?.skipCredentials) return true; + return hasProviderCredentials(provider, deps); + } + + function getEnabledProviders(settings: Settings): ProviderName[] { + return settings.providerOrder.filter((p) => isProviderAvailable(settings, p)); + } + + function resolveProvider( + ctx: ExtensionContext, + settings: Settings, + state: UsageControllerState, + options?: { skipCredentials?: boolean } + ): ProviderName | undefined { + const detected = detectProviderFromModel(ctx.model); + if (detected && isProviderAvailable(settings, detected, options)) { + return detected; + } + return undefined; + } + + function emitUpdate(state: UsageControllerState, onUpdate: UsageUpdateHandler): void { + onUpdate({ + provider: state.currentProvider, + usage: state.cachedUsage, + }); + } + + async function refresh( + ctx: ExtensionContext, + settings: Settings, + state: UsageControllerState, + onUpdate: UsageUpdateHandler, + options?: { force?: boolean; allowStaleCache?: boolean; forceStatus?: boolean; skipFetch?: boolean } + ): Promise { + const provider = resolveProvider(ctx, settings, state, { skipCredentials: options?.skipFetch }); + if (!provider) { + state.currentProvider = undefined; + state.cachedUsage = undefined; + emitUpdate(state, onUpdate); + return; + } + + const providerChanged = provider !== state.currentProvider; + state.currentProvider = provider; + if (providerChanged) { + state.cachedUsage = undefined; + } + + const cache = readCache(); + let cachedEntry = await getCachedData(provider, settings.behavior.refreshInterval * 1000, cache); + if (!cachedEntry && options?.allowStaleCache) { + cachedEntry = cache[provider] ?? null; + } + if (cachedEntry?.usage) { + state.cachedUsage = { + ...cachedEntry.usage, + status: cachedEntry.status, + lastSuccessAt: cachedEntry.fetchedAt, + }; + if (!cachedEntry.usage.error) { + state.lastSuccessAt = cachedEntry.fetchedAt; + } + } + emitUpdate(state, onUpdate); + + if (options?.skipFetch) { + return; + } + + const result = await fetchUsageForProvider(deps, settings, provider, options); + const error = result.usage?.error; + const fetchError = Boolean(error && !isExpectedMissingData(error)); + if (fetchError) { + let fallback = state.cachedUsage; + let fallbackFetchedAt = state.lastSuccessAt; + if (!fallback || fallback.windows.length === 0) { + const cachedEntry = cache[provider]; + const cachedUsage = cachedEntry?.usage ? { ...cachedEntry.usage, status: cachedEntry.status } : undefined; + fallback = cachedUsage && cachedUsage.windows.length > 0 ? cachedUsage : undefined; + if (cachedEntry?.fetchedAt) fallbackFetchedAt = cachedEntry.fetchedAt; + } + if (fallback && fallback.windows.length > 0) { + const lastSuccessAt = fallbackFetchedAt ?? state.lastSuccessAt; + const elapsed = lastSuccessAt ? formatElapsedSince(lastSuccessAt) : undefined; + const description = elapsed ? (elapsed === "just now" ? "just now" : `${elapsed} ago`) : "Fetch failed"; + state.cachedUsage = { + ...fallback, + lastSuccessAt, + error, + status: { indicator: "minor", description }, + }; + } else { + state.cachedUsage = result.usage ? { ...result.usage, status: result.status } : undefined; + } + } else { + const successAt = Date.now(); + state.cachedUsage = result.usage + ? { ...result.usage, status: result.status, lastSuccessAt: successAt } + : undefined; + if (result.usage && !result.usage.error) { + state.lastSuccessAt = successAt; + } + } + emitUpdate(state, onUpdate); + } + + async function refreshStatus( + ctx: ExtensionContext, + settings: Settings, + state: UsageControllerState, + onUpdate: UsageUpdateHandler, + options?: { force?: boolean; allowStaleCache?: boolean; skipFetch?: boolean } + ): Promise { + const provider = resolveProvider(ctx, settings, state, { skipCredentials: options?.skipFetch }); + if (!provider) { + state.currentProvider = undefined; + state.cachedUsage = undefined; + emitUpdate(state, onUpdate); + return; + } + + const providerChanged = provider !== state.currentProvider; + state.currentProvider = provider; + if (providerChanged) { + state.cachedUsage = undefined; + } + + const cache = readCache(); + let cachedEntry = await getCachedData(provider, settings.behavior.refreshInterval * 1000, cache); + if (!cachedEntry && options?.allowStaleCache) { + cachedEntry = cache[provider] ?? null; + } + if (cachedEntry?.usage) { + state.cachedUsage = { + ...cachedEntry.usage, + status: cachedEntry.status, + lastSuccessAt: cachedEntry.fetchedAt, + }; + if (!cachedEntry.usage.error) { + state.lastSuccessAt = cachedEntry.fetchedAt; + } + } + + if (options?.skipFetch) { + emitUpdate(state, onUpdate); + return; + } + + const status = await refreshStatusForProvider(deps, settings, provider, { force: options?.force }); + if (status && state.cachedUsage) { + state.cachedUsage = { ...state.cachedUsage, status }; + } + + emitUpdate(state, onUpdate); + } + + async function cycleProvider( + ctx: ExtensionContext, + settings: Settings, + state: UsageControllerState, + onUpdate: UsageUpdateHandler + ): Promise { + const enabledProviders = getEnabledProviders(settings); + if (enabledProviders.length === 0) { + state.currentProvider = undefined; + state.cachedUsage = undefined; + emitUpdate(state, onUpdate); + return; + } + + const currentIndex = state.currentProvider + ? enabledProviders.indexOf(state.currentProvider) + : -1; + if (currentIndex >= 0) { + state.providerCycleIndex = currentIndex; + } + + const total = enabledProviders.length; + for (let i = 0; i < total; i += 1) { + state.providerCycleIndex = (state.providerCycleIndex + 1) % total; + const nextProvider = enabledProviders[state.providerCycleIndex]; + const result = await fetchUsageForProvider(deps, settings, nextProvider); + if (!isUsageAvailable(result.usage)) { + continue; + } + state.currentProvider = nextProvider; + state.cachedUsage = result.usage ? { ...result.usage, status: result.status } : undefined; + emitUpdate(state, onUpdate); + return; + } + + state.currentProvider = undefined; + state.cachedUsage = undefined; + emitUpdate(state, onUpdate); + } + + function isUsageAvailable(usage: UsageSnapshot | undefined): usage is UsageSnapshot { + if (!usage) return false; + if (usage.windows.length > 0) return true; + if (!usage.error) return false; + return !isExpectedMissingData(usage.error); + } + + return { + getEnabledProviders, + resolveProvider, + refresh, + refreshStatus, + cycleProvider, + }; +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/usage/fetch.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/usage/fetch.ts new file mode 100644 index 0000000..ef34016 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/usage/fetch.ts @@ -0,0 +1,215 @@ +/** + * Usage fetching helpers with cache integration. + */ + +import type { Dependencies, ProviderName, ProviderStatus, UsageSnapshot } from "../types.js"; +import type { Settings } from "../settings-types.js"; +import type { ProviderUsageEntry } from "./types.js"; +import { createProvider } from "../providers/registry.js"; +import { fetchWithCache, getCachedData, readCache, updateCacheStatus, type Cache } from "../cache.js"; +import { fetchProviderStatusWithFallback, providerHasStatus } from "../providers/status.js"; +import { hasProviderCredentials } from "../providers/registry.js"; +import { isExpectedMissingData } from "../errors.js"; + +export function getCacheTtlMs(settings: Settings): number { + return settings.behavior.refreshInterval * 1000; +} + +export function getMinRefreshIntervalMs(settings: Settings): number { + return settings.behavior.minRefreshInterval * 1000; +} + +export function getStatusCacheTtlMs(settings: Settings): number { + return settings.statusRefresh.refreshInterval * 1000; +} + +export function getStatusMinRefreshIntervalMs(settings: Settings): number { + return settings.statusRefresh.minRefreshInterval * 1000; +} + +const PROVIDER_FETCH_CONCURRENCY = 3; + +async function mapWithConcurrency( + items: T[], + limit: number, + mapper: (item: T, index: number) => Promise +): Promise { + if (items.length === 0) return []; + const results = new Array(items.length); + let nextIndex = 0; + const workerCount = Math.min(limit, items.length); + const workers = Array.from({ length: workerCount }, async () => { + while (true) { + const currentIndex = nextIndex++; + if (currentIndex >= items.length) { + return; + } + results[currentIndex] = await mapper(items[currentIndex], currentIndex); + } + }); + await Promise.all(workers); + return results; +} + +function resolveStatusFetchedAt(entry?: { fetchedAt: number; statusFetchedAt?: number } | null): number | undefined { + if (!entry) return undefined; + return entry.statusFetchedAt ?? entry.fetchedAt; +} + +function isWithinMinInterval(fetchedAt: number | undefined, minIntervalMs: number): boolean { + if (!fetchedAt || minIntervalMs <= 0) return false; + return Date.now() - fetchedAt < minIntervalMs; +} + +function shouldRefreshStatus( + settings: Settings, + entry?: { fetchedAt: number; statusFetchedAt?: number } | null, + options?: { force?: boolean } +): boolean { + const fetchedAt = resolveStatusFetchedAt(entry); + const minIntervalMs = getStatusMinRefreshIntervalMs(settings); + if (isWithinMinInterval(fetchedAt, minIntervalMs)) return false; + if (options?.force) return true; + const ttlMs = getStatusCacheTtlMs(settings); + if (ttlMs <= 0) return true; + if (!fetchedAt) return true; + return Date.now() - fetchedAt >= ttlMs; +} + +export async function refreshStatusForProvider( + deps: Dependencies, + settings: Settings, + provider: ProviderName, + options?: { force?: boolean } +): Promise { + const enabledSetting = settings.providers[provider].enabled; + if (enabledSetting === "off" || enabledSetting === false) { + return undefined; + } + if (enabledSetting === "auto" && !hasProviderCredentials(provider, deps)) { + return undefined; + } + if (!settings.providers[provider].fetchStatus) { + return undefined; + } + + const cache = readCache(); + const entry = cache[provider]; + const providerInstance = createProvider(provider); + const shouldFetch = providerHasStatus(provider, providerInstance) && shouldRefreshStatus(settings, entry, options); + if (!shouldFetch) { + return entry?.status; + } + const status = await fetchProviderStatusWithFallback(provider, providerInstance, deps); + await updateCacheStatus(provider, status, { statusFetchedAt: Date.now() }); + return status; +} + +export async function fetchUsageForProvider( + deps: Dependencies, + settings: Settings, + provider: ProviderName, + options?: { force?: boolean; forceStatus?: boolean } +): Promise<{ usage?: UsageSnapshot; status?: ProviderStatus }> { + const enabledSetting = settings.providers[provider].enabled; + if (enabledSetting === "off" || enabledSetting === false) { + return {}; + } + if (enabledSetting === "auto" && !hasProviderCredentials(provider, deps)) { + return {}; + } + + const ttlMs = getCacheTtlMs(settings); + const cache = readCache(); + const cachedEntry = cache[provider]; + const cachedStatus = cachedEntry?.status; + const minIntervalMs = getMinRefreshIntervalMs(settings); + if (cachedEntry?.usage && isWithinMinInterval(cachedEntry.fetchedAt, minIntervalMs)) { + const usage = { ...cachedEntry.usage, status: cachedEntry.status } as UsageSnapshot; + return { usage, status: cachedEntry.status }; + } + const providerInstance = createProvider(provider); + const shouldFetchStatus = Boolean(options?.forceStatus) + && settings.providers[provider].fetchStatus + && providerHasStatus(provider, providerInstance); + + if (!options?.force) { + const cachedUsage = await getCachedData(provider, ttlMs, cache); + if (cachedUsage) { + let status = cachedUsage.status; + if (shouldFetchStatus) { + status = await refreshStatusForProvider(deps, settings, provider, { force: options?.forceStatus ?? options?.force }); + } + const usage = cachedUsage.usage ? { ...cachedUsage.usage, status } : undefined; + return { usage, status }; + } + } + + return fetchWithCache( + provider, + ttlMs, + async () => { + const usage = await providerInstance.fetchUsage(deps); + let status = cachedStatus; + let statusFetchedAt = resolveStatusFetchedAt(cachedEntry); + if (shouldFetchStatus) { + status = await fetchProviderStatusWithFallback(provider, providerInstance, deps); + statusFetchedAt = Date.now(); + } else if (!status) { + status = { indicator: "none" as const }; + } + + return { usage, status, statusFetchedAt }; + }, + options, + ); +} + +export async function getCachedUsageEntry( + provider: ProviderName, + settings: Settings, + cacheSnapshot?: Cache +): Promise { + const ttlMs = getCacheTtlMs(settings); + const cachedEntry = await getCachedData(provider, ttlMs, cacheSnapshot); + const usage = cachedEntry?.usage ? { ...cachedEntry.usage, status: cachedEntry.status } : undefined; + if (!usage || (usage.error && isExpectedMissingData(usage.error))) { + return undefined; + } + return { provider, usage }; +} + +export async function getCachedUsageEntries( + providers: ProviderName[], + settings: Settings +): Promise { + const cache = readCache(); + const entries: ProviderUsageEntry[] = []; + for (const provider of providers) { + const entry = await getCachedUsageEntry(provider, settings, cache); + if (entry) { + entries.push(entry); + } + } + return entries; +} + +export async function fetchUsageEntries( + deps: Dependencies, + settings: Settings, + providers: ProviderName[], + options?: { force?: boolean } +): Promise { + const concurrency = Math.max(1, Math.min(PROVIDER_FETCH_CONCURRENCY, providers.length)); + const results = await mapWithConcurrency(providers, concurrency, async (provider) => { + const result = await fetchUsageForProvider(deps, settings, provider, options); + const usage = result.usage + ? ({ ...result.usage, status: result.status } as UsageSnapshot) + : undefined; + if (!usage || (usage.error && isExpectedMissingData(usage.error))) { + return undefined; + } + return { provider, usage } as ProviderUsageEntry; + }); + return results.filter(Boolean) as ProviderUsageEntry[]; +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/usage/types.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/usage/types.ts new file mode 100644 index 0000000..48ee631 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/usage/types.ts @@ -0,0 +1,5 @@ +/** + * Usage data types shared across modules. + */ + +export type { ProviderUsageEntry } from "../../../src/shared.js"; diff --git a/pi/files/agent/extensions/sub-bar/sub-core/src/utils.ts b/pi/files/agent/extensions/sub-bar/sub-core/src/utils.ts new file mode 100644 index 0000000..67a7649 --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/src/utils.ts @@ -0,0 +1,122 @@ +/** + * Utility functions for the sub-bar extension + */ + +import type { Dependencies } from "./types.js"; +import { MODEL_MULTIPLIERS } from "./config.js"; + +// Only allow simple CLI names (no spaces/paths) to avoid unsafe command execution. +const SAFE_CLI_NAME = /^[a-zA-Z0-9._-]+$/; + +/** + * Format a reset date as a relative time string + */ +export function formatReset(date: Date): string { + const diffMs = date.getTime() - Date.now(); + if (diffMs < 0) return "now"; + + const diffMins = Math.floor(diffMs / 60000); + if (diffMins < 60) return `${diffMins}m`; + + const hours = Math.floor(diffMins / 60); + const mins = diffMins % 60; + if (hours < 24) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`; + + const days = Math.floor(hours / 24); + const remHours = hours % 24; + return remHours > 0 ? `${days}d${remHours}h` : `${days}d`; +} + +/** + * Format elapsed time since a timestamp (milliseconds) + */ +export function formatElapsedSince(timestamp: number): string { + const diffMs = Date.now() - timestamp; + if (diffMs < 60000) return "just now"; + + const diffMins = Math.floor(diffMs / 60000); + if (diffMins < 60) return `${diffMins}m`; + + const hours = Math.floor(diffMins / 60); + const mins = diffMins % 60; + if (hours < 24) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`; + + const days = Math.floor(hours / 24); + const remHours = hours % 24; + return remHours > 0 ? `${days}d${remHours}h` : `${days}d`; +} + +/** + * Strip ANSI escape codes from a string + */ +export function stripAnsi(text: string): string { + return text.replace(/\x1B\[[0-9;?]*[A-Za-z]|\x1B\].*?\x07/g, ""); +} + +/** + * Normalize a string into tokens for fuzzy matching + */ +export function normalizeTokens(value: string): string[] { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim() + .split(" ") + .filter(Boolean); +} + +// Pre-computed token entries for model multiplier matching +const MODEL_MULTIPLIER_TOKENS = Object.entries(MODEL_MULTIPLIERS).map(([label, multiplier]) => ({ + label, + multiplier, + tokens: normalizeTokens(label), +})); + +/** + * Get the request multiplier for a model ID + * Uses fuzzy matching against known model names + */ +export function getModelMultiplier(modelId: string | undefined): number | undefined { + if (!modelId) return undefined; + const modelTokens = normalizeTokens(modelId); + if (modelTokens.length === 0) return undefined; + + let bestMatch: { multiplier: number; tokenCount: number } | undefined; + for (const entry of MODEL_MULTIPLIER_TOKENS) { + const isMatch = entry.tokens.every((token) => modelTokens.includes(token)); + if (!isMatch) continue; + const tokenCount = entry.tokens.length; + if (!bestMatch || tokenCount > bestMatch.tokenCount) { + bestMatch = { multiplier: entry.multiplier, tokenCount }; + } + } + + return bestMatch?.multiplier; +} + +/** + * Check if a command exists in PATH + */ +export function whichSync(cmd: string, deps: Dependencies): string | null { + if (!SAFE_CLI_NAME.test(cmd)) { + return null; + } + + try { + return deps.execFileSync("which", [cmd], { encoding: "utf-8" }).trim(); + } catch { + return null; + } +} + +/** + * Create an abort controller with a timeout + */ +export function createTimeoutController(timeoutMs: number): { controller: AbortController; clear: () => void } { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + return { + controller, + clear: () => clearTimeout(timeoutId), + }; +} diff --git a/pi/files/agent/extensions/sub-bar/sub-core/tsconfig.json b/pi/files/agent/extensions/sub-bar/sub-core/tsconfig.json new file mode 100644 index 0000000..79cf05d --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/sub-core/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["index.ts", "src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/pi/files/agent/extensions/sub-bar/tsconfig.json b/pi/files/agent/extensions/sub-bar/tsconfig.json new file mode 100644 index 0000000..0b9334d --- /dev/null +++ b/pi/files/agent/extensions/sub-bar/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["index.ts", "shared.ts", "src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/pi/files/agent/skills/pi-skills/browser-tools/SKILL.md b/pi/files/agent/skills/pi-skills/browser-tools/SKILL.md index f05b1da..bd2eba0 100644 --- a/pi/files/agent/skills/pi-skills/browser-tools/SKILL.md +++ b/pi/files/agent/skills/pi-skills/browser-tools/SKILL.md @@ -5,7 +5,7 @@ description: Interactive browser automation via Chrome DevTools Protocol. Use wh # Browser Tools -Chrome DevTools Protocol tools for agent-assisted web automation. These tools connect to Chrome running on `:9222` with remote debugging enabled. +Chrome DevTools Protocol tools for agent-assisted web automation. These tools connect to a Chromium-based browser running on `:9222` with remote debugging enabled. Supports **Helium** (recommended on Linux), Chrome, Chromium, Brave, and Edge. ## Setup @@ -18,14 +18,20 @@ npm install where baseDir is usually ~/.pi/agent/skills/pi-skills/browser-tools/ -## Start Chrome +**Note:** On NixOS/Linux, Helium is recommended: +```bash +nix-env -iA nixpkgs.helium +# or via home-manager +``` + +## Start Browser ```bash {baseDir}/browser-start.js # Fresh profile -{baseDir}/browser-start.js --profile # Copy user's profile (cookies, logins) +{baseDir}/browser-start.js --profile # Copy your profile (cookies, logins) ``` -Launch Chrome with remote debugging on `:9222`. Use `--profile` to preserve user's authentication state. +Launches the first available browser (Helium → chromium → chrome → brave → edge) with remote debugging on `:9222`. Use `--profile` to preserve your authentication state. ## Navigate