clauddy 1.11.0 → 1.12.0

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.
package/README.md CHANGED
@@ -9,7 +9,7 @@ A cute pixel-art desktop pet for macOS that tracks your Claude Code usage — mi
9
9
 
10
10
  ## What it shows
11
11
 
12
- - **Current session** — real % used + **"resets in Xh Ym"** + session tokens
12
+ - **Current session** — real % used + **"resets in Xh Ym"** + session tokens, and a projection of where that pace is taking you (see [Burn rate](#burn-rate))
13
13
  - **Weekly · all models** — real % used + tokens over the last 7 days
14
14
  - **Status line** under the pet: `● working · 1.6M tok/min` (or today's tokens when idle)
15
15
  - **By model · 7 days** — Opus / Sonnet / Haiku / Fable, in tokens
@@ -26,6 +26,19 @@ The session/weekly **%** comes straight from your Anthropic account, so it match
26
26
 
27
27
  The token is saved locally (see [Data & privacy](#data--privacy)) and refreshed automatically. **Until you connect**, the limits area shows a _"Connect your account"_ prompt instead of percentages.
28
28
 
29
+ ## Burn rate
30
+
31
+ Knowing you're at **82%** with **1h 12m** left on the window still leaves you doing arithmetic in your head. So Clauddy does it for you: it fits the slope of your recent usage and projects when you'd hit 100% — showing one extra line under the session bar:
32
+
33
+ - **`~35m left at this pace`** (in coral) — you'd run out before the window resets. Ease off, or wrap up.
34
+ - **`resets before you run out`** — the reset gets there first. Carry on.
35
+
36
+ The slope is fitted over your **session tokens** rather than the account %. The % is the number you care about, but it arrives as a whole number every ~5 minutes — over a short window the whole signal is a single `16 → 17` step, which throws the fitted pace off by multiples. Local-log tokens step too — one jump per assistant turn — but in increments some 10–20× finer, so the slope is far steadier; the account % then anchors it, converting tokens into % and re-calibrating on every poll.
37
+
38
+ It reads your **recent** pace, not the session average: go quiet for a few minutes and the projection eases off, which is the point.
39
+
40
+ It only appears once there's enough to say honestly — roughly 5 minutes into a session — and stays hidden while you're idle, when the pace is flat, or right after a reset. A projection is a projection: change your pace and it changes with you.
41
+
29
42
  ## The pet's states
30
43
 
31
44
  <table>
@@ -109,9 +122,7 @@ The quickest path works the same as macOS — with [Bun](https://bun.sh) or Node
109
122
  bunx clauddy # or: npx clauddy
110
123
  ```
111
124
 
112
- Prefer a standalone app with no Node/Bun? Grab the **portable zip** (`Clauddy-<version>-win.zip`) from the [latest release](https://github.com/renatoaug/claude-usage-monitor/releases), unzip it anywhere, and run `Clauddy.exe`. Because the app is unsigned, Windows **SmartScreen** shows a "Windows protected your PC" prompt the first time — click **More info → Run anyway**. From then on it starts with Windows.
113
-
114
- > Windows builds are produced by the **Build** workflow (Actions ▸ Build) — attaching them to every release automatically is on the roadmap.
125
+ Prefer a standalone app with no Node/Bun? Grab the **portable zip** (`Clauddy-<version>-win-x64.zip`) from the [latest release](https://github.com/renatoaug/claude-usage-monitor/releases), unzip it anywhere, and run `Clauddy.exe`. Because the app is unsigned, Windows **SmartScreen** shows a "Windows protected your PC" prompt the first time — click **More info → Run anyway**. From then on it starts with Windows.
115
126
 
116
127
  ### Linux (x64)
117
128
 
@@ -121,15 +132,13 @@ The quickest path works the same as macOS — with [Bun](https://bun.sh) or Node
121
132
  bunx clauddy # or: npx clauddy
122
133
  ```
123
134
 
124
- Prefer a standalone app? Grab the **AppImage** or **tar.gz** (`Clauddy-<version>.AppImage` / `clauddy-<version>.tar.gz`) from the [latest release](https://github.com/renatoaug/claude-usage-monitor/releases), then:
135
+ Prefer a standalone app? Grab the **AppImage** or **tar.gz** (`Clauddy-<version>-linux-x64.AppImage` / `Clauddy-<version>-linux-x64.tar.gz`) from the [latest release](https://github.com/renatoaug/claude-usage-monitor/releases), then:
125
136
 
126
137
  ```bash
127
138
  chmod +x Clauddy-*.AppImage
128
139
  ./Clauddy-*.AppImage
129
140
  ```
130
141
 
131
- > Linux builds are produced by the **Build** workflow (Actions ▸ Build) — attaching them to every release automatically is on the roadmap.
132
-
133
142
  > The system tray icon needs an indicator extension on vanilla GNOME (e.g. "AppIndicator and KStatusNotifier Item Support") — it works out of the box on Cinnamon, KDE, and XFCE. Autostart-at-login is wired up via an XDG `.desktop` entry in `~/.config/autostart/`.
134
143
 
135
144
  > The app keeps its data in `~/.claude-usage-monitor`, regardless of platform or how you run it.
@@ -218,9 +227,15 @@ Nothing leaves your machine except the OAuth calls to Anthropic's own login and
218
227
  Releases are **fully automated**. Every push to `main` runs
219
228
  [semantic-release](https://semantic-release.gitbook.io) (`.github/workflows/release.yml`):
220
229
  it reads the **Conventional Commits** and, when there's something to ship,
221
- computes the version, builds the macOS app, publishes `clauddy` to npm, and
222
- cuts a GitHub Release with the `.app` zip. Nothing to do by hand just merge
223
- your PRs.
230
+ computes the version, builds the app for **macOS, Windows and Linux** on their
231
+ own runners, publishes `clauddy` to npm, and cuts a GitHub Release with every
232
+ artifact attached. Nothing to do by hand — just merge your PRs.
233
+
234
+ The pipeline runs in three stages, because electron-builder can't cross-build
235
+ Windows/Linux from macOS: `version` (a semantic-release dry-run that computes
236
+ the next version) → `build` (a matrix that stamps that version into
237
+ `package.json` so the filenames are right) → `publish` (downloads every
238
+ artifact and runs semantic-release for real).
224
239
 
225
240
  - `feat:` → minor, `fix:` → patch, `feat!:`/`BREAKING CHANGE` → major.
226
241
  - `docs:`/`chore:`/`ci:` etc. don't trigger a release.
package/main.js CHANGED
@@ -36,6 +36,9 @@ if (process.env.CLAUDE_CONFIG_DIR) {
36
36
  const EXTERNAL_CONFIG = path.join(DATA_DIR, 'config.json')
37
37
  // debug channel: `./pet <state>` writes here to force a state (dev only)
38
38
  const DEBUG_FILE = path.join(DATA_DIR, 'debug.json')
39
+ // remembered floating-widget position: survives restarts and lets the widget
40
+ // return to the monitor the user parked it on after a display is unplugged/replugged
41
+ const WINDOW_STATE = path.join(DATA_DIR, 'window.json')
39
42
 
40
43
  let win
41
44
  let pollTimer
@@ -49,6 +52,8 @@ let currentMode = 'floating' // 'floating' widget | 'menubar' popover
49
52
  let trayBounds = null // last known tray icon rect, to anchor the popover
50
53
  let lastBlurHide = 0 // debounce: ignore the tray click that dismissed the popover
51
54
  let sessionPct = null // authoritative session % shown in the tray title
55
+ let lastProgrammaticMove = 0 // ignore the 'moved' event our own setPosition triggers
56
+ let displayChanging = 0 // ignore OS window-shuffles while a display (dis)connects
52
57
 
53
58
  function publicConfig(c) {
54
59
  return {
@@ -151,6 +156,27 @@ function createWindow() {
151
156
  }
152
157
  })
153
158
 
159
+ // remember where the user parks the widget — but not the moves we make
160
+ // ourselves (resize re-anchoring) nor the ones the OS forces when a display
161
+ // (dis)connects, so a monitor going dark never overwrites the saved spot
162
+ win.on('moved', () => {
163
+ if (Date.now() - lastProgrammaticMove < 500) return
164
+ if (Date.now() - displayChanging < 2000) return
165
+ saveWindowState()
166
+ })
167
+
168
+ // when a monitor is unplugged/replugged (or its layout changes), put the
169
+ // floating widget back on the display the user parked it on — the OS dumps
170
+ // it on the primary display otherwise, and never moves it back on its own
171
+ const onDisplayChange = () => {
172
+ displayChanging = Date.now()
173
+ if (currentMode === 'floating' && win && !win.isDestroyed() && win.isVisible())
174
+ positionFloating()
175
+ }
176
+ screen.on('display-added', onDisplayChange)
177
+ screen.on('display-removed', onDisplayChange)
178
+ screen.on('display-metrics-changed', onDisplayChange)
179
+
154
180
  const tick = () => {
155
181
  if (!win || win.isDestroyed()) return
156
182
  try {
@@ -250,6 +276,43 @@ function showPopover() {
250
276
  win.focus()
251
277
  }
252
278
 
279
+ // route every programmatic move through here so the 'moved' handler can tell
280
+ // our own repositioning apart from a genuine user drag (and skip saving it)
281
+ function moveWindow(x, y) {
282
+ lastProgrammaticMove = Date.now()
283
+ win.setPosition(Math.round(x), Math.round(y))
284
+ }
285
+
286
+ // persist the widget's anchor — its bottom-right corner, since the window's
287
+ // size changes as the pet animates — so it can be restored later
288
+ function saveWindowState() {
289
+ if (currentMode !== 'floating' || !win || win.isDestroyed()) return
290
+ const b = win.getBounds()
291
+ try {
292
+ fs.mkdirSync(DATA_DIR, { recursive: true })
293
+ fs.writeFileSync(WINDOW_STATE, JSON.stringify({ right: b.x + b.width, bottom: b.y + b.height }))
294
+ } catch {}
295
+ }
296
+
297
+ function loadWindowState() {
298
+ try {
299
+ const s = JSON.parse(fs.readFileSync(WINDOW_STATE, 'utf8'))
300
+ return Number.isFinite(s?.right) && Number.isFinite(s?.bottom) ? s : null
301
+ } catch {
302
+ return null
303
+ }
304
+ }
305
+
306
+ // is the saved bottom-right corner on a display that's currently connected?
307
+ function cornerVisible(right, bottom) {
308
+ const x = Math.round(right - 1)
309
+ const y = Math.round(bottom - 1)
310
+ return screen.getAllDisplays().some((d) => {
311
+ const b = d.bounds
312
+ return x >= b.x && x < b.x + b.width && y >= b.y && y < b.y + b.height
313
+ })
314
+ }
315
+
253
316
  // center the popover under the tray icon, kept on-screen
254
317
  function positionUnderTray() {
255
318
  const b = win.getBounds()
@@ -261,13 +324,20 @@ function positionUnderTray() {
261
324
  y = Math.round(trayBounds.y + trayBounds.height)
262
325
  }
263
326
  x = Math.max(8, Math.min(x, workAreaSize.width - b.width - 8))
264
- win.setPosition(x, y)
327
+ moveWindow(x, y)
265
328
  }
266
329
 
267
330
  function positionFloating() {
268
- const { workAreaSize } = screen.getPrimaryDisplay()
269
331
  const b = win.getBounds()
270
- win.setPosition(workAreaSize.width - b.width - 24, workAreaSize.height - b.height - 24)
332
+ // reuse the saved spot when that monitor is still connected; otherwise fall
333
+ // back to the primary display's bottom-right corner
334
+ const saved = loadWindowState()
335
+ if (saved && cornerVisible(saved.right, saved.bottom)) {
336
+ moveWindow(saved.right - b.width, saved.bottom - b.height)
337
+ return
338
+ }
339
+ const { workAreaSize } = screen.getPrimaryDisplay()
340
+ moveWindow(workAreaSize.width - b.width - 24, workAreaSize.height - b.height - 24)
271
341
  }
272
342
 
273
343
  // the tray shows the live session % (macOS title), turning 🔥 near the limit
@@ -296,12 +366,20 @@ ipcMain.on('resize', (_e, w, h) => {
296
366
  if (!win || win.isDestroyed()) return
297
367
  const width = Math.max(100, Math.round(w))
298
368
  const height = Math.max(110, Math.round(h))
299
- win.setContentSize(width, height)
300
369
  if (currentMode === 'menubar') {
370
+ win.setContentSize(width, height)
301
371
  if (win.isVisible()) positionUnderTray() // keep it anchored under the tray
302
372
  } else {
303
- const { workAreaSize } = screen.getPrimaryDisplay()
304
- win.setPosition(workAreaSize.width - width - 24, workAreaSize.height - height - 24)
373
+ // keep the widget pinned to its current bottom-right corner on whatever
374
+ // display the user dragged it to. setContentSize grows from the top-left
375
+ // origin, so re-anchor by the old corner instead of snapping to the primary
376
+ // display's bottom-right (which yanked the widget back on every update).
377
+ const before = win.getBounds()
378
+ const right = before.x + before.width
379
+ const bottom = before.y + before.height
380
+ win.setContentSize(width, height)
381
+ const after = win.getBounds()
382
+ moveWindow(right - after.width, bottom - after.height)
305
383
  }
306
384
  })
307
385
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clauddy",
3
3
  "desktopName": "clauddy.desktop",
4
- "version": "1.11.0",
4
+ "version": "1.12.0",
5
5
  "description": "A cute desktop pet that tracks your Claude Code usage",
6
6
  "main": "main.js",
7
7
  "bin": {
@@ -80,17 +80,20 @@
80
80
  "category": "public.app-category.developer-tools",
81
81
  "target": "dir",
82
82
  "identity": null,
83
- "icon": "build/icon.icns"
83
+ "icon": "build/icon.icns",
84
+ "artifactName": "${productName}-${version}-mac-${arch}.${ext}"
84
85
  },
85
86
  "win": {
86
87
  "target": "zip",
87
- "icon": "build/icon.ico"
88
+ "icon": "build/icon.ico",
89
+ "artifactName": "${productName}-${version}-win-${arch}.${ext}"
88
90
  },
89
91
  "linux": {
90
92
  "target": [
91
93
  "tar.gz",
92
94
  "AppImage"
93
95
  ],
96
+ "artifactName": "${productName}-${version}-linux-${arch}.${ext}",
94
97
  "category": "Utility",
95
98
  "icon": "build/icon.png",
96
99
  "syncDesktopName": true
@@ -101,7 +104,6 @@
101
104
  },
102
105
  "devDependencies": {
103
106
  "@biomejs/biome": "^2.5.0",
104
- "@semantic-release/exec": "^7.1.0",
105
107
  "electron-builder": "^26.15.3",
106
108
  "semantic-release": "^25.0.5"
107
109
  }
@@ -246,6 +246,7 @@
246
246
  <div class="meter-top"><span>current session</span><span id="session-pct">0%</span></div>
247
247
  <div class="track"><div class="fill" id="session-fill"></div></div>
248
248
  <div class="sub" id="session-sub">no active session</div>
249
+ <div class="sub proj" id="session-proj" hidden></div>
249
250
  </div>
250
251
  <div class="meter">
251
252
  <div class="meter-top"><span>weekly · all models</span><span id="week-pct">0%</span></div>
package/renderer/pet.js CHANGED
@@ -154,6 +154,80 @@ function fmtReset(ms) {
154
154
  const m = Math.floor((ms % 3600000) / 60000)
155
155
  return h > 0 ? `${h}h ${m}m` : `${m}m`
156
156
  }
157
+ // ---- burn-rate projection ----
158
+ // The panel knows where you are (82%) and when the window resets (1h 12m); it
159
+ // can also say where you're headed — fit a slope through recent usage and
160
+ // project the crossing of 100%. If the reset lands first there's nothing to
161
+ // worry about, which is worth saying out loud rather than leaving blank.
162
+ //
163
+ // The slope is fitted over session *tokens*, not the account %. The % is the
164
+ // number we ultimately care about, but it arrives as a whole number every ~5
165
+ // min: over a short window the whole signal is a single 16 → 17 step, which
166
+ // makes the fitted pace wrong by multiples. Local-log tokens step too — one
167
+ // jump per assistant turn, with plateaus in between — but in increments some
168
+ // 10-20x finer, so they carry a far steadier slope. The
169
+ // account % still anchors it: pct/tokens converts tokens/ms into %/ms and
170
+ // re-calibrates on every poll, so the projection stays tied to the real number.
171
+ const PROJ_WINDOW_MS = 45 * 60 * 1000 // only fit recent samples — pace changes
172
+ const PROJ_MIN_SAMPLES = 4
173
+ const PROJ_MIN_SPAN_MS = 5 * 60 * 1000 // shorter than a % fit affords, and steadier
174
+ const PROJ_SAMPLE_EVERY_MS = 30 * 1000 // usage polls every few seconds; thin it out
175
+ let tokTrail = []
176
+
177
+ function noteTokens(tokens, active) {
178
+ if (!active || !(tokens > 0)) {
179
+ tokTrail = [] // no session, no trend
180
+ return
181
+ }
182
+ // session tokens only climb within a window — a drop means it rolled over,
183
+ // and the fresh sample starts the new trail rather than being throttled away
184
+ let last = tokTrail[tokTrail.length - 1]
185
+ if (last && tokens < last.tokens) {
186
+ tokTrail = []
187
+ last = undefined
188
+ }
189
+ const now = Date.now()
190
+ if (last && now - last.t < PROJ_SAMPLE_EVERY_MS) return
191
+ tokTrail.push({ t: now, tokens })
192
+ tokTrail = tokTrail.filter((s) => s.t >= now - PROJ_WINDOW_MS)
193
+ }
194
+
195
+ // least-squares slope in tokens per ms, or null when there isn't enough to say
196
+ function burnSlope() {
197
+ if (tokTrail.length < PROJ_MIN_SAMPLES) return null
198
+ const span = tokTrail[tokTrail.length - 1].t - tokTrail[0].t
199
+ if (span < PROJ_MIN_SPAN_MS) return null
200
+ const n = tokTrail.length
201
+ const t0 = tokTrail[0].t
202
+ let sx = 0
203
+ let sy = 0
204
+ let sxy = 0
205
+ let sxx = 0
206
+ for (const s of tokTrail) {
207
+ const x = s.t - t0
208
+ sx += x
209
+ sy += s.tokens
210
+ sxy += x * s.tokens
211
+ sxx += x * x
212
+ }
213
+ const denom = n * sxx - sx * sx
214
+ if (denom === 0) return null
215
+ return (n * sxy - sx * sy) / denom
216
+ }
217
+
218
+ // → { kind: 'eta', ms } when you'd run out first, { kind: 'safe' } when the
219
+ // reset beats you there, or null when we can't tell yet.
220
+ function project(pct, resetMs, tokens) {
221
+ if (!(pct > 0) || !(tokens > 0)) return null // no anchor to convert tokens → %
222
+ const tokPerMs = burnSlope()
223
+ if (tokPerMs == null || tokPerMs <= 0) return null // idle or flat
224
+ const pctPerMs = tokPerMs * (pct / tokens)
225
+ const eta = (100 - pct) / pctPerMs
226
+ if (!Number.isFinite(eta) || eta <= 0) return null
227
+ if (resetMs != null && eta >= resetMs) return { kind: 'safe' }
228
+ return { kind: 'eta', ms: eta }
229
+ }
230
+
157
231
  function setState(name) {
158
232
  const b = document.body
159
233
  ;[...b.classList].forEach((c) => {
@@ -500,6 +574,17 @@ function render(d) {
500
574
  ? `resets in ${fmtReset(sessReset)} · ${fmtTokens(d.session.tokens)} tokens`
501
575
  : 'no active session'
502
576
 
577
+ // where this pace is taking you — hidden until there's enough trail to tell
578
+ noteTokens(d.session.tokens, sessActive)
579
+ const proj = sessActive && sessPct < 100 ? project(sessPct, sessReset, d.session.tokens) : null
580
+ const pe = el('session-proj')
581
+ pe.hidden = !proj
582
+ pe.classList.toggle('tight', proj?.kind === 'eta')
583
+ if (proj) {
584
+ pe.textContent =
585
+ proj.kind === 'eta' ? `~${fmtReset(proj.ms)} left at this pace` : 'resets before you run out'
586
+ }
587
+
503
588
  el('week-pct').textContent = `${Math.round(wkPct)}%`
504
589
  const wf = el('week-fill')
505
590
  wf.style.width = `${wkPct}%`
@@ -1618,6 +1618,18 @@ body.state-tired #status-text {
1618
1618
  letter-spacing: 0.2px;
1619
1619
  }
1620
1620
 
1621
+ /* burn-rate projection — calm by default, warm when you'd run out first */
1622
+ .sub.proj {
1623
+ margin-top: 2px;
1624
+ font-style: italic;
1625
+ opacity: 0.85;
1626
+ }
1627
+ .sub.proj.tight {
1628
+ color: var(--coral);
1629
+ font-style: normal;
1630
+ opacity: 1;
1631
+ }
1632
+
1621
1633
  /* 30-day map */
1622
1634
  #heat {
1623
1635
  margin-top: 12px;