feat: Add system tray application with Claude Code style icon#181
feat: Add system tray application with Claude Code style icon#181ViniciusBrown wants to merge 2 commits intoMaciek-roboblog:mainfrom
Conversation
…onitor, including menu, settings, and stats display.
…onitoring, updating the README and including new static images.
📝 WalkthroughWalkthroughThis 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
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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~65 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
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. Comment |
There was a problem hiding this comment.
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 respectingXDG_CONFIG_HOMEenvironment variable.The XDG Base Directory Specification allows users to override the config location via
XDG_CONFIG_HOME. Hardcoding~/.configmay 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 forpgrep.The pattern
claude-monitor-traymay match processes that aren't the actual tray app (e.g., editor processes with that file open,grepitself on some systems). Consider usingpgrep -f "python.*claude-monitor-tray"or checking the process command more precisely.Also,
pgrepis Linux-specific, which aligns with the PR scope but worth noting for future cross-platform consideration.
99-107: Consolidate settings persistence.When both
--planand--refresh-rateare 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 adjustscriticaltowarning + 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
44000for 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.
jsonandPathare imported but not used in this module. The JSON operations are handled bywrite_status_fileandread_status_file, andSTATUS_FILEis imported directly as aPathobject.-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_fileandread_status_fileperform synchronous I/O on the main thread. For typical usage this is likely fine, but ifanalyze_usagebecomes 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
⛔ Files ignored due to path filters (2)
static/ccmt.pngis excluded by!**/*.pngstatic/tray.pngis excluded by!**/*.png
📒 Files selected for processing (12)
README.mdpyproject.tomlsrc/claude_monitor/tray/__init__.pysrc/claude_monitor/tray/__main__.pysrc/claude_monitor/tray/app.pysrc/claude_monitor/tray/autostart.pysrc/claude_monitor/tray/icons.pysrc/claude_monitor/tray/menu.pysrc/claude_monitor/tray/settings.pysrc/claude_monitor/tray/settings_dialog.pysrc/claude_monitor/tray/stats_window.pysrc/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-trayandccmtaliases 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-trayandccmtaliases properly point toclaude_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.
| 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 "" |
There was a problem hiding this comment.
Improve timezone handling and exception specificity.
Two issues:
- The timezone stripping only handles
+offsets, missing negative offsets (e.g.,-05:00). - 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.
| 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 |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| 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.
| 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 "--" |
There was a problem hiding this comment.
🧩 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.pyRepository: 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.pyRepository: 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 -80Repository: 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.pyRepository: 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.pyRepository: 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.
| 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()} |
There was a problem hiding this comment.
🧩 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 -100Repository: 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 -150Repository: 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.pyRepository: Maciek-roboblog/Claude-Code-Usage-Monitor
Length of output: 1893
🏁 Script executed:
# Find where SessionAnalyzer is defined
rg "class SessionAnalyzer" -A 100 | head -150Repository: 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 30Repository: Maciek-roboblog/Claude-Code-Usage-Monitor
Length of output: 2156
🏁 Script executed:
# Find load_usage_entries function
rg "def load_usage_entries" -A 30Repository: 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.pyRepository: 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.pyRepository: 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.pyRepository: 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.
Summary
ccmtcommand alias for quick tray app launchChanges
System Tray Application
New Commands
claude-monitor-tray- Launch the tray applicationccmt- Short alias for the tray appIcon Update
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