diff --git a/docs/_Sidebar.md b/docs/_Sidebar.md index 2ec9b345e..dcfc989d3 100644 --- a/docs/_Sidebar.md +++ b/docs/_Sidebar.md @@ -17,6 +17,7 @@ - [Brightness](./(Widget)-Brightness) - [Cava](./(Widget)-Cava) - [Claude Usage](./(Widget)-Claude-Usage) + - [Codex Usage](./(Widget)-Codex-Usage) - [Copilot](./(Widget)-Copilot) - [CPU](./(Widget)-CPU) - [Clock](./(Widget)-Clock) diff --git a/docs/widgets/(Widget)-Codex-Usage.md b/docs/widgets/(Widget)-Codex-Usage.md new file mode 100644 index 000000000..27af1713b --- /dev/null +++ b/docs/widgets/(Widget)-Codex-Usage.md @@ -0,0 +1,164 @@ +# Codex Usage Widget Options + +Shows your Codex (Codex CLI) subscription usage on the bar - the 5-hour and weekly +rolling limit utilization - with a popup menu that displays both windows as progress bars +together with their reset times (a countdown plus the exact reset timestamp). + +The data is read from the same login credentials the Codex CLI uses +(`~/.codex/auth.json`) and fetched from OpenAI's usage endpoint. No API key or extra +configuration is required as long as you are signed in to Codex CLI. + +| Option | Type | Default | Description | +|-------------------|---------|---------|-------------| +| `label` | string | `'Codex {five_hour}%'` | The format string for the label. Supports the placeholders below. | +| `label_alt` | string | `'Codex {weekly}%'` | The alternative format string, toggled by the `toggle_label` callback. | +| `update_interval` | integer | `60` | How often the label and reset countdown are refreshed, in seconds. Must be between 30 and 3600. | +| `cache_ttl` | integer | `120` | How long (seconds) a fetched result is cached on disk before the endpoint is queried again. The endpoint is rate-limited, so keep this at a sane value. | +| `tooltip` | boolean | `true` | Whether to show a summary tooltip on hover. | +| `callbacks` | dict | `{'on_left': 'toggle_menu', 'on_middle': 'do_nothing', 'on_right': 'toggle_label'}` | Mouse-click callbacks. | +| `menu` | dict | `{'blur': true, 'round_corners': true, 'round_corners_type': 'normal', 'border_color': 'System', 'alignment': 'right', 'direction': 'down', 'offset_top': 6, 'offset_left': 0}` | Popup menu settings. | + +## Placeholders + +The label is plain text by default. You can prepend a Nerd Font glyph in a `` if you +want an icon (e.g. `\U000f06a9 {five_hour}%`). The following placeholders can be +used in `label` / `label_alt`: + +- `{five_hour}` - 5-hour window utilization (percent, `--` when unavailable). +- `{weekly}` - weekly window utilization (percent, `--` when unavailable). +- `{five_hour_reset}` - time until the 5-hour window resets. Shown as a countdown when under a + day away (e.g. `4h 27m`), otherwise as a local weekday + time (e.g. `Sat 6:00 AM`). +- `{weekly_reset}` - time until the weekly window resets (e.g. `Sat 6:00 AM`). + +```yaml +codex_usage: + type: "yasb.codex_usage.CodexUsageWidget" + options: + label: "Codex {five_hour}% · {five_hour_reset}" + label_alt: "Codex weekly {weekly}% · {weekly_reset}" + update_interval: 60 + cache_ttl: 120 + callbacks: + on_left: "toggle_menu" # open the usage menu + on_middle: "do_nothing" + on_right: "toggle_label" # switch the bar text between 5h and weekly + menu: + blur: true + round_corners: true + round_corners_type: "normal" + border_color: "System" + alignment: "right" + direction: "down" + offset_top: 6 + offset_left: 0 +``` + +## Description of Options + +- **label:** The format string for the label. Supports the placeholders listed above. +- **label_alt:** The alternative format string, toggled with the `toggle_label` callback. +- **update_interval:** How often the bar label and reset countdown are refreshed, in seconds (30-3600). +- **cache_ttl:** How long a fetched result is cached on disk before the usage endpoint is queried again. Because the endpoint is rate-limited, the widget serves the last cached value on any error instead of going blank. +- **tooltip:** Whether to show a summary tooltip on hover. +- **callbacks:** Mouse-click callbacks. Built-in actions: `toggle_menu` (open/close the popup menu), `toggle_label` (swap between `label` and `label_alt`), `do_nothing`, and `exec`. +- **menu:** A dictionary specifying the popup menu settings: + - **blur:** Enable blur effect for the menu. + - **round_corners:** Enable round corners (not supported on Windows 10). + - **round_corners_type:** Type of round corners (`normal`, `small`). + - **border_color:** Border color of the menu. + - **alignment:** Horizontal alignment of the menu (`left`, `right`, `center`). + - **direction:** Whether the menu opens `down` or `up`. + - **offset_top / offset_left:** Pixel offsets for fine positioning. + +## Authentication + +The widget reuses Codex CLI's existing login session. It reads the access token and +account id from `%USERPROFILE%\.codex\auth.json` (or `$CODEX_HOME\auth.json` when that +environment variable is set) and never logs or stores them elsewhere. If you are not +signed in to Codex CLI, the widget shows `--` until you sign in. + +## Widget Style +```css +.codex-usage {} +.codex-usage .widget-container {} +.codex-usage .icon {} +.codex-usage .label {} +/* Popup menu */ +.codex-usage-menu {} +.codex-usage-menu .header {} /* "Codex Usage" title */ +.codex-usage-menu .section {} +.codex-usage-menu .section .title {} +.codex-usage-menu .section .progress {} /* progress-bar track */ +.codex-usage-menu .section .progress .fill {} /* filled portion */ +.codex-usage-menu .section .progress.low .fill {} /* < 50% */ +.codex-usage-menu .section .progress.medium .fill {} /* 50-79% */ +.codex-usage-menu .section .progress.high .fill {} /* >= 80% */ +.codex-usage-menu .section .footer .reset {} +.codex-usage-menu .section .footer .percent {} +.codex-usage-menu .section .footer .percent.low {} +.codex-usage-menu .section .footer .percent.medium {} +.codex-usage-menu .section .footer .percent.high {} +.codex-usage-menu .section .date {} /* absolute reset timestamp */ +``` + +## Example Style +```css +.codex-usage .icon { + color: #91c9ff; + font-size: 16px; + margin: 1px 4px 0 0; +} +.codex-usage .label { + color: #cdd6f4; + padding: 0 2px; +} +.codex-usage-menu { + background-color: #1e1e2e; + min-width: 260px; +} +.codex-usage-menu .header { + color: #cdd6f4; + font-size: 15px; + font-weight: bold; + padding: 14px 16px 10px 16px; +} +.codex-usage-menu .section { + padding: 4px 16px 12px 16px; +} +.codex-usage-menu .section .title { + color: #cdd6f4; + font-size: 13px; + font-weight: 600; + padding-bottom: 6px; +} +.codex-usage-menu .progress { + background-color: #313244; + border-radius: 5px; + min-height: 10px; + max-height: 10px; +} +.codex-usage-menu .progress .fill { + background-color: #a6e3a1; + border-radius: 5px; +} +.codex-usage-menu .progress.medium .fill { background-color: #f9e2af; } +.codex-usage-menu .progress.high .fill { background-color: #f38ba8; } +.codex-usage-menu .reset { + color: #a6adc8; + font-size: 12px; + padding-top: 6px; +} +.codex-usage-menu .percent { + color: #a6e3a1; + font-size: 13px; + font-weight: bold; + padding-top: 6px; +} +.codex-usage-menu .percent.medium { color: #f9e2af; } +.codex-usage-menu .percent.high { color: #f38ba8; } +.codex-usage-menu .date { + color: #6c7086; + font-size: 11px; + padding-top: 2px; +} +``` diff --git a/schema.json b/schema.json index 7707bc731..86c92db85 100644 --- a/schema.json +++ b/schema.json @@ -2820,26 +2820,610 @@ "title": "ClockEntry", "type": "object" }, + "CodexUsageCallbacksConfig": { + "additionalProperties": false, + "properties": { + "on_left": { + "default": "toggle_menu", + "title": "On Left", + "type": "string" + }, + "on_middle": { + "default": "do_nothing", + "title": "On Middle", + "type": "string" + }, + "on_right": { + "default": "toggle_label", + "title": "On Right", + "type": "string" + } + }, + "title": "CodexUsageCallbacksConfig", + "type": "object" + }, + "CodexUsageConfig": { + "additionalProperties": false, + "properties": { + "label": { + "default": "Codex {five_hour}%", + "title": "Label", + "type": "string" + }, + "label_alt": { + "default": "Codex {weekly}%", + "title": "Label Alt", + "type": "string" + }, + "update_interval": { + "default": 60, + "maximum": 3600, + "minimum": 30, + "title": "Update Interval", + "type": "integer" + }, + "cache_ttl": { + "default": 120, + "maximum": 3600, + "minimum": 0, + "title": "Cache Ttl", + "type": "integer" + }, + "tooltip": { + "default": true, + "title": "Tooltip", + "type": "boolean" + }, + "callbacks": { + "$ref": "#/$defs/CodexUsageCallbacksConfig", + "default": { + "on_left": "toggle_menu", + "on_middle": "do_nothing", + "on_right": "toggle_label" + } + }, + "menu": { + "$ref": "#/$defs/CodexUsageMenuConfig", + "default": { + "blur": true, + "round_corners": true, + "round_corners_type": "normal", + "border_color": "System", + "alignment": "right", + "direction": "down", + "offset_top": 6, + "offset_left": 0 + } + }, + "keybindings": { + "default": [], + "items": { + "$ref": "#/$defs/KeybindingConfig" + }, + "title": "Keybindings", + "type": "array" + } + }, + "title": "CodexUsageConfig", + "type": "object" + }, + "CodexUsageEntry": { + "properties": { + "type": { + "const": "yasb.codex_usage.CodexUsageWidget", + "title": "Type", + "type": "string" + }, + "options": { + "$ref": "#/$defs/CodexUsageConfig" + } + }, + "required": [ + "type", + "options" + ], + "title": "CodexUsageEntry", + "type": "object" + }, + "CodexUsageMenuConfig": { + "additionalProperties": false, + "properties": { + "blur": { + "default": true, + "title": "Blur", + "type": "boolean" + }, + "round_corners": { + "default": true, + "title": "Round Corners", + "type": "boolean" + }, + "round_corners_type": { + "default": "normal", + "title": "Round Corners Type", + "type": "string" + }, + "border_color": { + "default": "System", + "title": "Border Color", + "type": "string" + }, + "alignment": { + "default": "right", + "title": "Alignment", + "type": "string" + }, + "direction": { + "default": "down", + "title": "Direction", + "type": "string" + }, + "offset_top": { + "default": 6, + "title": "Offset Top", + "type": "integer" + }, + "offset_left": { + "default": 0, + "title": "Offset Left", + "type": "integer" + } + }, + "title": "CodexUsageMenuConfig", + "type": "object" + }, "ColorProviderConfig": { "additionalProperties": false, "properties": { - "enabled": { + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + }, + "prefix": { + "default": "c:", + "title": "Prefix", + "type": "string" + }, + "priority": { + "default": 0, + "title": "Priority", + "type": "integer" + } + }, + "title": "ColorProviderConfig", + "type": "object" + }, + "ControlCenterActionConfig": { + "additionalProperties": false, + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "icon": { + "title": "Icon", + "type": "string" + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Command" + } + }, + "required": [ + "id", + "label", + "icon" + ], + "title": "ControlCenterActionConfig", + "type": "object" + }, + "ControlCenterCallbacksConfig": { + "additionalProperties": false, + "properties": { + "on_left": { + "default": "toggle_menu", + "title": "On Left", + "type": "string" + }, + "on_middle": { + "default": "do_nothing", + "title": "On Middle", + "type": "string" + }, + "on_right": { + "default": "do_nothing", + "title": "On Right", + "type": "string" + } + }, + "title": "ControlCenterCallbacksConfig", + "type": "object" + }, + "ControlCenterConfig": { + "additionalProperties": false, + "properties": { + "label": { + "default": "\ue90a", + "title": "Label", + "type": "string" + }, + "label_alt": { + "default": "\ue90a", + "title": "Label Alt", + "type": "string" + }, + "class_name": { + "default": "", + "title": "Class Name", + "type": "string" + }, + "tooltip": { + "default": false, + "title": "Tooltip", + "type": "boolean" + }, + "popup": { + "$ref": "#/$defs/ControlCenterPopupConfig", + "default": { + "blur": true, + "round_corners": true, + "round_corners_type": "normal", + "border_color": "System", + "alignment": "right", + "direction": "down", + "offset_top": 6, + "offset_left": 0 + } + }, + "sections": { + "$ref": "#/$defs/ControlCenterSectionsConfig", + "default": { + "system_controls": { + "lock_icon": "\ue72e", + "power_icon": "\ue7e8", + "profile_image_size": 28, + "settings_icon": "\ue713", + "show": true + }, + "quick_actions": { + "actions": [ + { + "command": null, + "icon": "\uf285", + "id": "toggle_dnd", + "label": "Do Not Disturb" + }, + { + "command": null, + "icon": "\ue708", + "id": "toggle_theme", + "label": "Dark Mode" + }, + { + "command": null, + "icon": "\ue765", + "id": "touch_keyboard", + "label": "Keyboard" + }, + { + "command": null, + "icon": "\ue74f", + "id": "toggle_mute", + "label": "Mute" + }, + { + "command": null, + "icon": "\uf12e", + "id": "toggle_mic_mute", + "label": "Mic Mute" + }, + { + "command": null, + "icon": "\ue91b", + "id": "screenshot", + "label": "Screenshot" + } + ], + "columns": 3, + "label_position": "default", + "show": true + }, + "sliders": { + "brightness": { + "icon": "\ue706", + "show_slider": true, + "show_source_selector": false, + "source_selector_icon": "\ue972" + }, + "microphone": { + "icon": "\ue720", + "show_slider": true, + "show_source_selector": false, + "source_selector_icon": "\ue972" + }, + "show": true, + "volume": { + "icon": "\ue767", + "show_slider": true, + "show_source_selector": false, + "source_selector_icon": "\ue972" + } + }, + "power": { + "button_menu_icon": "\ue76c", + "power_mode_title": "Power Mode", + "power_plan_title": "Power Plan", + "show": true + }, + "media": { + "icons": { + "next_track": "\ue893", + "pause": "\ue769", + "play": "\ue768", + "prev_track": "\ue892" + }, + "show": true, + "thumbnail_radius": 8, + "thumbnail_size": 64 + } + } + }, + "sections_order": { + "default": [ + "system_controls", + "quick_actions", + "sliders", + "power", + "media" + ], + "items": { + "enum": [ + "system_controls", + "quick_actions", + "sliders", + "power", + "media" + ], + "type": "string" + }, + "title": "Sections Order", + "type": "array" + }, + "keybindings": { + "default": [], + "items": { + "$ref": "#/$defs/KeybindingConfig" + }, + "title": "Keybindings", + "type": "array" + }, + "callbacks": { + "$ref": "#/$defs/ControlCenterCallbacksConfig", + "default": { + "on_left": "toggle_menu", + "on_middle": "do_nothing", + "on_right": "do_nothing" + } + } + }, + "title": "ControlCenterConfig", + "type": "object" + }, + "ControlCenterEntry": { + "properties": { + "type": { + "const": "yasb.control_center.ControlCenterWidget", + "title": "Type", + "type": "string" + }, + "options": { + "$ref": "#/$defs/ControlCenterConfig" + } + }, + "required": [ + "type", + "options" + ], + "title": "ControlCenterEntry", + "type": "object" + }, + "ControlCenterPopupConfig": { + "additionalProperties": false, + "properties": { + "blur": { + "default": true, + "title": "Blur", + "type": "boolean" + }, + "round_corners": { + "default": true, + "title": "Round Corners", + "type": "boolean" + }, + "round_corners_type": { + "default": "normal", + "title": "Round Corners Type", + "type": "string" + }, + "border_color": { + "default": "System", + "title": "Border Color", + "type": "string" + }, + "alignment": { + "default": "right", + "title": "Alignment", + "type": "string" + }, + "direction": { + "default": "down", + "title": "Direction", + "type": "string" + }, + "offset_top": { + "default": 6, + "title": "Offset Top", + "type": "integer" + }, + "offset_left": { + "default": 0, + "title": "Offset Left", + "type": "integer" + } + }, + "title": "ControlCenterPopupConfig", + "type": "object" + }, + "ControlCenterSectionsConfig": { + "additionalProperties": false, + "properties": { + "system_controls": { + "$ref": "#/$defs/SystemControlsConfig", + "default": { + "show": true, + "profile_image_size": 28, + "power_icon": "\ue7e8", + "lock_icon": "\ue72e", + "settings_icon": "\ue713" + } + }, + "quick_actions": { + "$ref": "#/$defs/QuickActionsSectionConfig", + "default": { + "show": true, + "columns": 3, + "label_position": "default", + "actions": [ + { + "command": null, + "icon": "\uf285", + "id": "toggle_dnd", + "label": "Do Not Disturb" + }, + { + "command": null, + "icon": "\ue708", + "id": "toggle_theme", + "label": "Dark Mode" + }, + { + "command": null, + "icon": "\ue765", + "id": "touch_keyboard", + "label": "Keyboard" + }, + { + "command": null, + "icon": "\ue74f", + "id": "toggle_mute", + "label": "Mute" + }, + { + "command": null, + "icon": "\uf12e", + "id": "toggle_mic_mute", + "label": "Mic Mute" + }, + { + "command": null, + "icon": "\ue91b", + "id": "screenshot", + "label": "Screenshot" + } + ] + } + }, + "sliders": { + "$ref": "#/$defs/SlidersSectionConfig", + "default": { + "show": true, + "brightness": { + "icon": "\ue706", + "show_slider": true, + "show_source_selector": false, + "source_selector_icon": "\ue972" + }, + "volume": { + "icon": "\ue767", + "show_slider": true, + "show_source_selector": false, + "source_selector_icon": "\ue972" + }, + "microphone": { + "icon": "\ue720", + "show_slider": true, + "show_source_selector": false, + "source_selector_icon": "\ue972" + } + } + }, + "power": { + "$ref": "#/$defs/PowerSectionConfig", + "default": { + "show": true, + "power_plan_title": "Power Plan", + "power_mode_title": "Power Mode", + "button_menu_icon": "\ue76c" + } + }, + "media": { + "$ref": "#/$defs/MediaSectionConfig", + "default": { + "show": true, + "thumbnail_size": 64, + "thumbnail_radius": 8, + "icons": { + "next_track": "\ue893", + "pause": "\ue769", + "play": "\ue768", + "prev_track": "\ue892" + } + } + } + }, + "title": "ControlCenterSectionsConfig", + "type": "object" + }, + "ControlCenterSliderConfig": { + "additionalProperties": false, + "properties": { + "show_slider": { "default": true, - "title": "Enabled", + "title": "Show Slider", "type": "boolean" }, - "prefix": { - "default": "c:", - "title": "Prefix", + "icon": { + "title": "Icon", "type": "string" }, - "priority": { - "default": 0, - "title": "Priority", - "type": "integer" + "show_source_selector": { + "default": false, + "title": "Show Source Selector", + "type": "boolean" + }, + "source_selector_icon": { + "default": "\ue972", + "title": "Source Selector Icon", + "type": "string" } }, - "title": "ColorProviderConfig", + "required": [ + "icon" + ], + "title": "ControlCenterSliderConfig", "type": "object" }, "CopilotCallbacksConfig": { @@ -6567,6 +7151,64 @@ "title": "MediaMenuIconsConfig", "type": "object" }, + "MediaSectionConfig": { + "additionalProperties": false, + "properties": { + "show": { + "default": true, + "title": "Show", + "type": "boolean" + }, + "thumbnail_size": { + "default": 64, + "title": "Thumbnail Size", + "type": "integer" + }, + "thumbnail_radius": { + "default": 8, + "title": "Thumbnail Radius", + "type": "integer" + }, + "icons": { + "$ref": "#/$defs/MediaSectionIconsConfig", + "default": { + "prev_track": "\ue892", + "next_track": "\ue893", + "play": "\ue768", + "pause": "\ue769" + } + } + }, + "title": "MediaSectionConfig", + "type": "object" + }, + "MediaSectionIconsConfig": { + "additionalProperties": false, + "properties": { + "prev_track": { + "default": "\ue892", + "title": "Prev Track", + "type": "string" + }, + "next_track": { + "default": "\ue893", + "title": "Next Track", + "type": "string" + }, + "play": { + "default": "\ue768", + "title": "Play", + "type": "string" + }, + "pause": { + "default": "\ue769", + "title": "Pause", + "type": "string" + } + }, + "title": "MediaSectionIconsConfig", + "type": "object" + }, "MediaWidgetConfig": { "additionalProperties": false, "properties": { @@ -8804,11 +9446,13 @@ "title": "Class Name", "type": "string" }, - "update_interval": { - "default": 5000, - "minimum": 0, - "title": "Update Interval", - "type": "integer" + "class_map": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "title": "Class Map", + "type": "object" }, "menu": { "$ref": "#/$defs/PowerPlanMenuConfig", @@ -8917,6 +9561,33 @@ "title": "PowerPlanMenuConfig", "type": "object" }, + "PowerSectionConfig": { + "additionalProperties": false, + "properties": { + "show": { + "default": true, + "title": "Show", + "type": "boolean" + }, + "power_plan_title": { + "default": "Power Plan", + "title": "Power Plan Title", + "type": "string" + }, + "power_mode_title": { + "default": "Power Mode", + "title": "Power Mode Title", + "type": "string" + }, + "button_menu_icon": { + "default": "\ue76c", + "title": "Button Menu Icon", + "type": "string" + } + }, + "title": "PowerSectionConfig", + "type": "object" + }, "PreviewConfig": { "additionalProperties": false, "properties": { @@ -9017,6 +9688,77 @@ "title": "ProviderConfig", "type": "object" }, + "QuickActionsSectionConfig": { + "additionalProperties": false, + "properties": { + "show": { + "default": true, + "title": "Show", + "type": "boolean" + }, + "columns": { + "default": 3, + "title": "Columns", + "type": "integer" + }, + "label_position": { + "default": "default", + "enum": [ + "default", + "inline" + ], + "title": "Label Position", + "type": "string" + }, + "actions": { + "default": [ + { + "id": "toggle_dnd", + "label": "Do Not Disturb", + "icon": "\uf285", + "command": null + }, + { + "id": "toggle_theme", + "label": "Dark Mode", + "icon": "\ue708", + "command": null + }, + { + "id": "touch_keyboard", + "label": "Keyboard", + "icon": "\ue765", + "command": null + }, + { + "id": "toggle_mute", + "label": "Mute", + "icon": "\ue74f", + "command": null + }, + { + "id": "toggle_mic_mute", + "label": "Mic Mute", + "icon": "\uf12e", + "command": null + }, + { + "id": "screenshot", + "label": "Screenshot", + "icon": "\ue91b", + "command": null + } + ], + "items": { + "$ref": "#/$defs/ControlCenterActionConfig" + }, + "title": "Actions", + "type": "array" + } + }, + "title": "QuickActionsSectionConfig", + "type": "object" + }, "QuickLaunchCallbacksConfig": { "additionalProperties": false, "properties": { @@ -10062,6 +10804,45 @@ "title": "ShortcutsConfig", "type": "object" }, + "SlidersSectionConfig": { + "additionalProperties": false, + "properties": { + "show": { + "default": true, + "title": "Show", + "type": "boolean" + }, + "brightness": { + "$ref": "#/$defs/ControlCenterSliderConfig", + "default": { + "show_slider": true, + "icon": "\ue706", + "show_source_selector": false, + "source_selector_icon": "\ue972" + } + }, + "volume": { + "$ref": "#/$defs/ControlCenterSliderConfig", + "default": { + "show_slider": true, + "icon": "\ue767", + "show_source_selector": false, + "source_selector_icon": "\ue972" + } + }, + "microphone": { + "$ref": "#/$defs/ControlCenterSliderConfig", + "default": { + "show_slider": true, + "icon": "\ue720", + "show_source_selector": false, + "source_selector_icon": "\ue972" + } + } + }, + "title": "SlidersSectionConfig", + "type": "object" + }, "SnippetsProviderConfig": { "additionalProperties": false, "properties": { @@ -10368,6 +11149,38 @@ "title": "SystemCommandsProviderConfig", "type": "object" }, + "SystemControlsConfig": { + "additionalProperties": false, + "properties": { + "show": { + "default": true, + "title": "Show", + "type": "boolean" + }, + "profile_image_size": { + "default": 28, + "title": "Profile Image Size", + "type": "integer" + }, + "power_icon": { + "default": "\ue7e8", + "title": "Power Icon", + "type": "string" + }, + "lock_icon": { + "default": "\ue72e", + "title": "Lock Icon", + "type": "string" + }, + "settings_icon": { + "default": "\ue713", + "title": "Settings Icon", + "type": "string" + } + }, + "title": "SystemControlsConfig", + "type": "object" + }, "SystrayPopupConfig": { "additionalProperties": false, "properties": { @@ -11120,29 +11933,17 @@ "type": "string" }, "menu_title": { - "default": "VScode recents", + "default": "VS code recents", "title": "Menu Title", "type": "string" }, - "folder_icon": { - "default": "\uf114", - "title": "Folder Icon", - "type": "string" - }, - "file_icon": { - "default": "\uf016", - "title": "File Icon", - "type": "string" - }, - "hide_folder_icon": { - "default": false, - "title": "Hide Folder Icon", - "type": "boolean" - }, - "hide_file_icon": { - "default": false, - "title": "Hide File Icon", - "type": "boolean" + "icons": { + "$ref": "#/$defs/VSCodeIconsConfig", + "default": { + "folder": "\uf114", + "file": "\uf016", + "remote": "" + } }, "truncate_to_root_dir": { "default": false, @@ -11161,12 +11962,6 @@ "title": "Max Number Of Files", "type": "integer" }, - "max_field_size": { - "default": 100, - "minimum": 1, - "title": "Max Field Size", - "type": "integer" - }, "state_storage_path": { "default": "", "title": "State Storage Path", @@ -11233,6 +12028,28 @@ "title": "VSCodeEntry", "type": "object" }, + "VSCodeIconsConfig": { + "additionalProperties": false, + "properties": { + "folder": { + "default": "\uf114", + "title": "Folder", + "type": "string" + }, + "file": { + "default": "\uf016", + "title": "File", + "type": "string" + }, + "remote": { + "default": "", + "title": "Remote", + "type": "string" + } + }, + "title": "VSCodeIconsConfig", + "type": "object" + }, "VSCodeMenuConfig": { "additionalProperties": false, "properties": { @@ -14605,6 +15422,12 @@ { "$ref": "#/$defs/ClockEntry" }, + { + "$ref": "#/$defs/CodexUsageEntry" + }, + { + "$ref": "#/$defs/ControlCenterEntry" + }, { "$ref": "#/$defs/CopilotEntry" }, diff --git a/src/core/validation/widgets/yasb/codex_usage.py b/src/core/validation/widgets/yasb/codex_usage.py new file mode 100644 index 000000000..fddcf3611 --- /dev/null +++ b/src/core/validation/widgets/yasb/codex_usage.py @@ -0,0 +1,34 @@ +from pydantic import Field + +from core.validation.widgets.base_model import ( + CallbacksConfig, + CustomBaseModel, + KeybindingConfig, +) + + +class CodexUsageCallbacksConfig(CallbacksConfig): + on_left: str = "toggle_menu" + on_right: str = "toggle_label" + + +class CodexUsageMenuConfig(CustomBaseModel): + blur: bool = True + round_corners: bool = True + round_corners_type: str = "normal" + border_color: str = "System" + alignment: str = "right" + direction: str = "down" + offset_top: int = 6 + offset_left: int = 0 + + +class CodexUsageConfig(CustomBaseModel): + label: str = "Codex {five_hour}%" + label_alt: str = "Codex {weekly}%" + update_interval: int = Field(default=60, ge=30, le=3600) + cache_ttl: int = Field(default=120, ge=0, le=3600) + tooltip: bool = True + callbacks: CodexUsageCallbacksConfig = CodexUsageCallbacksConfig() + menu: CodexUsageMenuConfig = CodexUsageMenuConfig() + keybindings: list[KeybindingConfig] = [] diff --git a/src/core/widgets/services/codex_usage/codex_api.py b/src/core/widgets/services/codex_usage/codex_api.py new file mode 100644 index 000000000..0a62c0648 --- /dev/null +++ b/src/core/widgets/services/codex_usage/codex_api.py @@ -0,0 +1,199 @@ +import json +import logging +import os +import time +import urllib.request +from datetime import UTC, datetime +from typing import Any, ClassVar + +from PyQt6.QtCore import QObject, QThread, QTimer, pyqtSignal + +from core.utils.system import app_data_path + +logger = logging.getLogger("codex_usage") + +USAGE_URL = "https://chatgpt.com/backend-api/wham/usage" + +EMPTY_RECORD: dict[str, Any] = { + "five": None, + "five_raw": None, + "five_reset_iso": None, + "weekly": None, + "weekly_raw": None, + "weekly_reset_iso": None, + "fetched_at": 0, +} + + +def _codex_home() -> str: + return os.environ.get("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex") + + +def _cache_path() -> str: + return str(app_data_path("codex_usage_cache.json")) + + +def _read_cache(path: str) -> dict[str, Any] | None: + try: + with open(path, encoding="utf-8") as f: + return json.load(f) + except Exception: + return None + + +def _write_cache(path: str, data: dict[str, Any]) -> None: + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f) + except Exception as e: + logger.debug("failed to write cache: %s", e) + + +def _epoch_to_iso(value: Any) -> str | None: + """Convert a Unix epoch (seconds) reset timestamp to an ISO 8601 UTC string.""" + if value is None: + return None + try: + return datetime.fromtimestamp(float(value), tz=UTC).isoformat() + except TypeError, ValueError, OSError: + return None + + +def fetch_usage(cache_path: str, cache_ttl: int) -> dict[str, Any]: + """Return a usage record, hitting the network only when the cache is stale. + + On any error (including HTTP 429 rate limiting) the last cached record is + returned so the widget keeps showing the most recent known values. The OAuth + token is read from Codex CLI's credentials store and is never logged. + """ + cache = _read_cache(cache_path) + now = int(time.time()) + if cache and (now - int(cache.get("fetched_at", 0))) < cache_ttl: + return cache + + try: + auth_path = os.path.join(_codex_home(), "auth.json") + with open(auth_path, encoding="utf-8") as f: + tokens = json.load(f)["tokens"] + access_token = tokens["access_token"] + account_id = tokens["account_id"] + + request = urllib.request.Request( + USAGE_URL, + headers={"Authorization": f"Bearer {access_token}", "ChatGPT-Account-ID": account_id}, + ) + with urllib.request.urlopen(request, timeout=10) as response: + payload = json.loads(response.read().decode("utf-8")) + + rate_limit = payload["rate_limit"] + primary = rate_limit.get("primary_window") + secondary = rate_limit.get("secondary_window") + + five_raw = float(primary["used_percent"]) if primary else None + weekly_raw = float(secondary["used_percent"]) if secondary else None + record = { + "five": round(five_raw) if five_raw is not None else None, + "five_raw": five_raw, + "five_reset_iso": _epoch_to_iso(primary.get("reset_at")) if primary else None, + "weekly": round(weekly_raw) if weekly_raw is not None else None, + "weekly_raw": weekly_raw, + "weekly_reset_iso": _epoch_to_iso(secondary.get("reset_at")) if secondary else None, + "fetched_at": now, + } + _write_cache(cache_path, record) + return record + except Exception as e: + logger.debug("usage fetch failed: %s", e) + if cache: + return cache + return dict(EMPTY_RECORD) + + +class _UsageWorker(QThread): + """Runs the (blocking) credential read + HTTP request off the UI thread.""" + + data_ready = pyqtSignal(dict) + + def __init__(self, cache_path: str, cache_ttl: int, parent: Any = None): + super().__init__(parent) + self._cache_path = cache_path + self._cache_ttl = cache_ttl + + def run(self) -> None: + self.data_ready.emit(fetch_usage(self._cache_path, self._cache_ttl)) + + +class CodexUsageService(QObject): + """Shared Codex usage poller. + + One service instance fetches the usage record on a timer and shares it with + every widget that requests the same ``(update_interval, cache_ttl)`` pair, so + multiple Codex widgets never duplicate the network request or the on-disk + cache. Instances are reference-counted and released when the last widget goes + away (mirrors ``ClaudeUsageService``). + """ + + data_ready = pyqtSignal(dict) + + _instances: ClassVar[dict[tuple, CodexUsageService]] = {} + + @classmethod + def get_instance(cls, update_interval_s: int, cache_ttl: int) -> CodexUsageService: + key = (int(update_interval_s), int(cache_ttl)) + inst = cls._instances.get(key) + if inst is None: + inst = cls(update_interval_s=int(update_interval_s), cache_ttl=int(cache_ttl), _key=key) + cls._instances[key] = inst + inst._refcount += 1 + return inst + + def __init__(self, update_interval_s: int, cache_ttl: int, _key: tuple): + super().__init__() + self._key = _key + self._refcount = 0 + self._cache_path = _cache_path() + self._cache_ttl = cache_ttl + self._worker: _UsageWorker | None = None + self._data: dict[str, Any] = _read_cache(self._cache_path) or dict(EMPTY_RECORD) + + self._timer = QTimer(self) + self._timer.setInterval(max(int(update_interval_s), 1) * 1000) + self._timer.timeout.connect(self._tick) + self._timer.start() + self._tick() + + def latest(self) -> dict[str, Any]: + """The most recent usage record (cached value, available immediately).""" + return self._data + + def release(self) -> None: + self._refcount -= 1 + if self._refcount > 0: + return + self._timer.stop() + CodexUsageService._instances.pop(self._key, None) + if self._worker is not None and self._worker.isRunning(): + # Tear down only once the in-flight fetch finishes, so we never block the + # GUI thread (or destroy a running QThread) during a config reload. + self._worker.finished.connect(self.deleteLater) + else: + self.deleteLater() + + def _tick(self) -> None: + if self._worker is not None: + return # a fetch is already in flight + worker = _UsageWorker(self._cache_path, self._cache_ttl, self) + worker.data_ready.connect(self._on_data) + worker.finished.connect(self._on_finished) + self._worker = worker + worker.start() + + def _on_data(self, data: dict[str, Any]) -> None: + self._data = data + self.data_ready.emit(data) + + def _on_finished(self) -> None: + worker = self._worker + self._worker = None + if worker is not None: + worker.deleteLater() diff --git a/src/core/widgets/yasb/claude_usage.py b/src/core/widgets/yasb/claude_usage.py index add84b068..d40530ac2 100644 --- a/src/core/widgets/yasb/claude_usage.py +++ b/src/core/widgets/yasb/claude_usage.py @@ -28,7 +28,8 @@ def __init__(self, value: int, level: str, parent: QFrame | None = None): def _update_fill(self) -> None: fill_width = int(self.width() * self._value / 100) if fill_width > 0: - fill_width = max(fill_width, self.height()) + # height*1 gives a perfect circle whose rounded corners leave a gap versus the track; widen it into a pill. + fill_width = max(fill_width, self.height() * 3) self._fill.setGeometry(0, 0, fill_width, self.height()) def resizeEvent(self, event): diff --git a/src/core/widgets/yasb/codex_usage.py b/src/core/widgets/yasb/codex_usage.py new file mode 100644 index 000000000..9fc01ba2c --- /dev/null +++ b/src/core/widgets/yasb/codex_usage.py @@ -0,0 +1,257 @@ +import re +from datetime import UTC, datetime +from typing import Any + +from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout + +from core.utils.tooltip import set_tooltip +from core.utils.utilities import PopupWidget, refresh_widget_style +from core.validation.widgets.yasb.codex_usage import CodexUsageConfig +from core.widgets.base import BaseWidget +from core.widgets.services.codex_usage.codex_api import CodexUsageService + + +class UsageBar(QFrame): + """A rounded progress bar drawn as a track QFrame with a child `.fill` QFrame. + + Keeps the fill at least as wide as it is tall so the rounded corners are preserved at + low values (the QProgressBar::chunk square-fill issue), and stays fully CSS-styleable. + """ + + def __init__(self, value: int, level: str, parent: QFrame | None = None): + super().__init__(parent) + self._value = max(0, min(100, value)) + self.setProperty("class", f"progress {level}") + self._fill = QFrame(self) + self._fill.setProperty("class", "fill") + + def _update_fill(self) -> None: + fill_width = int(self.width() * self._value / 100) + if fill_width > 0: + # height*1 gives a perfect circle whose rounded corners leave a gap versus the track; widen it into a pill. + fill_width = max(fill_width, self.height() * 3) + self._fill.setGeometry(0, 0, fill_width, self.height()) + + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_fill() + + +class CodexUsageWidget(BaseWidget): + validation_schema = CodexUsageConfig + + def __init__(self, config: CodexUsageConfig): + super().__init__(class_name="codex-usage") + self.config = config + self._show_alt_label = False + self._menu: PopupWidget | None = None + self._service_released = False + + self._service = CodexUsageService.get_instance(self.config.update_interval, self.config.cache_ttl) + self._data: dict[str, Any] = self._service.latest() + + self._init_container() + self.build_widget_label(self.config.label, self.config.label_alt) + + self.register_callback("toggle_label", self._toggle_label) + self.register_callback("toggle_menu", self._toggle_menu) + + self.callback_left = self.config.callbacks.on_left + self.callback_middle = self.config.callbacks.on_middle + self.callback_right = self.config.callbacks.on_right + + self._service.data_ready.connect(self._on_data) + self.destroyed.connect(lambda *_: self._release_service()) + self._update_label() + + def _release_service(self) -> None: + if getattr(self, "_service_released", False): + return + self._service_released = True + try: + self._service.release() + except RuntimeError: + pass + + def closeEvent(self, event): + self._release_service() + super().closeEvent(event) + + def _on_data(self, data: dict[str, Any]) -> None: + self._data = data + self._update_label() + + def _format_values(self) -> dict[str, str]: + return { + "five_hour": self._pct(self._data.get("five")), + "weekly": self._pct(self._data.get("weekly")), + "five_hour_reset": self._fmt_reset(self._data.get("five_reset_iso")), + "weekly_reset": self._fmt_reset(self._data.get("weekly_reset_iso")), + } + + @staticmethod + def _pct(value: Any) -> str: + return "--" if value is None else str(value) + + @staticmethod + def _pct_decimal(raw: Any, rounded: Any) -> str: + """One-decimal percentage ('28.0') from the raw value, falling back to the rounded one.""" + value = raw if isinstance(raw, (int, float)) else rounded + return f"{value:.1f}" if isinstance(value, (int, float)) else "--" + + @staticmethod + def _fmt_reset(iso: str | None) -> str: + """Short time-until-reset: a countdown ('4h 14m') when under a day away, + otherwise a local weekday + time ('Sat 6:00 AM').""" + if not iso: + return "--" + try: + target = datetime.fromisoformat(iso.replace("Z", "+00:00")) + seconds = int((target - datetime.now(UTC)).total_seconds()) + if seconds <= 0: + return "0m" + if seconds < 24 * 3600: + hours, minutes = divmod(seconds // 60, 60) + return f"{hours}h {minutes}m" if hours else f"{minutes}m" + local = target.astimezone() + hour12 = local.hour % 12 or 12 + ampm = "AM" if local.hour < 12 else "PM" + return f"{local:%a} {hour12}:{local.minute:02d} {ampm}" + except Exception: + return "--" + + @staticmethod + def _fmt_reset_at(iso: str | None) -> str: + """Absolute local reset timestamp, e.g. '6/7/2026, 5:50:00 AM'.""" + if not iso: + return "--" + try: + local = datetime.fromisoformat(iso.replace("Z", "+00:00")).astimezone() + hour12 = local.hour % 12 or 12 + ampm = "AM" if local.hour < 12 else "PM" + return f"{local.month}/{local.day}/{local.year}, {hour12}:{local.minute:02d}:{local.second:02d} {ampm}" + except Exception: + return "--" + + @staticmethod + def _level_class(value: Any) -> str: + if not isinstance(value, (int, float)): + return "unknown" + if value >= 80: + return "high" + if value >= 50: + return "medium" + return "low" + + def _toggle_label(self) -> None: + self._show_alt_label = not self._show_alt_label + for widget in self._widgets: + widget.setVisible(not self._show_alt_label) + for widget in self._widgets_alt: + widget.setVisible(self._show_alt_label) + self._update_label() + + def _update_label(self) -> None: + active_widgets = self._widgets_alt if self._show_alt_label else self._widgets + active_template = self.config.label_alt if self._show_alt_label else self.config.label + values = self._format_values() + # Skip whitespace-only parts so the widget/template indices stay aligned with + # build_widget_label (which drops them); otherwise multi- labels misalign. + label_parts = [part for part in re.split(r"(.*?)", active_template) if part.strip()] + + for index, part in enumerate(label_parts): + if index >= len(active_widgets): + continue + current_widget = active_widgets[index] + if "" in part: + current_widget.setText(re.sub(r"|", "", part).strip()) + else: + try: + current_widget.setText(part.strip().format(**values)) + except Exception: + current_widget.setText(part.strip()) + if self.config.tooltip: + set_tooltip( + current_widget, + f"Codex usage - 5h: {values['five_hour']}% · Weekly: {values['weekly']}%", + ) + refresh_widget_style(*active_widgets) + + def _toggle_menu(self) -> None: + self._build_menu() + + def _build_section(self, title: str, value: Any, raw: Any, reset_iso: str | None) -> QFrame: + level = self._level_class(value) + frame = QFrame() + frame.setProperty("class", "section") + layout = QVBoxLayout(frame) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + title_label = QLabel(f"{title} Window") + title_label.setProperty("class", "title") + layout.addWidget(title_label) + + progress = UsageBar(int(value) if isinstance(value, (int, float)) else 0, level) + layout.addWidget(progress) + + footer = QFrame() + footer.setProperty("class", "footer") + footer_layout = QHBoxLayout(footer) + footer_layout.setContentsMargins(0, 0, 0, 0) + footer_layout.setSpacing(0) + + reset_label = QLabel(f"Resets in {self._fmt_reset(reset_iso)}") + reset_label.setProperty("class", "reset") + footer_layout.addWidget(reset_label) + footer_layout.addStretch() + + percent_label = QLabel(f"{self._pct_decimal(raw, value)}%") + percent_label.setProperty("class", f"percent {level}") + footer_layout.addWidget(percent_label) + + layout.addWidget(footer) + + date_label = QLabel(self._fmt_reset_at(reset_iso)) + date_label.setProperty("class", "date") + layout.addWidget(date_label) + + return frame + + def _build_menu(self) -> None: + self._menu = PopupWidget( + self, + self.config.menu.blur, + self.config.menu.round_corners, + self.config.menu.round_corners_type, + self.config.menu.border_color, + ) + self._menu.setProperty("class", "codex-usage-menu") + + layout = QVBoxLayout(self._menu) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + header = QLabel("Codex Usage") + header.setProperty("class", "header") + layout.addWidget(header) + + layout.addWidget( + self._build_section( + "5-Hour", self._data.get("five"), self._data.get("five_raw"), self._data.get("five_reset_iso") + ) + ) + layout.addWidget( + self._build_section( + "Weekly", self._data.get("weekly"), self._data.get("weekly_raw"), self._data.get("weekly_reset_iso") + ) + ) + + self._menu.adjustSize() + self._menu.setPosition( + alignment=self.config.menu.alignment, + direction=self.config.menu.direction, + offset_left=self.config.menu.offset_left, + offset_top=self.config.menu.offset_top, + ) + self._menu.show()