diff --git a/codexbar-usage/BarWidget.qml b/codexbar-usage/BarWidget.qml new file mode 100644 index 000000000..3ce3b2577 --- /dev/null +++ b/codexbar-usage/BarWidget.qml @@ -0,0 +1,286 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Services.UI +import qs.Widgets +import "codexbar.js" as CodexBar + +Item { + id: root + + property var pluginApi: null + property ShellScreen screen + property string widgetId: "" + property string section: "" + property int sectionWidgetIndex: -1 + property int sectionWidgetsCount: 0 + + readonly property string screenName: screen ? screen.name : "" + readonly property string barPosition: Settings.getBarPositionForScreen(screenName) + readonly property bool isBarVertical: barPosition === "left" || barPosition === "right" + readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName) + readonly property real barFontSize: Style.getBarFontSizeForScreen(screenName) + + property int primaryPercent: -1 + property int secondaryPercent: -1 + property string primaryReset: "" + property string secondaryReset: "" + property string accountEmail: "" + property string statusText: "…" + property string errorText: "" + property bool loading: false + + readonly property string displayText: { + if (loading && primaryPercent < 0) + return "…"; + if (errorText !== "") + return "ERR"; + if (primaryPercent >= 0 && secondaryPercent >= 0) + return primaryPercent + "%·" + secondaryPercent + "%"; + if (primaryPercent >= 0) + return primaryPercent + "%"; + return "—"; + } + + readonly property string tooltipText: { + if (errorText !== "") + return "CodexBar: " + errorText; + var tip = "CodexBar"; + if (accountEmail !== "") + tip += " — " + accountEmail; + if (primaryPercent >= 0) + tip += "\n5h: " + primaryPercent + "%" + (primaryReset !== "" ? " · resets " + primaryReset : ""); + if (secondaryPercent >= 0) + tip += "\nWeekly: " + secondaryPercent + "%" + (secondaryReset !== "" ? " · resets " + secondaryReset : ""); + return tip; + } + + readonly property real contentWidth: isBarVertical ? capsuleHeight : content.implicitWidth + Style.marginM * 2 + readonly property real contentHeight: isBarVertical ? content.implicitHeight + Style.marginM * 2 : capsuleHeight + readonly property real gaugeWidth: Math.max(4, Math.round(capsuleHeight * 0.16)) + readonly property real gaugeHeight: Math.max(16, Math.round(capsuleHeight * 0.56)) + readonly property real meterFontSize: Math.max(7, barFontSize - 3) + readonly property int refreshIntervalMs: CodexBar.refreshIntervalMs(pluginApi) + + anchors.centerIn: parent + implicitWidth: contentWidth + implicitHeight: contentHeight + + Component.onCompleted: refresh() + + Timer { + interval: root.refreshIntervalMs + running: true + repeat: true + onTriggered: root.refresh() + } + + Process { + id: codexbarProcess + command: CodexBar.command(root.pluginApi) + running: false + stdout: StdioCollector { + id: codexbarStdout + onStreamFinished: root.parseOutput(text) + } + stderr: StdioCollector { + id: codexbarStderr + } + onRunningChanged: root.loading = running + onExited: function(exitCode) { + if (exitCode !== 0) { + var err = String(codexbarStderr.text || "").trim(); + root.errorText = err !== "" ? err : "codexbar exited " + exitCode; + } + } + } + + function refresh() { + if (codexbarProcess.running) + return; + errorText = ""; + codexbarProcess.running = true; + } + + function parseOutput(output) { + var usage = CodexBar.parseUsage(output); + if (usage.error !== "") { + errorText = usage.error; + return; + } + accountEmail = usage.accountEmail; + primaryPercent = usage.primaryPercent; + secondaryPercent = usage.secondaryPercent; + primaryReset = usage.primaryReset; + secondaryReset = usage.secondaryReset; + statusText = displayText; + errorText = ""; + } + + function clampPercent(value) { + return CodexBar.clampPercent(value); + } + + function usageColor(value) { + if (errorText !== "") + return Color.mError; + if (value < 0 || isNaN(value)) + return Color.mOutline; + if (value >= 85) + return Color.mError; + if (value >= 60) + return Color.mSecondary; + return Color.mPrimary; + } + + Rectangle { + id: visualCapsule + x: Style.pixelAlignCenter(parent.width, width) + y: Style.pixelAlignCenter(parent.height, height) + width: root.contentWidth + height: root.contentHeight + radius: Style.radiusL + color: mouseArea.containsMouse ? Color.mHover : Style.capsuleColor + border.color: Style.capsuleBorderColor + border.width: Style.capsuleBorderWidth + + Item { + id: content + anchors.centerIn: parent + implicitWidth: rowLayout.visible ? rowLayout.implicitWidth : colLayout.implicitWidth + implicitHeight: rowLayout.visible ? rowLayout.implicitHeight : colLayout.implicitHeight + + RowLayout { + id: rowLayout + visible: !root.isBarVertical + spacing: Style.marginS + + NIcon { + icon: root.errorText !== "" ? "alert-circle" : "ai" + pointSize: root.barFontSize + applyUiScale: false + color: root.errorText !== "" ? Color.mError : Color.mPrimary + Layout.alignment: Qt.AlignVCenter + } + + RowLayout { + spacing: Style.marginXS + Layout.alignment: Qt.AlignVCenter + + NText { + text: pluginApi?.tr("bar.primary-short") + pointSize: root.meterFontSize + applyUiScale: false + font.weight: Style.fontWeightSemiBold + color: Qt.alpha(Color.mOnSurface, 0.78) + Layout.alignment: Qt.AlignVCenter + } + + NLinearGauge { + orientation: Qt.Vertical + ratio: root.clampPercent(root.primaryPercent) / 100 + fillColor: root.usageColor(root.primaryPercent) + width: root.gaugeWidth + height: root.gaugeHeight + Layout.alignment: Qt.AlignVCenter + } + + NText { + text: pluginApi?.tr("bar.secondary-short") + pointSize: root.meterFontSize + applyUiScale: false + font.weight: Style.fontWeightSemiBold + color: Qt.alpha(Color.mOnSurface, 0.78) + Layout.leftMargin: Style.marginXS + Layout.alignment: Qt.AlignVCenter + } + + NLinearGauge { + orientation: Qt.Vertical + ratio: root.clampPercent(root.secondaryPercent) / 100 + fillColor: root.usageColor(root.secondaryPercent) + width: root.gaugeWidth + height: root.gaugeHeight + Layout.alignment: Qt.AlignVCenter + } + } + } + + ColumnLayout { + id: colLayout + visible: root.isBarVertical + spacing: Style.marginXS + + NIcon { + icon: root.errorText !== "" ? "alert-circle" : "ai" + pointSize: root.barFontSize + applyUiScale: false + color: root.errorText !== "" ? Color.mError : Color.mPrimary + Layout.alignment: Qt.AlignHCenter + } + + RowLayout { + spacing: Style.marginXS + Layout.alignment: Qt.AlignHCenter + + NText { + text: pluginApi?.tr("bar.primary-short") + pointSize: root.meterFontSize + applyUiScale: false + font.weight: Style.fontWeightSemiBold + color: Qt.alpha(Color.mOnSurface, 0.78) + } + + NLinearGauge { + orientation: Qt.Vertical + ratio: root.clampPercent(root.primaryPercent) / 100 + fillColor: root.usageColor(root.primaryPercent) + width: root.gaugeWidth + height: root.gaugeHeight + } + } + + RowLayout { + spacing: Style.marginXS + Layout.alignment: Qt.AlignHCenter + + NText { + text: pluginApi?.tr("bar.secondary-short") + pointSize: root.meterFontSize + applyUiScale: false + font.weight: Style.fontWeightSemiBold + color: Qt.alpha(Color.mOnSurface, 0.78) + } + + NLinearGauge { + orientation: Qt.Vertical + ratio: root.clampPercent(root.secondaryPercent) / 100 + fillColor: root.usageColor(root.secondaryPercent) + width: root.gaugeWidth + height: root.gaugeHeight + } + } + } + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + + onClicked: mouse => { + TooltipService.hide(); + if (mouse.button === Qt.LeftButton && pluginApi) + pluginApi.togglePanel(root.screen, visualCapsule); + else if (mouse.button === Qt.RightButton) + root.refresh(); + } + onEntered: TooltipService.show(root, root.tooltipText, BarService.getTooltipDirection(root.screenName)) + onExited: TooltipService.hide() + } +} diff --git a/codexbar-usage/LICENSE b/codexbar-usage/LICENSE new file mode 100644 index 000000000..72bebdf95 --- /dev/null +++ b/codexbar-usage/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ray García + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/codexbar-usage/Panel.qml b/codexbar-usage/Panel.qml new file mode 100644 index 000000000..750dd824e --- /dev/null +++ b/codexbar-usage/Panel.qml @@ -0,0 +1,430 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Widgets +import "codexbar.js" as CodexBar + +Item { + id: root + + property var pluginApi: null + + readonly property var geometryPlaceholder: panelContainer + readonly property bool allowAttach: true + property real contentPreferredWidth: 380 * Style.uiScaleRatio + property real contentPreferredHeight: Math.min(520 * Style.uiScaleRatio, panelContent.implicitHeight + Style.marginL * 2) + + anchors.fill: parent + + property bool loading: false + property string errorText: "" + property string sourceName: "" + property string providerName: "Codex" + property string versionText: "" + property string updatedAt: "" + property string accountEmail: "" + property string loginMethod: "" + property string providerId: "" + property int primaryPercent: -1 + property int secondaryPercent: -1 + property int primaryWindowMinutes: 0 + property int secondaryWindowMinutes: 0 + property string primaryResetAt: "" + property string secondaryResetAt: "" + property string primaryReset: "" + property string secondaryReset: "" + property int creditsRemaining: 0 + property int creditEventsCount: 0 + + Component.onCompleted: refresh() + onVisibleChanged: if (visible) refresh() + + Process { + id: codexbarProcess + command: CodexBar.command(root.pluginApi) + running: false + stdout: StdioCollector { id: codexbarStdout } + stderr: StdioCollector { id: codexbarStderr } + onRunningChanged: root.loading = running + onExited: function(exitCode) { + if (exitCode !== 0) { + var err = String(codexbarStderr.text || "").trim() + root.errorText = err !== "" ? err : "codexbar exited " + exitCode + return + } + root.parseOutput(codexbarStdout.text) + } + } + + function refresh() { + if (codexbarProcess.running) + return + errorText = "" + codexbarProcess.running = true + } + + function parseOutput(output) { + var parsed = CodexBar.parseUsage(output) + if (parsed.error !== "") { + errorText = parsed.error + return + } + + sourceName = parsed.sourceName + providerName = parsed.providerName + versionText = parsed.versionText + updatedAt = parsed.updatedAt + accountEmail = parsed.accountEmail + loginMethod = parsed.loginMethod + providerId = parsed.providerId + primaryPercent = parsed.primaryPercent + secondaryPercent = parsed.secondaryPercent + primaryWindowMinutes = parsed.primaryWindowMinutes + secondaryWindowMinutes = parsed.secondaryWindowMinutes + primaryResetAt = parsed.primaryResetAt + secondaryResetAt = parsed.secondaryResetAt + primaryReset = parsed.primaryReset + secondaryReset = parsed.secondaryReset + creditsRemaining = parsed.creditsRemaining + creditEventsCount = parsed.creditEventsCount + errorText = "" + } + + function clampPercent(value) { + return CodexBar.clampPercent(value) + } + + function ratio(value) { + return CodexBar.clampPercent(value) / 100 + } + + function percentLabel(value) { + return CodexBar.percentLabel(value) + } + + function windowLabel(minutes) { + return CodexBar.windowLabel(minutes) + } + + function formatDateTime(isoText) { + if (isoText === "") + return "" + var d = new Date(isoText) + if (isNaN(d.getTime())) + return isoText + return d.toLocaleString(Qt.locale(), Locale.ShortFormat) + } + + function updatedLabel() { + var formatted = formatDateTime(updatedAt) + return formatted !== "" ? formatted : "—" + } + + function usageColor(value) { + if (errorText !== "") + return Color.mError + if (value < 0 || isNaN(value)) + return Color.mOutline + if (value >= 85) + return Color.mError + if (value >= 60) + return Color.mSecondary + return Color.mPrimary + } + + Rectangle { + id: panelContainer + anchors.fill: parent + color: "transparent" + + ColumnLayout { + id: panelContent + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: Style.marginL + } + spacing: Style.marginM + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + NIcon { + icon: root.errorText !== "" ? "alert-circle" : "ai" + color: root.errorText !== "" ? Color.mError : Color.mPrimary + pointSize: Style.fontSizeXL + Layout.alignment: Qt.AlignVCenter + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXXS + + NText { + text: pluginApi?.tr("panel.title") + pointSize: Style.fontSizeL + font.weight: Style.fontWeightBold + color: Color.mOnSurface + Layout.fillWidth: true + } + + NText { + text: root.accountEmail !== "" ? root.accountEmail : root.sourceName + pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + elide: Text.ElideRight + Layout.fillWidth: true + } + } + + Rectangle { + width: 34 + height: 34 + radius: Style.radiusM + color: refreshMouse.containsMouse ? Color.mPrimary : Color.mSurfaceVariant + border.color: refreshMouse.containsMouse ? Color.mPrimary : Style.capsuleBorderColor + border.width: Style.capsuleBorderWidth + + NIcon { + anchors.centerIn: parent + icon: root.loading ? "loader" : "refresh" + color: refreshMouse.containsMouse ? Color.mOnPrimary : Color.mOnSurfaceVariant + RotationAnimation on rotation { + running: root.loading + from: 0 + to: 360 + duration: 1000 + loops: Animation.Infinite + } + } + + MouseArea { + id: refreshMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.refresh() + } + } + } + + Rectangle { + visible: root.errorText !== "" + Layout.fillWidth: true + implicitHeight: errorTextItem.implicitHeight + Style.marginL * 2 + radius: Style.radiusM + color: Qt.alpha(Color.mError, 0.12) + + NText { + id: errorTextItem + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + margins: Style.marginL + } + text: root.errorText + wrapMode: Text.WordWrap + pointSize: Style.fontSizeS + font.weight: Style.fontWeightSemiBold + color: Color.mError + } + } + + LimitCard { + Layout.fillWidth: true + title: "5h limit" + subtitle: root.windowLabel(root.primaryWindowMinutes) + percent: root.primaryPercent + resetShort: root.primaryReset + resetFull: root.formatDateTime(root.primaryResetAt) + barColor: root.usageColor(root.primaryPercent) + } + + LimitCard { + Layout.fillWidth: true + title: "Weekly limit" + subtitle: root.windowLabel(root.secondaryWindowMinutes) + percent: root.secondaryPercent + resetShort: root.secondaryReset + resetFull: root.formatDateTime(root.secondaryResetAt) + barColor: root.usageColor(root.secondaryPercent) + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: metaGrid.implicitHeight + Style.marginL * 2 + radius: Style.radiusL + color: Color.mSurfaceVariant + + GridLayout { + id: metaGrid + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: Style.marginL + } + columns: 2 + rowSpacing: Style.marginS + columnSpacing: Style.marginL + + MetaItem { label: pluginApi?.tr("panel.login"); value: root.loginMethod !== "" ? root.loginMethod : "—" } + MetaItem { label: pluginApi?.tr("panel.provider"); value: root.providerId !== "" ? root.providerId : root.providerName } + MetaItem { label: pluginApi?.tr("panel.source"); value: root.sourceName !== "" ? root.sourceName : "—" } + MetaItem { label: pluginApi?.tr("panel.codexbar"); value: root.versionText !== "" ? "v" + root.versionText : "—" } + MetaItem { label: pluginApi?.tr("panel.credits"); value: pluginApi?.tr("panel.credits-remaining", { count: root.creditsRemaining }) } + MetaItem { label: pluginApi?.tr("panel.credit-events"); value: String(root.creditEventsCount) } + } + } + + NText { + Layout.fillWidth: true + text: pluginApi?.tr("panel.updated", { time: root.updatedLabel() }) + pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + horizontalAlignment: Text.AlignHCenter + } + } + } + + component LimitCard: Rectangle { + id: card + property string title: "" + property string subtitle: "" + property int percent: -1 + property string resetShort: "" + property string resetFull: "" + property color barColor: Color.mPrimary + + radius: Style.radiusL + color: Color.mSurfaceVariant + implicitHeight: limitLayout.implicitHeight + Style.marginL * 2 + + ColumnLayout { + id: limitLayout + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: Style.marginL + } + spacing: Style.marginS + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXXS + + NText { + text: card.title + pointSize: Style.fontSizeM + font.weight: Style.fontWeightBold + color: Color.mOnSurface + Layout.fillWidth: true + } + + NText { + text: card.subtitle + pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + Layout.fillWidth: true + } + } + + NText { + text: root.percentLabel(card.percent) + pointSize: Style.fontSizeXXL + font.weight: Style.fontWeightBold + color: card.barColor + Layout.alignment: Qt.AlignVCenter + } + } + + Rectangle { + Layout.fillWidth: true + height: 10 + radius: Style.radiusXXS + color: Qt.alpha(Color.mOutline, 0.22) + clip: true + + Rectangle { + anchors { + left: parent.left + top: parent.top + bottom: parent.bottom + } + radius: parent.radius + color: card.barColor + width: parent.width * root.ratio(card.percent) + + Behavior on width { + NumberAnimation { duration: Style.animationNormal; easing.type: Easing.OutCubic } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + NText { + text: pluginApi?.tr("panel.resets") + pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + } + + Item { Layout.fillWidth: true } + + NText { + text: card.resetShort !== "" ? card.resetShort : (card.resetFull !== "" ? card.resetFull : "—") + pointSize: Style.fontSizeXS + font.weight: Style.fontWeightSemiBold + color: Color.mOnSurface + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + Layout.maximumWidth: 210 * Style.uiScaleRatio + } + } + + NText { + visible: card.resetFull !== "" && card.resetFull !== card.resetShort + text: card.resetFull + pointSize: Style.fontSizeXXS + color: Color.mOnSurfaceVariant + horizontalAlignment: Text.AlignRight + Layout.fillWidth: true + } + } + } + + component MetaItem: ColumnLayout { + property string label: "" + property string value: "" + spacing: Style.marginXXS + Layout.fillWidth: true + + NText { + text: parent.label + pointSize: Style.fontSizeXXS + color: Color.mOnSurfaceVariant + Layout.fillWidth: true + } + + NText { + text: parent.value + pointSize: Style.fontSizeS + font.weight: Style.fontWeightSemiBold + color: Color.mOnSurface + elide: Text.ElideRight + Layout.fillWidth: true + } + } +} diff --git a/codexbar-usage/README.md b/codexbar-usage/README.md new file mode 100644 index 000000000..33f64e192 --- /dev/null +++ b/codexbar-usage/README.md @@ -0,0 +1,105 @@ +# Codex Usage for Noctalia + +A compact Noctalia/Quickshell plugin that shows OpenAI Codex usage limits in the Noctalia bar and opens an attached details panel on click. + +![Noctalia CodexBar usage panel](assets/codex-usage.png) + +## Features + +- Compact bar gauges for the Codex 5 hour and weekly limits. +- Left-click opens a Noctalia-style attached details panel. +- Right-click refreshes usage data. +- Details panel with horizontal usage bars and reset times. +- Metadata from CodexBar: account, login method, provider/source, version, credits, credit events, and update time. +- Settings UI for the CodexBar executable path, source mode, and polling interval. + +## Dependency: CodexBar + +This plugin is a UI wrapper around the [`codexbar`](https://github.com/steipete/codexbar) CLI. It does not try to parse OpenAI/Codex auth, cookies, sessions, or quota pages itself. + +Default command: + +```bash +codexbar usage --provider codex --source cli --format json +``` + +That is deliberate: + +- CodexBar owns provider/auth/session details. +- This plugin stays small and focused on Noctalia UI. +- If OpenAI changes quota internals, users can update CodexBar without updating this plugin. + +## Settings + +Default settings: + +```json +{ + "refreshIntervalSec": 60, + "codexbarPath": "codexbar", + "codexbarSource": "cli" +} +``` + +If Noctalia/Quickshell does not inherit your shell `PATH`, set `codexbarPath` to an absolute path: + +```json +{ + "codexbarPath": "/home/you/.local/bin/codexbar" +} +``` + +`codexbarSource` is passed to `--source`. The default is `cli` because it works from local Codex CLI state. If your CodexBar setup uses another supported source, set it to that value. + +Verify the dependency manually: + +```bash +codexbar usage --provider codex --source cli --format json +``` + +The output must be valid JSON. + +## Install + +Install from the Noctalia plugin manager once the plugin is listed, or copy/clone this repository into Noctalia's plugin directory: + +```bash +mkdir -p ~/.config/noctalia/plugins +git clone https://github.com/rayoplateado/noctalia-codex-usage ~/.config/noctalia/plugins/codexbar-usage +``` + +Enable it in `~/.config/noctalia/plugins.json`: + +```json +{ + "states": { + "codexbar-usage": { + "enabled": true, + "sourceUrl": "local" + } + } +} +``` + +Reload Noctalia/Quickshell if hot reload does not pick it up. + +## Local development + +Sync this repo into Noctalia: + +```bash +rsync -a --delete ./ ~/.config/noctalia/plugins/codexbar-usage/ +``` + +## Files + +- `manifest.json` — Noctalia plugin metadata and entry points. +- `BarWidget.qml` — compact bar widget. +- `Panel.qml` — attached detail panel. +- `Settings.qml` — Noctalia settings UI for command path, source, and refresh interval. +- `codexbar.js` — shared CodexBar command construction and JSON parsing. +- `settings.json` — default/local settings. + +## License + +MIT diff --git a/codexbar-usage/Settings.qml b/codexbar-usage/Settings.qml new file mode 100644 index 000000000..ad74055a2 --- /dev/null +++ b/codexbar-usage/Settings.qml @@ -0,0 +1,185 @@ +import QtQuick +import QtQuick.Layouts +import qs.Commons +import qs.Widgets +import "codexbar.js" as CodexBar + +ColumnLayout { + id: root + + property var pluginApi: null + property bool loaded: false + property string codexbarPath: "codexbar" + property string codexbarSource: "cli" + property int refreshIntervalSec: 60 + + spacing: Style.marginL + + function loadSettings() { + var defaults = pluginApi && pluginApi.manifest && pluginApi.manifest.metadata + ? pluginApi.manifest.metadata.defaultSettings || {} + : {} + var settings = pluginApi && pluginApi.pluginSettings ? pluginApi.pluginSettings : defaults + loaded = false + codexbarPath = settings.codexbarPath || defaults.codexbarPath || "codexbar" + codexbarSource = settings.codexbarSource || defaults.codexbarSource || "cli" + refreshIntervalSec = Number(settings.refreshIntervalSec !== undefined ? settings.refreshIntervalSec : (defaults.refreshIntervalSec !== undefined ? defaults.refreshIntervalSec : 60)) + if (isNaN(refreshIntervalSec)) + refreshIntervalSec = 60 + loaded = true + } + + function saveSettings() { + if (!pluginApi || !loaded) + return + pluginApi.pluginSettings.codexbarPath = codexbarPath.trim() !== "" ? codexbarPath.trim() : "codexbar" + pluginApi.pluginSettings.codexbarSource = codexbarSource.trim() !== "" ? codexbarSource.trim() : "cli" + pluginApi.pluginSettings.refreshIntervalSec = Math.max(5, Number(refreshIntervalSec)) + pluginApi.saveSettings() + } + + Component.onCompleted: loadSettings() + onPluginApiChanged: loadSettings() + + NText { + text: pluginApi?.tr("settings.title") + pointSize: Style.fontSizeXL + font.weight: Style.fontWeightBold + color: Color.mOnSurface + Layout.fillWidth: true + } + + Rectangle { + Layout.fillWidth: true + color: Color.mSurfaceVariant + radius: Style.radiusS + implicitHeight: content.implicitHeight + Style.marginXL + + ColumnLayout { + id: content + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: Style.marginL + } + spacing: Style.marginM + + NText { + text: pluginApi?.tr("settings.cli-section") + pointSize: Style.fontSizeL + font.weight: Style.fontWeightSemiBold + color: Color.mPrimary + Layout.fillWidth: true + } + + NTextInput { + Layout.fillWidth: true + label: pluginApi?.tr("settings.codexbar-path.label") + description: pluginApi?.tr("settings.codexbar-path.description") + placeholderText: "codexbar" + text: root.codexbarPath + onTextChanged: { + root.codexbarPath = text + root.saveSettings() + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXS + + NText { + text: pluginApi?.tr("settings.source.label") + pointSize: Style.fontSizeM + font.weight: Style.fontWeightSemiBold + color: Color.mOnSurface + Layout.fillWidth: true + } + + NText { + text: pluginApi?.tr("settings.source.description") + pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + NComboBox { + Layout.fillWidth: true + model: [ + { key: "cli", name: "CLI" }, + { key: "auto", name: "Auto" }, + { key: "web", name: "Web" }, + { key: "oauth", name: "OAuth" }, + { key: "api", name: "API" } + ] + currentKey: root.codexbarSource + onSelected: key => { + root.codexbarSource = key + root.saveSettings() + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXS + + NText { + text: pluginApi?.tr("settings.refresh-interval") + pointSize: Style.fontSizeM + font.weight: Style.fontWeightSemiBold + color: Color.mOnSurface + Layout.fillWidth: true + } + + NSpinBox { + from: 5 + to: 3600 + stepSize: 5 + value: root.refreshIntervalSec + onValueChanged: { + root.refreshIntervalSec = value + root.saveSettings() + } + } + } + } + } + + Rectangle { + Layout.fillWidth: true + radius: Style.radiusS + color: Qt.alpha(Color.mPrimary, 0.10) + implicitHeight: commandColumn.implicitHeight + Style.marginXL + + ColumnLayout { + id: commandColumn + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: Style.marginL + } + spacing: Style.marginS + + NText { + text: pluginApi?.tr("settings.command-preview") + pointSize: Style.fontSizeM + font.weight: Style.fontWeightSemiBold + color: Color.mPrimary + Layout.fillWidth: true + } + + NText { + text: CodexBar.command(root.pluginApi).join(" ") + pointSize: Style.fontSizeXS + color: Color.mOnSurface + font.family: "monospace" + wrapMode: Text.WrapAnywhere + Layout.fillWidth: true + } + } + } +} diff --git a/codexbar-usage/assets/codex-usage.png b/codexbar-usage/assets/codex-usage.png new file mode 100644 index 000000000..278d92c4d Binary files /dev/null and b/codexbar-usage/assets/codex-usage.png differ diff --git a/codexbar-usage/codexbar.js b/codexbar-usage/codexbar.js new file mode 100644 index 000000000..7774dc30a --- /dev/null +++ b/codexbar-usage/codexbar.js @@ -0,0 +1,130 @@ +function defaultSettings(pluginApi) { + if (!pluginApi || !pluginApi.manifest || !pluginApi.manifest.metadata || !pluginApi.manifest.metadata.defaultSettings) + return {} + return pluginApi.manifest.metadata.defaultSettings +} + +function setting(pluginApi, key, fallback) { + var settings = pluginApi && pluginApi.pluginSettings ? pluginApi.pluginSettings : {} + var defaults = defaultSettings(pluginApi) + var value = settings[key] + if (value === undefined || value === null || value === "") + value = defaults[key] + if (value === undefined || value === null || value === "") + value = fallback + return value +} + +function command(pluginApi) { + return [ + setting(pluginApi, "codexbarPath", "codexbar"), + "usage", + "--provider", "codex", + "--source", setting(pluginApi, "codexbarSource", "cli"), + "--format", "json" + ] +} + +function refreshIntervalMs(pluginApi) { + var seconds = Number(setting(pluginApi, "refreshIntervalSec", 60)) + if (isNaN(seconds)) + seconds = 60 + return Math.max(5, seconds) * 1000 +} + +function valueOrFallback(value, fallback) { + return value === undefined || value === null ? fallback : value +} + +function emptyUsage() { + return { + error: "", + sourceName: "", + providerName: "Codex", + versionText: "", + updatedAt: "", + accountEmail: "", + loginMethod: "", + providerId: "codex", + primaryPercent: -1, + secondaryPercent: -1, + primaryWindowMinutes: 0, + secondaryWindowMinutes: 0, + primaryResetAt: "", + secondaryResetAt: "", + primaryReset: "", + secondaryReset: "", + creditsRemaining: 0, + creditEventsCount: 0 + } +} + +function parseUsage(output) { + var result = emptyUsage() + try { + var trimmed = String(output || "").trim() + if (trimmed === "") { + result.error = "empty codexbar output" + return result + } + + var data = JSON.parse(trimmed) + var item = Array.isArray(data) && data.length > 0 ? data[0] : data + if (item.error) { + result.error = item.error.message || "codexbar error" + return result + } + + var usage = item.usage || {} + var primary = usage.primary || {} + var secondary = usage.secondary || {} + var identity = usage.identity || {} + var credits = item.credits || {} + + result.sourceName = item.source || "codex-cli" + result.providerName = item.provider || identity.providerID || "codex" + result.versionText = item.version || "" + result.updatedAt = usage.updatedAt || credits.updatedAt || "" + result.accountEmail = usage.accountEmail || identity.accountEmail || "" + result.loginMethod = usage.loginMethod || identity.loginMethod || "" + result.providerId = identity.providerID || item.provider || "codex" + result.primaryPercent = Number(valueOrFallback(primary.usedPercent, -1)) + result.secondaryPercent = Number(valueOrFallback(secondary.usedPercent, -1)) + result.primaryWindowMinutes = Number(valueOrFallback(primary.windowMinutes, 0)) + result.secondaryWindowMinutes = Number(valueOrFallback(secondary.windowMinutes, 0)) + result.primaryResetAt = primary.resetsAt || "" + result.secondaryResetAt = secondary.resetsAt || "" + result.primaryReset = primary.resetDescription || "" + result.secondaryReset = secondary.resetDescription || "" + result.creditsRemaining = Number(valueOrFallback(credits.remaining, 0)) + result.creditEventsCount = Array.isArray(credits.events) ? credits.events.length : 0 + return result + } catch (e) { + result.error = "parse failed: " + e + return result + } +} + +function clampPercent(value) { + if (value < 0 || isNaN(value)) + return 0 + return Math.min(100, Math.max(0, value)) +} + +function percentLabel(value) { + if (value < 0 || isNaN(value)) + return "—" + return Math.round(value) + "%" +} + +function windowLabel(minutes) { + if (minutes === 300) + return "5 hour window" + if (minutes === 10080) + return "Weekly window" + if (minutes >= 1440) + return Math.round(minutes / 1440) + " day window" + if (minutes >= 60) + return Math.round(minutes / 60) + " hour window" + return minutes > 0 ? minutes + " minute window" : "Window" +} diff --git a/codexbar-usage/i18n/en.json b/codexbar-usage/i18n/en.json new file mode 100644 index 000000000..ec49c49e2 --- /dev/null +++ b/codexbar-usage/i18n/en.json @@ -0,0 +1,32 @@ +{ + "bar": { + "primary-short": "5h", + "secondary-short": "W" + }, + "panel": { + "title": "Codex usage", + "login": "Login", + "provider": "Provider", + "source": "Source", + "codexbar": "CodexBar", + "credits": "Credits", + "credits-remaining": "{count} remaining", + "credit-events": "Credit events", + "updated": "Updated {time}", + "resets": "Resets" + }, + "settings": { + "title": "CodexBar Usage Settings", + "cli-section": "CodexBar CLI", + "codexbar-path": { + "label": "CodexBar path", + "description": "Command name or absolute path available to Noctalia/Quickshell. Use an absolute path if your graphical session does not inherit shell PATH." + }, + "source": { + "label": "Source", + "description": "Passed to `codexbar usage --source`. `cli` reads local Codex CLI state; use another source if your CodexBar setup requires it." + }, + "refresh-interval": "Refresh interval (seconds)", + "command-preview": "Command preview" + } +} diff --git a/codexbar-usage/manifest.json b/codexbar-usage/manifest.json new file mode 100644 index 000000000..1973ba340 --- /dev/null +++ b/codexbar-usage/manifest.json @@ -0,0 +1,35 @@ +{ + "id": "codexbar-usage", + "name": "Codex Usage", + "version": "1.0.0", + "minNoctaliaVersion": "4.6.0", + "author": "Ray García", + "license": "MIT", + "repository": "https://github.com/noctalia-dev/noctalia-plugins", + "description": "Codex usage limits in the Noctalia bar via CodexBar CLI.", + "tags": [ + "Bar", + "Panel", + "AI", + "Development", + "Indicator" + ], + "entryPoints": { + "barWidget": "BarWidget.qml", + "panel": "Panel.qml", + "settings": "Settings.qml" + }, + "dependencies": { + "plugins": [] + }, + "metadata": { + "icon": "ai", + "name": "CodexBar", + "description": "Codex rate-limit usage from codexbar usage --source cli", + "defaultSettings": { + "refreshIntervalSec": 60, + "codexbarPath": "codexbar", + "codexbarSource": "cli" + } + } +} diff --git a/codexbar-usage/preview.png b/codexbar-usage/preview.png new file mode 100644 index 000000000..3e7d74a85 Binary files /dev/null and b/codexbar-usage/preview.png differ