Skip to content

feat: Add system tray application with Claude Code style icon#181

Open
ViniciusBrown wants to merge 2 commits intoMaciek-roboblog:mainfrom
ViniciusBrown:main
Open

feat: Add system tray application with Claude Code style icon#181
ViniciusBrown wants to merge 2 commits intoMaciek-roboblog:mainfrom
ViniciusBrown:main

Conversation

@ViniciusBrown
Copy link

@ViniciusBrown ViniciusBrown commented Jan 16, 2026

ccmt

tray

Summary

  • Add Linux system tray application for monitoring Claude Code token usage
  • Implement Claude Code style icon (">_" terminal prompt) with color-coded status
  • Add ccmt command alias for quick tray app launch

Changes

System Tray Application

  • Real-time token usage monitoring directly from the desktop
  • Color-coded icon states: green (normal), yellow (warning), red (critical), gray (loading/error)
  • Click to view detailed statistics window
  • Right-click menu for settings and actions
  • Configurable warning/critical thresholds
  • Autostart support

New Commands

  • claude-monitor-tray - Launch the tray application
  • ccmt - Short alias for the tray app

Icon Update

  • Replaced simple circular icon with Claude Code style ">_" terminal prompt
  • Dark rounded rectangle background with colored prompt symbol
  • Visual consistency with Claude Code branding

Installation

# Install with tray support
pip install claude-monitor[tray]

Test plan

- Run ccmt and verify tray icon appears
- Check icon color changes based on usage thresholds
- Click tray icon to open statistics window
- Verify settings dialog works correctly

🤖 Generated with https://claude.com/claude-code

…onitor, including menu, settings, and stats display.
…onitoring, updating the README and including new static images.
@coderabbitai
Copy link

coderabbitai bot commented Jan 16, 2026

📝 Walkthrough

Walkthrough

This PR introduces a complete system tray application for Claude Code Usage Monitor, adding PyQt6-based UI with status monitoring, icon indicators, settings management, and Linux autostart support. Implementation spans configuration, dependency declarations, core app logic, UI components, and utility modules for icons, menus, settings persistence, and status generation.

Changes

Cohort / File(s) Summary
Configuration & Documentation
README.md, pyproject.toml
Expanded README with tray feature details, command aliases (claude-monitor-tray, ccmt); added PyQt6>=6.4.0 optional dependency and two new console script entry points.
Core Infrastructure
src/claude_monitor/tray/__init__.py, src/claude_monitor/tray/__main__.py
Dependency gatekeeper exposing PYQT_AVAILABLE flag and check_dependencies() function; entry point with CLI arg parsing (--plan, --refresh-rate), logging setup, existing instance killing, settings loading, and TrayApplication orchestration.
Application & UI Components
src/claude_monitor/tray/app.py, src/claude_monitor/tray/menu.py, src/claude_monitor/tray/stats_window.py, src/claude_monitor/tray/settings_dialog.py
TrayApplication lifecycle (init, refresh timer, signals); context menu with view stats/refresh/settings/quit actions; stats window with usage rows and formatting; settings dialog with validation for plan, thresholds, and autostart toggle.
Icon, Settings & Persistence
src/claude_monitor/tray/icons.py, src/claude_monitor/tray/settings.py, src/claude_monitor/tray/autostart.py
Icon state machine (NORMAL/WARNING/CRITICAL/LOADING/ERROR) with color mapping and threshold logic; TraySettings dataclass and TraySettingsManager for JSON persistence; AutostartManager for Linux XDG desktop entry control.
Status Generation
src/claude_monitor/tray/status_generator.py
Generates status JSON from usage analytics (plan, tokens, cost, messages, percentages, timestamps); writes to ~/.claude-monitor/tray_status.json; reads/parses with error handling.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant CLI as CLI Entry<br/>(__main__)
    participant DepCheck as Dependency<br/>Checker
    participant Logger as Logger
    participant SettingsMgr as Settings<br/>Manager
    participant TrayApp as TrayApplication
    participant StatusGen as Status<br/>Generator
    participant IconMgr as Icon<br/>Manager
    participant Menu as Menu<br/>Builder

    User->>CLI: Run claude-monitor-tray<br/>(--plan max5)
    CLI->>CLI: Kill existing instances
    CLI->>DepCheck: check_dependencies()
    DepCheck-->>CLI: PyQt6 available?
    alt PyQt6 unavailable
        CLI->>Logger: Log error & exit
        CLI-->>User: Error message
    else PyQt6 available
        CLI->>Logger: setup_logging()
        CLI->>SettingsMgr: load() settings
        SettingsMgr-->>CLI: TraySettings
        CLI->>SettingsMgr: Override with CLI args<br/>(--plan, --refresh-rate)
        SettingsMgr->>SettingsMgr: save() to JSON
        CLI->>TrayApp: new TrayApplication(argv)
        TrayApp->>SettingsMgr: load settings
        TrayApp->>IconMgr: new TrayIconManager<br/>(thresholds)
        TrayApp->>Menu: build_menu()
        Menu-->>TrayApp: QMenu
        TrayApp->>StatusGen: write_status_file(plan)
        StatusGen-->>TrayApp: Status JSON
        TrayApp->>IconMgr: get_icon_for_usage(ratio)
        IconMgr-->>TrayApp: QIcon
        TrayApp->>TrayApp: Set tray icon & tooltip
        TrayApp->>TrayApp: Start refresh timer
        TrayApp-->>User: Tray icon visible
        
        loop Every refresh_rate seconds
            TrayApp->>StatusGen: write_status_file()
            StatusGen-->>TrayApp: Updated status
            TrayApp->>IconMgr: get_icon_for_usage()
            IconMgr-->>TrayApp: New icon state
            TrayApp->>TrayApp: Update tooltip & icon
        end
    end
Loading
sequenceDiagram
    actor User
    participant Tray as Tray Icon
    participant TrayApp as TrayApplication
    participant StatsWin as Stats Window
    participant SettingsD as Settings Dialog
    participant SettingsMgr as Settings Manager
    participant AutoMgr as Autostart Manager

    User->>Tray: Click tray icon
    Tray->>TrayApp: Activation signal
    TrayApp->>StatsWin: Show or refresh
    TrayApp->>StatusGen: read_status_file()
    StatusGen-->>TrayApp: Status dict
    TrayApp->>StatsWin: update_status()
    StatsWin-->>User: Display usage rows<br/>(tokens, cost, messages)
    
    User->>Tray: Right-click
    Tray->>TrayApp: Context menu
    User->>TrayApp: Click "Settings..."
    TrayApp->>SettingsD: new SettingsDialog(settings)
    SettingsD->>SettingsD: _load_settings()
    SettingsD-->>User: Show dialog
    User->>SettingsD: Change plan/thresholds<br/>/autostart/notifications
    User->>SettingsD: Click OK
    SettingsD->>SettingsD: _on_accept()<br/>(validate thresholds)
    SettingsD->>SettingsMgr: settings_changed signal
    TrayApp->>SettingsMgr: save() new settings
    TrayApp->>IconMgr: update_thresholds()
    TrayApp->>AutoMgr: set_enabled() on autostart
    TrayApp->>TrayApp: Trigger refresh cycle
    TrayApp-->>User: Icon & settings updated
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~65 minutes

Possibly related PRs

Suggested reviewers

  • rvaidya
  • kory-
  • PedramNavid
  • adawalli

Poem

🐰 A tray for all the busy folk,
With icons bright—no need to poke!
PyQt6 weaves the UI thread,
Status flows, thresholds spread,
Settings dance in desktop files so neat,
Our monitoring app is now complete! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main feature: adding a system tray application with a Claude Code style icon, which directly corresponds to the primary changes across the PR.
Docstring Coverage ✅ Passed Docstring coverage is 85.45% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@src/claude_monitor/tray/app.py`:
- Around line 130-146: The _format_reset function currently strips only "+"
timezone offsets and uses a bare except; update it to handle both "+" and "-"
offsets (e.g., detect the last '+' or '-' after the date/time portion and strip
the timezone or normalize 'Z' to '+00:00') before calling
datetime.fromisoformat(reset_time) or, alternatively, pass the full ISO string
to fromisoformat if it supports offsets, and replace a trailing 'Z' with
'+00:00' as needed; also replace the bare except with specific exception
handlers (catch ValueError and TypeError) so only parsing errors are swallowed
and other exceptions bubble up.

In `@src/claude_monitor/tray/icons.py`:
- Around line 158-172: The update_thresholds method updates warning/critical
values but does not invalidate the icon cache, so get_icon_for_usage keeps
returning icons based on old thresholds; modify update_thresholds (in the
update_thresholds method) to clear whatever cache get_icon_for_usage relies on
(e.g. self._icon_cache.clear() or set self._icon_cache = {} / None) and
optionally refresh the currently displayed icon by calling get_icon_for_usage
with the current usage value (or triggering the same update path) so the UI
immediately reflects the new thresholds.

In `@src/claude_monitor/tray/stats_window.py`:
- Around line 123-127: The update_status method only updates self._plan_label on
error and leaves other UI labels showing stale data; modify update_status to
clear/reset all related display fields when status is falsy or contains "error"
(e.g., set self._plan_label, self._usage_label, self._model_label,
self._status_label, and any _last_updated/_quota labels to an explicit "Error"
or empty/default text) so the UI doesn't display misleading stale values; locate
this logic in update_status and apply consistent resets for each label/widget
referenced there.
- Around line 167-191: The current _fmt_reset and _fmt_time strip offsets and
use naive datetimes; add a helper method _parse_iso(self, ts: str) ->
Optional[datetime] that handles trailing "Z" by converting to "+00:00", calls
datetime.fromisoformat, and returns None on ValueError/TypeError, then refactor
_fmt_reset to call _parse_iso(reset_time), compute now using
datetime.now(end.tzinfo) when end.tzinfo is present, compare timezone-aware
datetimes and calculate hours/minutes only when end > now, and refactor
_fmt_time to use _parse_iso(ts) and format only if a datetime is returned.

In `@src/claude_monitor/tray/status_generator.py`:
- Around line 17-91: The fallback for selecting the most recent block in
generate_status is wrong: when no active block is found it sets current_block =
blocks[0] (oldest); change the fallback to use the most recent block by
assigning current_block = blocks[-1] in the current_block selection logic (the
loop that inspects blocks and the subsequent if-not-current_block block). This
keeps the ordering produced by load_usage_entries/transform_to_blocks and
ensures token/cost calculations use the latest block.
🧹 Nitpick comments (9)
src/claude_monitor/tray/autostart.py (2)

39-46: Consider respecting XDG_CONFIG_HOME environment variable.

The XDG Base Directory Specification allows users to override the config location via XDG_CONFIG_HOME. Hardcoding ~/.config may not work correctly for users who have customized this.

Proposed fix
+import os
+
     def _get_autostart_dir(self) -> Path:
         """Get XDG autostart directory.

         Returns:
             Path to autostart directory
         """
-        xdg_config = Path.home() / ".config"
+        xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
+        if xdg_config_home:
+            xdg_config = Path(xdg_config_home)
+        else:
+            xdg_config = Path.home() / ".config"
         return xdg_config / "autostart"

48-63: Minor: is_available() has a side effect of creating the autostart directory.

The method creates the directory to test writability. This is pragmatic but could be surprising. Consider documenting this behavior in the docstring or renaming to ensure_available().

src/claude_monitor/tray/__main__.py (2)

46-67: Consider using a more specific pattern for pgrep.

The pattern claude-monitor-tray may match processes that aren't the actual tray app (e.g., editor processes with that file open, grep itself on some systems). Consider using pgrep -f "python.*claude-monitor-tray" or checking the process command more precisely.

Also, pgrep is Linux-specific, which aligns with the PR scope but worth noting for future cross-platform consideration.


99-107: Consolidate settings persistence.

When both --plan and --refresh-rate are provided, settings_manager.save() is called twice. Consider consolidating into a single save after applying all CLI overrides.

Suggested improvement
         # Override from CLI args
+        settings_changed = False
         if args.plan:
             settings.plan = args.plan
-            settings_manager.save(settings)
+            settings_changed = True
             logger.info(f"Plan set to: {args.plan}")

         if args.refresh_rate:
             settings.refresh_rate = args.refresh_rate
+            settings_changed = True
+
+        if settings_changed:
             settings_manager.save(settings)
src/claude_monitor/tray/settings_dialog.py (2)

194-221: Silent threshold auto-correction may confuse users.

When critical <= warning, the code silently adjusts critical to warning + 5. Consider showing a brief notification or message to inform the user of the adjustment rather than silently changing their input.

Example improvement
         if critical <= warning:
             critical = warning + 5
             self._critical_spin.setValue(min(100, critical))
+            from PyQt6.QtWidgets import QMessageBox
+            QMessageBox.information(
+                self,
+                "Threshold Adjusted",
+                f"Critical threshold must be greater than warning. Adjusted to {min(100, critical)}%."
+            )

165-169: Extract magic number to a constant.

The default value 44000 for custom token limit is hardcoded. Consider extracting this to a named constant for clarity and maintainability.

DEFAULT_CUSTOM_LIMIT_TOKENS = 44000
# ...
self._custom_limit_spin.setValue(DEFAULT_CUSTOM_LIMIT_TOKENS)
src/claude_monitor/tray/app.py (2)

3-7: Remove unused imports.

json and Path are imported but not used in this module. The JSON operations are handled by write_status_file and read_status_file, and STATUS_FILE is imported directly as a Path object.

-import json
 import logging
 from datetime import datetime
-from pathlib import Path
 from typing import Any, Dict, Optional

78-96: Consider async status generation for UI responsiveness.

write_status_file and read_status_file perform synchronous I/O on the main thread. For typical usage this is likely fine, but if analyze_usage becomes slow, it could briefly freeze the UI. Consider moving this to a worker thread if users report unresponsiveness.

src/claude_monitor/tray/settings.py (1)

41-52: Validate and normalize settings loaded from JSON
Right now any malformed values (e.g., negative refresh_rate, thresholds outside 0–1, non-numeric strings) flow into runtime behavior. It’s safer to coerce + clamp during deserialization so the UI/status logic doesn’t have to defend downstream.

♻️ Suggested normalization in from_dict
     `@classmethod`
     def from_dict(cls, data: Dict[str, Any]) -> "TraySettings":
         """Create settings from dictionary."""
+        def _clamp01(value: Any, default: float) -> float:
+            try:
+                v = float(value)
+            except (TypeError, ValueError):
+                return default
+            return max(0.0, min(1.0, v))
+
+        def _pos_int(value: Any, default: int) -> int:
+            try:
+                v = int(value)
+            except (TypeError, ValueError):
+                return default
+            return max(1, v)
+
+        def _opt_pos_int(value: Any) -> Optional[int]:
+            if value is None:
+                return None
+            try:
+                v = int(value)
+            except (TypeError, ValueError):
+                return None
+            return v if v > 0 else None
+
+        warning = _clamp01(data.get("warning_threshold"), 0.70)
+        critical = _clamp01(data.get("critical_threshold"), 0.90)
+        if critical < warning:
+            critical = warning
+
         return cls(
-            refresh_rate=data.get("refresh_rate", 60),
+            refresh_rate=_pos_int(data.get("refresh_rate"), 60),
             plan=data.get("plan", "custom"),
-            custom_limit_tokens=data.get("custom_limit_tokens"),
-            warning_threshold=data.get("warning_threshold", 0.70),
-            critical_threshold=data.get("critical_threshold", 0.90),
+            custom_limit_tokens=_opt_pos_int(data.get("custom_limit_tokens")),
+            warning_threshold=warning,
+            critical_threshold=critical,
             autostart=data.get("autostart", False),
             show_notifications=data.get("show_notifications", True),
-            notification_threshold=data.get("notification_threshold", 0.90),
+            notification_threshold=_clamp01(data.get("notification_threshold"), 0.90),
         )
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 06f0fe1 and 773a42a.

⛔ Files ignored due to path filters (2)
  • static/ccmt.png is excluded by !**/*.png
  • static/tray.png is excluded by !**/*.png
📒 Files selected for processing (12)
  • README.md
  • pyproject.toml
  • src/claude_monitor/tray/__init__.py
  • src/claude_monitor/tray/__main__.py
  • src/claude_monitor/tray/app.py
  • src/claude_monitor/tray/autostart.py
  • src/claude_monitor/tray/icons.py
  • src/claude_monitor/tray/menu.py
  • src/claude_monitor/tray/settings.py
  • src/claude_monitor/tray/settings_dialog.py
  • src/claude_monitor/tray/stats_window.py
  • src/claude_monitor/tray/status_generator.py
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-08-16T10:20:08.249Z
Learnt from: aster-void
Repo: Maciek-roboblog/Claude-Code-Usage-Monitor PR: 139
File: flake.nix:151-176
Timestamp: 2025-08-16T10:20:08.249Z
Learning: In pyproject.toml files, all console script entry points are declared under the [project.scripts] section. When analyzing Nix flakes that reference console scripts, always check the complete [project.scripts] section in pyproject.toml to verify all declared entry points before flagging missing scripts as issues.

Applied to files:

  • pyproject.toml
🧬 Code graph analysis (4)
src/claude_monitor/tray/__main__.py (3)
src/claude_monitor/tray/__init__.py (1)
  • check_dependencies (13-34)
src/claude_monitor/tray/settings.py (3)
  • TraySettingsManager (55-120)
  • load (68-92)
  • save (94-120)
src/claude_monitor/tray/app.py (2)
  • TrayApplication (23-207)
  • start (71-76)
src/claude_monitor/tray/app.py (7)
src/claude_monitor/tray/icons.py (5)
  • IconState (13-20)
  • TrayIconManager (23-172)
  • get_icon (64-75)
  • get_icon_for_usage (52-62)
  • update_thresholds (158-172)
src/claude_monitor/tray/menu.py (2)
  • TrayMenuBuilder (13-71)
  • build_menu (32-71)
src/claude_monitor/tray/settings.py (4)
  • TraySettings (15-52)
  • TraySettingsManager (55-120)
  • load (68-92)
  • save (94-120)
src/claude_monitor/tray/stats_window.py (2)
  • StatsWindow (61-191)
  • update_status (123-158)
src/claude_monitor/tray/settings_dialog.py (1)
  • SettingsDialog (27-221)
src/claude_monitor/tray/autostart.py (3)
  • AutostartManager (24-139)
  • is_available (48-63)
  • set_enabled (113-125)
src/claude_monitor/tray/status_generator.py (2)
  • write_status_file (94-106)
  • read_status_file (109-118)
src/claude_monitor/tray/settings.py (1)
src/claude_monitor/core/settings.py (1)
  • exists (82-84)
src/claude_monitor/tray/settings_dialog.py (2)
src/claude_monitor/core/plans.py (1)
  • PlanType (12-26)
src/claude_monitor/tray/settings.py (1)
  • TraySettings (15-52)
🪛 LanguageTool
README.md

[grammar] ~900-~900: Ensure spelling is correct
Context: .... Available settings: - Plan: pro, max5, max20, or custom - Refresh rate: H...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)


[grammar] ~900-~900: Ensure spelling is correct
Context: ...ilable settings: - Plan: pro, max5, max20, or custom - Refresh rate: How ofte...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🔇 Additional comments (11)
src/claude_monitor/tray/__init__.py (1)

1-41: LGTM!

The dependency gatekeeper is well-structured with clear separation between availability check and error messaging. The use of global state is acceptable here since this is a startup-time check that runs once before the application initializes.

src/claude_monitor/tray/icons.py (2)

95-156: Icon rendering logic is well-implemented.

The Claude Code style ">_" prompt icon with color-coded status and error overlay is cleanly implemented. The use of anti-aliasing and fixed 22px size is appropriate for system tray icons.


37-50: LGTM on initialization and caching strategy.

The icon caching by state is a good optimization to avoid re-rendering icons on each update cycle.

src/claude_monitor/tray/autostart.py (1)

73-95: LGTM on enable/disable logic.

The enable method properly creates the directory, resolves the executable path, and writes the desktop entry. Error handling with logging and boolean returns is appropriate.

README.md (2)

221-222: LGTM on command alias documentation.

The new claude-monitor-tray and ccmt aliases are properly documented in both the command list and usage examples sections.

Also applies to: 274-277


857-921: System Tray Application section is well-documented and accurate.

The documentation comprehensively covers features, usage, icon states, configuration, and installation with clear visual references. All referenced image files exist in the static directory.

pyproject.toml (2)

74-74: LGTM on optional tray dependency.

PyQt6>=6.4.0 as an optional dependency is appropriate, keeping the core package lightweight while allowing tray functionality for users who need it.


92-93: Entry points correctly configured.

Both claude-monitor-tray and ccmt aliases properly point to claude_monitor.tray.__main__:main, consistent with the existing entry point patterns in this project.

src/claude_monitor/tray/menu.py (1)

13-71: LGTM!

Clean implementation of the tray menu builder. The signal-based architecture properly decouples menu actions from their handlers, and the menu structure with separators is well-organized.

src/claude_monitor/tray/app.py (1)

23-56: LGTM - Well-structured application class.

The initialization properly sets up all components with sensible defaults, and setQuitOnLastWindowClosed(False) is the correct pattern for tray applications. The separation of concerns between managers (settings, icons, autostart) and UI components is clean.

src/claude_monitor/tray/settings.py (1)

68-116: Atomic write + caching looks solid
Temp file + replace and the in-memory cache are a good balance of safety and performance here.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment on lines +130 to +146
def _format_reset(self, reset_time: str) -> str:
"""Format reset time."""
if not reset_time:
return ""
try:
if "+" in reset_time:
reset_time = reset_time.split("+")[0]
end = datetime.fromisoformat(reset_time)
now = datetime.now()
if end > now:
delta = end - now
hours = int(delta.total_seconds() // 3600)
mins = int((delta.total_seconds() % 3600) // 60)
return f"Resets in {hours}h {mins}m"
except:
pass
return ""
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Improve timezone handling and exception specificity.

Two issues:

  1. The timezone stripping only handles + offsets, missing negative offsets (e.g., -05:00).
  2. The bare except: clause is overly broad; catching specific exceptions improves debuggability.
Suggested fix
     def _format_reset(self, reset_time: str) -> str:
         """Format reset time."""
         if not reset_time:
             return ""
         try:
-            if "+" in reset_time:
-                reset_time = reset_time.split("+")[0]
+            # Strip timezone offset (handles both + and - offsets)
+            for sep in ("+", "-"):
+                if sep in reset_time[10:]:  # Skip date portion
+                    reset_time = reset_time.rsplit(sep, 1)[0]
+                    break
             end = datetime.fromisoformat(reset_time)
             now = datetime.now()
             if end > now:
                 delta = end - now
                 hours = int(delta.total_seconds() // 3600)
                 mins = int((delta.total_seconds() % 3600) // 60)
                 return f"Resets in {hours}h {mins}m"
-        except:
-            pass
+        except (ValueError, TypeError) as e:
+            logger.debug(f"Failed to parse reset time '{reset_time}': {e}")
         return ""
🤖 Prompt for AI Agents
In `@src/claude_monitor/tray/app.py` around lines 130 - 146, The _format_reset
function currently strips only "+" timezone offsets and uses a bare except;
update it to handle both "+" and "-" offsets (e.g., detect the last '+' or '-'
after the date/time portion and strip the timezone or normalize 'Z' to '+00:00')
before calling datetime.fromisoformat(reset_time) or, alternatively, pass the
full ISO string to fromisoformat if it supports offsets, and replace a trailing
'Z' with '+00:00' as needed; also replace the bare except with specific
exception handlers (catch ValueError and TypeError) so only parsing errors are
swallowed and other exceptions bubble up.

Comment on lines +158 to +172
def update_thresholds(
self,
warning_threshold: Optional[float] = None,
critical_threshold: Optional[float] = None,
) -> None:
"""Update threshold values.

Args:
warning_threshold: New warning threshold (0.0-1.0)
critical_threshold: New critical threshold (0.0-1.0)
"""
if warning_threshold is not None:
self.warning_threshold = warning_threshold
if critical_threshold is not None:
self.critical_threshold = critical_threshold
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Cache invalidation missing after threshold update.

When thresholds are updated, the cached icons remain unchanged. This means get_icon_for_usage() will return icons based on old thresholds until the cache is cleared or the application restarts.

Proposed fix
     def update_thresholds(
         self,
         warning_threshold: Optional[float] = None,
         critical_threshold: Optional[float] = None,
     ) -> None:
         """Update threshold values.

         Args:
             warning_threshold: New warning threshold (0.0-1.0)
             critical_threshold: New critical threshold (0.0-1.0)
         """
+        changed = False
         if warning_threshold is not None:
             self.warning_threshold = warning_threshold
+            changed = True
         if critical_threshold is not None:
             self.critical_threshold = critical_threshold
+            changed = True
+        if changed:
+            self._icon_cache.clear()
🤖 Prompt for AI Agents
In `@src/claude_monitor/tray/icons.py` around lines 158 - 172, The
update_thresholds method updates warning/critical values but does not invalidate
the icon cache, so get_icon_for_usage keeps returning icons based on old
thresholds; modify update_thresholds (in the update_thresholds method) to clear
whatever cache get_icon_for_usage relies on (e.g. self._icon_cache.clear() or
set self._icon_cache = {} / None) and optionally refresh the currently displayed
icon by calling get_icon_for_usage with the current usage value (or triggering
the same update path) so the UI immediately reflects the new thresholds.

Comment on lines +123 to +127
def update_status(self, status: Optional[Dict[str, Any]]) -> None:
"""Update from status dict."""
if not status or "error" in status:
self._plan_label.setText("Plan: Error")
return
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clear stale values when status is missing/error
On error you only update the plan label, leaving prior usage/labels on screen. This can mislead users after a failure.

✅ Suggested reset on error
     def update_status(self, status: Optional[Dict[str, Any]]) -> None:
         """Update from status dict."""
         if not status or "error" in status:
             self._plan_label.setText("Plan: Error")
+            self._token_row.update(0, "-- / --")
+            self._cost_row.update(0, "-- / --")
+            self._msg_row.update(0, "-- / --")
+            self._reset_label.setText("Time to reset: --")
+            self._updated_label.setText("Last updated: --")
             return
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def update_status(self, status: Optional[Dict[str, Any]]) -> None:
"""Update from status dict."""
if not status or "error" in status:
self._plan_label.setText("Plan: Error")
return
def update_status(self, status: Optional[Dict[str, Any]]) -> None:
"""Update from status dict."""
if not status or "error" in status:
self._plan_label.setText("Plan: Error")
self._token_row.update(0, "-- / --")
self._cost_row.update(0, "-- / --")
self._msg_row.update(0, "-- / --")
self._reset_label.setText("Time to reset: --")
self._updated_label.setText("Last updated: --")
return
🤖 Prompt for AI Agents
In `@src/claude_monitor/tray/stats_window.py` around lines 123 - 127, The
update_status method only updates self._plan_label on error and leaves other UI
labels showing stale data; modify update_status to clear/reset all related
display fields when status is falsy or contains "error" (e.g., set
self._plan_label, self._usage_label, self._model_label, self._status_label, and
any _last_updated/_quota labels to an explicit "Error" or empty/default text) so
the UI doesn't display misleading stale values; locate this logic in
update_status and apply consistent resets for each label/widget referenced
there.

Comment on lines +167 to +191
def _fmt_reset(self, reset_time: str) -> str:
if not reset_time:
return "--"
try:
if "+" in reset_time:
reset_time = reset_time.split("+")[0]
end = datetime.fromisoformat(reset_time)
now = datetime.now()
if end > now:
delta = end - now
h = int(delta.total_seconds() // 3600)
m = int((delta.total_seconds() % 3600) // 60)
return f"{h}h {m}m"
except:
pass
return "--"

def _fmt_time(self, ts: str) -> str:
if not ts:
return "--"
try:
dt = datetime.fromisoformat(ts)
return dt.strftime("%H:%M:%S")
except:
return "--"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect timestamp/reset_time formatting in the status generator
rg -n "reset_time|timestamp|isoformat" -C3 src/claude_monitor/tray/status_generator.py

Repository: Maciek-roboblog/Claude-Code-Usage-Monitor

Length of output: 1834


🏁 Script executed:

#!/bin/bash
# Find where analyze_usage is defined and how endTime is formatted
rg -n "def analyze_usage" -A 30 src/claude_monitor/

Repository: Maciek-roboblog/Claude-Code-Usage-Monitor

Length of output: 2341


🏁 Script executed:

#!/bin/bash
# Search for endTime creation in analysis.py
rg -n "endTime" -B 2 -A 2 src/claude_monitor/data/analysis.py

Repository: Maciek-roboblog/Claude-Code-Usage-Monitor

Length of output: 341


🏁 Script executed:

#!/bin/bash
# Find where Block class is defined and end_time is set
rg -n "class.*Block|end_time.*=" -B 2 -A 2 src/claude_monitor/data/analysis.py | head -80

Repository: Maciek-roboblog/Claude-Code-Usage-Monitor

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Search for Block class definition across the codebase
rg -n "class.*Block" src/claude_monitor/

Repository: Maciek-roboblog/Claude-Code-Usage-Monitor

Length of output: 211


🏁 Script executed:

#!/bin/bash
# Read SessionBlock class to understand end_time
sed -n '72,120p' src/claude_monitor/core/models.py

Repository: Maciek-roboblog/Claude-Code-Usage-Monitor

Length of output: 1763


🏁 Script executed:

#!/bin/bash
# Search for SessionBlock instantiation to see how datetime objects are created
rg -n "SessionBlock(" -B 3 -A 3 src/claude_monitor/data/

Repository: Maciek-roboblog/Claude-Code-Usage-Monitor

Length of output: 161


🏁 Script executed:

#!/bin/bash
# Search for SessionBlock instantiation with proper escaping
rg -n "SessionBlock\(" -B 3 -A 3 src/claude_monitor/data/

Repository: Maciek-roboblog/Claude-Code-Usage-Monitor

Length of output: 1136


🏁 Script executed:

#!/bin/bash
# Show more context around SessionBlock creation to see where start_time comes from
sed -n '100,150p' src/claude_monitor/data/analyzer.py

Repository: Maciek-roboblog/Claude-Code-Usage-Monitor

Length of output: 1960


Consider using timezone-aware datetime handling for robustness

The timestamps are correctly emitted in UTC-aware ISO format (e.g., 2025-01-01T12:00:00+00:00). While the current code works by stripping the offset and comparing naive datetimes (both in UTC), this approach is fragile and obscures intent. The suggested _parse_iso helper improves clarity and future-proofs against format variations (e.g., Z notation). Refactor to use timezone-aware comparison:

def _parse_iso(self, ts: str) -> Optional[datetime]:
    if not ts:
        return None
    try:
        if ts.endswith("Z"):
            ts = ts[:-1] + "+00:00"
        return datetime.fromisoformat(ts)
    except (ValueError, TypeError):
        return None

def _fmt_reset(self, reset_time: str) -> str:
    if not reset_time:
        return "--"
    end = self._parse_iso(reset_time)
    if end:
        now = datetime.now(end.tzinfo) if end.tzinfo else datetime.now()
        if end > now:
            delta = end - now
            h = int(delta.total_seconds() // 3600)
            m = int((delta.total_seconds() % 3600) // 60)
            return f"{h}h {m}m"
    return "--"

def _fmt_time(self, ts: str) -> str:
    if not ts:
        return "--"
    dt = self._parse_iso(ts)
    return dt.strftime("%H:%M:%S") if dt else "--"
🤖 Prompt for AI Agents
In `@src/claude_monitor/tray/stats_window.py` around lines 167 - 191, The current
_fmt_reset and _fmt_time strip offsets and use naive datetimes; add a helper
method _parse_iso(self, ts: str) -> Optional[datetime] that handles trailing "Z"
by converting to "+00:00", calls datetime.fromisoformat, and returns None on
ValueError/TypeError, then refactor _fmt_reset to call _parse_iso(reset_time),
compute now using datetime.now(end.tzinfo) when end.tzinfo is present, compare
timezone-aware datetimes and calculate hours/minutes only when end > now, and
refactor _fmt_time to use _parse_iso(ts) and format only if a datetime is
returned.

Comment on lines +17 to +91
def generate_status(plan: str = "max20") -> Dict[str, Any]:
"""Generate status data using claude-monitor internals."""
try:
data = analyze_usage(hours_back=192, use_cache=False)

if not data:
return {"error": "No data available", "timestamp": datetime.now().isoformat()}

blocks = data.get("blocks", [])

# Find active or most recent block
current_block = None
for block in blocks:
if block.get("isActive"):
current_block = block
break
if not current_block and blocks:
current_block = blocks[0]

# Get token limit for plan
token_limit = Plans.get_token_limit(plan, blocks)

# Calculate session usage
session_tokens = 0
session_cost = 0.0
session_messages = 0
reset_time = ""

if current_block:
# Use camelCase keys as returned by analyze_usage
# Only count input + output tokens (not cache) for limit calculation
tc = current_block.get("tokenCounts", {})
session_tokens = tc.get("inputTokens", 0) + tc.get("outputTokens", 0)
session_cost = current_block.get("costUSD", 0.0)
session_messages = current_block.get("sentMessagesCount", 0)
reset_time = current_block.get("endTime", "")

# Percentage
session_pct = int((session_tokens / token_limit * 100)) if token_limit > 0 else 0

# Plan config
plan_config = Plans.get_plan_by_name(plan)
cost_limit = plan_config.cost_limit if plan_config else 140.0
message_limit = plan_config.message_limit if plan_config else 2000

cost_pct = int((session_cost / cost_limit * 100)) if cost_limit > 0 else 0
message_pct = int((session_messages / message_limit * 100)) if message_limit > 0 else 0

return {
"timestamp": datetime.now().isoformat(),
"plan": plan,
"token_limit": token_limit,
"cost_limit": cost_limit,
"message_limit": message_limit,
"session": {
"tokens": session_tokens,
"tokens_pct": min(100, session_pct),
"cost": session_cost,
"cost_pct": min(100, cost_pct),
"messages": session_messages,
"messages_pct": min(100, message_pct),
"reset_time": reset_time,
"is_active": current_block.get("isActive", False) if current_block else False,
},
"totals": {
"entries": data.get("entries_count", 0),
"blocks": len(blocks),
"total_tokens": data.get("total_tokens", 0),
"total_cost": data.get("total_cost", 0.0),
}
}

except Exception as e:
logger.exception(f"Error generating status: {e}")
return {"error": str(e), "timestamp": datetime.now().isoformat()}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, find where analyze_usage is defined
fd -t f "*.py" | xargs rg -l "def analyze_usage"

Repository: Maciek-roboblog/Claude-Code-Usage-Monitor

Length of output: 456


🏁 Script executed:

# Search for the analyze_usage function definition to understand block ordering
ast-grep --pattern $'def analyze_usage($$$) {
  $$$
}'

Repository: Maciek-roboblog/Claude-Code-Usage-Monitor

Length of output: 67


🏁 Script executed:

# Also search for where blocks are returned or ordered
rg "blocks.*=" -A 3 -B 3 | head -100

Repository: Maciek-roboblog/Claude-Code-Usage-Monitor

Length of output: 6152


🏁 Script executed:

# Read the analyze_usage function in analysis.py
cat -n src/claude_monitor/data/analysis.py | head -150

Repository: Maciek-roboblog/Claude-Code-Usage-Monitor

Length of output: 6261


🏁 Script executed:

# Search for where blocks are assembled or returned in analyze_usage
rg "def analyze_usage" -A 50 src/claude_monitor/data/analysis.py

Repository: Maciek-roboblog/Claude-Code-Usage-Monitor

Length of output: 1893


🏁 Script executed:

# Find where SessionAnalyzer is defined
rg "class SessionAnalyzer" -A 100 | head -150

Repository: Maciek-roboblog/Claude-Code-Usage-Monitor

Length of output: 7095


🏁 Script executed:

# Search for transform_to_blocks method specifically
rg "def transform_to_blocks" -A 30

Repository: Maciek-roboblog/Claude-Code-Usage-Monitor

Length of output: 2156


🏁 Script executed:

# Find load_usage_entries function
rg "def load_usage_entries" -A 30

Repository: Maciek-roboblog/Claude-Code-Usage-Monitor

Length of output: 2282


🏁 Script executed:

# Check if entries are sorted anywhere
rg "sort" src/claude_monitor/data/reader.py

Repository: Maciek-roboblog/Claude-Code-Usage-Monitor

Length of output: 132


🏁 Script executed:

# Find _mark_active_blocks to understand how active blocks are marked
rg "_mark_active_blocks" -A 20 src/claude_monitor/data/analyzer.py

Repository: Maciek-roboblog/Claude-Code-Usage-Monitor

Length of output: 1429


🏁 Script executed:

# Get more context on the sort in reader.py
rg "all_entries.sort" -B 5 -A 5 src/claude_monitor/data/reader.py

Repository: Maciek-roboblog/Claude-Code-Usage-Monitor

Length of output: 383


Use blocks[-1] instead of blocks[0] for the fallback on line 34.

The blocks list is ordered from oldest to newest (entries are sorted ascending by timestamp in load_usage_entries, and transform_to_blocks appends blocks sequentially). The fallback logic should reference blocks[-1] to get the most recent block when no active block is found.

Affected code section
# Find active or most recent block
current_block = None
for block in blocks:
    if block.get("isActive"):
        current_block = block
        break
if not current_block and blocks:
    current_block = blocks[-1]  # Use blocks[-1], not blocks[0]
🤖 Prompt for AI Agents
In `@src/claude_monitor/tray/status_generator.py` around lines 17 - 91, The
fallback for selecting the most recent block in generate_status is wrong: when
no active block is found it sets current_block = blocks[0] (oldest); change the
fallback to use the most recent block by assigning current_block = blocks[-1] in
the current_block selection logic (the loop that inspects blocks and the
subsequent if-not-current_block block). This keeps the ordering produced by
load_usage_entries/transform_to_blocks and ensures token/cost calculations use
the latest block.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant