@zhangferry-dev/tokendash 1.4.0 → 1.5.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/electron/main.cjs CHANGED
@@ -51,7 +51,7 @@ let popover = null;
51
51
  let server = null;
52
52
  let trayProcess = null;
53
53
  let selectedAgents = null; // null = use all available agents
54
- const serverPort = parseInt(process.env.TOKENDASH_PORT || '3456', 10);
54
+ let serverPort = parseInt(process.env.TOKENDASH_PORT || '3456', 10);
55
55
  const POPOVER_WIDTH = 380;
56
56
  const POPOVER_HEIGHT = 540;
57
57
  const PACKAGE_NAME = '@zhangferry-dev/tokendash';
@@ -250,6 +250,27 @@ function stopTrayHelper() {
250
250
  // ---------------------------------------------------------------------------
251
251
 
252
252
  let updateTimer = null;
253
+ let lastTraySnapshot = null;
254
+
255
+ function getTrayAgentKey(agents) {
256
+ return agents.slice().sort().join(',');
257
+ }
258
+
259
+ function applyTraySnapshot(snapshot) {
260
+ const totalTokens = Number(snapshot && snapshot.totalTokens) || 0;
261
+ const totalCost = Number(snapshot && snapshot.totalCost) || 0;
262
+ const totalCacheRead = Number(snapshot && snapshot.totalCacheRead) || 0;
263
+ const today = snapshot && snapshot.today;
264
+ const agentKey = snapshot && snapshot.agentKey;
265
+
266
+ lastTraySnapshot = { today, agentKey, totalTokens, totalCost, totalCacheRead };
267
+
268
+ const tokenStr = formatTokens(totalTokens);
269
+ sendTrayCommand('title:' + tokenStr);
270
+
271
+ const cacheRate = totalTokens > 0 ? ((totalCacheRead / totalTokens) * 100).toFixed(1) : '0.0';
272
+ sendTrayCommand('tooltip:TokenDash - ' + tokenStr + ' tokens today ($' + totalCost.toFixed(2) + ') | cache: ' + cacheRate + '%');
273
+ }
253
274
 
254
275
  function updateTrayBadge() {
255
276
  const d = new Date(); const today = d.getFullYear() + "-" + String(d.getMonth()+1).padStart(2,"0") + "-" + String(d.getDate()).padStart(2,"0");
@@ -257,20 +278,35 @@ function updateTrayBadge() {
257
278
  // Fetch agents list, then fetch daily data for each agent in parallel
258
279
  fetchJson(`http://localhost:${serverPort}/api/agents`)
259
280
  .then((agentData) => {
260
- let agents = (agentData && agentData.available) ? agentData.available : ['claude'];
281
+ let agents = (agentData && Array.isArray(agentData.available)) ? agentData.available : ['claude'];
282
+ if (agents.length === 0) {
283
+ // Transient agent detection failures should not clear a previously valid tray badge.
284
+ return null;
285
+ }
286
+
261
287
  // Apply agent filter from popover settings
262
288
  if (selectedAgents && selectedAgents.length > 0) {
263
- agents = agents.filter(a => selectedAgents.includes(a));
264
- if (agents.length === 0) agents = (agentData && agentData.available) ? agentData.available : ['claude'];
289
+ const filtered = agents.filter(a => selectedAgents.includes(a));
290
+ if (filtered.length > 0) agents = filtered;
265
291
  }
292
+
293
+ const agentKey = getTrayAgentKey(agents);
266
294
  return Promise.all(
267
295
  agents.map(agent =>
268
296
  fetchJson(`http://localhost:${serverPort}/api/daily?agent=${agent}`)
269
297
  .catch(() => null)
270
298
  )
271
- );
299
+ ).then(results => ({ agentKey, results }));
272
300
  })
273
- .then((results) => {
301
+ .then((payload) => {
302
+ if (!payload) return;
303
+ const { agentKey, results } = payload;
304
+ const successfulResults = results.filter(data => data && data.daily);
305
+ if (successfulResults.length === 0) {
306
+ // Keep the last good value when every daily request failed.
307
+ return;
308
+ }
309
+
274
310
  let totalTokens = 0;
275
311
  let totalCost = 0;
276
312
  let totalInput = 0;
@@ -288,13 +324,20 @@ function updateTrayBadge() {
288
324
  totalCacheRead += entry.cacheReadTokens || 0;
289
325
  }
290
326
 
291
- // Show token count on tray icon
292
- const tokenStr = formatTokens(totalTokens);
293
- sendTrayCommand('title:' + tokenStr);
327
+ const shouldPreserveLastPositive =
328
+ totalTokens === 0 &&
329
+ lastTraySnapshot &&
330
+ lastTraySnapshot.today === today &&
331
+ lastTraySnapshot.agentKey === agentKey &&
332
+ lastTraySnapshot.totalTokens > 0;
333
+
334
+ if (shouldPreserveLastPositive) {
335
+ // Daily usage should not drop to zero during the same day for the same agent filter.
336
+ // Treat a zero refresh after a positive value as transient empty data and keep the badge stable.
337
+ return;
338
+ }
294
339
 
295
- // Tooltip with breakdown
296
- const cacheRate = totalTokens > 0 ? ((totalCacheRead / totalTokens) * 100).toFixed(1) : '0.0';
297
- sendTrayCommand('tooltip:TokenDash - ' + tokenStr + ' tokens today ($' + totalCost.toFixed(2) + ') | cache: ' + cacheRate + '%');
340
+ applyTraySnapshot({ today, agentKey, totalTokens, totalCost, totalCacheRead });
298
341
  })
299
342
  .catch((err) => {
300
343
  if (err.code !== 'ECONNREFUSED') {
@@ -403,10 +446,17 @@ function registerIpcHandlers() {
403
446
 
404
447
  ipcMain.handle('tokendash:set-selected-agents', (_event, agents) => {
405
448
  selectedAgents = Array.isArray(agents) ? agents : null;
449
+ lastTraySnapshot = null;
406
450
  // Immediately refresh badge with new filter
407
451
  updateTrayBadge();
408
452
  return { ok: true };
409
453
  });
454
+
455
+ ipcMain.handle('tokendash:update-tray-snapshot', (_event, snapshot) => {
456
+ if (!snapshot || typeof snapshot !== 'object') return { ok: false };
457
+ applyTraySnapshot(snapshot);
458
+ return { ok: true };
459
+ });
410
460
  }
411
461
 
412
462
  // ---------------------------------------------------------------------------
@@ -414,6 +464,10 @@ function registerIpcHandlers() {
414
464
  // ---------------------------------------------------------------------------
415
465
 
416
466
  app.whenReady().then(async () => {
467
+ if (process.platform === 'darwin' && app.dock) {
468
+ app.dock.hide();
469
+ }
470
+
417
471
  registerIpcHandlers();
418
472
 
419
473
  app.on('before-quit', () => {
@@ -430,6 +484,7 @@ app.whenReady().then(async () => {
430
484
  try {
431
485
  const result = await listenWithFallback(expressApp, serverPort);
432
486
  server = result.server;
487
+ serverPort = result.port;
433
488
  console.log(`tokendash running on http://localhost:${result.port}`);
434
489
  } catch (err) {
435
490
  console.error('Failed to start server:', err);
@@ -19,4 +19,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
19
19
  setSelectedAgents(agents) {
20
20
  return ipcRenderer.invoke('tokendash:set-selected-agents', agents);
21
21
  },
22
+ updateTraySnapshot(snapshot) {
23
+ return ipcRenderer.invoke('tokendash:update-tray-snapshot', snapshot);
24
+ },
22
25
  });
@@ -5,10 +5,12 @@
5
5
  * Examples: 1234 -> "1.2K", 567890 -> "567.9K", 1500000 -> "1.5M"
6
6
  */
7
7
  function formatTokens(tokens) {
8
+ tokens = Number(tokens) || 0;
9
+
8
10
  if (tokens >= 1e6) return (tokens / 1e6).toFixed(1) + 'M';
9
11
  if (tokens >= 1e3) return (tokens / 1e3).toFixed(1) + 'K';
10
12
  if (tokens > 0) return String(tokens);
11
- return '0.0M';
13
+ return '0';
12
14
  }
13
15
 
14
16
  /**
Binary file
@@ -9,18 +9,17 @@ import Cocoa
9
9
  class AppDelegate: NSObject, NSApplicationDelegate {
10
10
  var statusItem: NSStatusItem!
11
11
  var readHandle: FileHandle?
12
+ var currentTitle = "0"
12
13
 
13
14
  func applicationDidFinishLaunching(_ notification: Notification) {
14
15
  // Create status bar item
15
16
  statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
16
17
 
17
- // Add a small template icon (filled circle) before the title
18
- let icon = createTemplateIcon(size: NSSize(width: 18, height: 18))
19
- statusItem.button?.image = icon
20
- statusItem.button?.imagePosition = .imageLeft
18
+ statusItem.button?.image = renderCombinedImage(title: currentTitle)
19
+ statusItem.button?.imagePosition = .imageOnly
21
20
  statusItem.button?.imageScaling = .scaleProportionallyDown
22
-
23
- statusItem.button?.title = "0.0M"
21
+ statusItem.button?.isBordered = false
22
+ statusItem.button?.title = ""
24
23
  statusItem.button?.toolTip = "TokenDash"
25
24
 
26
25
  // Set up click actions — both left and right click
@@ -42,9 +41,39 @@ class AppDelegate: NSObject, NSApplicationDelegate {
42
41
  sendEvent("ready")
43
42
  }
44
43
 
44
+ /// Render icon + title text into a single template image for the status bar.
45
+ func renderCombinedImage(title: String) -> NSImage {
46
+ let iconW: CGFloat = 18
47
+ let iconH: CGFloat = 18
48
+ let fontSize: CGFloat = 13
49
+ let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .medium)
50
+ let textAttrs: [NSAttributedString.Key: Any] = [.font: font]
51
+ let textWidth = (title as NSString).size(withAttributes: textAttrs).width
52
+ let padding: CGFloat = 4 // gap between icon and text
53
+
54
+ let totalWidth = iconW + padding + textWidth
55
+ // Status bar height is ~22pt; center vertically
56
+ let totalHeight: CGFloat = 20
57
+
58
+ let image = NSImage(size: NSSize(width: totalWidth, height: totalHeight))
59
+ image.lockFocus()
60
+
61
+ // Draw icon centered vertically
62
+ let icon = createTemplateIcon(size: NSSize(width: iconW, height: iconH))
63
+ let iconY = (totalHeight - iconH) / 2.0
64
+ icon.draw(in: NSRect(x: 0, y: iconY, width: iconW, height: iconH))
65
+
66
+ // Draw text centered vertically (baseline-adjusted)
67
+ let textY = (totalHeight - fontSize) / 2.0 - 1
68
+ (title as NSString).draw(at: NSPoint(x: iconW + padding, y: textY), withAttributes: textAttrs)
69
+
70
+ image.unlockFocus()
71
+ image.isTemplate = true
72
+ return image
73
+ }
74
+
45
75
  @objc func handleClick(_ sender: Any?) {
46
76
  guard let _ = NSApp.currentEvent else { return }
47
- // Send click with screen coordinates so Electron can position the popover
48
77
  let loc = NSEvent.mouseLocation
49
78
  sendEvent("click:\(Int(loc.x)),\(Int(loc.y))")
50
79
  }
@@ -60,7 +89,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
60
89
  let cmd = command.trimmingCharacters(in: .whitespacesAndNewlines)
61
90
  if cmd.hasPrefix("title:") {
62
91
  let title = String(cmd.dropFirst(6))
63
- statusItem.button?.title = title
92
+ currentTitle = title
93
+ statusItem.button?.image = renderCombinedImage(title: title)
64
94
  } else if cmd.hasPrefix("tooltip:") {
65
95
  let tooltip = String(cmd.dropFirst(8))
66
96
  statusItem.button?.toolTip = tooltip
@@ -83,32 +113,24 @@ class AppDelegate: NSObject, NSApplicationDelegate {
83
113
  let image = NSImage(size: size)
84
114
  image.lockFocus()
85
115
 
86
- // Draw the TokenDash wavy line icon (from SVG, 64x64 viewBox scaled to size)
87
- // SVG coords use top-left origin; AppKit uses bottom-left, so flip Y: y = (64 - svgY)
88
116
  let sx = size.width / 64.0
89
117
  let sy = size.height / 64.0
90
118
 
91
119
  let path = NSBezierPath()
92
- // M6,32 H18
93
120
  path.move(to: NSPoint(x: 6 * sx, y: (64 - 32) * sy))
94
121
  path.line(to: NSPoint(x: 18 * sx, y: (64 - 32) * sy))
95
- // C21,32 22.5,34 24.5,39
96
122
  path.curve(to: NSPoint(x: 24.5 * sx, y: (64 - 39) * sy),
97
123
  controlPoint1: NSPoint(x: 21 * sx, y: (64 - 32) * sy),
98
124
  controlPoint2: NSPoint(x: 22.5 * sx, y: (64 - 34) * sy))
99
- // C27,45.5 30,50 34,50
100
125
  path.curve(to: NSPoint(x: 34 * sx, y: (64 - 50) * sy),
101
126
  controlPoint1: NSPoint(x: 27 * sx, y: (64 - 45.5) * sy),
102
127
  controlPoint2: NSPoint(x: 30 * sx, y: (64 - 50) * sy))
103
- // C38,50 40.5,42 44,22
104
128
  path.curve(to: NSPoint(x: 44 * sx, y: (64 - 22) * sy),
105
129
  controlPoint1: NSPoint(x: 38 * sx, y: (64 - 50) * sy),
106
130
  controlPoint2: NSPoint(x: 40.5 * sx, y: (64 - 42) * sy))
107
- // C46,11 49,8 52,8
108
131
  path.curve(to: NSPoint(x: 52 * sx, y: (64 - 8) * sy),
109
132
  controlPoint1: NSPoint(x: 46 * sx, y: (64 - 11) * sy),
110
133
  controlPoint2: NSPoint(x: 49 * sx, y: (64 - 8) * sy))
111
- // C55,8 57.5,13 60,22
112
134
  path.curve(to: NSPoint(x: 60 * sx, y: (64 - 22) * sy),
113
135
  controlPoint1: NSPoint(x: 55 * sx, y: (64 - 8) * sy),
114
136
  controlPoint2: NSPoint(x: 57.5 * sx, y: (64 - 13) * sy))
@@ -10,8 +10,11 @@ files:
10
10
  mac:
11
11
  target: dmg
12
12
  category: public.app-category.developer-tools
13
- icon: resources/icon.png
13
+ icon: resources/icon.icns
14
14
  identity: null
15
15
  darkModeSupport: true
16
+ minimumSystemVersion: '14.0'
17
+ extendInfo:
18
+ LSUIElement: true
16
19
  extraMetadata:
17
20
  main: electron/main.cjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangferry-dev/tokendash",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "description": "Token Usage Analytics Dashboard",
6
6
  "publishConfig": {
Binary file
Binary file