Skip to content

Commit 1ea7730

Browse files
feat(ai): add Claude usage menu-bar chip
Surface subscription rate limits in-app so agents are not stalled by an exhausted 5-hour or weekly window without warning. Reuse the Claude CLI OAuth token already on disk; keep parsing and formatting pure and unit-tested so the UI can stay thin.
1 parent d873a03 commit 1ea7730

19 files changed

Lines changed: 1871 additions & 2 deletions

‎src/ApplicationSettings.cpp‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ CREATE_SETTING(Ai, CommitMessageStreamIdleTimeoutSec, commitMessageStreamIdleTim
188188
CREATE_SETTING(Ai, CommitMessageGenerateShortcut, commitMessageGenerateShortcut, QString, QStringLiteral("Ctrl+Alt+G"))
189189

190190
CREATE_SETTING(Ai, SyncWorkspaceOnAiSwitch, syncWorkspaceOnAiSwitch, bool, true)
191+
CREATE_SETTING(Ai, UsagePercentRemaining, usagePercentRemaining, bool, false)
191192
// NOLINTEND(bugprone-throwing-static-initialization,performance-unnecessary-value-param)
192193

193194
// --- AI / ACP agent settings ---------------------------------------------------

‎src/ApplicationSettings.h‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,10 @@ public slots:
221221

222222
DEFINE_SETTING(SyncWorkspaceOnAiSwitch, syncWorkspaceOnAiSwitch, bool)
223223

224+
// Claude usage chip (menu-bar corner): show "% left" instead of "% used".
225+
// Storage key Ai/UsagePercentRemaining.
226+
DEFINE_SETTING(UsagePercentRemaining, usagePercentRemaining, bool)
227+
224228
// AI / ACP agent settings. Stored under the "Ai/" group.
225229
public:
226230
QString defaultAiAgentId() const;

‎src/CMakeLists.txt‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,13 @@ qt_add_executable(NotepadAI
476476
widgets/AcpTurnFoldRow.h
477477
widgets/AcpUsageIndicator.cpp
478478
widgets/AcpUsageIndicator.h
479+
widgets/UsageButton.cpp
480+
widgets/UsageButton.h
481+
widgets/UsagePopover.cpp
482+
widgets/UsagePopover.h
483+
widgets/UsageStyle.h
484+
widgets/UsageWindowBar.cpp
485+
widgets/UsageWindowBar.h
479486
widgets/AiDockSessionStrip.cpp
480487
widgets/AiDockSessionStrip.h
481488
widgets/DockMiddleClickCloser.cpp
@@ -563,6 +570,10 @@ qt_add_executable(NotepadAI
563570
ai/SsePartialParser.cpp
564571
ai/CredentialStore.h
565572
ai/CredentialStore_Common.cpp
573+
ai/ClaudeUsage.cpp
574+
ai/ClaudeUsage.h
575+
ai/ClaudeUsageService.cpp
576+
ai/ClaudeUsageService.h
566577
ai/IAnthropicMessagesClient.h
567578
ai/AnthropicMessagesClient.h
568579
ai/AnthropicMessagesClient.cpp

‎src/ai/ClaudeUsage.cpp‎

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
#include "ClaudeUsage.h"
2+
3+
#include <QDateTime>
4+
#include <QDir>
5+
#include <QJsonArray>
6+
#include <QJsonDocument>
7+
#include <QRegularExpression>
8+
9+
#include <algorithm>
10+
#include <cmath>
11+
12+
namespace ai::claudeusage {
13+
14+
namespace {
15+
16+
std::optional<qint64> fromEpochNumber(double n)
17+
{
18+
if (!std::isfinite(n) || n <= 0.0)
19+
return std::nullopt;
20+
return n > 1e10 ? static_cast<qint64>(n) : static_cast<qint64>(n * 1000.0);
21+
}
22+
23+
// Qt's ISO parser rejects fractional seconds longer than 3 digits (the API
24+
// sends microseconds), so clamp the fraction to milliseconds first.
25+
QString clampFraction(const QString &iso)
26+
{
27+
static const QRegularExpression re(QStringLiteral("^(.*T\\d{2}:\\d{2}:\\d{2})\\.(\\d+)(.*)$"));
28+
const QRegularExpressionMatch m = re.match(iso);
29+
if (!m.hasMatch())
30+
return iso;
31+
const QString ms = (m.captured(2) + QStringLiteral("000")).left(3);
32+
return m.captured(1) + QLatin1Char('.') + ms + m.captured(3);
33+
}
34+
35+
std::optional<RateWindow> mapWindow(const QJsonValue &raw, int windowMinutes)
36+
{
37+
if (!raw.isObject())
38+
return std::nullopt;
39+
const QJsonObject o = raw.toObject();
40+
QJsonValue used = o.value(QLatin1String("utilization"));
41+
if (!used.isDouble())
42+
used = o.value(QLatin1String("used_percentage"));
43+
if (!used.isDouble())
44+
return std::nullopt;
45+
46+
RateWindow w;
47+
w.usedPercent = std::clamp(used.toDouble(), 0.0, 100.0);
48+
w.windowMinutes = windowMinutes;
49+
w.resetsAtMs = parseTimestampMs(o.value(QLatin1String("resets_at")));
50+
return w;
51+
}
52+
53+
// Weekly window scoped to the Fable model: a `limits[]` entry or a top-level
54+
// key, depending on the account rollout.
55+
std::optional<RateWindow> mapFableWeekly(const QJsonObject &body)
56+
{
57+
const QJsonArray limits = body.value(QLatin1String("limits")).toArray();
58+
for (const QJsonValue &v : limits) {
59+
const QJsonObject limit = v.toObject();
60+
if (limit.value(QLatin1String("kind")).toString() != QLatin1String("weekly_scoped"))
61+
continue;
62+
if (!limit.value(QLatin1String("percent")).isDouble())
63+
continue;
64+
const QString name = limit.value(QLatin1String("scope")).toObject()
65+
.value(QLatin1String("model")).toObject()
66+
.value(QLatin1String("display_name")).toString().trimmed();
67+
if (name.compare(QLatin1String("fable"), Qt::CaseInsensitive) != 0)
68+
continue;
69+
const QJsonObject synthetic{
70+
{QLatin1String("used_percentage"), limit.value(QLatin1String("percent"))},
71+
{QLatin1String("resets_at"), limit.value(QLatin1String("resets_at"))},
72+
};
73+
if (auto w = mapWindow(synthetic, kWeeklyMinutes))
74+
return w;
75+
}
76+
for (const char *key : {"fable_weekly", "fable_seven_day", "seven_day_fable"}) {
77+
if (auto w = mapWindow(body.value(QLatin1String(key)), kWeeklyMinutes))
78+
return w;
79+
}
80+
return std::nullopt;
81+
}
82+
83+
QString normalizeSpaces(QString s)
84+
{
85+
// CLDR 42+ puts a narrow no-break space before AM/PM.
86+
s.replace(QChar(0x202F), QLatin1Char(' '));
87+
s.replace(QChar(0x00A0), QLatin1Char(' '));
88+
return s;
89+
}
90+
91+
} // namespace
92+
93+
std::optional<qint64> parseTimestampMs(const QJsonValue &value)
94+
{
95+
if (value.isDouble())
96+
return fromEpochNumber(value.toDouble());
97+
if (!value.isString())
98+
return std::nullopt;
99+
100+
const QString s = value.toString().trimmed();
101+
if (s.isEmpty())
102+
return std::nullopt;
103+
104+
bool numeric = false;
105+
const double n = s.toDouble(&numeric);
106+
if (numeric)
107+
return fromEpochNumber(n);
108+
109+
const QDateTime dt = QDateTime::fromString(clampFraction(s), Qt::ISODateWithMs);
110+
if (!dt.isValid())
111+
return std::nullopt;
112+
return dt.toMSecsSinceEpoch();
113+
}
114+
115+
QString parseCredentialsJson(const QByteArray &raw)
116+
{
117+
const QJsonValue token = QJsonDocument::fromJson(raw).object()
118+
.value(QLatin1String("claudeAiOauth")).toObject()
119+
.value(QLatin1String("accessToken"));
120+
return token.isString() ? token.toString().trimmed() : QString();
121+
}
122+
123+
QString credentialsPath(const QString &configDirEnv, const QString &homeDir)
124+
{
125+
const QString base = configDirEnv.isEmpty() ? homeDir + QStringLiteral("/.claude") : configDirEnv;
126+
return QDir::cleanPath(base + QStringLiteral("/.credentials.json"));
127+
}
128+
129+
Usage parseUsage(const QJsonObject &body)
130+
{
131+
Usage u;
132+
u.status = Status::Ok;
133+
u.session = mapWindow(body.value(QLatin1String("five_hour")), kSessionMinutes);
134+
u.weekly = mapWindow(body.value(QLatin1String("seven_day")), kWeeklyMinutes);
135+
u.fableWeekly = mapFableWeekly(body);
136+
return u;
137+
}
138+
139+
QString httpErrorMessage(int httpStatus)
140+
{
141+
switch (httpStatus) {
142+
case 401:
143+
case 403:
144+
return QStringLiteral("Sign-in expired - run claude once to refresh it");
145+
case 429:
146+
return QStringLiteral("Rate limited by provider - try again shortly");
147+
default:
148+
return QStringLiteral("Provider returned HTTP %1").arg(httpStatus);
149+
}
150+
}
151+
152+
Tone toneFor(double usedPercent)
153+
{
154+
if (usedPercent >= kDangerPercent)
155+
return Tone::Danger;
156+
if (usedPercent >= kWarningPercent)
157+
return Tone::Warning;
158+
return Tone::Ok;
159+
}
160+
161+
int displayPercent(double usedPercent, PercentMode mode)
162+
{
163+
const double used = std::clamp(usedPercent, 0.0, 100.0);
164+
return static_cast<int>(std::lround(mode == PercentMode::Used ? used : 100.0 - used));
165+
}
166+
167+
std::optional<double> elapsedPercent(const RateWindow &window, qint64 nowMs)
168+
{
169+
if (!window.resetsAtMs || window.windowMinutes <= 0)
170+
return std::nullopt;
171+
const double total = static_cast<double>(window.windowMinutes) * 60'000.0;
172+
const double elapsed = total - static_cast<double>(*window.resetsAtMs - nowMs);
173+
return std::clamp(elapsed / total * 100.0, 0.0, 100.0);
174+
}
175+
176+
QString formatResetIn(std::optional<qint64> resetsAtMs, qint64 nowMs)
177+
{
178+
if (!resetsAtMs)
179+
return QStringLiteral("reset time unknown");
180+
const qint64 minutes = (*resetsAtMs - nowMs) / 60'000;
181+
if (minutes <= 0)
182+
return QStringLiteral("resetting now");
183+
if (minutes < 60)
184+
return QStringLiteral("in %1m").arg(minutes);
185+
const qint64 hours = minutes / 60;
186+
if (hours < 24) {
187+
const qint64 rest = minutes % 60;
188+
return rest ? QStringLiteral("in %1h %2m").arg(hours).arg(rest)
189+
: QStringLiteral("in %1h").arg(hours);
190+
}
191+
const qint64 days = hours / 24;
192+
const qint64 restHours = hours % 24;
193+
return restHours ? QStringLiteral("in %1d %2h").arg(days).arg(restHours)
194+
: QStringLiteral("in %1d").arg(days);
195+
}
196+
197+
QString formatResetAt(std::optional<qint64> resetsAtMs, qint64 nowMs, const QLocale &locale)
198+
{
199+
if (!resetsAtMs)
200+
return {};
201+
const QDateTime at = QDateTime::fromMSecsSinceEpoch(*resetsAtMs);
202+
if (!at.isValid())
203+
return {};
204+
const QString clock = normalizeSpaces(locale.toString(at.time(), QLocale::ShortFormat));
205+
if (at.date() == QDateTime::fromMSecsSinceEpoch(nowMs).date())
206+
return clock;
207+
return locale.dayName(at.date().dayOfWeek(), QLocale::ShortFormat) + QLatin1Char(' ') + clock;
208+
}
209+
210+
QString percentText(double usedPercent, PercentMode mode)
211+
{
212+
return QStringLiteral("%1% %2")
213+
.arg(displayPercent(usedPercent, mode))
214+
.arg(mode == PercentMode::Used ? QStringLiteral("used") : QStringLiteral("left"));
215+
}
216+
217+
QVector<NamedWindow> windowsOf(const Usage &usage)
218+
{
219+
QVector<NamedWindow> out;
220+
out.reserve(3);
221+
if (usage.session)
222+
out.push_back({WindowKey::Session, QStringLiteral("5-hour"), *usage.session});
223+
if (usage.weekly)
224+
out.push_back({WindowKey::Weekly, QStringLiteral("Weekly"), *usage.weekly});
225+
if (usage.fableWeekly)
226+
out.push_back({WindowKey::FableWeekly, QStringLiteral("Fable weekly"), *usage.fableWeekly});
227+
return out;
228+
}
229+
230+
std::optional<NamedWindow> tightestWindow(const Usage &usage)
231+
{
232+
std::optional<NamedWindow> best;
233+
for (const NamedWindow &w : windowsOf(usage)) {
234+
if (!best || w.window.usedPercent > best->window.usedPercent)
235+
best = w;
236+
}
237+
return best;
238+
}
239+
240+
} // namespace ai::claudeusage

‎src/ai/ClaudeUsage.h‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#ifndef AI_CLAUDE_USAGE_H
2+
#define AI_CLAUDE_USAGE_H
3+
4+
#include <QByteArray>
5+
#include <QJsonObject>
6+
#include <QJsonValue>
7+
#include <QLocale>
8+
#include <QString>
9+
#include <QVector>
10+
11+
#include <optional>
12+
13+
// Claude Code subscription usage (5-hour / weekly / Fable-weekly windows), read
14+
// from `GET https://api.anthropic.com/api/oauth/usage` with the access token the
15+
// Claude CLI already stored on disk. Everything here is pure (no I/O, no
16+
// widgets) so the parsing and formatting rules are unit-testable.
17+
namespace ai::claudeusage {
18+
19+
struct RateWindow {
20+
double usedPercent = 0.0; // 0..100, clamped
21+
int windowMinutes = 0;
22+
std::optional<qint64> resetsAtMs; // unix ms
23+
24+
bool operator==(const RateWindow &o) const = default;
25+
};
26+
27+
enum class Status { Ok, Unavailable, Error };
28+
29+
struct Usage {
30+
Status status = Status::Unavailable;
31+
QString error;
32+
std::optional<RateWindow> session;
33+
std::optional<RateWindow> weekly;
34+
std::optional<RateWindow> fableWeekly;
35+
};
36+
37+
enum class Tone { Ok, Warning, Danger };
38+
enum class PercentMode { Used, Remaining };
39+
40+
enum class WindowKey { Session, Weekly, FableWeekly };
41+
42+
struct NamedWindow {
43+
WindowKey key = WindowKey::Session;
44+
QString label;
45+
RateWindow window;
46+
};
47+
48+
constexpr double kWarningPercent = 80.0;
49+
constexpr double kDangerPercent = 95.0;
50+
constexpr int kSessionMinutes = 300;
51+
constexpr int kWeeklyMinutes = 10080;
52+
53+
// Epoch seconds, epoch ms, numeric string or RFC 3339 -> unix ms. 1e10 splits
54+
// seconds (< year 2286) from ms (> 1970-04).
55+
std::optional<qint64> parseTimestampMs(const QJsonValue &value);
56+
57+
// `{"claudeAiOauth":{"accessToken":"..."}}` -> trimmed token, empty when absent.
58+
QString parseCredentialsJson(const QByteArray &raw);
59+
60+
// `$CLAUDE_CONFIG_DIR/.credentials.json`, else `<home>/.claude/.credentials.json`.
61+
QString credentialsPath(const QString &configDirEnv, const QString &homeDir);
62+
63+
Usage parseUsage(const QJsonObject &body);
64+
65+
// 401/403 expired sign-in, 429 rate limited, anything else "HTTP <n>".
66+
QString httpErrorMessage(int httpStatus);
67+
68+
Tone toneFor(double usedPercent);
69+
int displayPercent(double usedPercent, PercentMode mode);
70+
// Share of the window already elapsed (0..100); nullopt when unknown.
71+
std::optional<double> elapsedPercent(const RateWindow &window, qint64 nowMs);
72+
73+
// "resetting now", "in 45m", "in 2h 15m", "in 3d 4h"; "reset time unknown" when null.
74+
QString formatResetIn(std::optional<qint64> resetsAtMs, qint64 nowMs);
75+
// Local clock time of the reset: "2:30 PM" same day, otherwise "Thu 2:30 PM".
76+
QString formatResetAt(std::optional<qint64> resetsAtMs, qint64 nowMs, const QLocale &locale = QLocale());
77+
// "26% used" / "74% left".
78+
QString percentText(double usedPercent, PercentMode mode);
79+
80+
// Windows the provider reported, in display order (5-hour, weekly, Fable weekly).
81+
QVector<NamedWindow> windowsOf(const Usage &usage);
82+
// The most-consumed window - the one that will stall an agent first.
83+
std::optional<NamedWindow> tightestWindow(const Usage &usage);
84+
85+
} // namespace ai::claudeusage
86+
87+
#endif // AI_CLAUDE_USAGE_H

0 commit comments

Comments
 (0)