diff --git a/.github/actions/build-linux-desktop/action.yml b/.github/actions/build-linux-desktop/action.yml index 3d1b38cd54..60ca88e56f 100644 --- a/.github/actions/build-linux-desktop/action.yml +++ b/.github/actions/build-linux-desktop/action.yml @@ -25,7 +25,7 @@ runs: shell: bash run: | sudo apt-get update - sudo apt-get install -y qt6-base-dev qt6-declarative-dev qt6-wayland libqt6svg6 qml6-module-qtquick qml6-module-qtquick-controls qml6-module-qtquick-layouts qml6-module-qtquick-window qml6-module-qtquick-templates qml6-module-qtqml-workerscript + sudo apt-get install -y qt6-base-dev qt6-declarative-dev qt6-svg-dev qt6-wayland libqt6svg6 qml6-module-qtquick qml6-module-qtquick-controls qml6-module-qtquick-layouts qml6-module-qtquick-window qml6-module-qtquick-templates qml6-module-qtqml-workerscript - name: Build and test shell: bash env: diff --git a/Integrations/Linux/DesktopController.cpp b/Integrations/Linux/DesktopController.cpp index 018bfce0d0..e4791a6037 100644 --- a/Integrations/Linux/DesktopController.cpp +++ b/Integrations/Linux/DesktopController.cpp @@ -98,7 +98,7 @@ bool DesktopController::validate(QVariantMap &values) { !QStringList{"meters", "icon"}.contains(values.value("trayStyle").toString())) { m_configError = "Unsupported display preference."; return false; } - for (const auto &key : {"allAccounts", "showIdentity", "showCosts", "showStatus", "notifications", "showTray", "refreshOnOpen", "showPace", "warningColors", "followOmarchyTheme"}) + for (const auto &key : {"allAccounts", "showIdentity", "showCosts", "showStatus", "notifications", "showTray", "compactQuickView", "refreshOnOpen", "showPace", "warningColors", "followOmarchyTheme"}) values[key] = values.value(key).toBool(); return true; } @@ -106,7 +106,7 @@ bool DesktopController::validate(QVariantMap &values) { void DesktopController::loadSettings(const QString &cliOverride) { m_settings = {{"executable", "codexbar"}, {"provider", "codex"}, {"source", "auto"}, {"refreshSeconds", 300}, {"accountIndex", 0}, {"notifyThreshold", 10}, {"allAccounts", false}, - {"showIdentity", false}, {"showCosts", true}, {"showStatus", true}, {"notifications", false}, {"showTray", true}, {"refreshOnOpen", false}, {"providerOrder", QStringList{}}, + {"showIdentity", false}, {"showCosts", true}, {"showStatus", true}, {"notifications", false}, {"showTray", true}, {"compactQuickView", false}, {"refreshOnOpen", false}, {"providerOrder", QStringList{}}, {"quotaDisplay", "remaining"}, {"resetDisplay", "countdown"}, {"showPace", true}, {"warningColors", true}, {"trayStyle", "meters"}, {"followOmarchyTheme", false}}; m_configPath = QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) + "/codexbar/linux.json"; @@ -288,7 +288,9 @@ void DesktopController::copySummary() { void DesktopController::showWindow(const QString &page) { if (page == "spending" && QDateTime::currentMSecsSinceEpoch() - m_costUpdated > 300000) refreshCosts(); - if (page == "usage" && m_settings.value("refreshOnOpen").toBool()) refresh(); + if (page == "quick-view" && m_settings.value("showCosts").toBool() && + QDateTime::currentMSecsSinceEpoch() - m_costUpdated > 300000) refreshCosts(); + if ((page == "usage" || page == "dashboard" || page == "quick-view") && m_settings.value("refreshOnOpen").toBool()) refresh(); emit windowRequested(page); } @@ -356,7 +358,7 @@ bool DesktopController::listen(const QString &socketPath) { response = {{"ok", ok}, {"enabled", launchAtLogin()}}; } else if (command == "refresh") { refresh(); refreshCosts(); } - else if (command == "settings" || command == "usage" || command == "spending") showWindow(command); + else if (command == "settings" || command == "usage" || command == "dashboard" || command == "spending" || command == "quick-view") showWindow(command); else if (command == "configure" && request.value("settings").isObject()) { const bool ok = saveSettings(request.value("settings").toObject().toVariantMap()); response = {{"ok", ok}, {"error", m_configError}}; diff --git a/Integrations/Linux/README.md b/Integrations/Linux/README.md index 5fa4672753..2ea87e74e7 100644 --- a/Integrations/Linux/README.md +++ b/Integrations/Linux/README.md @@ -1,7 +1,7 @@ # CodexBar for Linux -A Qt 6 desktop app with separate Usage & Spend and Settings windows, an optional -system tray icon, and a launcher entry. The Swift `codexbar` CLI owns provider +A Qt 6 desktop app with an opt-in compact Quick View, separate Usage & Spend and Settings +windows, an optional system tray icon, and a launcher entry. The Swift `codexbar` CLI owns provider fetching and authentication. The desktop owns polling, settings, notifications, and a private local socket for desktop adapters. No HTTP server is needed. @@ -185,8 +185,14 @@ The app does not read terminal output or store credentials. Finish the flow, the refresh usage. These controls manage the active CLI session; browser imports, token-account editing and Mac managed profiles are not implemented here. -Usage displays used or remaining quota, reset times, pace, credits, status, generic provider -details, and charts. Unknown values stay unknown. Identity is hidden by default. Display preferences control reset countdowns, +Ordinary launch and `--usage` open the full Usage & Spend window. Quick View opens +with `--quick-view`, from the tray menu, or by clicking the tray icon after enabling +"Use compact Quick View from the tray" in Settings (off by default). It presents +an Overview and provider tabs with the same provider icons used by the macOS menu, compact quota meters, +reset times, pace, and optional local cost. The footer opens the full Usage & Spend +window, Settings, About, or Quit. `--dashboard` also opens the full Usage & Spend window +directly; `--spending` opens its Spending tab. The full Usage & Spend window retains +generic provider details and charts. Unknown values stay unknown. Identity is hidden by default. Display preferences control reset countdowns, absolute times, pace visibility, and low-quota colors. The tray can show two quota meters for the first displayed provider or a static icon. Unknown meters remain empty tracks. The tooltip identifies the displayed providers and stale data. @@ -203,7 +209,7 @@ are not invoices. Opening spending scans independently of quota polling, with a five-minute cache; Refresh forces a new scan. Quota polling defaults to five minutes. Optional refresh-on-open updates usage -when its window opens. Refresh and Ctrl+R update the selected tab independently; +when Quick View or the full usage window opens. Refresh and Ctrl+R update the selected tab independently; Ctrl+, opens Settings, and Ctrl+Q quits. Queries never overlap within each stream, stop after 60 seconds, and cap output at 8 MiB. Failed refreshes retain previous results with a stale indicator. Changing selection rejects old in-flight results. @@ -211,7 +217,7 @@ Optional notifications use the desktop's D-Bus notification service for remainin quota threshold crossings, observed resets, and service-status transitions. Startup, provider errors, and ambiguous multi-account results stay silent. -Closing a window leaves the backend running. Quit from the usage window or tray, +Closing a window leaves the backend running. Quit from Quick View or the tray, or use `codexbar-linux --quit`. Launching again opens the existing process. Preferences live in `$XDG_CONFIG_HOME/codexbar/linux.json` (normally `~/.config`), written atomically with user-only permissions. Invalid files are never overwritten: @@ -222,6 +228,8 @@ fix or remove the file and restart. Authentication remains in the CLI's stores. ```sh codexbar-linux --background codexbar-linux --usage +codexbar-linux --quick-view +codexbar-linux --dashboard codexbar-linux --settings codexbar-linux --spending codexbar-linux --refresh diff --git a/Integrations/Linux/codexbar-linux.pro b/Integrations/Linux/codexbar-linux.pro index eadc7a5083..b1daba0cd9 100644 --- a/Integrations/Linux/codexbar-linux.pro +++ b/Integrations/Linux/codexbar-linux.pro @@ -1,10 +1,10 @@ -QT += core gui widgets quick qml quickcontrols2 network dbus +QT += core gui widgets quick qml quickcontrols2 network dbus svg CONFIG += c++17 console CONFIG -= app_bundle TARGET = codexbar-linux SOURCES += main.cpp DesktopController.cpp HEADERS += DesktopController.h -RESOURCES += desktop.qrc +RESOURCES += desktop.qrc provider-icons.qrc QMAKE_CXXFLAGS += -Wall -Wextra target.path = $$PREFIX/bin isEmpty(PREFIX): target.path = /usr/local/bin diff --git a/Integrations/Linux/desktop.qrc b/Integrations/Linux/desktop.qrc index 7fbc924eaa..003659babb 100644 --- a/Integrations/Linux/desktop.qrc +++ b/Integrations/Linux/desktop.qrc @@ -2,6 +2,8 @@ Shared/Usage.js Shared/Notifications.js qml/Main.qml + qml/QuickView.qml + qml/MenuMetric.qml qml/Dashboard.qml qml/SettingsWindow.qml qml/UsageCard.qml diff --git a/Integrations/Linux/main.cpp b/Integrations/Linux/main.cpp index 3f3d1e0a71..c696339254 100644 --- a/Integrations/Linux/main.cpp +++ b/Integrations/Linux/main.cpp @@ -1,12 +1,14 @@ #include "DesktopController.h" #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -15,13 +17,41 @@ #include #include #include +#include #include #include +#include #include #include #include #include +// The shared provider SVGs are white template assets on macOS. Tint them using the +// active Qt palette so they stay legible in both light and dark Linux appearances. +class ProviderIconProvider final : public QQuickImageProvider { +public: + ProviderIconProvider() : QQuickImageProvider(QQuickImageProvider::Image) {} + + QImage requestImage(const QString &id, QSize *size, const QSize &requestedSize) override { + const auto provider = id.section('?', 0, 0); + if (!QRegularExpression("^[a-z0-9-]{1,80}$").match(provider).hasMatch()) return {}; + QFile icon(":/logos/" + provider + ".svg"); + if (!icon.open(QIODevice::ReadOnly)) return {}; + QSvgRenderer svg(icon.readAll()); + if (!svg.isValid()) return {}; + const auto color = QColor("#" + id.section("color=", 1, 1).section('&', 0, 0)); + const auto dimensions = requestedSize.isValid() ? requestedSize : QSize(36, 36); + QImage image(dimensions, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + QPainter painter(&image); + svg.render(&painter); + painter.setCompositionMode(QPainter::CompositionMode_SourceIn); + painter.fillRect(image.rect(), color.isValid() ? color : QColor(Qt::black)); + if (size) *size = dimensions; + return image; + } +}; + static QByteArray request(const QString &socketPath, const QJsonObject &message) { QLocalSocket socket; socket.connectToServer(socketPath); @@ -49,14 +79,14 @@ int main(int argc, char **argv) { QCommandLineParser parser; parser.setApplicationDescription("CodexBar desktop for Linux · Qt windows and optional tray"); parser.addHelpOption(); parser.addVersionOption(); - for (const auto &name : {"snapshot", "refresh", "settings", "spending", "usage", "quit", "background", "no-tray"}) + for (const auto &name : {"snapshot", "refresh", "settings", "spending", "dashboard", "usage", "quick-view", "quit", "background", "no-tray"}) parser.addOption(QCommandLineOption(name, QString("%1 the running desktop app").arg(name))); parser.addOption(QCommandLineOption("autostart", "Set login startup: enable, disable, status", "action")); parser.addOption(QCommandLineOption("configure", "Update desktop settings through local IPC", "json")); parser.addOption(QCommandLineOption("cli", "CodexBar CLI executable for a new instance", "path")); parser.process(*application); QString command = "usage"; - for (const auto &name : {"background", "usage", "settings", "spending", "refresh", "snapshot", "quit", "configure", "autostart"}) + for (const auto &name : {"background", "usage", "quick-view", "settings", "spending", "dashboard", "refresh", "snapshot", "quit", "configure", "autostart"}) if (parser.isSet(name)) command = name; const bool clientOnly = QStringList{"snapshot", "refresh", "quit", "configure", "autostart"}.contains(command); const bool noTray = parser.isSet("no-tray"); @@ -139,20 +169,23 @@ int main(int argc, char **argv) { QObject::connect(&controller, &DesktopController::settingsChanged, &app, updateTheme); themeTimer.start(10000); updateTheme(); QQmlApplicationEngine engine; + engine.addImageProvider("provider-icon", new ProviderIconProvider); QObject::connect(&engine, &QQmlApplicationEngine::quit, &app, &QCoreApplication::quit); engine.rootContext()->setContextProperty("desktop", &controller); engine.load(QUrl("qrc:/qml/Main.qml")); if (engine.rootObjects().isEmpty()) return 1; QSystemTrayIcon tray(QIcon(":/icon.svg")); QMenu menu; - menu.addAction("Usage & Spend…", &controller, [&controller] { controller.showWindow("usage"); }); + menu.addAction("Quick View", &controller, [&controller] { controller.showWindow("quick-view"); }); + menu.addAction("Usage & Spend…", &controller, [&controller] { controller.showWindow("dashboard"); }); menu.addAction("Settings…", &controller, [&controller] { controller.showWindow("settings"); }); menu.addSeparator(); menu.addAction("Refresh", &controller, [&controller] { controller.refresh(); controller.refreshCosts(); }); menu.addAction("Quit CodexBar", &app, &QCoreApplication::quit); tray.setContextMenu(&menu); QObject::connect(&tray, &QSystemTrayIcon::activated, &controller, [&controller](QSystemTrayIcon::ActivationReason reason) { - if (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::DoubleClick) controller.showWindow("usage"); + if (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::DoubleClick) + controller.showWindow(controller.settings().value("compactQuickView").toBool() ? "quick-view" : "usage"); }); auto updateTray = [&] { tray.setVisible(!noTray && controller.settings().value("showTray").toBool()); diff --git a/Integrations/Linux/provider-icons.qrc b/Integrations/Linux/provider-icons.qrc new file mode 100644 index 0000000000..ddf86a758b --- /dev/null +++ b/Integrations/Linux/provider-icons.qrc @@ -0,0 +1,84 @@ + + + ../../Sources/CodexBar/Resources/ProviderIcon-abacus.svg + ../../Sources/CodexBar/Resources/ProviderIcon-aiand.svg + ../../Sources/CodexBar/Resources/ProviderIcon-alibaba.svg + ../../Sources/CodexBar/Resources/ProviderIcon-amp.svg + ../../Sources/CodexBar/Resources/ProviderIcon-antigravity.svg + ../../Sources/CodexBar/Resources/ProviderIcon-atlascloud.svg + ../../Sources/CodexBar/Resources/ProviderIcon-augment.svg + ../../Sources/CodexBar/Resources/ProviderIcon-bedrock.svg + ../../Sources/CodexBar/Resources/ProviderIcon-bifrost.svg + ../../Sources/CodexBar/Resources/ProviderIcon-chutes.svg + ../../Sources/CodexBar/Resources/ProviderIcon-claude.svg + ../../Sources/CodexBar/Resources/ProviderIcon-clawrouter.svg + ../../Sources/CodexBar/Resources/ProviderIcon-clinepass.svg + ../../Sources/CodexBar/Resources/ProviderIcon-codebuff.svg + ../../Sources/CodexBar/Resources/ProviderIcon-coderabbit.svg + ../../Sources/CodexBar/Resources/ProviderIcon-codex.svg + ../../Sources/CodexBar/Resources/ProviderIcon-commandcode.svg + ../../Sources/CodexBar/Resources/ProviderIcon-copilot.svg + ../../Sources/CodexBar/Resources/ProviderIcon-cursor.svg + ../../Sources/CodexBar/Resources/ProviderIcon-deepgram.svg + ../../Sources/CodexBar/Resources/ProviderIcon-deepinfra.svg + ../../Sources/CodexBar/Resources/ProviderIcon-deepseek.svg + ../../Sources/CodexBar/Resources/ProviderIcon-devin.svg + ../../Sources/CodexBar/Resources/ProviderIcon-devpass.svg + ../../Sources/CodexBar/Resources/ProviderIcon-doubao.svg + ../../Sources/CodexBar/Resources/ProviderIcon-elevenlabs.svg + ../../Sources/CodexBar/Resources/ProviderIcon-factory.svg + ../../Sources/CodexBar/Resources/ProviderIcon-fireworks.svg + ../../Sources/CodexBar/Resources/ProviderIcon-gemini.svg + ../../Sources/CodexBar/Resources/ProviderIcon-gitkraken.svg + ../../Sources/CodexBar/Resources/ProviderIcon-grok.svg + ../../Sources/CodexBar/Resources/ProviderIcon-groq.svg + ../../Sources/CodexBar/Resources/ProviderIcon-helmcode.svg + ../../Sources/CodexBar/Resources/ProviderIcon-huggingface.svg + ../../Sources/CodexBar/Resources/ProviderIcon-hyper.svg + ../../Sources/CodexBar/Resources/ProviderIcon-ibmbob.svg + ../../Sources/CodexBar/Resources/ProviderIcon-jetbrains.svg + ../../Sources/CodexBar/Resources/ProviderIcon-kilo.svg + ../../Sources/CodexBar/Resources/ProviderIcon-kimi.svg + ../../Sources/CodexBar/Resources/ProviderIcon-kiro.svg + ../../Sources/CodexBar/Resources/ProviderIcon-litellm.svg + ../../Sources/CodexBar/Resources/ProviderIcon-llmman.svg + ../../Sources/CodexBar/Resources/ProviderIcon-llmproxy.svg + ../../Sources/CodexBar/Resources/ProviderIcon-longcat.svg + ../../Sources/CodexBar/Resources/ProviderIcon-manus.svg + ../../Sources/CodexBar/Resources/ProviderIcon-mimo.svg + ../../Sources/CodexBar/Resources/ProviderIcon-minimax.svg + ../../Sources/CodexBar/Resources/ProviderIcon-mistral.svg + ../../Sources/CodexBar/Resources/ProviderIcon-muse.svg + ../../Sources/CodexBar/Resources/ProviderIcon-neuralwatt.svg + ../../Sources/CodexBar/Resources/ProviderIcon-notion.svg + ../../Sources/CodexBar/Resources/ProviderIcon-nous.svg + ../../Sources/CodexBar/Resources/ProviderIcon-ollama.svg + ../../Sources/CodexBar/Resources/ProviderIcon-opencode.svg + ../../Sources/CodexBar/Resources/ProviderIcon-opencodego.svg + ../../Sources/CodexBar/Resources/ProviderIcon-openrouter.svg + ../../Sources/CodexBar/Resources/ProviderIcon-perplexity.svg + ../../Sources/CodexBar/Resources/ProviderIcon-pi.svg + ../../Sources/CodexBar/Resources/ProviderIcon-poe.svg + ../../Sources/CodexBar/Resources/ProviderIcon-qoder.svg + ../../Sources/CodexBar/Resources/ProviderIcon-qwencloud.svg + ../../Sources/CodexBar/Resources/ProviderIcon-replicate.svg + ../../Sources/CodexBar/Resources/ProviderIcon-sakana.svg + ../../Sources/CodexBar/Resources/ProviderIcon-stepfun.svg + ../../Sources/CodexBar/Resources/ProviderIcon-sub2api.svg + ../../Sources/CodexBar/Resources/ProviderIcon-synthetic.svg + ../../Sources/CodexBar/Resources/ProviderIcon-t3chat.svg + ../../Sources/CodexBar/Resources/ProviderIcon-typesafe.svg + ../../Sources/CodexBar/Resources/ProviderIcon-v0.svg + ../../Sources/CodexBar/Resources/ProviderIcon-venice.svg + ../../Sources/CodexBar/Resources/ProviderIcon-vercel.svg + ../../Sources/CodexBar/Resources/ProviderIcon-vertexai.svg + ../../Sources/CodexBar/Resources/ProviderIcon-warp.svg + ../../Sources/CodexBar/Resources/ProviderIcon-wayfinder.svg + ../../Sources/CodexBar/Resources/ProviderIcon-windsurf.svg + ../../Sources/CodexBar/Resources/ProviderIcon-xai.svg + ../../Sources/CodexBar/Resources/ProviderIcon-zai.svg + ../../Sources/CodexBar/Resources/ProviderIcon-zed.svg + ../../Sources/CodexBar/Resources/ProviderIcon-zenmux.svg + ../../Sources/CodexBar/Resources/ProviderIcon-zoommate.svg + + diff --git a/Integrations/Linux/qml/Dashboard.qml b/Integrations/Linux/qml/Dashboard.qml index 9c6c3f50c0..d5b31dbaf6 100644 --- a/Integrations/Linux/qml/Dashboard.qml +++ b/Integrations/Linux/qml/Dashboard.qml @@ -43,7 +43,7 @@ ApplicationWindow { TabBar { Layout.fillWidth: true currentIndex: window.selectedTab - TabButton { text: "Usage"; onClicked: { window.selectedTab = 0; desktop.showWindow("usage"); } } + TabButton { text: "Usage"; onClicked: { window.selectedTab = 0; desktop.showWindow("dashboard"); } } TabButton { text: "Spending"; onClicked: { window.selectedTab = 1; desktop.showWindow("spending"); } } } Label { diff --git a/Integrations/Linux/qml/Main.qml b/Integrations/Linux/qml/Main.qml index dc0d86c46f..041fb34123 100644 --- a/Integrations/Linux/qml/Main.qml +++ b/Integrations/Linux/qml/Main.qml @@ -2,13 +2,16 @@ import QtQuick QtObject { id: root + property QuickView quickView: QuickView {} property Dashboard dashboard: Dashboard {} property SettingsWindow preferences: SettingsWindow {} property Connections routing: Connections { target: desktop function onWindowRequested(page) { - var target = page === "settings" ? root.preferences : root.dashboard; - if (page !== "settings") root.dashboard.selectedTab = page === "spending" ? 1 : 0; + var target = page === "settings" ? root.preferences : + page === "quick-view" ? root.quickView : root.dashboard; + if (page === "usage" || page === "dashboard" || page === "spending") + root.dashboard.selectedTab = page === "spending" ? 1 : 0; target.show(); target.raise(); target.requestActivate(); diff --git a/Integrations/Linux/qml/MenuMetric.qml b/Integrations/Linux/qml/MenuMetric.qml new file mode 100644 index 0000000000..f6fbf2b928 --- /dev/null +++ b/Integrations/Linux/qml/MenuMetric.qml @@ -0,0 +1,84 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "../Shared/Usage.js" as Usage + +ColumnLayout { + id: root + required property var metric + property string quotaDisplay: "used" + property string resetDisplay: "countdown" + property bool showPace: true + property bool warningColors: true + property int notifyThreshold: 20 + property color accent + property color primaryText + property color secondaryText + property color trackColor + readonly property int amount: Usage.quotaValue(metric.remaining, quotaDisplay) + readonly property bool warning: warningColors && metric.remaining <= notifyThreshold + spacing: 6 + Layout.fillWidth: true + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { + text: root.metric.label + color: root.primaryText + font.pixelSize: 15 + font.weight: Font.DemiBold + elide: Text.ElideRight + Layout.fillWidth: true + textFormat: Text.PlainText + } + Label { + text: Usage.resetText(root.metric.resetsAt, clock.now, root.resetDisplay) + color: root.secondaryText + font.pixelSize: 12 + horizontalAlignment: Text.AlignRight + wrapMode: Text.Wrap + Layout.maximumWidth: root.width * 0.48 + textFormat: Text.PlainText + } + } + Rectangle { + id: track + Layout.fillWidth: true + height: 7 + radius: height / 2 + color: root.trackColor + Accessible.role: Accessible.ProgressBar + Accessible.name: root.metric.label + ": " + root.amount + "% " + + (root.quotaDisplay === "used" ? "used" : "left") + Rectangle { + width: track.width * Math.max(0, Math.min(100, root.amount)) / 100 + height: parent.height + radius: parent.radius + color: root.warning ? "#d9874a" : root.accent + } + } + Label { + Layout.fillWidth: true + text: root.amount + "% " + (root.quotaDisplay === "used" ? "used" : "left") + color: root.primaryText + font.pixelSize: 13 + } + Label { + Layout.fillWidth: true + visible: root.showPace && text !== "" + text: root.metric.pace || "" + color: root.secondaryText + font.pixelSize: 12 + wrapMode: Text.Wrap + textFormat: Text.PlainText + } + Timer { + id: clock + property double now: Date.now() + interval: 30000 + running: root.visible + repeat: true + onTriggered: now = Date.now() + } +} diff --git a/Integrations/Linux/qml/QuickView.qml b/Integrations/Linux/qml/QuickView.qml new file mode 100644 index 0000000000..f1e2c9817a --- /dev/null +++ b/Integrations/Linux/qml/QuickView.qml @@ -0,0 +1,443 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "../Shared/Usage.js" as Usage + +ApplicationWindow { + id: window + title: "CodexBar" + width: 420 + height: selectedIndex === -1 ? Math.min(650, 405 + desktop.entries.length * 54) : 680 + minimumWidth: 360; minimumHeight: 400 + property int selectedIndex: 0 // -1 is Overview; entries may include multiple accounts. + readonly property var selectedEntry: selectedIndex >= 0 && selectedIndex < desktop.entries.length ? + desktop.entries[selectedIndex] : null + readonly property var selectedCost: selectedEntry ? costFor(selectedEntry.provider) : null + readonly property color primaryText: nativePalette.windowText + readonly property color secondaryText: Qt.rgba(primaryText.r, primaryText.g, primaryText.b, 0.65) + readonly property color accent: nativePalette.highlight + readonly property color meterColor: "#c98757" + readonly property color trackColor: Qt.rgba(primaryText.r, primaryText.g, primaryText.b, 0.14) + readonly property color dividerColor: Qt.rgba(primaryText.r, primaryText.g, primaryText.b, 0.17) + readonly property color hoverColor: Qt.rgba(primaryText.r, primaryText.g, primaryText.b, 0.08) + + function costFor(provider) { + for (var i = 0; i < desktop.spending.length; ++i) { + if (desktop.spending[i].provider === provider) return desktop.spending[i]; + } + return null; + } + function openPage(page) { hide(); desktop.showWindow(page); } + + onClosing: function(event) { event.accepted = false; hide(); } + Shortcut { sequence: "Ctrl+R"; onActivated: desktop.refresh() } + Shortcut { sequence: "Ctrl+,"; onActivated: window.openPage("settings") } + Shortcut { sequence: "Ctrl+Q"; onActivated: Qt.quit() } + Shortcut { sequence: "Escape"; onActivated: window.hide() } + Connections { + target: desktop + function onChanged() { + if (desktop.entries.length && window.selectedIndex >= desktop.entries.length) + window.selectedIndex = 0; + } + } + SystemPalette { id: nativePalette } + background: Rectangle { color: nativePalette.window } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 20; Layout.rightMargin: 14 + Layout.topMargin: 12; Layout.bottomMargin: 8 + Label { + text: "CodexBar" + color: window.primaryText + font.pixelSize: 14 + font.weight: Font.DemiBold + Layout.fillWidth: true + } + ToolButton { + text: "↻" + enabled: !desktop.busy + Accessible.name: "Refresh usage" + onClicked: desktop.refresh() + ToolTip.visible: hovered + ToolTip.text: "Refresh usage" + contentItem: Label { + text: "↻" + color: window.primaryText + font.pixelSize: 19 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 6 + color: parent.hovered ? window.hoverColor : "transparent" + } + } + } + + Flickable { + id: switcher + Layout.fillWidth: true + Layout.preferredHeight: 69 + clip: true + contentWidth: tabs.implicitWidth + 32 + contentHeight: height + flickableDirection: Flickable.HorizontalFlick + boundsBehavior: Flickable.StopAtBounds + Row { + id: tabs + x: 16 + spacing: 3 + height: parent.height - 6 + Item { + width: 72; height: parent.height + Rectangle { + anchors.fill: parent; anchors.bottomMargin: 4 + radius: 9 + color: window.selectedIndex === -1 ? window.accent : "transparent" + } + Label { + anchors.centerIn: parent + anchors.verticalCenterOffset: -7 + text: "▦" + color: window.selectedIndex === -1 ? "white" : window.secondaryText + font.pixelSize: 17 + } + Label { + anchors.bottom: parent.bottom; anchors.bottomMargin: 13 + anchors.horizontalCenter: parent.horizontalCenter + text: "Overview" + color: window.selectedIndex === -1 ? "white" : window.secondaryText + font.pixelSize: 11 + } + MouseArea { + anchors.fill: parent + activeFocusOnTab: true + Accessible.role: Accessible.Button + Accessible.name: "Overview" + onClicked: window.selectedIndex = -1 + Keys.onReturnPressed: window.selectedIndex = -1 + Keys.onSpacePressed: window.selectedIndex = -1 + } + } + Repeater { + model: desktop.entries + Item { + required property var modelData + required property int index + width: 72; height: tabs.height + readonly property bool selected: window.selectedIndex === index + readonly property var firstWindow: modelData.windows.length ? modelData.windows[0] : null + Rectangle { + anchors.fill: parent; anchors.bottomMargin: 4 + radius: 9 + color: selected ? window.accent : "transparent" + } + Image { + id: providerIcon + source: "image://provider-icon/" + modelData.provider + "?color=" + + (selected ? "ffffff" : window.secondaryText.toString().slice(1)) + width: 18; height: 18 + sourceSize.width: 36; sourceSize.height: 36 + anchors.horizontalCenter: parent.horizontalCenter + y: 5 + visible: status === Image.Ready + } + Label { + visible: providerIcon.status !== Image.Ready + text: Usage.providerName(modelData.provider).slice(0, 2).toUpperCase() + anchors.horizontalCenter: parent.horizontalCenter + y: 5 + font.pixelSize: 12; font.bold: true + color: selected ? "white" : window.secondaryText + } + Label { + text: Usage.providerName(modelData.provider) + width: parent.width - 6 + x: 3; y: 28 + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + font.pixelSize: 11 + color: selected ? "white" : window.secondaryText + textFormat: Text.PlainText + } + Rectangle { + x: 10; width: parent.width - 20; height: 3 + y: parent.height - 10; radius: 2 + color: selected ? Qt.rgba(1, 1, 1, 0.4) : window.trackColor + Rectangle { + width: parent.width * (firstWindow ? 100 - firstWindow.remaining : 0) / 100 + height: parent.height; radius: parent.radius + color: selected ? "white" : window.meterColor + } + } + MouseArea { + anchors.fill: parent + activeFocusOnTab: true + Accessible.role: Accessible.Button + Accessible.name: Usage.providerName(modelData.provider) + + (modelData.accountLabel ? " " + modelData.accountLabel : "") + onClicked: window.selectedIndex = index + Keys.onReturnPressed: window.selectedIndex = index + Keys.onSpacePressed: window.selectedIndex = index + } + } + } + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: window.dividerColor } + + Flickable { + id: bodyScroll + Layout.fillWidth: true; Layout.fillHeight: true + Layout.minimumHeight: 0 + clip: true + contentWidth: width + contentHeight: bodyContent.height + boundsBehavior: Flickable.StopAtBounds + Column { + id: bodyContent + width: bodyScroll.width + spacing: 14 + Item { height: 2 } + + Label { + visible: desktop.entries.length === 0 + text: desktop.busy ? "Loading usage…" : desktop.error || "No providers to show. Enable one in Settings." + x: 20; width: parent.width - 40 + color: window.secondaryText + wrapMode: Text.Wrap + } + + ColumnLayout { + visible: window.selectedIndex === -1 && desktop.entries.length > 0 + x: 20; width: parent.width - 40 + spacing: 12 + Label { + text: "Overview" + color: window.primaryText + font.pixelSize: 20; font.weight: Font.DemiBold + } + Label { + text: desktop.busy ? "Refreshing usage…" : desktop.stale ? + "Usage may be out of date" : desktop.updated ? "Updated " + desktop.updated : "Waiting for usage…" + color: window.secondaryText; font.pixelSize: 12 + } + Repeater { + model: desktop.entries + Rectangle { + required property var modelData + required property int index + Layout.fillWidth: true + Layout.preferredHeight: 54 + radius: 10 + color: window.hoverColor + ColumnLayout { + id: overviewContent + anchors.fill: parent; anchors.margins: 11 + spacing: 6 + RowLayout { + Layout.fillWidth: true + Label { + text: Usage.providerName(modelData.provider) + color: window.primaryText + font.pixelSize: 14; font.weight: Font.DemiBold + Layout.fillWidth: true; elide: Text.ElideRight + } + Label { + text: modelData.windows.length ? + Usage.quotaValue(modelData.windows[0].remaining, desktop.settings.quotaDisplay) + "% " + + (desktop.settings.quotaDisplay === "used" ? "used" : "left") : "—" + color: window.secondaryText; font.pixelSize: 12 + } + } + Rectangle { + Layout.fillWidth: true; height: 5; radius: 3; color: window.trackColor + Rectangle { + width: parent.width * (modelData.windows.length ? + Usage.quotaValue(modelData.windows[0].remaining, desktop.settings.quotaDisplay) : 0) / 100 + height: parent.height; radius: parent.radius; color: window.meterColor + } + } + } + MouseArea { + anchors.fill: parent + activeFocusOnTab: true + Accessible.role: Accessible.Button + Accessible.name: "Show " + Usage.providerName(modelData.provider) + onClicked: window.selectedIndex = index + Keys.onReturnPressed: window.selectedIndex = index + Keys.onSpacePressed: window.selectedIndex = index + } + } + } + } + + Column { + visible: window.selectedEntry !== null + x: 20; width: parent.width - 40 + spacing: 14 + RowLayout { + width: parent.width + Label { + text: window.selectedEntry ? Usage.providerName(window.selectedEntry.provider) : "" + color: window.primaryText + font.pixelSize: 21; font.weight: Font.DemiBold + Layout.fillWidth: true; elide: Text.ElideRight + } + Label { + text: window.selectedEntry ? window.selectedEntry.accountLabel : "" + visible: text !== "" + color: window.secondaryText + font.pixelSize: 12; elide: Text.ElideMiddle + Layout.maximumWidth: 150 + textFormat: Text.PlainText + } + } + RowLayout { + width: parent.width + Label { + text: desktop.busy ? "Refreshing usage…" : desktop.stale ? + "Usage may be out of date" : desktop.updated ? "Updated " + desktop.updated : "Waiting for usage…" + color: window.secondaryText; font.pixelSize: 12 + Layout.fillWidth: true; elide: Text.ElideRight + } + Label { + text: window.selectedEntry ? window.selectedEntry.plan : "" + visible: text !== "" + color: window.secondaryText; font.pixelSize: 12 + textFormat: Text.PlainText + } + } + Label { + visible: text !== "" + text: window.selectedEntry ? window.selectedEntry.error || window.selectedEntry.status : "" + color: window.selectedEntry && window.selectedEntry.error ? "#cf7443" : window.secondaryText + font.pixelSize: 12; wrapMode: Text.Wrap + width: parent.width; textFormat: Text.PlainText + } + Rectangle { width: parent.width; height: 1; color: window.dividerColor } + Repeater { + model: window.selectedEntry ? window.selectedEntry.windows : [] + MenuMetric { + required property var modelData + width: parent.width + metric: modelData + quotaDisplay: desktop.settings.quotaDisplay + resetDisplay: desktop.settings.resetDisplay + showPace: desktop.settings.showPace + warningColors: desktop.settings.warningColors + notifyThreshold: desktop.settings.notifyThreshold + accent: window.meterColor + primaryText: window.primaryText + secondaryText: window.secondaryText + trackColor: window.trackColor + } + } + ColumnLayout { + visible: window.selectedEntry && window.selectedEntry.credits !== null && + window.selectedEntry.credits !== undefined + width: parent.width + spacing: 9 + Rectangle { Layout.fillWidth: true; height: 1; color: window.dividerColor } + Label { text: "Credits"; color: window.primaryText; font.pixelSize: 15; font.weight: Font.DemiBold } + Label { + text: window.selectedEntry ? String(window.selectedEntry.credits) + " remaining" : "" + color: window.secondaryText; font.pixelSize: 13 + } + } + ColumnLayout { + visible: desktop.settings.showCosts && (window.selectedCost !== null || desktop.costBusy) + width: parent.width + spacing: 8 + Rectangle { Layout.fillWidth: true; height: 1; color: window.dividerColor } + Label { text: "Cost across accounts"; color: window.primaryText; font.pixelSize: 15; font.weight: Font.DemiBold } + Label { + text: window.selectedCost ? "Today " + Usage.money(window.selectedCost.today) + + " · Last 30 days " + Usage.money(window.selectedCost.month) : "Reading local history…" + color: window.primaryText; font.pixelSize: 13 + Layout.fillWidth: true; wrapMode: Text.Wrap + } + Label { + visible: window.selectedCost !== null + text: window.selectedCost ? Usage.count(window.selectedCost.tokens) + " tokens · " + + Usage.provenance(window.selectedCost.provenance) : "" + color: window.secondaryText; font.pixelSize: 12 + Layout.fillWidth: true; wrapMode: Text.Wrap + } + } + Repeater { + model: window.selectedEntry ? window.selectedEntry.details : [] + ColumnLayout { + required property var modelData + width: parent.width + spacing: 6 + Rectangle { Layout.fillWidth: true; height: 1; color: window.dividerColor } + Label { + text: modelData.title + visible: text !== "" + color: window.primaryText + font.pixelSize: 14; font.weight: Font.DemiBold + Layout.fillWidth: true; wrapMode: Text.Wrap; textFormat: Text.PlainText + } + Repeater { + model: modelData.rows + Label { + required property var modelData + text: modelData.label + ": " + modelData.value + + (modelData.secondaryValue ? " · " + modelData.secondaryValue : "") + color: window.secondaryText + font.pixelSize: 12 + Layout.fillWidth: true; wrapMode: Text.Wrap; textFormat: Text.PlainText + } + } + } + } + } + Item { height: 6 } + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: window.dividerColor } + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: 12; Layout.rightMargin: 12 + Layout.topMargin: 6; Layout.bottomMargin: 8 + spacing: 1 + ActionRow { text: "Usage & Spend…"; onClicked: window.openPage("dashboard") } + ActionRow { text: "Settings…"; onClicked: window.openPage("settings") } + ActionRow { text: "About CodexBar"; onClicked: about.open() } + ActionRow { text: "Quit"; onClicked: Qt.quit() } + } + } + Dialog { + id: about + anchors.centerIn: parent + modal: true + title: "About CodexBar" + standardButtons: Dialog.Ok + Label { text: "CodexBar Linux " + Qt.application.version; padding: 14 } + } + component ActionRow: Button { + Layout.fillWidth: true + implicitHeight: 31 + flat: true + contentItem: Label { + text: parent.text + color: window.primaryText + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + background: Rectangle { + radius: 6 + color: parent.hovered || parent.down ? window.hoverColor : "transparent" + } + } +} diff --git a/Integrations/Linux/qml/SettingsWindow.qml b/Integrations/Linux/qml/SettingsWindow.qml index 7ed3700bf0..47273134d2 100644 --- a/Integrations/Linux/qml/SettingsWindow.qml +++ b/Integrations/Linux/qml/SettingsWindow.qml @@ -11,6 +11,18 @@ ApplicationWindow { property string feedback: "" property int section: 0 property var providerOrder: [] + SystemPalette { id: nativePalette } + palette.window: nativePalette.window + palette.windowText: nativePalette.windowText + palette.base: nativePalette.base + palette.text: nativePalette.text + palette.button: nativePalette.button + palette.buttonText: nativePalette.buttonText + palette.highlight: nativePalette.highlight + palette.highlightedText: nativePalette.highlightedText + background: Rectangle { color: nativePalette.window } + readonly property color secondaryText: Qt.rgba(palette.windowText.r, palette.windowText.g, + palette.windowText.b, 0.65) function moveProvider(index, offset) { var order = providerOrder.slice(); var item = order.splice(index, 1)[0]; order.splice(index + offset, 0, item); @@ -29,7 +41,7 @@ ApplicationWindow { reset.currentIndex = reset.model.indexOf(s.resetDisplay); trayStyle.currentIndex = trayStyle.model.indexOf(s.trayStyle); theme.checked = s.followOmarchyTheme; pace.checked = s.showPace; warnings.checked = s.warningColors; - refreshOnOpen.checked = s.refreshOnOpen; + refreshOnOpen.checked = s.refreshOnOpen; compactQuickView.checked = s.compactQuickView; tray.checked = s.showTray; executable.text = s.executable; feedback = ""; } Shortcut { sequence: "Escape"; onActivated: window.hide() } @@ -39,141 +51,158 @@ ApplicationWindow { } ColumnLayout { anchors.fill: parent; anchors.margins: 16; spacing: 12 - TabBar { - Layout.fillWidth: true - currentIndex: window.section - TabButton { text: "General"; onClicked: window.section = 0 } - TabButton { text: "Providers"; onClicked: window.section = 1 } - TabButton { text: "Advanced"; onClicked: window.section = 2 } - } - ScrollView { - id: scroll + RowLayout { Layout.fillWidth: true; Layout.fillHeight: true - contentWidth: availableWidth; clip: true - ColumnLayout { - width: scroll.availableWidth; spacing: 20 - GroupBox { - title: "Provider"; Layout.fillWidth: true; visible: window.section === 1 - ColumnLayout { - anchors.fill: parent; spacing: 10 - Label { text: "Provider" } - ComboBox { - id: provider; Layout.fillWidth: true; editable: true - model: ["codex", "claude", "both", "enabled", "cursor", "gemini", "copilot", "antigravity", "custom", "all"] - validator: RegularExpressionValidator { regularExpression: /^[a-z0-9-]{1,80}$/ } - } + spacing: 14 + Rectangle { + Layout.preferredWidth: 172; Layout.fillHeight: true + color: Qt.rgba(palette.windowText.r, palette.windowText.g, palette.windowText.b, 0.06) + radius: 10 + ColumnLayout { + anchors.fill: parent; anchors.margins: 8 + spacing: 3 + Label { + text: "PREFERENCES" + color: window.secondaryText + font.pixelSize: 11; font.weight: Font.DemiBold + Layout.leftMargin: 9; Layout.topMargin: 8; Layout.bottomMargin: 5 + } + NavigationButton { text: "General"; destination: 0 } + NavigationButton { text: "Providers"; destination: 1 } + NavigationButton { text: "Advanced"; destination: 2 } + Item { Layout.fillHeight: true } + } + } + ScrollView { + id: scroll + Layout.fillWidth: true; Layout.fillHeight: true + contentWidth: availableWidth; clip: true + ColumnLayout { + width: scroll.availableWidth; spacing: 20 + SettingSection { + title: "Provider"; Layout.fillWidth: true; visible: window.section === 1 ColumnLayout { - visible: provider.editText === "custom" - Layout.fillWidth: true - RowLayout { - Layout.fillWidth: true - ComboBox { id: available; model: desktop.providers; textRole: "displayName"; Layout.fillWidth: true } - Button { - text: "Add" - enabled: available.currentIndex >= 0 - onClicked: { - var id = desktop.providers[available.currentIndex].provider; - if (window.providerOrder.indexOf(id) < 0) window.providerOrder = window.providerOrder.concat([id]); - } - } + anchors.fill: parent; spacing: 10 + Label { text: "Provider" } + ComboBox { + id: provider; Layout.fillWidth: true; editable: true + model: ["codex", "claude", "both", "enabled", "cursor", "gemini", "copilot", "antigravity", "custom", "all"] + validator: RegularExpressionValidator { regularExpression: /^[a-z0-9-]{1,80}$/ } } - Repeater { - model: window.providerOrder + ColumnLayout { + visible: provider.editText === "custom" + Layout.fillWidth: true RowLayout { - required property string modelData - required property int index Layout.fillWidth: true - Label { text: Usage.providerName(modelData); Layout.fillWidth: true; wrapMode: Text.Wrap } - Button { text: "↑"; Accessible.name: "Move " + modelData + " up"; enabled: index > 0; onClicked: window.moveProvider(index, -1) } - Button { text: "↓"; Accessible.name: "Move " + modelData + " down"; enabled: index < window.providerOrder.length - 1; onClicked: window.moveProvider(index, 1) } - Button { text: "Remove"; onClicked: { var order = window.providerOrder.slice(); order.splice(index, 1); window.providerOrder = order; } } + ComboBox { id: available; model: desktop.providers; textRole: "displayName"; Layout.fillWidth: true } + Button { + text: "Add" + enabled: available.currentIndex >= 0 + onClicked: { + var id = desktop.providers[available.currentIndex].provider; + if (window.providerOrder.indexOf(id) < 0) window.providerOrder = window.providerOrder.concat([id]); + } + } + } + Repeater { + model: window.providerOrder + RowLayout { + required property string modelData + required property int index + Layout.fillWidth: true + Label { text: Usage.providerName(modelData); Layout.fillWidth: true; wrapMode: Text.Wrap } + Button { text: "↑"; Accessible.name: "Move " + modelData + " up"; enabled: index > 0; onClicked: window.moveProvider(index, -1) } + Button { text: "↓"; Accessible.name: "Move " + modelData + " down"; enabled: index < window.providerOrder.length - 1; onClicked: window.moveProvider(index, 1) } + Button { text: "Remove"; onClicked: { var order = window.providerOrder.slice(); order.splice(index, 1); window.providerOrder = order; } } + } } } + Label { text: "Choose custom to select and order providers, or enabled to follow your CLI configuration."; Layout.fillWidth: true; wrapMode: Text.Wrap; opacity: 0.65 } + RowLayout { + Layout.fillWidth: true + Label { text: "Source"; Layout.fillWidth: true } + ComboBox { id: source; model: ["auto", "oauth", "cli", "api", "web"] } + } + RowLayout { + Layout.fillWidth: true + Label { text: "Account (0 = default)"; Layout.fillWidth: true; wrapMode: Text.Wrap } + SpinBox { enabled: !allAccounts.checked && ["custom", "both", "all", "enabled"].indexOf(provider.editText) < 0; id: account; from: 0; to: 999; editable: true } + } + Option { enabled: ["custom", "both", "all", "enabled"].indexOf(provider.editText) < 0; id: allAccounts; text: "All accounts" } + Option { id: identity; text: "Show account identity" } + RowLayout { + Layout.fillWidth: true + ComboBox { id: loginProvider; model: ["codex", "claude"]; Layout.fillWidth: true } + Button { text: "Sign in…"; onClicked: { if (desktop.accountAction(loginProvider.currentText, "login")) window.feedback = "Finish signing in in the terminal, then refresh usage."; } } + Button { text: "Sign out…"; onClicked: signOut.open() } + } + Label { text: "Sign-in opens your provider CLI in a terminal. Account selection above only changes displayed usage."; Layout.fillWidth: true; wrapMode: Text.Wrap; opacity: 0.65 } } - Label { text: "Choose custom to select and order providers, or enabled to follow your CLI configuration."; Layout.fillWidth: true; wrapMode: Text.Wrap; opacity: 0.65 } - RowLayout { - Layout.fillWidth: true - Label { text: "Source"; Layout.fillWidth: true } - ComboBox { id: source; model: ["auto", "oauth", "cli", "api", "web"] } - } - RowLayout { - Layout.fillWidth: true - Label { text: "Account (0 = default)"; Layout.fillWidth: true; wrapMode: Text.Wrap } - SpinBox { enabled: !allAccounts.checked && ["custom", "both", "all", "enabled"].indexOf(provider.editText) < 0; id: account; from: 0; to: 999; editable: true } - } - Option { enabled: ["custom", "both", "all", "enabled"].indexOf(provider.editText) < 0; id: allAccounts; text: "All accounts" } - Option { id: identity; text: "Show account identity" } - RowLayout { - Layout.fillWidth: true - ComboBox { id: loginProvider; model: ["codex", "claude"]; Layout.fillWidth: true } - Button { text: "Sign in…"; onClicked: { if (desktop.accountAction(loginProvider.currentText, "login")) window.feedback = "Finish signing in in the terminal, then refresh usage."; } } - Button { text: "Sign out…"; onClicked: signOut.open() } - } - Label { text: "Sign-in opens your provider CLI in a terminal. Account selection above only changes displayed usage."; Layout.fillWidth: true; wrapMode: Text.Wrap; opacity: 0.65 } } - } - GroupBox { - title: "Updates"; Layout.fillWidth: true; visible: window.section === 0 - ColumnLayout { - anchors.fill: parent; spacing: 10 - RowLayout { - Layout.fillWidth: true - Label { text: "Refresh every (seconds)"; Layout.fillWidth: true; wrapMode: Text.Wrap } - SpinBox { id: interval; from: 60; to: 3600; stepSize: 60; editable: true } - } - Option { id: refreshOnOpen; text: "Refresh when opening usage" } - Option { id: notices; text: "Notify about low quota, resets, and outages" } - RowLayout { - Layout.fillWidth: true; enabled: notices.checked - Label { text: "Remaining quota threshold (%)"; Layout.fillWidth: true; wrapMode: Text.Wrap } - SpinBox { id: threshold; from: 1; to: 99; editable: true } + SettingSection { + title: "Updates"; Layout.fillWidth: true; visible: window.section === 0 + ColumnLayout { + anchors.fill: parent; spacing: 10 + RowLayout { + Layout.fillWidth: true + Label { text: "Refresh every (seconds)"; Layout.fillWidth: true; wrapMode: Text.Wrap } + SpinBox { id: interval; from: 60; to: 3600; stepSize: 60; editable: true } + } + Option { id: refreshOnOpen; text: "Refresh when opening usage" } + Option { id: notices; text: "Notify about low quota, resets, and outages" } + RowLayout { + Layout.fillWidth: true; enabled: notices.checked + Label { text: "Remaining quota threshold (%)"; Layout.fillWidth: true; wrapMode: Text.Wrap } + SpinBox { id: threshold; from: 1; to: 99; editable: true } + } + Option { id: status; text: "Service status" } } - Option { id: status; text: "Service status" } } - } - GroupBox { - title: "Desktop"; Layout.fillWidth: true; visible: window.section === 0 - ColumnLayout { - anchors.fill: parent; spacing: 10 - Button { - text: desktop.launchAtLogin ? "Disable start at login" : "Enable start at login" - onClicked: desktop.setLaunchAtLogin(!desktop.launchAtLogin) - } - Option { id: tray; text: "Tray icon" } - Label { text: "Optional with the Omarchy widget. CodexBar is also available in the application launcher."; Layout.fillWidth: true; wrapMode: Text.Wrap; opacity: 0.65 } - RowLayout { - Layout.fillWidth: true - Label { text: "Tray style"; Layout.fillWidth: true } - ComboBox { id: trayStyle; model: ["meters", "icon"] } + SettingSection { + title: "Desktop"; Layout.fillWidth: true; visible: window.section === 0 + ColumnLayout { + anchors.fill: parent; spacing: 10 + Button { + text: desktop.launchAtLogin ? "Disable start at login" : "Enable start at login" + onClicked: desktop.setLaunchAtLogin(!desktop.launchAtLogin) + } + Option { id: tray; text: "Tray icon" } + Option { id: compactQuickView; text: "Use compact Quick View from the tray" } + Label { text: "Optional with the Omarchy widget. CodexBar is also available in the application launcher."; Layout.fillWidth: true; wrapMode: Text.Wrap; opacity: 0.65 } + RowLayout { + Layout.fillWidth: true + Label { text: "Tray style"; Layout.fillWidth: true } + ComboBox { id: trayStyle; model: ["meters", "icon"] } + } + Option { id: costs; text: "Local spending" } } - Option { id: costs; text: "Local spending" } } - } - GroupBox { - title: "Display"; Layout.fillWidth: true; visible: window.section === 0 - ColumnLayout { - anchors.fill: parent; spacing: 10 - RowLayout { - Layout.fillWidth: true - Label { text: "Quota"; Layout.fillWidth: true } - ComboBox { id: quota; model: ["remaining", "used"] } - } - RowLayout { - Layout.fillWidth: true - Label { text: "Reset time"; Layout.fillWidth: true } - ComboBox { id: reset; model: ["countdown", "absolute", "both"] } + SettingSection { + title: "Display"; Layout.fillWidth: true; visible: window.section === 0 + ColumnLayout { + anchors.fill: parent; spacing: 10 + RowLayout { + Layout.fillWidth: true + Label { text: "Quota"; Layout.fillWidth: true } + ComboBox { id: quota; model: ["remaining", "used"] } + } + RowLayout { + Layout.fillWidth: true + Label { text: "Reset time"; Layout.fillWidth: true } + ComboBox { id: reset; model: ["countdown", "absolute", "both"] } + } + Option { id: theme; text: "Follow Omarchy theme colors" } + Option { id: pace; text: "Show pace" } + Option { id: warnings; text: "Highlight low quota" } } - Option { id: theme; text: "Follow Omarchy theme colors" } - Option { id: pace; text: "Show pace" } - Option { id: warnings; text: "Highlight low quota" } } - } - GroupBox { - title: "CLI"; Layout.fillWidth: true; visible: window.section === 2 - ColumnLayout { - anchors.fill: parent; spacing: 10 - Label { text: "Executable" } - TextField { id: executable; Layout.fillWidth: true; selectByMouse: true; placeholderText: "codexbar" } + SettingSection { + title: "CLI"; Layout.fillWidth: true; visible: window.section === 2 + ColumnLayout { + anchors.fill: parent; spacing: 10 + Label { text: "Executable" } + TextField { id: executable; Layout.fillWidth: true; selectByMouse: true; placeholderText: "codexbar" } + } } } } @@ -192,7 +221,8 @@ ApplicationWindow { notifyThreshold: threshold.value, refreshSeconds: interval.value, providerOrder: window.providerOrder, quotaDisplay: quota.currentText, resetDisplay: reset.currentText, followOmarchyTheme: theme.checked, showPace: pace.checked, warningColors: warnings.checked, trayStyle: trayStyle.currentText, - refreshOnOpen: refreshOnOpen.checked, showTray: tray.checked, executable: executable.text.trim()})) window.feedback = "Settings saved"; + refreshOnOpen: refreshOnOpen.checked, showTray: tray.checked, compactQuickView: compactQuickView.checked, + executable: executable.text.trim()})) window.feedback = "Settings saved"; } } } @@ -217,4 +247,43 @@ ApplicationWindow { wrapMode: Text.Wrap } } + component NavigationButton: Button { + required property int destination + Layout.fillWidth: true + implicitHeight: 36 + flat: true + contentItem: Label { + text: parent.text + color: window.section === parent.destination ? "white" : palette.windowText + font.pixelSize: 13 + font.weight: window.section === parent.destination ? Font.DemiBold : Font.Normal + verticalAlignment: Text.AlignVCenter + leftPadding: 12 + } + background: Rectangle { + radius: 7 + color: window.section === parent.destination ? palette.highlight : + parent.hovered ? Qt.rgba(palette.windowText.r, palette.windowText.g, palette.windowText.b, 0.08) : + "transparent" + } + onClicked: window.section = destination + } + component SettingSection: GroupBox { + Layout.fillWidth: true + padding: 14 + label: Label { + text: parent.title + color: palette.windowText + font.pixelSize: 15 + font.weight: Font.DemiBold + leftPadding: 4 + } + background: Rectangle { + y: 8 + height: parent.height - 8 + radius: 10 + color: Qt.rgba(palette.windowText.r, palette.windowText.g, palette.windowText.b, 0.035) + border.color: Qt.rgba(palette.windowText.r, palette.windowText.g, palette.windowText.b, 0.13) + } + } } diff --git a/Integrations/Linux/tests/test_desktop.py b/Integrations/Linux/tests/test_desktop.py index 3e39f165c7..f1f6c35ce0 100644 --- a/Integrations/Linux/tests/test_desktop.py +++ b/Integrations/Linux/tests/test_desktop.py @@ -199,12 +199,19 @@ def test_failed_refresh_preserves_previous_usage(self): self.assertTrue(value['stale']) self.assertEqual(value['summary'], 'CX 60%') - def test_refresh_on_open_is_optional_and_does_not_scan_spending(self): + def test_quick_view_is_explicit_and_scans_cost_only_when_opened(self): calls = self.root / 'calls.jsonl' + self.assertFalse((self.root / 'config/codexbar/linux.json').exists()) self.client('--usage') - time.sleep(0.15) - self.assertEqual(len(calls.read_text().splitlines()), 1) - self.client('--configure', '{"refreshOnOpen":true}') + self.assertEqual([json.loads(line)['args'][0] for line in calls.read_text().splitlines()], ['usage']) + self.client('--quick-view') + self.wait_for(lambda value: value.get('costProviders') == 1 and not value['costBusy']) + self.assertEqual([json.loads(line)['args'][0] for line in calls.read_text().splitlines()], + ['usage', 'cost']) + self.client('--configure', '{"compactQuickView":true,"refreshOnOpen":true}') + self.assertTrue(json.loads((self.root / 'config/codexbar/linux.json').read_text())['compactQuickView']) + self.client('--configure', '{"showCosts":false}') + self.client('--configure', '{"showCosts":true}') self.wait_for(lambda value: bool(value.get('entries')) and not value['busy']) previous = len(calls.read_text().splitlines()) self.client('--usage') @@ -212,8 +219,11 @@ def test_refresh_on_open_is_optional_and_does_not_scan_spending(self): while len(calls.read_text().splitlines()) == previous and time.monotonic() < end: time.sleep(0.05) self.assertEqual(len(calls.read_text().splitlines()), previous + 1) - for line in calls.read_text().splitlines(): - self.assertEqual(json.loads(line)['args'][0], 'usage') + self.assertEqual(json.loads(calls.read_text().splitlines()[-1])['args'][0], 'usage') + self.assertNotIn('cost', [json.loads(line)['args'][0] for line in calls.read_text().splitlines()[previous:]]) + self.client('--quick-view') + self.wait_for(lambda value: value.get('costProviders') == 1 and not value['costBusy']) + self.assertIn('cost', [json.loads(line)['args'][0] for line in calls.read_text().splitlines()[previous + 1:]]) def test_failed_spending_preserves_previous_scan(self): self.client('--spending') diff --git a/docs/screenshots/linux-quick-view-1x-popos.png b/docs/screenshots/linux-quick-view-1x-popos.png new file mode 100644 index 0000000000..14cb990619 Binary files /dev/null and b/docs/screenshots/linux-quick-view-1x-popos.png differ diff --git a/docs/screenshots/linux-quick-view-after-popos.png b/docs/screenshots/linux-quick-view-after-popos.png new file mode 100644 index 0000000000..8ae9f6267a Binary files /dev/null and b/docs/screenshots/linux-quick-view-after-popos.png differ diff --git a/docs/screenshots/linux-quick-view-before-popos-v061.png b/docs/screenshots/linux-quick-view-before-popos-v061.png new file mode 100644 index 0000000000..473593ddbb Binary files /dev/null and b/docs/screenshots/linux-quick-view-before-popos-v061.png differ diff --git a/docs/screenshots/linux-quick-view-dark-popos.png b/docs/screenshots/linux-quick-view-dark-popos.png new file mode 100644 index 0000000000..9f5e99fd67 Binary files /dev/null and b/docs/screenshots/linux-quick-view-dark-popos.png differ diff --git a/docs/screenshots/linux-settings-after-popos.png b/docs/screenshots/linux-settings-after-popos.png new file mode 100644 index 0000000000..25f923125d Binary files /dev/null and b/docs/screenshots/linux-settings-after-popos.png differ diff --git a/docs/screenshots/linux-settings-before-popos-v061.png b/docs/screenshots/linux-settings-before-popos-v061.png new file mode 100644 index 0000000000..05b23b7df8 Binary files /dev/null and b/docs/screenshots/linux-settings-before-popos-v061.png differ diff --git a/docs/screenshots/linux-settings-dark-popos.png b/docs/screenshots/linux-settings-dark-popos.png new file mode 100644 index 0000000000..fd2d060fd8 Binary files /dev/null and b/docs/screenshots/linux-settings-dark-popos.png differ diff --git a/docs/screenshots/linux-usage-default-after-popos.png b/docs/screenshots/linux-usage-default-after-popos.png new file mode 100644 index 0000000000..fe748436ec Binary files /dev/null and b/docs/screenshots/linux-usage-default-after-popos.png differ