claude-code-usage 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- claude_code_usage-1.0.0.dist-info/METADATA +187 -0
- claude_code_usage-1.0.0.dist-info/RECORD +15 -0
- claude_code_usage-1.0.0.dist-info/WHEEL +5 -0
- claude_code_usage-1.0.0.dist-info/entry_points.txt +5 -0
- claude_code_usage-1.0.0.dist-info/licenses/LICENSE +21 -0
- claude_code_usage-1.0.0.dist-info/top_level.txt +1 -0
- src/__init__.py +1 -0
- src/api.py +202 -0
- src/autostart.py +94 -0
- src/config.py +173 -0
- src/icon_generator.py +92 -0
- src/main.py +24 -0
- src/notifier.py +289 -0
- src/tray.py +302 -0
- src/utils.py +35 -0
src/config.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Configuration module for loading Claude OAuth credentials."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
CREDENTIALS_PATH = Path.home() / ".claude" / ".credentials.json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_credentials() -> dict:
|
|
14
|
+
"""Load Claude OAuth credentials from ~/.claude/.credentials.json.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
dict: The claudeAiOauth object containing accessToken, expiresAt, etc.
|
|
18
|
+
|
|
19
|
+
Raises:
|
|
20
|
+
FileNotFoundError: If credentials file doesn't exist
|
|
21
|
+
ValueError: If credentials file format is invalid
|
|
22
|
+
"""
|
|
23
|
+
if not CREDENTIALS_PATH.exists():
|
|
24
|
+
raise FileNotFoundError(
|
|
25
|
+
"Claude credentials not found. Run `claude` first to authenticate."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
with open(CREDENTIALS_PATH, 'r') as f:
|
|
30
|
+
data = json.load(f)
|
|
31
|
+
except json.JSONDecodeError as e:
|
|
32
|
+
raise ValueError(f"Invalid credentials file format: {e}")
|
|
33
|
+
|
|
34
|
+
if "claudeAiOauth" not in data:
|
|
35
|
+
raise ValueError("Invalid credentials file format")
|
|
36
|
+
|
|
37
|
+
return data["claudeAiOauth"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_token_expired(credentials: dict) -> bool:
|
|
41
|
+
"""Check if the OAuth token has expired.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
credentials: The claudeAiOauth object with expiresAt field
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
bool: True if expired or expiresAt is None
|
|
48
|
+
"""
|
|
49
|
+
expires_at = credentials.get("expiresAt")
|
|
50
|
+
|
|
51
|
+
if expires_at is None:
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
# expiresAt is in milliseconds since epoch
|
|
55
|
+
current_time_ms = time.time() * 1000
|
|
56
|
+
return current_time_ms >= expires_at
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_access_token() -> str:
|
|
60
|
+
"""Get a valid OAuth access token.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
str: The access token string
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
FileNotFoundError: If credentials file doesn't exist
|
|
67
|
+
ValueError: If credentials are invalid or token expired
|
|
68
|
+
"""
|
|
69
|
+
credentials = load_credentials()
|
|
70
|
+
|
|
71
|
+
if is_token_expired(credentials):
|
|
72
|
+
raise ValueError("OAuth token has expired")
|
|
73
|
+
|
|
74
|
+
return credentials["accessToken"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_config_path() -> Path:
|
|
78
|
+
"""Get XDG-compliant config file path.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Path: Path to config.json in XDG config directory
|
|
82
|
+
"""
|
|
83
|
+
config_home = os.environ.get('XDG_CONFIG_HOME')
|
|
84
|
+
if config_home:
|
|
85
|
+
base = Path(config_home)
|
|
86
|
+
else:
|
|
87
|
+
base = Path.home() / '.config'
|
|
88
|
+
|
|
89
|
+
config_dir = base / 'claude-usage-overlay'
|
|
90
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
|
|
92
|
+
return config_dir / 'config.json'
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class UserConfig:
|
|
97
|
+
"""User configuration settings for Claude Usage Overlay."""
|
|
98
|
+
|
|
99
|
+
session_thresholds: list[int] = None
|
|
100
|
+
weekly_thresholds: list[int] = None
|
|
101
|
+
polling_interval: int = 300
|
|
102
|
+
pause_notifications: bool = False
|
|
103
|
+
autostart_enabled: bool = False
|
|
104
|
+
|
|
105
|
+
def __post_init__(self):
|
|
106
|
+
"""Validate configuration values and set defaults."""
|
|
107
|
+
# Set defaults for None fields (avoid mutable default trap)
|
|
108
|
+
if self.session_thresholds is None:
|
|
109
|
+
self.session_thresholds = [50, 75, 90]
|
|
110
|
+
if self.weekly_thresholds is None:
|
|
111
|
+
self.weekly_thresholds = [50, 75, 90]
|
|
112
|
+
|
|
113
|
+
# Validate thresholds
|
|
114
|
+
for threshold in self.session_thresholds:
|
|
115
|
+
if not isinstance(threshold, int) or not (0 <= threshold <= 100):
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"Invalid session threshold: {threshold}. Must be integer 0-100."
|
|
118
|
+
)
|
|
119
|
+
for threshold in self.weekly_thresholds:
|
|
120
|
+
if not isinstance(threshold, int) or not (0 <= threshold <= 100):
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"Invalid weekly threshold: {threshold}. Must be integer 0-100."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Validate polling_interval
|
|
126
|
+
if not isinstance(self.polling_interval, int) or not (30 <= self.polling_interval <= 3600):
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"Invalid polling_interval: {self.polling_interval}. Must be integer 30-3600."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Validate boolean fields
|
|
132
|
+
if not isinstance(self.pause_notifications, bool):
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"Invalid pause_notifications: {self.pause_notifications}. Must be bool."
|
|
135
|
+
)
|
|
136
|
+
if not isinstance(self.autostart_enabled, bool):
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"Invalid autostart_enabled: {self.autostart_enabled}. Must be bool."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def load(cls) -> 'UserConfig':
|
|
143
|
+
"""Load configuration from XDG config file.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
UserConfig: Configuration object with values from file or defaults
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
ValueError: If config file format is invalid
|
|
150
|
+
"""
|
|
151
|
+
config_path = get_config_path()
|
|
152
|
+
|
|
153
|
+
if not config_path.exists():
|
|
154
|
+
return cls()
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
with open(config_path, 'r') as f:
|
|
158
|
+
data = json.load(f)
|
|
159
|
+
except json.JSONDecodeError as e:
|
|
160
|
+
raise ValueError(f"Invalid config file format: {e}")
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
return cls(**data)
|
|
164
|
+
except TypeError as e:
|
|
165
|
+
raise ValueError(f"Invalid config file structure: {e}")
|
|
166
|
+
|
|
167
|
+
def save(self):
|
|
168
|
+
"""Save configuration to XDG config file."""
|
|
169
|
+
config_path = get_config_path()
|
|
170
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
|
|
172
|
+
with open(config_path, 'w') as f:
|
|
173
|
+
json.dump(asdict(self), f, indent=2)
|
src/icon_generator.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Icon generation utilities for Claude Code usage overlay.
|
|
3
|
+
|
|
4
|
+
Generates circular gauge icons using Cairo for system tray display.
|
|
5
|
+
"""
|
|
6
|
+
import cairo
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
from typing import Tuple
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_color_for_percentage(percentage: float) -> Tuple[float, float, float]:
|
|
13
|
+
"""
|
|
14
|
+
Return RGB color tuple based on usage percentage.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
percentage: Usage percentage (0-100)
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
RGB tuple in Cairo format (0.0-1.0 range)
|
|
21
|
+
- Green for < 50%
|
|
22
|
+
- Yellow for 50-75%
|
|
23
|
+
- Red for >= 75%
|
|
24
|
+
"""
|
|
25
|
+
if percentage < 50:
|
|
26
|
+
return (0.2, 0.8, 0.2) # Green
|
|
27
|
+
elif percentage < 75:
|
|
28
|
+
return (0.9, 0.7, 0.0) # Yellow
|
|
29
|
+
else:
|
|
30
|
+
return (0.9, 0.2, 0.2) # Red
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def generate_gauge_icon(percentage: float, color_percentage: float, size: int = 22) -> str:
|
|
34
|
+
"""
|
|
35
|
+
Generate a circular gauge icon showing usage percentage.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
percentage: Usage level to display as arc fill (0-100)
|
|
39
|
+
color_percentage: Percentage used to determine color (0-100)
|
|
40
|
+
size: Icon size in pixels (default: 22)
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Absolute path to generated PNG file in /tmp
|
|
44
|
+
|
|
45
|
+
Notes:
|
|
46
|
+
- percentage controls the arc fill amount (how full the gauge is)
|
|
47
|
+
- color_percentage controls the color (via get_color_for_percentage)
|
|
48
|
+
- This separation allows showing session usage while coloring by worst-case urgency
|
|
49
|
+
"""
|
|
50
|
+
# Get color based on color_percentage parameter
|
|
51
|
+
fill_color = get_color_for_percentage(color_percentage)
|
|
52
|
+
|
|
53
|
+
# Determine color name for filename (to bust icon cache)
|
|
54
|
+
if color_percentage < 50:
|
|
55
|
+
color_name = "green"
|
|
56
|
+
elif color_percentage < 75:
|
|
57
|
+
color_name = "yellow"
|
|
58
|
+
else:
|
|
59
|
+
color_name = "red"
|
|
60
|
+
|
|
61
|
+
# Create Cairo surface with transparent background
|
|
62
|
+
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, size, size)
|
|
63
|
+
ctx = cairo.Context(surface)
|
|
64
|
+
|
|
65
|
+
# Clear background (transparent)
|
|
66
|
+
ctx.set_source_rgba(0, 0, 0, 0)
|
|
67
|
+
ctx.paint()
|
|
68
|
+
|
|
69
|
+
# Calculate center and radius (leave 2px margin)
|
|
70
|
+
center = size / 2
|
|
71
|
+
radius = (size / 2) - 2
|
|
72
|
+
|
|
73
|
+
# Draw gray outline (full circle)
|
|
74
|
+
ctx.set_source_rgb(0.5, 0.5, 0.5)
|
|
75
|
+
ctx.set_line_width(2)
|
|
76
|
+
ctx.arc(center, center, radius, 0, 2 * math.pi)
|
|
77
|
+
ctx.stroke()
|
|
78
|
+
|
|
79
|
+
# Draw colored fill arc (based on percentage)
|
|
80
|
+
if percentage > 0:
|
|
81
|
+
ctx.set_source_rgb(*fill_color)
|
|
82
|
+
ctx.set_line_width(2)
|
|
83
|
+
# Start at 12 o'clock (-pi/2), go clockwise by percentage amount
|
|
84
|
+
end_angle = -math.pi / 2 + (2 * math.pi * percentage / 100)
|
|
85
|
+
ctx.arc(center, center, radius, -math.pi / 2, end_angle)
|
|
86
|
+
ctx.stroke()
|
|
87
|
+
|
|
88
|
+
# Save to unique filename per percentage and color state (bust GTK icon cache)
|
|
89
|
+
icon_path = f"/tmp/claude-usage-{int(percentage)}-{color_name}.png"
|
|
90
|
+
surface.write_to_png(icon_path)
|
|
91
|
+
|
|
92
|
+
return os.path.abspath(icon_path)
|
src/main.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Claude Code Usage Overlay - System tray application."""
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
"""Start the Claude Code Usage Overlay application."""
|
|
9
|
+
try:
|
|
10
|
+
from src.tray import TrayIndicator
|
|
11
|
+
|
|
12
|
+
indicator = TrayIndicator()
|
|
13
|
+
indicator.run()
|
|
14
|
+
|
|
15
|
+
except KeyboardInterrupt:
|
|
16
|
+
print("\nShutting down...")
|
|
17
|
+
sys.exit(0)
|
|
18
|
+
except Exception as e:
|
|
19
|
+
print(f"Fatal error: {e}", file=sys.stderr)
|
|
20
|
+
sys.exit(1)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
main()
|
src/notifier.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Desktop notification manager for usage threshold alerts."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import gi
|
|
7
|
+
|
|
8
|
+
gi.require_version('Notify', '0.7')
|
|
9
|
+
from gi.repository import Notify
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UsageNotifier:
|
|
13
|
+
"""Manages threshold-based usage notifications.
|
|
14
|
+
|
|
15
|
+
Displays popup notifications when session or weekly usage crosses
|
|
16
|
+
thresholds (50%, 75%, 90%) with escalating urgency and advice.
|
|
17
|
+
Tracks alerted thresholds to prevent notification spam.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, app_name="Claude Code Usage Monitor",
|
|
21
|
+
session_thresholds=None, weekly_thresholds=None):
|
|
22
|
+
"""Initialize the notifier.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
app_name: Name shown in notification server
|
|
26
|
+
session_thresholds: List of threshold percentages for session (default [50, 75, 90])
|
|
27
|
+
weekly_thresholds: List of threshold percentages for weekly (default to session_thresholds)
|
|
28
|
+
"""
|
|
29
|
+
# Initialize libnotify once
|
|
30
|
+
Notify.init(app_name)
|
|
31
|
+
|
|
32
|
+
# Configure thresholds
|
|
33
|
+
self.session_thresholds = session_thresholds if session_thresholds is not None else [50, 75, 90]
|
|
34
|
+
self.weekly_thresholds = weekly_thresholds if weekly_thresholds is not None else self.session_thresholds
|
|
35
|
+
|
|
36
|
+
# Track alerted thresholds: {(metric, threshold): True}
|
|
37
|
+
# metric = 'session' or 'weekly'
|
|
38
|
+
# threshold = 50, 75, or 90
|
|
39
|
+
self.alerted = {}
|
|
40
|
+
|
|
41
|
+
# Grace period flag - skip first poll to avoid startup spam
|
|
42
|
+
self.first_poll = True
|
|
43
|
+
|
|
44
|
+
# Pause notifications mode
|
|
45
|
+
self.pause_notifications = False
|
|
46
|
+
|
|
47
|
+
# Cache server capabilities
|
|
48
|
+
self._actions_supported = None
|
|
49
|
+
|
|
50
|
+
def check_and_notify(self, session_pct, weekly_pct, session_reset, weekly_reset):
|
|
51
|
+
"""Check thresholds and show notifications if needed.
|
|
52
|
+
|
|
53
|
+
Main entry point called by TrayIndicator after each usage update.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
session_pct: Session usage percentage (0-100)
|
|
57
|
+
weekly_pct: Weekly usage percentage (0-100)
|
|
58
|
+
session_reset: Unix timestamp when session resets
|
|
59
|
+
weekly_reset: Unix timestamp when weekly resets
|
|
60
|
+
"""
|
|
61
|
+
# Skip first poll (grace period)
|
|
62
|
+
if self.first_poll:
|
|
63
|
+
self.first_poll = False
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
# Respect pause mode
|
|
67
|
+
if self.pause_notifications:
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
# Find highest threshold crossed for each metric
|
|
71
|
+
session_threshold = self._highest_crossed(session_pct, self.session_thresholds)
|
|
72
|
+
weekly_threshold = self._highest_crossed(weekly_pct, self.weekly_thresholds)
|
|
73
|
+
|
|
74
|
+
# Determine if we should alert
|
|
75
|
+
session_needs_alert = (
|
|
76
|
+
session_threshold and
|
|
77
|
+
('session', session_threshold) not in self.alerted
|
|
78
|
+
)
|
|
79
|
+
weekly_needs_alert = (
|
|
80
|
+
weekly_threshold and
|
|
81
|
+
('weekly', weekly_threshold) not in self.alerted
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Handle combined case first (both need alerts)
|
|
85
|
+
if session_needs_alert and weekly_needs_alert:
|
|
86
|
+
if session_threshold == weekly_threshold:
|
|
87
|
+
# Same threshold - use that urgency
|
|
88
|
+
self._show_combined_notification(
|
|
89
|
+
session_threshold, session_pct, weekly_pct,
|
|
90
|
+
session_reset, weekly_reset
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
# Different thresholds - use highest urgency
|
|
94
|
+
max_threshold = max(session_threshold, weekly_threshold)
|
|
95
|
+
self._show_combined_notification(
|
|
96
|
+
max_threshold, session_pct, weekly_pct,
|
|
97
|
+
session_reset, weekly_reset
|
|
98
|
+
)
|
|
99
|
+
# Mark both as alerted
|
|
100
|
+
self.alerted[('session', session_threshold)] = True
|
|
101
|
+
self.alerted[('weekly', weekly_threshold)] = True
|
|
102
|
+
|
|
103
|
+
# Individual alerts
|
|
104
|
+
elif session_needs_alert:
|
|
105
|
+
self._show_notification(
|
|
106
|
+
'session', session_pct, session_threshold, session_reset
|
|
107
|
+
)
|
|
108
|
+
self.alerted[('session', session_threshold)] = True
|
|
109
|
+
|
|
110
|
+
elif weekly_needs_alert:
|
|
111
|
+
self._show_notification(
|
|
112
|
+
'weekly', weekly_pct, weekly_threshold, weekly_reset
|
|
113
|
+
)
|
|
114
|
+
self.alerted[('weekly', weekly_threshold)] = True
|
|
115
|
+
|
|
116
|
+
def _highest_crossed(self, percentage, thresholds):
|
|
117
|
+
"""Return highest threshold crossed, or None.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
percentage: Current usage percentage (0-100)
|
|
121
|
+
thresholds: List of threshold values to check
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
int: Highest crossed threshold, or None if no thresholds crossed
|
|
125
|
+
"""
|
|
126
|
+
crossed = [t for t in thresholds if percentage >= t]
|
|
127
|
+
return max(crossed) if crossed else None
|
|
128
|
+
|
|
129
|
+
def _show_notification(self, metric, percentage, threshold, reset_time):
|
|
130
|
+
"""Show notification for single metric threshold.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
metric: 'session' or 'weekly'
|
|
134
|
+
percentage: Current usage percentage (0-100)
|
|
135
|
+
threshold: Threshold that was crossed (50, 75, or 90)
|
|
136
|
+
reset_time: Unix timestamp when metric resets
|
|
137
|
+
"""
|
|
138
|
+
# Map threshold to urgency and advice
|
|
139
|
+
urgency_map = {
|
|
140
|
+
50: (Notify.Urgency.LOW, "Heads up"),
|
|
141
|
+
75: (Notify.Urgency.NORMAL, "Consider saving your work"),
|
|
142
|
+
90: (Notify.Urgency.CRITICAL, "Save your work now")
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
urgency, advice = urgency_map[threshold]
|
|
146
|
+
|
|
147
|
+
# Format title
|
|
148
|
+
metric_name = "Session Usage" if metric == 'session' else "Weekly Usage"
|
|
149
|
+
title = f"{metric_name}: {percentage:.0f}%"
|
|
150
|
+
|
|
151
|
+
# Format body with reset time
|
|
152
|
+
reset_str = self._format_reset_time(reset_time)
|
|
153
|
+
body = f"{advice}\n\nResets in {reset_str}"
|
|
154
|
+
|
|
155
|
+
# Create notification
|
|
156
|
+
notification = Notify.Notification.new(
|
|
157
|
+
title,
|
|
158
|
+
body,
|
|
159
|
+
"dialog-information"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Set urgency level
|
|
163
|
+
notification.set_urgency(urgency)
|
|
164
|
+
|
|
165
|
+
# Add action button if server supports it
|
|
166
|
+
if self._server_supports_actions():
|
|
167
|
+
notification.add_action(
|
|
168
|
+
"open-claude",
|
|
169
|
+
"Open Claude Code",
|
|
170
|
+
self._on_open_claude,
|
|
171
|
+
None
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Show notification
|
|
175
|
+
notification.show()
|
|
176
|
+
|
|
177
|
+
def _show_combined_notification(self, threshold, session_pct, weekly_pct,
|
|
178
|
+
session_reset, weekly_reset):
|
|
179
|
+
"""Show combined notification for both metrics.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
threshold: Threshold to use for urgency (highest of both)
|
|
183
|
+
session_pct: Session usage percentage (0-100)
|
|
184
|
+
weekly_pct: Weekly usage percentage (0-100)
|
|
185
|
+
session_reset: Unix timestamp when session resets
|
|
186
|
+
weekly_reset: Unix timestamp when weekly resets
|
|
187
|
+
"""
|
|
188
|
+
# Map threshold to urgency and advice
|
|
189
|
+
urgency_map = {
|
|
190
|
+
50: (Notify.Urgency.LOW, "Heads up"),
|
|
191
|
+
75: (Notify.Urgency.NORMAL, "Consider saving your work"),
|
|
192
|
+
90: (Notify.Urgency.CRITICAL, "Save your work now")
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
urgency, advice = urgency_map[threshold]
|
|
196
|
+
|
|
197
|
+
# Format title (show threshold, not individual percentages)
|
|
198
|
+
title = f"Session & Weekly Usage: {threshold}%"
|
|
199
|
+
|
|
200
|
+
# Format body with both reset times
|
|
201
|
+
session_reset_str = self._format_reset_time(session_reset)
|
|
202
|
+
weekly_reset_str = self._format_reset_time(weekly_reset)
|
|
203
|
+
body = (f"{advice}\n\n"
|
|
204
|
+
f"Session resets in {session_reset_str}\n"
|
|
205
|
+
f"Weekly resets in {weekly_reset_str}")
|
|
206
|
+
|
|
207
|
+
# Create notification
|
|
208
|
+
notification = Notify.Notification.new(
|
|
209
|
+
title,
|
|
210
|
+
body,
|
|
211
|
+
"dialog-information"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Set urgency level
|
|
215
|
+
notification.set_urgency(urgency)
|
|
216
|
+
|
|
217
|
+
# Add action button if server supports it
|
|
218
|
+
if self._server_supports_actions():
|
|
219
|
+
notification.add_action(
|
|
220
|
+
"open-claude",
|
|
221
|
+
"Open Claude Code",
|
|
222
|
+
self._on_open_claude,
|
|
223
|
+
None
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Show notification
|
|
227
|
+
notification.show()
|
|
228
|
+
|
|
229
|
+
def _format_reset_time(self, reset_timestamp):
|
|
230
|
+
"""Format Unix timestamp to human-readable time until reset.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
reset_timestamp: Unix timestamp (seconds since epoch)
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
str: Human-readable time string (e.g., "2h", "3d", "45m")
|
|
237
|
+
"""
|
|
238
|
+
now = time.time()
|
|
239
|
+
seconds = int(reset_timestamp - now)
|
|
240
|
+
|
|
241
|
+
if seconds <= 0:
|
|
242
|
+
return "shortly"
|
|
243
|
+
|
|
244
|
+
# Convert to hours and days using divmod
|
|
245
|
+
hours, remainder = divmod(seconds, 3600)
|
|
246
|
+
days, hours = divmod(hours, 24)
|
|
247
|
+
|
|
248
|
+
if days > 0:
|
|
249
|
+
return f"{days}d"
|
|
250
|
+
elif hours > 0:
|
|
251
|
+
return f"{hours}h"
|
|
252
|
+
else:
|
|
253
|
+
minutes = remainder // 60
|
|
254
|
+
return f"{minutes}m" if minutes > 0 else "shortly"
|
|
255
|
+
|
|
256
|
+
def _server_supports_actions(self):
|
|
257
|
+
"""Check if notification server supports action buttons.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
bool: True if server supports actions, False otherwise
|
|
261
|
+
"""
|
|
262
|
+
if self._actions_supported is None:
|
|
263
|
+
caps = Notify.get_server_caps()
|
|
264
|
+
self._actions_supported = caps and 'actions' in caps
|
|
265
|
+
return self._actions_supported
|
|
266
|
+
|
|
267
|
+
def _on_open_claude(self, notification, action, user_data):
|
|
268
|
+
"""Callback when 'Open Claude Code' button is clicked.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
notification: The notification that triggered the action
|
|
272
|
+
action: Action identifier string
|
|
273
|
+
user_data: User data passed to add_action (unused)
|
|
274
|
+
"""
|
|
275
|
+
# Close the notification
|
|
276
|
+
notification.close()
|
|
277
|
+
|
|
278
|
+
# Open Claude Code in browser
|
|
279
|
+
subprocess.Popen(["/usr/bin/xdg-open", "https://claude.ai"])
|
|
280
|
+
|
|
281
|
+
def set_thresholds(self, session_thresholds, weekly_thresholds=None):
|
|
282
|
+
"""Update threshold configuration.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
session_thresholds: List of session threshold percentages
|
|
286
|
+
weekly_thresholds: List of weekly threshold percentages (defaults to session)
|
|
287
|
+
"""
|
|
288
|
+
self.session_thresholds = session_thresholds
|
|
289
|
+
self.weekly_thresholds = weekly_thresholds if weekly_thresholds is not None else session_thresholds
|