plasmoids: añade commandoutput y codexbar al tracking
Ambos ya estaban puestos en un panel real pero no viajaban a la otra máquina -- detectado en la misma auditoría que quitó claudeusageswitcher y tt.launchpadPlasma (sin usar). Sin esto, una reinstalación desde arch-setup dejaría el panel sin estos dos widgets. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d434a3c635
commit
b98999cde9
@@ -0,0 +1,548 @@
|
||||
// Parsing and formatting helpers for the CodexBar plasmoid.
|
||||
// This file is imported both from QML (import "../code/parser.js" as Parser)
|
||||
// and from Node.js unit tests (tests/parser.test.mjs), so it must stay
|
||||
// engine-neutral: no QML pragmas, no Node-only APIs.
|
||||
|
||||
// Catalog captured from `codexbar config providers` (CodexBar CLI v0.42.1).
|
||||
var PROVIDER_CATALOG = [
|
||||
{ id: "codex", name: "Codex" },
|
||||
{ id: "openai", name: "OpenAI" },
|
||||
{ id: "azureopenai", name: "Azure OpenAI" },
|
||||
{ id: "claude", name: "Claude" },
|
||||
{ id: "cursor", name: "Cursor" },
|
||||
{ id: "opencode", name: "OpenCode" },
|
||||
{ id: "opencodego", name: "OpenCode Go" },
|
||||
{ id: "alibaba", name: "Alibaba" },
|
||||
{ id: "alibabatokenplan", name: "Alibaba Token Plan" },
|
||||
{ id: "factory", name: "Droid" },
|
||||
{ id: "gemini", name: "Gemini" },
|
||||
{ id: "antigravity", name: "Antigravity" },
|
||||
{ id: "copilot", name: "Copilot" },
|
||||
{ id: "devin", name: "Devin" },
|
||||
{ id: "zai", name: "z.ai" },
|
||||
{ id: "minimax", name: "MiniMax" },
|
||||
{ id: "manus", name: "Manus" },
|
||||
{ id: "kimi", name: "Kimi" },
|
||||
{ id: "kilo", name: "Kilo" },
|
||||
{ id: "kiro", name: "Kiro" },
|
||||
{ id: "vertexai", name: "Vertex AI" },
|
||||
{ id: "augment", name: "Augment" },
|
||||
{ id: "jetbrains", name: "JetBrains AI" },
|
||||
{ id: "kimik2", name: "Kimi K2 (unofficial)" },
|
||||
{ id: "moonshot", name: "Moonshot / Kimi API" },
|
||||
{ id: "amp", name: "Amp" },
|
||||
{ id: "t3chat", name: "T3 Chat" },
|
||||
{ id: "ollama", name: "Ollama" },
|
||||
{ id: "synthetic", name: "Synthetic" },
|
||||
{ id: "warp", name: "Warp" },
|
||||
{ id: "openrouter", name: "OpenRouter" },
|
||||
{ id: "elevenlabs", name: "ElevenLabs" },
|
||||
{ id: "windsurf", name: "Windsurf" },
|
||||
{ id: "zed", name: "Zed" },
|
||||
{ id: "perplexity", name: "Perplexity" },
|
||||
{ id: "mimo", name: "Xiaomi MiMo" },
|
||||
{ id: "doubao", name: "Doubao" },
|
||||
{ id: "sakana", name: "Sakana AI" },
|
||||
{ id: "abacus", name: "Abacus AI" },
|
||||
{ id: "mistral", name: "Mistral" },
|
||||
{ id: "deepseek", name: "DeepSeek" },
|
||||
{ id: "codebuff", name: "Codebuff" },
|
||||
{ id: "crof", name: "Crof" },
|
||||
{ id: "venice", name: "Venice" },
|
||||
{ id: "commandcode", name: "Command Code" },
|
||||
{ id: "qoder", name: "Qoder" },
|
||||
{ id: "stepfun", name: "StepFun" },
|
||||
{ id: "bedrock", name: "AWS Bedrock" },
|
||||
{ id: "grok", name: "Grok" },
|
||||
{ id: "groq", name: "Groq" },
|
||||
{ id: "llmproxy", name: "LLM Proxy" },
|
||||
{ id: "litellm", name: "LiteLLM" },
|
||||
{ id: "deepgram", name: "Deepgram" },
|
||||
{ id: "poe", name: "Poe" },
|
||||
{ id: "chutes", name: "Chutes" },
|
||||
{ id: "crossmodel", name: "CrossModel" },
|
||||
{ id: "clawrouter", name: "ClawRouter" },
|
||||
{ id: "wayfinder", name: "Wayfinder" },
|
||||
];
|
||||
|
||||
var SHORT_NAMES = {
|
||||
codex: "Cx",
|
||||
openai: "OA",
|
||||
azureopenai: "Az",
|
||||
claude: "Cl",
|
||||
cursor: "Cu",
|
||||
gemini: "Gm",
|
||||
copilot: "Cp",
|
||||
antigravity: "Ag",
|
||||
openrouter: "OR",
|
||||
minimax: "MM",
|
||||
deepseek: "DS",
|
||||
jetbrains: "JB",
|
||||
vertexai: "Vx",
|
||||
bedrock: "BR",
|
||||
litellm: "LL",
|
||||
llmproxy: "LP",
|
||||
groq: "Gq",
|
||||
grok: "Gk",
|
||||
};
|
||||
|
||||
function providerDisplayName(id) {
|
||||
for (var i = 0; i < PROVIDER_CATALOG.length; i++) {
|
||||
if (PROVIDER_CATALOG[i].id === id) {
|
||||
return PROVIDER_CATALOG[i].name;
|
||||
}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function shortName(id) {
|
||||
if (SHORT_NAMES[id]) {
|
||||
return SHORT_NAMES[id];
|
||||
}
|
||||
if (!id || id.length === 0) {
|
||||
return "?";
|
||||
}
|
||||
var base = id.slice(0, 2);
|
||||
return base.charAt(0).toUpperCase() + base.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
function windowLabel(windowMinutes) {
|
||||
if (windowMinutes === null || windowMinutes === undefined) {
|
||||
return "Usage";
|
||||
}
|
||||
if (windowMinutes === 10080) {
|
||||
return "Weekly";
|
||||
}
|
||||
if (windowMinutes === 43200) {
|
||||
return "Monthly";
|
||||
}
|
||||
if (windowMinutes === 1440) {
|
||||
return "Daily";
|
||||
}
|
||||
if (windowMinutes >= 180 && windowMinutes <= 360) {
|
||||
return "Session";
|
||||
}
|
||||
if (windowMinutes % 1440 === 0) {
|
||||
return (windowMinutes / 1440) + "d";
|
||||
}
|
||||
if (windowMinutes % 60 === 0) {
|
||||
return (windowMinutes / 60) + "h";
|
||||
}
|
||||
return windowMinutes + "m";
|
||||
}
|
||||
|
||||
function usageStage(usedPercent) {
|
||||
if (usedPercent >= 90) {
|
||||
return "crit";
|
||||
}
|
||||
if (usedPercent >= 70) {
|
||||
return "warn";
|
||||
}
|
||||
return "ok";
|
||||
}
|
||||
|
||||
function formatCountdown(resetsAtIso, nowMs) {
|
||||
if (!resetsAtIso) {
|
||||
return "";
|
||||
}
|
||||
var target = Date.parse(resetsAtIso);
|
||||
if (isNaN(target)) {
|
||||
return "";
|
||||
}
|
||||
var diffMinutes = Math.floor((target - nowMs) / 60000);
|
||||
if (diffMinutes <= 0) {
|
||||
return "now";
|
||||
}
|
||||
var days = Math.floor(diffMinutes / 1440);
|
||||
var hours = Math.floor((diffMinutes % 1440) / 60);
|
||||
var minutes = diffMinutes % 60;
|
||||
if (days > 0) {
|
||||
return days + "d " + hours + "h";
|
||||
}
|
||||
if (hours > 0) {
|
||||
return hours + "h " + minutes + "m";
|
||||
}
|
||||
return minutes + "m";
|
||||
}
|
||||
|
||||
function makeWindow(key, raw, pace, labelOverride, usageKnown) {
|
||||
var paceSummary = "";
|
||||
if (pace && pace[key] && pace[key].summary) {
|
||||
paceSummary = pace[key].summary;
|
||||
}
|
||||
var minutes = raw.windowMinutes === undefined ? null : raw.windowMinutes;
|
||||
return {
|
||||
key: key,
|
||||
label: labelOverride || windowLabel(minutes),
|
||||
windowMinutes: minutes,
|
||||
usedPercent: Math.round(raw.usedPercent || 0),
|
||||
resetsAt: raw.resetsAt || null,
|
||||
resetDescription: raw.resetDescription || "",
|
||||
paceSummary: paceSummary,
|
||||
usageKnown: usageKnown === undefined ? true : usageKnown,
|
||||
};
|
||||
}
|
||||
|
||||
function payloadToModel(payload) {
|
||||
var usage = payload.usage || null;
|
||||
var windows = [];
|
||||
if (usage) {
|
||||
var keys = ["primary", "secondary", "tertiary"];
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var raw = usage[keys[i]];
|
||||
if (!raw || raw.isSyntheticPlaceholder) {
|
||||
continue;
|
||||
}
|
||||
windows.push(makeWindow(keys[i], raw, payload.pace || null));
|
||||
}
|
||||
// Named model-specific lanes (e.g. Codex Spark, Code review).
|
||||
var extras = usage.extraRateWindows || [];
|
||||
for (var j = 0; j < extras.length; j++) {
|
||||
var extra = extras[j];
|
||||
if (!extra || !extra.window || extra.window.isSyntheticPlaceholder) {
|
||||
continue;
|
||||
}
|
||||
windows.push(makeWindow(
|
||||
"extra:" + (extra.id || j),
|
||||
extra.window,
|
||||
null,
|
||||
extra.title || extra.id || "Extra",
|
||||
extra.usageKnown === undefined ? true : extra.usageKnown));
|
||||
}
|
||||
}
|
||||
// OpenAI web dashboard exposes code review as a bare remaining percent.
|
||||
var dashboard = payload.openaiDashboard || null;
|
||||
if (dashboard && typeof dashboard.codeReviewRemainingPercent === "number") {
|
||||
var hasReviewLane = windows.some(function(w) {
|
||||
return w.label.toLowerCase().indexOf("code review") !== -1;
|
||||
});
|
||||
if (!hasReviewLane) {
|
||||
windows.push(makeWindow(
|
||||
"extra:code-review",
|
||||
{ usedPercent: 100 - dashboard.codeReviewRemainingPercent, windowMinutes: null, resetsAt: null },
|
||||
null,
|
||||
"Code review",
|
||||
true));
|
||||
}
|
||||
}
|
||||
var account = null;
|
||||
if (usage && usage.identity && usage.identity.accountEmail) {
|
||||
account = usage.identity.accountEmail;
|
||||
} else if (usage && usage.accountEmail) {
|
||||
account = usage.accountEmail;
|
||||
} else if (payload.account) {
|
||||
account = payload.account;
|
||||
}
|
||||
var plan = null;
|
||||
if (usage && usage.identity && usage.identity.loginMethod) {
|
||||
plan = usage.identity.loginMethod;
|
||||
} else if (usage && usage.loginMethod) {
|
||||
plan = usage.loginMethod;
|
||||
}
|
||||
var credits = null;
|
||||
if (payload.credits && typeof payload.credits.remaining === "number") {
|
||||
credits = payload.credits.remaining;
|
||||
}
|
||||
var status = null;
|
||||
if (payload.status && payload.status.indicator && payload.status.indicator !== "none") {
|
||||
status = {
|
||||
indicator: payload.status.indicator,
|
||||
description: payload.status.description || "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: payload.provider || "?",
|
||||
name: providerDisplayName(payload.provider || "?"),
|
||||
source: payload.source || "",
|
||||
version: payload.version || "",
|
||||
account: account,
|
||||
plan: plan,
|
||||
credits: credits,
|
||||
status: status,
|
||||
error: payload.error ? String(payload.error.message || "error") : null,
|
||||
windows: windows,
|
||||
updatedAt: usage && usage.updatedAt ? usage.updatedAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseUsageJson(text) {
|
||||
var parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (e) {
|
||||
return { ok: false, error: "Invalid JSON from codexbar: " + e.message, models: [] };
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
return { ok: false, error: "Unexpected codexbar payload (expected array)", models: [] };
|
||||
}
|
||||
var models = [];
|
||||
for (var i = 0; i < parsed.length; i++) {
|
||||
models.push(payloadToModel(parsed[i]));
|
||||
}
|
||||
return { ok: true, error: "", models: models };
|
||||
}
|
||||
|
||||
function worstUsedPercent(model) {
|
||||
var worst = -1;
|
||||
for (var i = 0; i < model.windows.length; i++) {
|
||||
if (model.windows[i].usedPercent > worst) {
|
||||
worst = model.windows[i].usedPercent;
|
||||
}
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
// Ring assignment for the radial gauge: outer ring shows the short
|
||||
// (session/daily) window, inner ring the long (weekly/monthly) window.
|
||||
function gaugeRings(model) {
|
||||
var outerIdx = -1;
|
||||
var innerIdx = -1;
|
||||
var firstKnownIdx = -1;
|
||||
for (var i = 0; i < model.windows.length; i++) {
|
||||
if (model.windows[i].usageKnown === false) {
|
||||
continue;
|
||||
}
|
||||
if (firstKnownIdx === -1) {
|
||||
firstKnownIdx = i;
|
||||
}
|
||||
var minutes = model.windows[i].windowMinutes;
|
||||
if (minutes !== null && minutes !== undefined && minutes <= 1440 && outerIdx === -1) {
|
||||
outerIdx = i;
|
||||
} else if (minutes !== null && minutes !== undefined && minutes >= 10080 && innerIdx === -1) {
|
||||
innerIdx = i;
|
||||
}
|
||||
}
|
||||
if (outerIdx === -1 && innerIdx === -1 && firstKnownIdx !== -1) {
|
||||
outerIdx = firstKnownIdx;
|
||||
}
|
||||
return { outerIdx: outerIdx, innerIdx: innerIdx };
|
||||
}
|
||||
|
||||
function gaugeCenterPercent(model) {
|
||||
var rings = gaugeRings(model);
|
||||
var idx = rings.outerIdx !== -1 ? rings.outerIdx : rings.innerIdx;
|
||||
if (idx === -1) {
|
||||
return -1;
|
||||
}
|
||||
return Math.max(0, Math.min(100, model.windows[idx].usedPercent));
|
||||
}
|
||||
|
||||
function sweepAngle(percent) {
|
||||
var clamped = Math.max(0, Math.min(100, percent));
|
||||
return clamped * 3.6;
|
||||
}
|
||||
|
||||
// Local token-cost scan payloads from `codexbar cost --format json`.
|
||||
function parseCostJson(text) {
|
||||
var parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (e) {
|
||||
return { ok: false, error: "Invalid cost JSON: " + e.message, byId: {} };
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
return { ok: false, error: "Unexpected cost payload (expected array)", byId: {} };
|
||||
}
|
||||
var byId = {};
|
||||
for (var i = 0; i < parsed.length; i++) {
|
||||
var p = parsed[i];
|
||||
if (!p || !p.provider) {
|
||||
continue;
|
||||
}
|
||||
var daily = [];
|
||||
var rawDaily = p.daily || [];
|
||||
for (var j = 0; j < rawDaily.length; j++) {
|
||||
daily.push({
|
||||
date: rawDaily[j].date || "",
|
||||
totalCost: Number(rawDaily[j].totalCost) || 0,
|
||||
totalTokens: Number(rawDaily[j].totalTokens) || 0,
|
||||
});
|
||||
}
|
||||
byId[p.provider] = {
|
||||
todayCostUSD: typeof p.sessionCostUSD === "number" ? p.sessionCostUSD : null,
|
||||
todayTokens: typeof p.sessionTokens === "number" ? p.sessionTokens : null,
|
||||
month30CostUSD: typeof p.last30DaysCostUSD === "number" ? p.last30DaysCostUSD : null,
|
||||
month30Tokens: typeof p.last30DaysTokens === "number" ? p.last30DaysTokens : null,
|
||||
daily: daily,
|
||||
};
|
||||
}
|
||||
return { ok: true, error: "", byId: byId };
|
||||
}
|
||||
|
||||
function humanTokens(value) {
|
||||
if (value === null || value === undefined || !isFinite(value)) {
|
||||
return "";
|
||||
}
|
||||
var n = Number(value);
|
||||
if (n < 1000) {
|
||||
return String(Math.round(n));
|
||||
}
|
||||
if (n < 1000000) {
|
||||
return (Math.round(n / 100) / 10) + "K";
|
||||
}
|
||||
if (n < 1000000000) {
|
||||
return (Math.round(n / 100000) / 10) + "M";
|
||||
}
|
||||
return (Math.round(n / 100000000) / 10) + "B";
|
||||
}
|
||||
|
||||
function formatMoney(value) {
|
||||
if (value === null || value === undefined || !isFinite(value)) {
|
||||
return "";
|
||||
}
|
||||
return "$" + Number(value).toFixed(2);
|
||||
}
|
||||
|
||||
// Zero-filled per-day cost series for the history chart, ending at todayIso.
|
||||
function chartSeries(daily, days, todayIso) {
|
||||
var byDate = {};
|
||||
for (var i = 0; i < (daily || []).length; i++) {
|
||||
byDate[daily[i].date] = daily[i];
|
||||
}
|
||||
var series = [];
|
||||
var today = new Date(todayIso + "T00:00:00Z");
|
||||
for (var d = days - 1; d >= 0; d--) {
|
||||
var day = new Date(today.getTime() - d * 86400000);
|
||||
var iso = day.toISOString().slice(0, 10);
|
||||
var entry = byDate[iso];
|
||||
series.push({
|
||||
date: iso,
|
||||
value: entry ? entry.totalCost : 0,
|
||||
tokens: entry ? entry.totalTokens : 0,
|
||||
});
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
return "'" + String(value).replace(/'/g, "'\"'\"'") + "'";
|
||||
}
|
||||
|
||||
function commandPrefix(proxyUrl) {
|
||||
// ~/.local/bin is not always on plasmashell's PATH.
|
||||
var prefix = 'env PATH="$HOME/.local/bin:$PATH"';
|
||||
if (proxyUrl && proxyUrl.trim().length > 0) {
|
||||
var quoted = shellQuote(proxyUrl.trim());
|
||||
prefix += " http_proxy=" + quoted + " https_proxy=" + quoted
|
||||
+ " HTTP_PROXY=" + quoted + " HTTPS_PROXY=" + quoted
|
||||
+ " ALL_PROXY=" + quoted;
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
// One usage command per selected provider; a single unscoped command when the
|
||||
// selection is empty (the CLI then reports the providers enabled in its own
|
||||
// config file).
|
||||
function buildCommands(codexbarPath, selected, proxyUrl) {
|
||||
var base = commandPrefix(proxyUrl) + " " + codexbarPath + " usage --format json --no-color";
|
||||
if (!selected || selected.length === 0) {
|
||||
return [base];
|
||||
}
|
||||
return selected.map(function(id) {
|
||||
return base + " --provider " + id;
|
||||
});
|
||||
}
|
||||
|
||||
function buildCostCommand(codexbarPath, proxyUrl) {
|
||||
return commandPrefix(proxyUrl) + " " + codexbarPath + " cost --format json --no-color";
|
||||
}
|
||||
|
||||
// Merge freshly parsed provider models into the cached map. Successful
|
||||
// fetches replace the cache entry; errors keep the last good data and only
|
||||
// mark it stale, so panel gauges never blank out during background refreshes.
|
||||
function applyUpdate(prevById, models, nowMs) {
|
||||
var byId = {};
|
||||
var key;
|
||||
for (key in prevById) {
|
||||
byId[key] = prevById[key];
|
||||
}
|
||||
for (var i = 0; i < models.length; i++) {
|
||||
var model = models[i];
|
||||
var cached = byId[model.id];
|
||||
if (model.error && cached && cached.windows && cached.windows.length > 0) {
|
||||
var stale = {};
|
||||
for (key in cached) {
|
||||
stale[key] = cached[key];
|
||||
}
|
||||
stale.staleError = model.error;
|
||||
byId[model.id] = stale;
|
||||
continue;
|
||||
}
|
||||
model.fetchedAtMs = nowMs;
|
||||
model.staleError = null;
|
||||
byId[model.id] = model;
|
||||
}
|
||||
return { byId: byId };
|
||||
}
|
||||
|
||||
function formatAgo(thenMs, nowMs) {
|
||||
if (thenMs === null || thenMs === undefined || thenMs < 0) {
|
||||
return "";
|
||||
}
|
||||
var minutes = Math.floor((nowMs - thenMs) / 60000);
|
||||
if (minutes < 1) {
|
||||
return "just now";
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return minutes + "m ago";
|
||||
}
|
||||
var hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) {
|
||||
return hours + "h " + (minutes % 60) + "m ago";
|
||||
}
|
||||
var days = Math.floor(hours / 24);
|
||||
return days + "d " + (hours % 24) + "h ago";
|
||||
}
|
||||
|
||||
function selectionList(csv) {
|
||||
if (!csv) {
|
||||
return [];
|
||||
}
|
||||
var out = [];
|
||||
var parts = csv.split(",");
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
var id = parts[i].trim();
|
||||
if (id.length > 0) {
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toggleSelection(csv, id, checked) {
|
||||
var list = selectionList(csv);
|
||||
var index = list.indexOf(id);
|
||||
if (checked && index === -1) {
|
||||
list.push(id);
|
||||
} else if (!checked && index !== -1) {
|
||||
list.splice(index, 1);
|
||||
}
|
||||
return list.join(",");
|
||||
}
|
||||
|
||||
// Node.js test hook; ignored by the QML JS engine.
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = {
|
||||
PROVIDER_CATALOG: PROVIDER_CATALOG,
|
||||
providerDisplayName: providerDisplayName,
|
||||
shortName: shortName,
|
||||
windowLabel: windowLabel,
|
||||
usageStage: usageStage,
|
||||
formatCountdown: formatCountdown,
|
||||
parseUsageJson: parseUsageJson,
|
||||
worstUsedPercent: worstUsedPercent,
|
||||
selectionList: selectionList,
|
||||
toggleSelection: toggleSelection,
|
||||
gaugeRings: gaugeRings,
|
||||
gaugeCenterPercent: gaugeCenterPercent,
|
||||
sweepAngle: sweepAngle,
|
||||
applyUpdate: applyUpdate,
|
||||
formatAgo: formatAgo,
|
||||
parseCostJson: parseCostJson,
|
||||
humanTokens: humanTokens,
|
||||
formatMoney: formatMoney,
|
||||
chartSeries: chartSeries,
|
||||
buildCommands: buildCommands,
|
||||
buildCostCommand: buildCostCommand,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import QtQuick
|
||||
import org.kde.plasma.configuration
|
||||
|
||||
ConfigModel {
|
||||
ConfigCategory {
|
||||
name: i18n("General")
|
||||
icon: "configure"
|
||||
source: "ConfigGeneral.qml"
|
||||
}
|
||||
ConfigCategory {
|
||||
name: i18n("Providers")
|
||||
icon: "applications-development"
|
||||
source: "ConfigProviders.qml"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kcfg xmlns="http://www.kde.org/standards/kcfg/1.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.kde.org/standards/kcfg/1.0
|
||||
http://www.kde.org/standards/kcfg/1.0/kcfg.xsd">
|
||||
<kcfgfile name=""/>
|
||||
<group name="General">
|
||||
<entry name="codexbarPath" type="String">
|
||||
<label>Path to the codexbar CLI binary.</label>
|
||||
<default>codexbar</default>
|
||||
</entry>
|
||||
<entry name="refreshMinutes" type="Int">
|
||||
<label>Refresh interval in minutes.</label>
|
||||
<default>5</default>
|
||||
</entry>
|
||||
<entry name="showPace" type="Bool">
|
||||
<label>Show pace summary lines under usage bars.</label>
|
||||
<default>true</default>
|
||||
</entry>
|
||||
<entry name="showCost" type="Bool">
|
||||
<label>Fetch and show local token cost usage (Codex and Claude).</label>
|
||||
<default>true</default>
|
||||
</entry>
|
||||
<entry name="showHistory" type="Bool">
|
||||
<label>Show the daily spend history chart in provider cards.</label>
|
||||
<default>true</default>
|
||||
</entry>
|
||||
<entry name="proxyUrl" type="String">
|
||||
<label>Proxy URL exported as http(s)_proxy/ALL_PROXY for CLI requests, e.g. socks5://127.0.0.1:1080.</label>
|
||||
<default></default>
|
||||
</entry>
|
||||
<entry name="selectedProviders" type="String">
|
||||
<label>Comma-separated provider ids to display. Empty means the providers enabled in the CodexBar CLI config.</label>
|
||||
<default>codex,claude</default>
|
||||
</entry>
|
||||
<entry name="cacheJson" type="String">
|
||||
<label>Serialized last-known usage data, shown instantly on startup while a refresh runs in the background.</label>
|
||||
<default></default>
|
||||
</entry>
|
||||
</group>
|
||||
</kcfg>
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#23272e"/>
|
||||
<stop offset="1" stop-color="#15181d"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="4" y="4" width="120" height="120" rx="27" fill="url(#bg)"/>
|
||||
<g transform="rotate(-90 64 64)">
|
||||
<circle cx="64" cy="64" r="44" fill="none" stroke="#ffffff" stroke-opacity="0.1" stroke-width="11"/>
|
||||
<circle cx="64" cy="64" r="27" fill="none" stroke="#ffffff" stroke-opacity="0.1" stroke-width="11"/>
|
||||
<circle cx="64" cy="64" r="44" fill="none" stroke="#3daee9" stroke-width="11" stroke-linecap="round" stroke-dasharray="196 281"/>
|
||||
<circle cx="64" cy="64" r="27" fill="none" stroke="#f67400" stroke-width="11" stroke-linecap="round" stroke-dasharray="76 170"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 876 B |
+65
@@ -0,0 +1,65 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import org.kde.plasma.plasmoid
|
||||
import org.kde.plasma.core as PlasmaCore
|
||||
import org.kde.kirigami as Kirigami
|
||||
import "../code/parser.js" as Parser
|
||||
|
||||
// Panel view: one two-ring radial gauge per provider
|
||||
// (outer ring = session left, inner ring = weekly left).
|
||||
Item {
|
||||
id: compact
|
||||
|
||||
readonly property bool vertical: Plasmoid.formFactor === PlasmaCore.Types.Vertical
|
||||
readonly property int gaugeSize: compact.vertical
|
||||
? Math.min(compact.width, Kirigami.Units.gridUnit * 2.4)
|
||||
: Math.min(compact.height, Kirigami.Units.gridUnit * 2.4)
|
||||
readonly property int count: Math.max(1, root.models.length)
|
||||
|
||||
Layout.minimumWidth: compact.vertical ? compact.gaugeSize : compact.count * (compact.gaugeSize + gaugeGrid.spacing)
|
||||
Layout.minimumHeight: compact.vertical ? compact.count * (compact.gaugeSize + gaugeGrid.spacing) : compact.gaugeSize
|
||||
|
||||
Grid {
|
||||
id: gaugeGrid
|
||||
anchors.centerIn: parent
|
||||
columns: compact.vertical ? 1 : compact.count
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
Kirigami.Icon {
|
||||
visible: root.models.length === 0
|
||||
source: Qt.resolvedUrl("../icons/codexbar.svg")
|
||||
width: compact.gaugeSize
|
||||
height: compact.gaugeSize
|
||||
opacity: root.loading ? 0.5 : 1
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.models
|
||||
|
||||
delegate: RadialGauge {
|
||||
id: meter
|
||||
required property var modelData
|
||||
readonly property var rings: Parser.gaugeRings(modelData)
|
||||
readonly property int centerPercent: Parser.gaugeCenterPercent(modelData)
|
||||
|
||||
width: compact.gaugeSize
|
||||
height: compact.gaugeSize
|
||||
outerColor: root.sessionColor
|
||||
innerColor: root.weeklyColor
|
||||
outerPercent: rings.outerIdx >= 0
|
||||
? modelData.windows[rings.outerIdx].usedPercent : -1
|
||||
innerPercent: rings.innerIdx >= 0
|
||||
? modelData.windows[rings.innerIdx].usedPercent : -1
|
||||
centerText: modelData.error
|
||||
? "!"
|
||||
: (meter.centerPercent >= 0 ? String(meter.centerPercent) : "")
|
||||
centerTextScale: 0.34
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.expanded = !root.expanded
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls as QQC2
|
||||
import QtQuick.Layouts
|
||||
import org.kde.kirigami as Kirigami
|
||||
|
||||
Kirigami.FormLayout {
|
||||
id: page
|
||||
|
||||
property alias cfg_codexbarPath: pathField.text
|
||||
property alias cfg_refreshMinutes: refreshSpin.value
|
||||
property alias cfg_showPace: paceCheck.checked
|
||||
property alias cfg_showCost: costCheck.checked
|
||||
property alias cfg_showHistory: historyCheck.checked
|
||||
property alias cfg_proxyUrl: proxyField.text
|
||||
|
||||
QQC2.TextField {
|
||||
id: pathField
|
||||
Kirigami.FormData.label: i18n("codexbar binary:")
|
||||
placeholderText: "codexbar"
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
QQC2.Label {
|
||||
text: i18n("Plain name is resolved via PATH (including ~/.local/bin); an absolute path also works.")
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
opacity: 0.6
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
QQC2.SpinBox {
|
||||
id: refreshSpin
|
||||
Kirigami.FormData.label: i18n("Refresh every:")
|
||||
from: 1
|
||||
to: 120
|
||||
textFromValue: function(value) { return i18np("%1 minute", "%1 minutes", value) }
|
||||
valueFromText: function(text) { return parseInt(text) || 5 }
|
||||
}
|
||||
|
||||
QQC2.CheckBox {
|
||||
id: paceCheck
|
||||
Kirigami.FormData.label: i18n("Details:")
|
||||
text: i18n("Show pace summaries")
|
||||
}
|
||||
|
||||
QQC2.CheckBox {
|
||||
id: costCheck
|
||||
text: i18n("Show local token costs (Codex/Claude)")
|
||||
}
|
||||
|
||||
QQC2.CheckBox {
|
||||
id: historyCheck
|
||||
text: i18n("Show daily spend history chart")
|
||||
}
|
||||
|
||||
QQC2.TextField {
|
||||
id: proxyField
|
||||
Kirigami.FormData.label: i18n("Proxy:")
|
||||
placeholderText: "socks5://127.0.0.1:1080"
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
QQC2.Label {
|
||||
text: i18n("Exported as http_proxy/https_proxy/ALL_PROXY for every CLI call. Useful when provider APIs are blocked in your region. Leave empty for a direct connection.")
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
opacity: 0.6
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls as QQC2
|
||||
import QtQuick.Layouts
|
||||
import org.kde.kirigami as Kirigami
|
||||
import "../code/parser.js" as Parser
|
||||
|
||||
ColumnLayout {
|
||||
id: page
|
||||
|
||||
property string cfg_selectedProviders: ""
|
||||
|
||||
readonly property var selectedList: Parser.selectionList(page.cfg_selectedProviders)
|
||||
|
||||
function isSelected(id) {
|
||||
return page.selectedList.indexOf(id) !== -1
|
||||
}
|
||||
|
||||
function toggle(id, checked) {
|
||||
page.cfg_selectedProviders = Parser.toggleSelection(page.cfg_selectedProviders, id, checked)
|
||||
}
|
||||
|
||||
QQC2.Label {
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
text: i18n("Pick the coding agents to display. With nothing selected, the widget shows the providers enabled in the CodexBar CLI config (~/.config/codexbar/config.json).")
|
||||
}
|
||||
|
||||
Kirigami.SearchField {
|
||||
id: searchField
|
||||
Layout.fillWidth: true
|
||||
placeholderText: i18n("Filter providers…")
|
||||
}
|
||||
|
||||
QQC2.ScrollView {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
ListView {
|
||||
id: providerListView
|
||||
clip: true
|
||||
model: {
|
||||
var query = searchField.text.toLowerCase().trim()
|
||||
if (query.length === 0) {
|
||||
return Parser.PROVIDER_CATALOG
|
||||
}
|
||||
return Parser.PROVIDER_CATALOG.filter(function(p) {
|
||||
return p.id.indexOf(query) !== -1
|
||||
|| p.name.toLowerCase().indexOf(query) !== -1
|
||||
})
|
||||
}
|
||||
|
||||
delegate: QQC2.CheckDelegate {
|
||||
required property var modelData
|
||||
width: providerListView.width
|
||||
text: modelData.name + " (" + modelData.id + ")"
|
||||
checked: page.isSelected(modelData.id)
|
||||
onToggled: page.toggle(modelData.id, checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QQC2.Label {
|
||||
Layout.fillWidth: true
|
||||
text: page.selectedList.length > 0
|
||||
? i18np("%1 provider selected", "%1 providers selected", page.selectedList.length)
|
||||
: i18n("Following the CLI config")
|
||||
opacity: 0.6
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import org.kde.plasma.plasmoid
|
||||
import org.kde.plasma.components as PlasmaComponents3
|
||||
import org.kde.plasma.extras as PlasmaExtras
|
||||
import org.kde.kirigami as Kirigami
|
||||
import "../code/parser.js" as Parser
|
||||
|
||||
PlasmaExtras.Representation {
|
||||
id: full
|
||||
|
||||
Layout.preferredWidth: Kirigami.Units.gridUnit * 22
|
||||
Layout.preferredHeight: Kirigami.Units.gridUnit * 26
|
||||
Layout.minimumWidth: Kirigami.Units.gridUnit * 16
|
||||
Layout.minimumHeight: Kirigami.Units.gridUnit * 10
|
||||
|
||||
collapseMarginsHint: true
|
||||
|
||||
function stageColor(usedPercent) {
|
||||
var stage = Parser.usageStage(usedPercent)
|
||||
if (stage === "crit") {
|
||||
return Kirigami.Theme.negativeTextColor
|
||||
}
|
||||
if (stage === "warn") {
|
||||
return Kirigami.Theme.neutralTextColor
|
||||
}
|
||||
return Kirigami.Theme.positiveTextColor
|
||||
}
|
||||
|
||||
function resetText(w) {
|
||||
var countdown = Parser.formatCountdown(w.resetsAt, root.nowMs)
|
||||
if (countdown === "now") {
|
||||
return i18n("resets soon")
|
||||
}
|
||||
if (countdown.length > 0) {
|
||||
return i18n("resets in %1", countdown)
|
||||
}
|
||||
return w.resetDescription
|
||||
}
|
||||
|
||||
function costLine(cost, kind) {
|
||||
var money = Parser.formatMoney(kind === "today" ? cost.todayCostUSD : cost.month30CostUSD)
|
||||
var tokens = Parser.humanTokens(kind === "today" ? cost.todayTokens : cost.month30Tokens)
|
||||
if (money.length === 0 && tokens.length === 0) {
|
||||
return ""
|
||||
}
|
||||
return money + (tokens.length > 0 ? " · " + tokens + " tok" : "")
|
||||
}
|
||||
|
||||
header: PlasmaExtras.PlasmoidHeading {
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
Kirigami.Heading {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: Kirigami.Units.smallSpacing
|
||||
level: 2
|
||||
text: i18n("CodexBar")
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
visible: root.lastUpdatedMs > 0
|
||||
text: i18n("updated %1", Parser.formatAgo(root.lastUpdatedMs, root.nowMs))
|
||||
opacity: 0.6
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
}
|
||||
|
||||
PlasmaComponents3.ToolButton {
|
||||
id: refreshButton
|
||||
icon.name: "view-refresh"
|
||||
enabled: !root.loading
|
||||
onClicked: root.refresh()
|
||||
PlasmaComponents3.ToolTip { text: i18n("Refresh") }
|
||||
|
||||
rotation: 0
|
||||
RotationAnimation on rotation {
|
||||
running: root.loading
|
||||
from: 0
|
||||
to: 360
|
||||
duration: 1100
|
||||
loops: Animation.Infinite
|
||||
}
|
||||
onEnabledChanged: if (enabled) { rotation = 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: ListView {
|
||||
id: providerList
|
||||
clip: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
model: root.models
|
||||
topMargin: Kirigami.Units.smallSpacing
|
||||
bottomMargin: Kirigami.Units.smallSpacing
|
||||
leftMargin: Kirigami.Units.smallSpacing
|
||||
rightMargin: Kirigami.Units.smallSpacing
|
||||
|
||||
PlasmaExtras.PlaceholderMessage {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - Kirigami.Units.gridUnit * 2
|
||||
visible: root.models.length === 0
|
||||
iconName: root.globalError.length > 0 ? "data-warning" : "office-chart-bar"
|
||||
text: {
|
||||
if (root.globalError.length > 0) {
|
||||
return root.globalError
|
||||
}
|
||||
if (root.loading) {
|
||||
return i18n("Fetching usage…")
|
||||
}
|
||||
return i18n("No providers configured. Pick providers in the widget settings or enable them with “codexbar config enable --provider <id>”.")
|
||||
}
|
||||
}
|
||||
|
||||
delegate: Item {
|
||||
id: card
|
||||
required property var modelData
|
||||
readonly property var rings: Parser.gaugeRings(modelData)
|
||||
readonly property int centerPercent: Parser.gaugeCenterPercent(modelData)
|
||||
readonly property var cost: root.showCost ? (root.costById[modelData.id] || null) : null
|
||||
|
||||
width: providerList.width - providerList.leftMargin - providerList.rightMargin
|
||||
height: cardContent.implicitHeight + Kirigami.Units.largeSpacing * 2
|
||||
|
||||
function ringColor(index) {
|
||||
if (index === card.rings.outerIdx) {
|
||||
return root.sessionColor
|
||||
}
|
||||
if (index === card.rings.innerIdx) {
|
||||
return root.weeklyColor
|
||||
}
|
||||
return Qt.alpha(Kirigami.Theme.textColor, 0.4)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Kirigami.Units.cornerRadius !== undefined ? Kirigami.Units.cornerRadius : 5
|
||||
color: Qt.alpha(Kirigami.Theme.textColor, 0.06)
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: cardContent
|
||||
anchors.fill: parent
|
||||
anchors.margins: Kirigami.Units.largeSpacing
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
|
||||
RadialGauge {
|
||||
Layout.preferredWidth: Kirigami.Units.gridUnit * 4.5
|
||||
Layout.preferredHeight: Kirigami.Units.gridUnit * 4.5
|
||||
Layout.alignment: Qt.AlignTop
|
||||
outerColor: root.sessionColor
|
||||
innerColor: root.weeklyColor
|
||||
outerPercent: card.rings.outerIdx >= 0
|
||||
? card.modelData.windows[card.rings.outerIdx].usedPercent : -1
|
||||
innerPercent: card.rings.innerIdx >= 0
|
||||
? card.modelData.windows[card.rings.innerIdx].usedPercent : -1
|
||||
centerText: card.modelData.error
|
||||
? "!"
|
||||
: (card.centerPercent >= 0 ? card.centerPercent + "%" : "")
|
||||
centerTextScale: 0.2
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
Kirigami.Heading {
|
||||
level: 4
|
||||
text: card.modelData.name
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: card.modelData.status !== null
|
||||
width: Kirigami.Units.smallSpacing * 2
|
||||
height: width
|
||||
radius: width / 2
|
||||
color: Kirigami.Theme.negativeTextColor
|
||||
|
||||
PlasmaComponents3.ToolTip {
|
||||
text: card.modelData.status
|
||||
? (card.modelData.status.indicator + ": " + card.modelData.status.description)
|
||||
: ""
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
visible: !!card.modelData.plan
|
||||
text: card.modelData.plan || ""
|
||||
opacity: 0.6
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
}
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
visible: !!card.modelData.account
|
||||
Layout.fillWidth: true
|
||||
text: card.modelData.account || ""
|
||||
elide: Text.ElideMiddle
|
||||
opacity: 0.6
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
visible: !!card.modelData.error
|
||||
Layout.fillWidth: true
|
||||
text: card.modelData.error || ""
|
||||
wrapMode: Text.WordWrap
|
||||
color: Kirigami.Theme.negativeTextColor
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
visible: !!card.modelData.staleError
|
||||
Layout.fillWidth: true
|
||||
text: i18n("Refresh failed, showing data from %1: %2",
|
||||
Parser.formatAgo(card.modelData.fetchedAtMs !== undefined ? card.modelData.fetchedAtMs : -1, root.nowMs),
|
||||
card.modelData.staleError || "")
|
||||
wrapMode: Text.WordWrap
|
||||
color: Kirigami.Theme.neutralTextColor
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: card.modelData.windows
|
||||
|
||||
delegate: ColumnLayout {
|
||||
id: windowRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
Rectangle {
|
||||
width: Kirigami.Units.smallSpacing * 2
|
||||
height: width
|
||||
radius: width / 2
|
||||
color: card.ringColor(windowRow.index)
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
text: windowRow.modelData.label
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
text: windowRow.modelData.usageKnown === false
|
||||
? i18n("usage n/a")
|
||||
: i18n("%1% used", windowRow.modelData.usedPercent)
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
color: windowRow.modelData.usageKnown === false
|
||||
? Kirigami.Theme.disabledTextColor
|
||||
: full.stageColor(windowRow.modelData.usedPercent)
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
text: "· " + full.resetText(windowRow.modelData)
|
||||
visible: full.resetText(windowRow.modelData).length > 0
|
||||
opacity: 0.6
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
}
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
visible: root.showPace && windowRow.modelData.paceSummary.length > 0
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: Kirigami.Units.smallSpacing * 3
|
||||
text: windowRow.modelData.paceSummary
|
||||
elide: Text.ElideRight
|
||||
opacity: 0.5
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
visible: card.modelData.credits !== null && card.modelData.credits > 0
|
||||
text: i18n("Credits: %1", card.modelData.credits !== null
|
||||
? Number(card.modelData.credits).toLocaleString(Qt.locale(), "f", 1)
|
||||
: "")
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
opacity: 0.7
|
||||
}
|
||||
|
||||
// Local token cost scan (Codex/Claude): spend and tokens.
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: Kirigami.Units.smallSpacing
|
||||
visible: card.cost !== null
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
text: i18n("Today")
|
||||
opacity: 0.6
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
text: card.cost ? full.costLine(card.cost, "today") : ""
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
text: i18n("Last 30 days")
|
||||
opacity: 0.6
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
horizontalAlignment: Text.AlignRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
text: card.cost ? full.costLine(card.cost, "month30") : ""
|
||||
font.bold: true
|
||||
horizontalAlignment: Text.AlignRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HistoryChart {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Kirigami.Units.gridUnit * 2.2
|
||||
visible: root.showHistory && card.cost !== null && card.cost.daily.length > 0
|
||||
accentColor: root.sessionColor
|
||||
series: card.cost !== null
|
||||
? Parser.chartSeries(card.cost.daily, 14, new Date(root.nowMs).toISOString().slice(0, 10))
|
||||
: []
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
Layout.fillWidth: true
|
||||
visible: text.length > 0
|
||||
text: {
|
||||
var parts = []
|
||||
if (card.modelData.source) {
|
||||
parts.push(i18n("Source: %1", card.modelData.source))
|
||||
}
|
||||
if (card.modelData.version) {
|
||||
parts.push(i18n("Version: %1", card.modelData.version))
|
||||
}
|
||||
return parts.join(" · ")
|
||||
}
|
||||
opacity: 0.45
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
horizontalAlignment: Text.AlignRight
|
||||
elide: Text.ElideLeft
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import org.kde.kirigami as Kirigami
|
||||
|
||||
// Rounded per-day spend bars for the last N days (zero days render as dots).
|
||||
Item {
|
||||
id: chart
|
||||
|
||||
property var series: []
|
||||
property color accentColor: Kirigami.Theme.highlightColor
|
||||
|
||||
readonly property real maxValue: {
|
||||
var max = 0
|
||||
for (var i = 0; i < chart.series.length; i++) {
|
||||
if (chart.series[i].value > max) {
|
||||
max = chart.series[i].value
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: chart.series
|
||||
|
||||
delegate: Item {
|
||||
required property var modelData
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: chart.maxValue > 0
|
||||
? Math.max(3, parent.height * modelData.value / chart.maxValue)
|
||||
: 3
|
||||
radius: Math.min(width / 2, 4)
|
||||
color: chart.accentColor
|
||||
opacity: modelData.value > 0 ? 0.75 : 0.2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import org.kde.kirigami as Kirigami
|
||||
import "../code/parser.js" as Parser
|
||||
|
||||
// Two-ring radial gauge: outer ring = session window, inner ring = weekly
|
||||
// window. A percent of -1 leaves the ring as a bare track.
|
||||
Item {
|
||||
id: gauge
|
||||
|
||||
property real outerPercent: -1
|
||||
property real innerPercent: -1
|
||||
property color outerColor: Kirigami.Theme.highlightColor
|
||||
property color innerColor: "#f67400"
|
||||
property string centerText: ""
|
||||
property real centerTextScale: 0.32
|
||||
|
||||
readonly property real ringWidth: Math.max(2, Math.min(width, height) / 15)
|
||||
readonly property real ringGap: ringWidth * 1.2
|
||||
readonly property real outerRadius: (Math.min(width, height) - ringWidth) / 2
|
||||
readonly property real innerRadius: outerRadius - ringWidth - ringGap
|
||||
readonly property color trackColor: Qt.alpha(Kirigami.Theme.textColor, 0.18)
|
||||
|
||||
Shape {
|
||||
anchors.fill: parent
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
|
||||
// Outer track.
|
||||
ShapePath {
|
||||
strokeColor: gauge.trackColor
|
||||
fillColor: "transparent"
|
||||
strokeWidth: gauge.ringWidth
|
||||
capStyle: ShapePath.FlatCap
|
||||
PathAngleArc {
|
||||
centerX: gauge.width / 2
|
||||
centerY: gauge.height / 2
|
||||
radiusX: gauge.outerRadius
|
||||
radiusY: gauge.outerRadius
|
||||
startAngle: 0
|
||||
sweepAngle: 360
|
||||
}
|
||||
}
|
||||
|
||||
// Inner track.
|
||||
ShapePath {
|
||||
strokeColor: gauge.trackColor
|
||||
fillColor: "transparent"
|
||||
strokeWidth: gauge.ringWidth
|
||||
capStyle: ShapePath.FlatCap
|
||||
PathAngleArc {
|
||||
centerX: gauge.width / 2
|
||||
centerY: gauge.height / 2
|
||||
radiusX: gauge.innerRadius
|
||||
radiusY: gauge.innerRadius
|
||||
startAngle: 0
|
||||
sweepAngle: 360
|
||||
}
|
||||
}
|
||||
|
||||
// Outer progress (session).
|
||||
ShapePath {
|
||||
strokeColor: gauge.outerColor
|
||||
fillColor: "transparent"
|
||||
strokeWidth: gauge.ringWidth
|
||||
capStyle: ShapePath.RoundCap
|
||||
PathAngleArc {
|
||||
centerX: gauge.width / 2
|
||||
centerY: gauge.height / 2
|
||||
radiusX: gauge.outerRadius
|
||||
radiusY: gauge.outerRadius
|
||||
startAngle: -90
|
||||
sweepAngle: gauge.outerPercent >= 0 ? Parser.sweepAngle(gauge.outerPercent) : 0
|
||||
}
|
||||
}
|
||||
|
||||
// Inner progress (weekly).
|
||||
ShapePath {
|
||||
strokeColor: gauge.innerColor
|
||||
fillColor: "transparent"
|
||||
strokeWidth: gauge.ringWidth
|
||||
capStyle: ShapePath.RoundCap
|
||||
PathAngleArc {
|
||||
centerX: gauge.width / 2
|
||||
centerY: gauge.height / 2
|
||||
radiusX: gauge.innerRadius
|
||||
radiusY: gauge.innerRadius
|
||||
startAngle: -90
|
||||
sweepAngle: gauge.innerPercent >= 0 ? Parser.sweepAngle(gauge.innerPercent) : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: gauge.centerText.length > 0
|
||||
text: gauge.centerText
|
||||
color: Kirigami.Theme.textColor
|
||||
font.pixelSize: Math.max(7, Math.min(gauge.width, gauge.height) * gauge.centerTextScale)
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import QtQuick
|
||||
import org.kde.plasma.plasmoid
|
||||
import org.kde.plasma.core as PlasmaCore
|
||||
import org.kde.plasma.plasma5support as Plasma5Support
|
||||
import org.kde.kirigami as Kirigami
|
||||
import "../code/parser.js" as Parser
|
||||
|
||||
PlasmoidItem {
|
||||
id: root
|
||||
|
||||
// Cached provider view models keyed by provider id. Entries are only
|
||||
// replaced when fresh data arrives, so the panel never blanks out while
|
||||
// a background refresh is in flight.
|
||||
property var modelsById: ({})
|
||||
// Display order of provider ids (selection order, or arrival order when
|
||||
// following the CLI config).
|
||||
property var modelOrder: []
|
||||
// Ordered list consumed by the representations.
|
||||
property var models: []
|
||||
property string globalError: ""
|
||||
property bool loading: false
|
||||
property int pendingCount: 0
|
||||
property double lastUpdatedMs: -1
|
||||
// Ticks every 30s so countdown and "ago" labels stay fresh.
|
||||
property double nowMs: Date.now()
|
||||
|
||||
// Ring palette, shared by compact and full representations. The weekly
|
||||
// orange is darkened on light themes to keep contrast against the track.
|
||||
readonly property bool darkTheme: Kirigami.Theme.textColor.hslLightness > 0.5
|
||||
readonly property color sessionColor: root.darkTheme ? "#3daee9" : "#2980b9"
|
||||
readonly property color weeklyColor: root.darkTheme ? "#f67400" : "#c85400"
|
||||
|
||||
// Local token-cost stats keyed by provider id (Codex/Claude scans).
|
||||
property var costById: ({})
|
||||
|
||||
readonly property string codexbarPath: Plasmoid.configuration.codexbarPath || "codexbar"
|
||||
readonly property bool showPace: Plasmoid.configuration.showPace
|
||||
readonly property bool showCost: Plasmoid.configuration.showCost
|
||||
readonly property bool showHistory: Plasmoid.configuration.showHistory
|
||||
readonly property string proxyUrl: Plasmoid.configuration.proxyUrl || ""
|
||||
readonly property var selectedProviders: Parser.selectionList(Plasmoid.configuration.selectedProviders || "")
|
||||
|
||||
readonly property var commands: Parser.buildCommands(root.codexbarPath, root.selectedProviders, root.proxyUrl)
|
||||
readonly property string costCommand: Parser.buildCostCommand(root.codexbarPath, root.proxyUrl)
|
||||
|
||||
switchWidth: Kirigami.Units.gridUnit * 15
|
||||
switchHeight: Kirigami.Units.gridUnit * 12
|
||||
|
||||
Plasmoid.icon: "office-chart-bar"
|
||||
toolTipMainText: i18n("CodexBar")
|
||||
toolTipSubText: {
|
||||
var lines = []
|
||||
for (var i = 0; i < root.models.length; i++) {
|
||||
var m = root.models[i]
|
||||
if (m.error) {
|
||||
lines.push(m.name + ": " + m.error)
|
||||
continue
|
||||
}
|
||||
var parts = []
|
||||
for (var j = 0; j < m.windows.length; j++) {
|
||||
var w = m.windows[j]
|
||||
if (w.usageKnown === false) {
|
||||
continue
|
||||
}
|
||||
parts.push(w.label + " " + w.usedPercent + "% used")
|
||||
}
|
||||
lines.push(m.name + ": " + (parts.length ? parts.join(", ") : i18n("no data")))
|
||||
}
|
||||
if (root.globalError.length > 0 && root.models.length === 0) {
|
||||
lines.push(root.globalError)
|
||||
}
|
||||
var ago = Parser.formatAgo(root.lastUpdatedMs, root.nowMs)
|
||||
if (root.loading) {
|
||||
lines.push(ago.length > 0
|
||||
? i18n("Updating… (last update %1)", ago)
|
||||
: i18n("Updating…"))
|
||||
} else if (ago.length > 0) {
|
||||
lines.push(i18n("Updated %1", ago))
|
||||
}
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
onCommandsChanged: root.refresh()
|
||||
|
||||
function refresh() {
|
||||
if (root.loading) {
|
||||
return
|
||||
}
|
||||
root.loading = true
|
||||
root.pendingCount = root.commands.length + (root.showCost ? 1 : 0)
|
||||
// Old models stay on screen; the executable engine reruns each
|
||||
// command and results merge in as they arrive.
|
||||
executable.connectedSources = []
|
||||
for (var i = 0; i < root.commands.length; i++) {
|
||||
executable.connectSource(root.commands[i])
|
||||
}
|
||||
if (root.showCost) {
|
||||
executable.connectSource(root.costCommand)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCostResult(stdout) {
|
||||
var parsed = Parser.parseCostJson(stdout)
|
||||
if (parsed.ok) {
|
||||
root.costById = parsed.byId
|
||||
root.persistCache()
|
||||
}
|
||||
// A failed cost scan keeps the previous numbers; usage is unaffected.
|
||||
}
|
||||
|
||||
function handleResult(stdout, stderr, exitCode) {
|
||||
var parsed = Parser.parseUsageJson(stdout)
|
||||
if (!parsed.ok) {
|
||||
var message = stderr && stderr.trim().length > 0
|
||||
? stderr.trim().split("\n").pop()
|
||||
: parsed.error
|
||||
if (exitCode === 127) {
|
||||
message = i18n("codexbar binary not found — set its path in the widget settings")
|
||||
}
|
||||
// No parseable payload: keep whatever is cached, surface the error.
|
||||
root.globalError = root.models.length === 0 ? message : ""
|
||||
return
|
||||
}
|
||||
root.globalError = ""
|
||||
var now = Date.now()
|
||||
var state = Parser.applyUpdate(root.modelsById, parsed.models, now)
|
||||
root.modelsById = state.byId
|
||||
var hadFreshData = parsed.models.some(function(m) { return !m.error })
|
||||
if (hadFreshData) {
|
||||
root.lastUpdatedMs = now
|
||||
}
|
||||
for (var i = 0; i < parsed.models.length; i++) {
|
||||
var id = parsed.models[i].id
|
||||
if (root.modelOrder.indexOf(id) === -1) {
|
||||
root.modelOrder = root.modelOrder.concat([id])
|
||||
}
|
||||
}
|
||||
root.rebuildModels()
|
||||
root.persistCache()
|
||||
}
|
||||
|
||||
function rebuildModels() {
|
||||
var order = root.selectedProviders.length > 0
|
||||
? root.selectedProviders
|
||||
: root.modelOrder
|
||||
var list = []
|
||||
for (var i = 0; i < order.length; i++) {
|
||||
var m = root.modelsById[order[i]]
|
||||
if (m) {
|
||||
list.push(m)
|
||||
}
|
||||
}
|
||||
root.models = list
|
||||
}
|
||||
|
||||
function persistCache() {
|
||||
Plasmoid.configuration.cacheJson = JSON.stringify({
|
||||
byId: root.modelsById,
|
||||
order: root.modelOrder,
|
||||
costById: root.costById,
|
||||
updatedAtMs: root.lastUpdatedMs,
|
||||
})
|
||||
}
|
||||
|
||||
function loadCache() {
|
||||
var raw = Plasmoid.configuration.cacheJson || ""
|
||||
if (raw.length === 0) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
var cache = JSON.parse(raw)
|
||||
root.modelsById = cache.byId || {}
|
||||
root.modelOrder = cache.order || []
|
||||
root.costById = cache.costById || {}
|
||||
root.lastUpdatedMs = cache.updatedAtMs !== undefined ? cache.updatedAtMs : -1
|
||||
root.rebuildModels()
|
||||
} catch (e) {
|
||||
// Corrupt cache is not fatal; the next refresh rewrites it.
|
||||
}
|
||||
}
|
||||
|
||||
Plasma5Support.DataSource {
|
||||
id: executable
|
||||
engine: "executable"
|
||||
connectedSources: []
|
||||
onNewData: function(source, data) {
|
||||
executable.disconnectSource(source)
|
||||
root.pendingCount = Math.max(0, root.pendingCount - 1)
|
||||
if (root.pendingCount === 0) {
|
||||
root.loading = false
|
||||
}
|
||||
if (source === root.costCommand) {
|
||||
root.handleCostResult(data["stdout"] || "")
|
||||
} else {
|
||||
root.handleResult(
|
||||
data["stdout"] || "",
|
||||
data["stderr"] || "",
|
||||
data["exit code"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: Math.max(1, Plasmoid.configuration.refreshMinutes) * 60 * 1000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 30 * 1000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: root.nowMs = Date.now()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
root.loadCache()
|
||||
root.refresh()
|
||||
}
|
||||
|
||||
compactRepresentation: CompactRepresentation {}
|
||||
fullRepresentation: FullRepresentation {}
|
||||
}
|
||||
Reference in New Issue
Block a user