Add Cinnamon panel applet that displays Claude Pro subscription usage and limits with configurable refresh interval and colors. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
307 lines
10 KiB
JavaScript
307 lines
10 KiB
JavaScript
const Applet = imports.ui.applet;
|
|
const PopupMenu = imports.ui.popupMenu;
|
|
const Mainloop = imports.mainloop;
|
|
const GLib = imports.gi.GLib;
|
|
const Soup = imports.gi.Soup;
|
|
const Lang = imports.lang;
|
|
const Settings = imports.ui.settings;
|
|
|
|
// Constants
|
|
const CREDENTIALS_PATH = GLib.get_home_dir() + '/.claude/.credentials.json';
|
|
const API_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
|
|
|
|
function ClaudeStatusApplet(metadata, orientation, panel_height, instance_id) {
|
|
this._init(metadata, orientation, panel_height, instance_id);
|
|
}
|
|
|
|
ClaudeStatusApplet.prototype = {
|
|
__proto__: Applet.TextIconApplet.prototype,
|
|
|
|
_init: function(metadata, orientation, panel_height, instance_id) {
|
|
Applet.TextIconApplet.prototype._init.call(this, orientation, panel_height, instance_id);
|
|
|
|
this.metadata = metadata;
|
|
this.orientation = orientation;
|
|
|
|
// Initialize settings
|
|
this.settings = new Settings.AppletSettings(this, metadata.uuid, instance_id);
|
|
this.settings.bind("refresh-interval", "refreshInterval", this._onSettingsChanged);
|
|
this.settings.bind("color-low", "colorLow", this._onSettingsChanged);
|
|
this.settings.bind("color-medium", "colorMedium", this._onSettingsChanged);
|
|
this.settings.bind("color-high", "colorHigh", this._onSettingsChanged);
|
|
this.settings.bind("show-label-prefix", "showLabelPrefix", this._onSettingsChanged);
|
|
|
|
// Initialize HTTP session
|
|
if (Soup.Session.prototype.hasOwnProperty('new')) {
|
|
this.httpSession = new Soup.Session();
|
|
} else {
|
|
this.httpSession = Soup.Session.new();
|
|
}
|
|
|
|
// Set up UI
|
|
this.set_applet_icon_symbolic_name("dialog-information");
|
|
this.set_applet_tooltip("Claude Status - Click for details");
|
|
|
|
// Initialize menu
|
|
this.menuManager = new PopupMenu.PopupMenuManager(this);
|
|
this.menu = new Applet.AppletPopupMenu(this, orientation);
|
|
this.menuManager.addMenu(this.menu);
|
|
|
|
// Menu items (will be populated later)
|
|
this.sessionItem = null;
|
|
this.weeklyItem = null;
|
|
this.errorItem = null;
|
|
|
|
this._buildMenu();
|
|
|
|
// Initial data fetch
|
|
this.accessToken = null;
|
|
this.updateTimeoutId = null;
|
|
this._loadCredentials();
|
|
|
|
if (this.accessToken) {
|
|
this._fetchUsageData();
|
|
}
|
|
|
|
// Set up periodic updates
|
|
this._updateLoop();
|
|
},
|
|
|
|
_buildMenu: function() {
|
|
// Session usage item
|
|
this.sessionItem = new PopupMenu.PopupMenuItem("Session: Loading...", {
|
|
reactive: false
|
|
});
|
|
this.menu.addMenuItem(this.sessionItem);
|
|
|
|
// Weekly usage item
|
|
this.weeklyItem = new PopupMenu.PopupMenuItem("Weekly: Loading...", {
|
|
reactive: false
|
|
});
|
|
this.menu.addMenuItem(this.weeklyItem);
|
|
|
|
// Error message item (hidden by default)
|
|
this.errorItem = new PopupMenu.PopupMenuItem("", {
|
|
reactive: false
|
|
});
|
|
this.menu.addMenuItem(this.errorItem);
|
|
this.errorItem.actor.hide();
|
|
|
|
// Separator
|
|
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
|
|
|
// Refresh button
|
|
let refreshItem = new PopupMenu.PopupMenuItem("Refresh");
|
|
refreshItem.connect('activate', Lang.bind(this, this._fetchUsageData));
|
|
this.menu.addMenuItem(refreshItem);
|
|
},
|
|
|
|
_loadCredentials: function() {
|
|
try {
|
|
let [success, contents] = GLib.file_get_contents(CREDENTIALS_PATH);
|
|
|
|
if (!success) {
|
|
this._showError("Could not read credentials file");
|
|
return;
|
|
}
|
|
|
|
// Convert ByteArray to string if needed
|
|
let credentialsText;
|
|
if (contents instanceof Uint8Array) {
|
|
credentialsText = new TextDecoder().decode(contents);
|
|
} else {
|
|
credentialsText = contents.toString();
|
|
}
|
|
|
|
let credentials = JSON.parse(credentialsText);
|
|
|
|
if (credentials && credentials.claudeAiOauth && credentials.claudeAiOauth.accessToken) {
|
|
this.accessToken = credentials.claudeAiOauth.accessToken;
|
|
} else {
|
|
this._showError("No access token found in credentials");
|
|
}
|
|
} catch (e) {
|
|
this._showError("Failed to load credentials: " + e.message);
|
|
}
|
|
},
|
|
|
|
_fetchUsageData: function() {
|
|
if (!this.accessToken) {
|
|
this._loadCredentials();
|
|
if (!this.accessToken) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
let message = Soup.Message.new('GET', API_ENDPOINT);
|
|
|
|
// Set headers
|
|
let headers = message.get_request_headers();
|
|
headers.append('Authorization', 'Bearer ' + this.accessToken);
|
|
headers.append('anthropic-beta', 'oauth-2025-04-20');
|
|
headers.append('Content-Type', 'application/json');
|
|
|
|
// Send async request
|
|
this.httpSession.send_and_read_async(
|
|
message,
|
|
GLib.PRIORITY_DEFAULT,
|
|
null,
|
|
Lang.bind(this, function(session, result) {
|
|
try {
|
|
let bytes = session.send_and_read_finish(result);
|
|
let decoder = new TextDecoder('utf-8');
|
|
let responseData = decoder.decode(bytes.get_data());
|
|
|
|
let statusCode = message.get_status();
|
|
|
|
if (statusCode === 200) {
|
|
let data = JSON.parse(responseData);
|
|
this._updateDisplay(data);
|
|
this._hideError();
|
|
} else if (statusCode === 401) {
|
|
this._showError("Authentication failed. Check credentials.");
|
|
} else {
|
|
this._showError("API error: " + statusCode);
|
|
}
|
|
} catch (e) {
|
|
this._showError("Failed to parse response: " + e.message);
|
|
}
|
|
})
|
|
);
|
|
} catch (e) {
|
|
this._showError("Request failed: " + e.message);
|
|
}
|
|
},
|
|
|
|
_updateDisplay: function(data) {
|
|
try {
|
|
// Extract five_hour (session) data
|
|
let sessionData = data ? data.five_hour : null;
|
|
let weeklyData = data ? data.seven_day : null;
|
|
|
|
// Update panel text with session percentage
|
|
if (sessionData && sessionData.utilization !== undefined) {
|
|
let percentage = Math.round(sessionData.utilization);
|
|
let label = percentage + "%";
|
|
if (this.showLabelPrefix) {
|
|
label = "Claude: " + label;
|
|
}
|
|
this.set_applet_label(label);
|
|
|
|
// Update session menu item
|
|
let resetTime = this._formatResetTime(sessionData.resets_at);
|
|
this.sessionItem.label.set_text("Session: " + percentage + "% (resets " + resetTime + ")");
|
|
|
|
// Update color based on usage using settings
|
|
let color;
|
|
if (percentage >= 80) {
|
|
color = this.colorHigh;
|
|
} else if (percentage >= 50) {
|
|
color = this.colorMedium;
|
|
} else {
|
|
color = this.colorLow;
|
|
}
|
|
this.actor.style = 'color: ' + color + ';';
|
|
} else {
|
|
this.set_applet_label("?%");
|
|
this.sessionItem.label.set_text("Session: No data");
|
|
}
|
|
|
|
// Update weekly menu item
|
|
if (weeklyData && weeklyData.utilization !== undefined) {
|
|
let percentage = Math.round(weeklyData.utilization);
|
|
let resetTime = this._formatResetTime(weeklyData.resets_at);
|
|
this.weeklyItem.label.set_text("Weekly: " + percentage + "% (resets " + resetTime + ")");
|
|
} else {
|
|
this.weeklyItem.label.set_text("Weekly: No data");
|
|
}
|
|
|
|
} catch (e) {
|
|
this._showError("Failed to update display: " + e.message);
|
|
}
|
|
},
|
|
|
|
_formatResetTime: function(resetAt) {
|
|
if (!resetAt) {
|
|
return "unknown";
|
|
}
|
|
|
|
try {
|
|
// Parse ISO 8601 timestamp
|
|
let resetDate = new Date(resetAt);
|
|
let now = new Date();
|
|
let diffMs = resetDate - now;
|
|
|
|
if (diffMs < 0) {
|
|
return "soon";
|
|
}
|
|
|
|
let diffSeconds = Math.floor(diffMs / 1000);
|
|
let diffMinutes = Math.floor(diffSeconds / 60);
|
|
let diffHours = Math.floor(diffMinutes / 60);
|
|
let diffDays = Math.floor(diffHours / 24);
|
|
|
|
if (diffDays > 0) {
|
|
let hours = diffHours % 24;
|
|
return "in " + diffDays + "d " + hours + "h";
|
|
} else if (diffHours > 0) {
|
|
let minutes = diffMinutes % 60;
|
|
return "in " + diffHours + "h " + minutes + "m";
|
|
} else if (diffMinutes > 0) {
|
|
return "in " + diffMinutes + "m";
|
|
} else {
|
|
return "in <1m";
|
|
}
|
|
} catch (e) {
|
|
return "unknown";
|
|
}
|
|
},
|
|
|
|
_showError: function(message) {
|
|
this.set_applet_label("ERR");
|
|
this.actor.style = 'color: #ff6b6b;';
|
|
this.errorItem.label.set_text("Error: " + message);
|
|
this.errorItem.actor.show();
|
|
},
|
|
|
|
_hideError: function() {
|
|
this.errorItem.actor.hide();
|
|
},
|
|
|
|
_updateLoop: function() {
|
|
// Cancel existing timeout if any
|
|
if (this.updateTimeoutId) {
|
|
Mainloop.source_remove(this.updateTimeoutId);
|
|
this.updateTimeoutId = null;
|
|
}
|
|
|
|
this._fetchUsageData();
|
|
|
|
// Convert minutes to seconds
|
|
let intervalSeconds = this.refreshInterval * 60;
|
|
this.updateTimeoutId = Mainloop.timeout_add_seconds(intervalSeconds, Lang.bind(this, this._updateLoop));
|
|
},
|
|
|
|
_onSettingsChanged: function() {
|
|
// Restart update loop with new interval
|
|
this._updateLoop();
|
|
},
|
|
|
|
on_applet_clicked: function(event) {
|
|
this.menu.toggle();
|
|
},
|
|
|
|
on_applet_removed_from_panel: function() {
|
|
// Cleanup timeout
|
|
if (this.updateTimeoutId) {
|
|
Mainloop.source_remove(this.updateTimeoutId);
|
|
this.updateTimeoutId = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
function main(metadata, orientation, panel_height, instance_id) {
|
|
return new ClaudeStatusApplet(metadata, orientation, panel_height, instance_id);
|
|
}
|