rippletide-package 0.1.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.
@@ -0,0 +1,601 @@
1
+ ObjC.import("Cocoa")
2
+
3
+ const args = $.NSProcessInfo.processInfo.arguments
4
+ const statePath = ObjC.unwrap(args.objectAtIndex(args.count - 1))
5
+ const app = $.NSApplication.sharedApplication
6
+ const width = 200
7
+ const height = 178
8
+ const detailWidth = 380
9
+ const detailHeight = 304
10
+
11
+ function readState() {
12
+ const text = $.NSString.stringWithContentsOfFileEncodingError(statePath, $.NSUTF8StringEncoding, null)
13
+ if (!text) return null
14
+ try {
15
+ return JSON.parse(ObjC.unwrap(text))
16
+ } catch (_) {
17
+ return null
18
+ }
19
+ }
20
+
21
+ app.setActivationPolicy($.NSApplicationActivationPolicyAccessory)
22
+
23
+ const screen = $.NSScreen.mainScreen.visibleFrame
24
+ const rect = $.NSMakeRect(
25
+ screen.origin.x + screen.size.width - width - 24,
26
+ screen.origin.y + screen.size.height - height - 24,
27
+ width,
28
+ height,
29
+ )
30
+ const style = $.NSWindowStyleMaskTitled | $.NSWindowStyleMaskClosable | $.NSWindowStyleMaskNonactivatingPanel
31
+ const win = $.NSPanel.alloc.initWithContentRectStyleMaskBackingDefer(rect, style, $.NSBackingStoreBuffered, false)
32
+
33
+ win.setLevel($.NSFloatingWindowLevel)
34
+ win.setCollectionBehavior(
35
+ $.NSWindowCollectionBehaviorCanJoinAllSpaces | $.NSWindowCollectionBehaviorFullScreenAuxiliary,
36
+ )
37
+ win.setMovableByWindowBackground(true)
38
+ win.setTitleVisibility($.NSWindowTitleHidden)
39
+ win.setTitlebarAppearsTransparent(true)
40
+ win.setAppearance($.NSAppearance.appearanceNamed($.NSAppearanceNameDarkAqua))
41
+ win.setOpaque(false)
42
+
43
+ const G = [0.36, 0.86, 0.52] // Codex green
44
+ const CLAUDE = [0.95, 0.55, 0.28]
45
+ const UNKNOWN = [0.72, 0.72, 0.72]
46
+ const RED = [1.0, 0.22, 0.24]
47
+ const GOLD = [1.0, 0.82, 0.3] // milestone celebration accent
48
+ const POPUP_BG = [0.13, 0.13, 0.13]
49
+ const POPUP_RISK_BG = [0.62, 0.03, 0.04]
50
+ const RING_BG = [0.19, 0.19, 0.19]
51
+ const RING_RISK_BG = [0.42, 0.02, 0.03]
52
+ const DIM = [0.55, 0.55, 0.55]
53
+ const rgb = (c, a) => $.NSColor.colorWithCalibratedRedGreenBlueAlpha(c[0], c[1], c[2], a)
54
+ const lerp = (a, b, t) => a + (b - a) * t
55
+ const mixColor = (a, b, t) => [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]
56
+ const dim = rgb(DIM, 1.0)
57
+
58
+ win.setBackgroundColor(rgb(POPUP_BG, 1.0))
59
+
60
+ function providerColor(provider) {
61
+ if (provider === "claude") return CLAUDE
62
+ if (provider === "codex") return G
63
+ return UNKNOWN
64
+ }
65
+
66
+ // Use NSBox for colored shapes: it takes NSColor directly. (Setting a CGColor on
67
+ // a CALayer through JXA crashes, so layer color setters are off-limits here.)
68
+ function box(frame) {
69
+ const b = $.NSBox.alloc.initWithFrame(frame)
70
+ b.setBoxType($.NSBoxCustom)
71
+ b.setTitlePosition($.NSNoTitle)
72
+ b.setBorderWidth(0)
73
+ return b
74
+ }
75
+
76
+ // A circular gauge whose green core grows to fill the ring as calls add up.
77
+ const D = 104
78
+ const cx = width / 2
79
+ const cy = 42 + D / 2
80
+
81
+ const ring = box($.NSMakeRect(cx - D / 2, cy - D / 2, D, D))
82
+ ring.setCornerRadius(D / 2)
83
+ ring.setFillColor(rgb(RING_BG, 1))
84
+ ring.setBorderColor(rgb(G, 0.5))
85
+ ring.setBorderWidth(1.5)
86
+ win.contentView.addSubview(ring)
87
+
88
+ const disc = box($.NSMakeRect(cx, cy, 0, 0))
89
+ disc.setFillColor(rgb(G, 1))
90
+ win.contentView.addSubview(disc)
91
+
92
+ // A thin outline that expands past the ring and fades out on every call.
93
+ const rippleRing = box($.NSMakeRect(cx - D / 2, cy - D / 2, D, D))
94
+ rippleRing.setFillColor($.NSColor.clearColor)
95
+ rippleRing.setBorderWidth(2)
96
+ rippleRing.setBorderColor(rgb(G, 0))
97
+ rippleRing.setCornerRadius(D / 2)
98
+ win.contentView.addSubview(rippleRing)
99
+
100
+ function label(y, font, color) {
101
+ const f = $.NSTextField.alloc.initWithFrame($.NSMakeRect(0, y, width, 20))
102
+ f.setBezeled(false)
103
+ f.setDrawsBackground(false)
104
+ f.setEditable(false)
105
+ f.setSelectable(false)
106
+ f.setFont(font)
107
+ f.setTextColor(color)
108
+ win.contentView.addSubview(f)
109
+ return f
110
+ }
111
+
112
+ const number = label(76, $.NSFont.monospacedDigitSystemFontOfSizeWeight(30, $.NSFontWeightBold), $.NSColor.whiteColor)
113
+ const caption = label(16, $.NSFont.systemFontOfSizeWeight(10.5, $.NSFontWeightSemibold), dim)
114
+
115
+ const detailRect = $.NSMakeRect(
116
+ screen.origin.x + screen.size.width - detailWidth - 24,
117
+ Math.max(screen.origin.y + 24, rect.origin.y - detailHeight - 12),
118
+ detailWidth,
119
+ detailHeight,
120
+ )
121
+ const detail = $.NSPanel.alloc.initWithContentRectStyleMaskBackingDefer(detailRect, style, $.NSBackingStoreBuffered, false)
122
+ detail.setLevel($.NSFloatingWindowLevel)
123
+ detail.setCollectionBehavior(
124
+ $.NSWindowCollectionBehaviorCanJoinAllSpaces | $.NSWindowCollectionBehaviorFullScreenAuxiliary,
125
+ )
126
+ detail.setMovableByWindowBackground(true)
127
+ detail.setTitle("rippletide counter")
128
+ detail.setAppearance($.NSAppearance.appearanceNamed($.NSAppearanceNameDarkAqua))
129
+ detail.setOpaque(false)
130
+ detail.setBackgroundColor($.NSColor.colorWithCalibratedWhiteAlpha(0.11, 0.98))
131
+ detail.setReleasedWhenClosed(false)
132
+
133
+ function detailField(frame, font, color) {
134
+ const f = $.NSTextField.alloc.initWithFrame(frame)
135
+ f.setBezeled(false)
136
+ f.setDrawsBackground(false)
137
+ f.setEditable(false)
138
+ f.setSelectable(false)
139
+ f.setFont(font)
140
+ f.setTextColor(color)
141
+ detail.contentView.addSubview(f)
142
+ return f
143
+ }
144
+
145
+ const detailTitle = detailField(
146
+ $.NSMakeRect(22, detailHeight - 42, detailWidth - 44, 24),
147
+ $.NSFont.systemFontOfSizeWeight(17, $.NSFontWeightBold),
148
+ $.NSColor.whiteColor,
149
+ )
150
+ const detailSub = detailField(
151
+ $.NSMakeRect(22, detailHeight - 63, detailWidth - 44, 18),
152
+ $.NSFont.systemFontOfSizeWeight(11, $.NSFontWeightMedium),
153
+ dim,
154
+ )
155
+ const legendCodex = detailField(
156
+ $.NSMakeRect(22, detailHeight - 84, 54, 16),
157
+ $.NSFont.systemFontOfSizeWeight(10, $.NSFontWeightSemibold),
158
+ rgb(G, 1),
159
+ )
160
+ legendCodex.setStringValue("Codex")
161
+ const legendClaude = detailField(
162
+ $.NSMakeRect(82, detailHeight - 84, 82, 16),
163
+ $.NSFont.systemFontOfSizeWeight(10, $.NSFontWeightSemibold),
164
+ rgb(CLAUDE, 1),
165
+ )
166
+ legendClaude.setStringValue("Claude")
167
+ const legendRisky = detailField(
168
+ $.NSMakeRect(166, detailHeight - 84, 76, 16),
169
+ $.NSFont.systemFontOfSizeWeight(10, $.NSFontWeightSemibold),
170
+ rgb(RED, 1),
171
+ )
172
+ legendRisky.setStringValue("Risky")
173
+
174
+ const rowCount = 8
175
+ const rowTop = detailHeight - 108
176
+ const rowGap = 27
177
+ const barX = 138
178
+ const barW = 168
179
+ const rows = []
180
+ for (let i = 0; i < rowCount; i++) {
181
+ const y = rowTop - i * rowGap
182
+ const name = detailField(
183
+ $.NSMakeRect(22, y - 1, 108, 18),
184
+ $.NSFont.systemFontOfSizeWeight(10.5, $.NSFontWeightMedium),
185
+ $.NSColor.colorWithCalibratedWhiteAlpha(0.82, 1.0),
186
+ )
187
+ const track = box($.NSMakeRect(barX, y + 4, barW, 7))
188
+ track.setCornerRadius(3.5)
189
+ track.setFillColor($.NSColor.colorWithCalibratedWhiteAlpha(0.22, 1.0))
190
+ detail.contentView.addSubview(track)
191
+ const bar = box($.NSMakeRect(barX, y + 4, 0, 7))
192
+ bar.setCornerRadius(3.5)
193
+ bar.setFillColor(rgb(G, 1))
194
+ detail.contentView.addSubview(bar)
195
+ const riskBar = box($.NSMakeRect(barX, y + 4, 0, 7))
196
+ riskBar.setCornerRadius(3.5)
197
+ riskBar.setFillColor(rgb(RED, 1))
198
+ detail.contentView.addSubview(riskBar)
199
+ const value = detailField(
200
+ $.NSMakeRect(barX + barW + 10, y - 1, 48, 18),
201
+ $.NSFont.monospacedDigitSystemFontOfSizeWeight(10.5, $.NSFontWeightSemibold),
202
+ $.NSColor.whiteColor,
203
+ )
204
+ rows.push({ name, track, bar, riskBar, value })
205
+ }
206
+
207
+ function toggleDetails() {
208
+ const now = Date.now()
209
+ if (now - lastToggleAt < 250) return
210
+ lastToggleAt = now
211
+ if (detail.isVisible) {
212
+ detail.orderOut(null)
213
+ } else {
214
+ detail.makeKeyAndOrderFront(null)
215
+ }
216
+ }
217
+
218
+ function toggleRiskDistribution() {
219
+ const now = Date.now()
220
+ if (now - lastRiskToggleAt < 250) return
221
+ lastRiskToggleAt = now
222
+ detailMode = detailMode === "models" ? "risks" : detailMode === "risks" ? "reroutes" : "models"
223
+ shownModelsKey = null
224
+ const state = readState()
225
+ const calls = state && typeof state.calls === "number" ? state.calls : 0
226
+ updateDetails(state, calls)
227
+ }
228
+
229
+ ObjC.registerSubclass({
230
+ name: "XrayClickView",
231
+ superclass: "NSView",
232
+ methods: {
233
+ "acceptsFirstMouse:": {
234
+ types: ["bool", ["id"]],
235
+ implementation: () => true,
236
+ },
237
+ "mouseDown:": {
238
+ types: ["void", ["id"]],
239
+ implementation: () => toggleDetails(),
240
+ },
241
+ },
242
+ })
243
+
244
+ ObjC.registerSubclass({
245
+ name: "XrayRiskClickView",
246
+ superclass: "NSView",
247
+ methods: {
248
+ "acceptsFirstMouse:": {
249
+ types: ["bool", ["id"]],
250
+ implementation: () => true,
251
+ },
252
+ "mouseDown:": {
253
+ types: ["void", ["id"]],
254
+ implementation: () => toggleRiskDistribution(),
255
+ },
256
+ },
257
+ })
258
+
259
+ const clickView = $.XrayClickView.alloc.initWithFrame($.NSMakeRect(0, 0, width, height))
260
+ clickView.setAutoresizingMask($.NSViewWidthSizable | $.NSViewHeightSizable)
261
+ win.contentView.addSubview(clickView)
262
+
263
+ const riskClickView = $.XrayRiskClickView.alloc.initWithFrame($.NSMakeRect(158, detailHeight - 92, 94, 30))
264
+ riskClickView.setToolTip("Cycle risk categories and rerouted calls")
265
+ detail.contentView.addSubview(riskClickView)
266
+
267
+ // Center by measuring the text and sizing the field to it.
268
+ function place(f, text, y, h) {
269
+ f.setStringValue(text)
270
+ const attrs = $.NSDictionary.dictionaryWithObjectForKey(f.font, $.NSFontAttributeName)
271
+ const w = Math.ceil($(text).sizeWithAttributes(attrs).width) + 6
272
+ f.setFrame($.NSMakeRect((width - w) / 2, y, w, h))
273
+ }
274
+
275
+ const K = 30 // the core approaches full as calls grow (about half-full around K calls)
276
+
277
+ let shownCalls = null
278
+ let shownRisky = null
279
+ let shownPrefix = null
280
+ let shownModelsKey = null
281
+ let shownDetailCalls = null
282
+ let shownDetailRisky = null
283
+ let shownDetailRerouted = null
284
+ let lastToggleAt = 0
285
+ let lastRiskToggleAt = 0
286
+ let detailMode = "models"
287
+ let displayFrac = 0
288
+ let displayCalls = 0 // the number rolls toward the true count like an odometer
289
+ let renderedInt = -1
290
+ let renderedCaption = null
291
+ let flash = 0 // 1 on a new call, decays each frame
292
+ let riskyFlash = 0 // risky responses briefly override the green pulse with red
293
+ let milestoneFlash = 0 // 1 when a milestone is crossed, tints everything gold
294
+ let milestoneValue = 0
295
+ let ripplePulse = 0 // 1 on a new call, drives the expanding outline
296
+ let rippleColor = G
297
+ let breathT = 0 // frame counter for the idle breathing sine
298
+
299
+ function truncate(s, n) {
300
+ s = String(s)
301
+ return s.length <= n ? s : s.slice(0, Math.max(0, n - 3)) + "..."
302
+ }
303
+
304
+ // Compact token count (1234 -> 1.2k, 3_400_000 -> 3.4M) and USD cost for the caption.
305
+ function formatTokens(n) {
306
+ n = Number(n) || 0
307
+ if (n >= 1e6) return (n / 1e6).toFixed(n >= 1e7 ? 0 : 1) + "M"
308
+ if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + "k"
309
+ return String(Math.round(n))
310
+ }
311
+ function formatUsd(n) {
312
+ n = Number(n) || 0
313
+ if (n >= 100) return "$" + n.toFixed(0)
314
+ if (n >= 1) return "$" + n.toFixed(2)
315
+ if (n > 0) return "$" + n.toFixed(3)
316
+ return "$0"
317
+ }
318
+
319
+ // Highest milestone crossed between two call counts (0 if none). Fixed stops
320
+ // early on, then every 1000 so the celebration keeps recurring on long runs.
321
+ function milestoneCrossed(prev, cur) {
322
+ const stops = [10, 25, 50, 100, 250, 500, 1000]
323
+ let hit = 0
324
+ for (let i = 0; i < stops.length; i++) {
325
+ if (prev < stops[i] && cur >= stops[i]) hit = stops[i]
326
+ }
327
+ if (cur >= 1000 && Math.floor(cur / 1000) > Math.floor(prev / 1000)) {
328
+ hit = Math.floor(cur / 1000) * 1000
329
+ }
330
+ return hit
331
+ }
332
+
333
+ function modelProvider(name, providers) {
334
+ const explicit = providers && providers[name]
335
+ if (explicit) return String(explicit)
336
+ const lower = String(name).toLowerCase()
337
+ if (lower.includes("claude")) return "claude"
338
+ if (lower.includes("gpt") || lower.includes("codex") || lower.includes("o3") || lower.includes("o4")) return "codex"
339
+ return "unknown"
340
+ }
341
+
342
+ function modelEntries(state) {
343
+ const models = state && state.models && typeof state.models === "object" ? state.models : {}
344
+ const providers = state && state.providers && typeof state.providers === "object" ? state.providers : {}
345
+ const riskyModels = state && state.riskyModels && typeof state.riskyModels === "object" ? state.riskyModels : {}
346
+ return Object.keys(Object.assign({}, models, riskyModels))
347
+ .map((name) => {
348
+ const value = Number(models[name]) || 0
349
+ const risky = Math.max(0, Math.min(value, Number(riskyModels[name]) || 0))
350
+ return [name, value, modelProvider(name, providers), risky]
351
+ })
352
+ .filter((entry) => entry[1] > 0)
353
+ .sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])))
354
+ }
355
+
356
+ function riskActionEntries(state) {
357
+ const actions = state && state.riskyActions && typeof state.riskyActions === "object" ? state.riskyActions : {}
358
+ return Object.keys(actions)
359
+ .map((name) => [name, Number(actions[name]) || 0])
360
+ .filter((entry) => entry[1] > 0)
361
+ .sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])))
362
+ }
363
+
364
+ function rerouteEntries(state) {
365
+ const reroutes = state && state.reroutes && typeof state.reroutes === "object" ? state.reroutes : {}
366
+ return Object.keys(reroutes)
367
+ .map((name) => [name.split("claude-").join(""), Number(reroutes[name]) || 0])
368
+ .filter((entry) => entry[1] > 0)
369
+ .sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])))
370
+ }
371
+
372
+ function updateDetails(state, calls) {
373
+ const risky = state && typeof state.risky === "number" ? state.risky : 0
374
+ const rerouted = state && typeof state.rerouted === "number" ? state.rerouted : 0
375
+ const entries =
376
+ detailMode === "risks" ? riskActionEntries(state) : detailMode === "reroutes" ? rerouteEntries(state) : modelEntries(state)
377
+ const key = `${detailMode}:${JSON.stringify(entries)}`
378
+ if (key === shownModelsKey && calls === shownDetailCalls && risky === shownDetailRisky && rerouted === shownDetailRerouted) return
379
+ shownModelsKey = key
380
+ shownDetailCalls = calls
381
+ shownDetailRisky = risky
382
+ shownDetailRerouted = rerouted
383
+
384
+ if (detailMode === "risks") {
385
+ updateRiskDetails(entries, risky)
386
+ } else if (detailMode === "reroutes") {
387
+ updateRerouteDetails(entries, rerouted)
388
+ } else {
389
+ updateModelDetails(entries, calls, risky, rerouted)
390
+ }
391
+ }
392
+
393
+ function clearRow(i) {
394
+ rows[i].name.setStringValue("")
395
+ rows[i].value.setStringValue("")
396
+ rows[i].bar.setFrame($.NSMakeRect(barX, rowTop - i * rowGap + 4, 0, 7))
397
+ rows[i].riskBar.setFrame($.NSMakeRect(barX, rowTop - i * rowGap + 4, 0, 7))
398
+ }
399
+
400
+ function updateModelDetails(entries, calls, risky, rerouted) {
401
+ detailTitle.setStringValue("LLM calls by model")
402
+ const reroutedNote = rerouted > 0 ? ` · ${rerouted} rerouted` : ""
403
+ detailSub.setStringValue(entries.length ? `${calls} total · ${risky} risky${reroutedNote} · click Risky for more` : `${risky} risky${reroutedNote} · no model calls yet`)
404
+ legendCodex.setFrame($.NSMakeRect(22, detailHeight - 84, 54, 16))
405
+ legendCodex.setStringValue("Codex")
406
+ legendCodex.setTextColor(rgb(G, 1))
407
+ legendClaude.setStringValue("Claude")
408
+ legendClaude.setTextColor(rgb(CLAUDE, 1))
409
+ legendRisky.setStringValue("Risky")
410
+ legendRisky.setTextColor(rgb(RED, 1))
411
+
412
+ const max = entries.length ? entries[0][1] : 1
413
+ for (let i = 0; i < rowCount; i++) {
414
+ const entry = entries[i]
415
+ if (!entry) {
416
+ clearRow(i)
417
+ continue
418
+ }
419
+ const [name, value, provider, riskyValue] = entry
420
+ const y = rowTop - i * rowGap
421
+ const w = Math.max(4, Math.round((value / max) * barW))
422
+ const riskW = riskyValue > 0 ? Math.max(4, Math.round((riskyValue / value) * w)) : 0
423
+ rows[i].name.setStringValue(truncate(name, 18))
424
+ rows[i].value.setStringValue(riskyValue > 0 ? `${riskyValue}/${value}` : String(value))
425
+ rows[i].bar.setFillColor(rgb(providerColor(provider), 1))
426
+ rows[i].bar.setFrame($.NSMakeRect(barX, y + 4, w, 7))
427
+ rows[i].riskBar.setFrame($.NSMakeRect(barX + w - riskW, y + 4, riskW, 7))
428
+ }
429
+ }
430
+
431
+ function updateRiskDetails(entries, risky) {
432
+ // Header sums the ACTION bars (a call can flag several), which differs from the
433
+ // risky-CALL count shown on the compact window; label it as actions to avoid confusion.
434
+ const actionTotal = entries.reduce((sum, entry) => sum + (Number(entry[1]) || 0), 0)
435
+ detailTitle.setStringValue("Risky actions by category")
436
+ detailSub.setStringValue(entries.length ? `${actionTotal} risky actions · ${risky} risky calls · click Risky for reroutes` : "No risk categories yet · click Risky for reroutes")
437
+ legendCodex.setFrame($.NSMakeRect(22, detailHeight - 84, 54, 16))
438
+ legendCodex.setStringValue("Categories")
439
+ legendCodex.setTextColor(rgb(RED, 1))
440
+ legendClaude.setStringValue("")
441
+ legendRisky.setStringValue("Risky")
442
+ legendRisky.setTextColor(rgb(RED, 1))
443
+
444
+ const max = entries.length ? entries[0][1] : 1
445
+ for (let i = 0; i < rowCount; i++) {
446
+ const entry = entries[i]
447
+ if (!entry) {
448
+ clearRow(i)
449
+ continue
450
+ }
451
+ const [name, value] = entry
452
+ const y = rowTop - i * rowGap
453
+ const w = Math.max(4, Math.round((value / max) * barW))
454
+ rows[i].name.setStringValue(truncate(name, 18))
455
+ rows[i].value.setStringValue(String(value))
456
+ rows[i].bar.setFillColor(rgb(RED, 1))
457
+ rows[i].bar.setFrame($.NSMakeRect(barX, y + 4, w, 7))
458
+ rows[i].riskBar.setFrame($.NSMakeRect(barX, y + 4, 0, 7))
459
+ }
460
+ }
461
+
462
+ // Calls answered by a different model than the one requested. Row names read
463
+ // "requested → served" with the shared claude- prefix dropped to fit.
464
+ function updateRerouteDetails(entries, rerouted) {
465
+ detailTitle.setStringValue("Rerouted calls by model")
466
+ detailSub.setStringValue(entries.length ? `${rerouted} answered by another model · click Risky to return` : "No reroutes detected yet · click Risky to return")
467
+ legendCodex.setFrame($.NSMakeRect(22, detailHeight - 84, 130, 16))
468
+ legendCodex.setStringValue("Requested → served")
469
+ legendCodex.setTextColor(rgb(GOLD, 1))
470
+ legendClaude.setStringValue("")
471
+ legendRisky.setStringValue("Risky")
472
+ legendRisky.setTextColor(rgb(RED, 1))
473
+
474
+ const max = entries.length ? entries[0][1] : 1
475
+ for (let i = 0; i < rowCount; i++) {
476
+ const entry = entries[i]
477
+ if (!entry) {
478
+ clearRow(i)
479
+ continue
480
+ }
481
+ const [name, value] = entry
482
+ const y = rowTop - i * rowGap
483
+ const w = Math.max(4, Math.round((value / max) * barW))
484
+ rows[i].name.setStringValue(truncate(name, 18))
485
+ rows[i].value.setStringValue(String(value))
486
+ rows[i].bar.setFillColor(rgb(GOLD, 1))
487
+ rows[i].bar.setFrame($.NSMakeRect(barX, y + 4, w, 7))
488
+ rows[i].riskBar.setFrame($.NSMakeRect(barX, y + 4, 0, 7))
489
+ }
490
+ }
491
+
492
+ function tick() {
493
+ if (!win.isVisible) return app.terminate(null)
494
+ const state = readState()
495
+ const calls = state && typeof state.calls === "number" ? state.calls : 0
496
+ const risky = state && typeof state.risky === "number" ? state.risky : 0
497
+ const prefix = state && state.label ? String(state.label).toUpperCase() + " · " : ""
498
+ if (calls !== shownCalls || risky !== shownRisky || prefix !== shownPrefix) {
499
+ const prevCalls = shownCalls
500
+ const gainedCall = prevCalls !== null && calls > prevCalls
501
+ const gainedRisky = shownRisky !== null && risky > shownRisky
502
+ const milestone = gainedCall ? milestoneCrossed(prevCalls, calls) : 0
503
+ if (gainedCall) {
504
+ flash = 1
505
+ ripplePulse = 1
506
+ rippleColor = G
507
+ }
508
+ if (milestone) {
509
+ milestoneFlash = 1
510
+ milestoneValue = milestone
511
+ rippleColor = GOLD
512
+ }
513
+ if (gainedRisky) {
514
+ riskyFlash = 1
515
+ ripplePulse = 1
516
+ if (!milestone) rippleColor = RED
517
+ }
518
+ shownCalls = calls
519
+ shownRisky = risky
520
+ shownPrefix = prefix
521
+ }
522
+ updateDetails(state, calls)
523
+
524
+ // 1. Odometer: roll the shown number toward the true count.
525
+ displayCalls += (calls - displayCalls) * 0.16
526
+ if (Math.abs(calls - displayCalls) < 0.5) displayCalls = calls
527
+ const shownInt = Math.round(displayCalls)
528
+ if (shownInt !== renderedInt) {
529
+ place(number, String(shownInt), 76, 40)
530
+ renderedInt = shownInt
531
+ }
532
+
533
+ // 3. Milestone banner briefly replaces the caption, then reverts on decay.
534
+ const totalTokens = state && state.tokens && typeof state.tokens.totalTokens === "number" ? state.tokens.totalTokens : 0
535
+ const costUsd = state && typeof state.costUsd === "number" ? state.costUsd : 0
536
+ const captionText =
537
+ milestoneFlash > 0.3 ? `MILESTONE · ${milestoneValue} CALLS` : `${prefix}${formatTokens(totalTokens)} TOK · ${formatUsd(costUsd)}`
538
+ if (captionText !== renderedCaption) {
539
+ place(caption, captionText, 16, 15)
540
+ renderedCaption = captionText
541
+ }
542
+
543
+ const pulse = Math.max(flash, riskyFlash, milestoneFlash)
544
+
545
+ // 2. Idle breathing: a slow sine on the core (and ring glow) when nothing is
546
+ // happening, so the popup stays alive between calls. It fades out under pulses.
547
+ breathT += 1
548
+ const idle = Math.max(0, 1 - pulse * 4)
549
+ const breath = Math.sin(breathT * 0.07) * 0.014 * idle
550
+
551
+ const target = calls / (calls + K)
552
+ displayFrac += (target - displayFrac) * 0.18 // ease the core toward its size
553
+ const s = Math.max(0, Math.min(1, displayFrac + pulse * 0.06 + breath)) * D // surge on a new call
554
+ disc.setFrame($.NSMakeRect(cx - s / 2, cy - s / 2, s, s))
555
+ disc.setCornerRadius(s / 2)
556
+ const callFill = [lerp(G[0], 0.7, flash), lerp(G[1], 1, flash), lerp(G[2], 0.8, flash)]
557
+ const fill = mixColor(mixColor(callFill, RED, riskyFlash), GOLD, milestoneFlash)
558
+ const popupBg = mixColor(POPUP_BG, POPUP_RISK_BG, riskyFlash)
559
+ const ringBg = mixColor(RING_BG, RING_RISK_BG, riskyFlash)
560
+ // Tint the number on call/risky pulses but not on the gold milestone, so it
561
+ // stays legible over the gold core during the burst.
562
+ const numPulse = Math.max(flash, riskyFlash)
563
+ const numTint = mixColor(callFill, RED, riskyFlash)
564
+ const textColor = [lerp(1, numTint[0], numPulse), lerp(1, numTint[1], numPulse), lerp(1, numTint[2], numPulse)]
565
+ win.setBackgroundColor(rgb(popupBg, 1.0))
566
+ ring.setFillColor(rgb(ringBg, 1))
567
+ disc.setFillColor(rgb(fill, 1))
568
+ ring.setBorderColor(rgb(fill, Math.min(1, lerp(0.45, 1, pulse) + breath * 6)))
569
+ number.setTextColor(rgb(textColor, 1))
570
+ caption.setTextColor(rgb(mixColor(mixColor(DIM, [1, 0.88, 0.88], riskyFlash), GOLD, milestoneFlash), 1))
571
+
572
+ // 4. Ring ripple: expand the outline past the ring and fade it as it decays.
573
+ if (ripplePulse > 0.02) {
574
+ const rs = lerp(s, D * 1.28, 1 - ripplePulse)
575
+ rippleRing.setFrame($.NSMakeRect(cx - rs / 2, cy - rs / 2, rs, rs))
576
+ rippleRing.setCornerRadius(rs / 2)
577
+ rippleRing.setBorderColor(rgb(rippleColor, ripplePulse * 0.55))
578
+ } else {
579
+ rippleRing.setBorderColor(rgb(rippleColor, 0))
580
+ }
581
+
582
+ flash *= 0.88
583
+ riskyFlash *= 0.84
584
+ milestoneFlash *= 0.94
585
+ ripplePulse *= 0.9
586
+ if (flash < 0.02) flash = 0
587
+ if (riskyFlash < 0.02) riskyFlash = 0
588
+ if (milestoneFlash < 0.02) milestoneFlash = 0
589
+ if (ripplePulse < 0.02) ripplePulse = 0
590
+ }
591
+
592
+ win.makeKeyAndOrderFront(null)
593
+ tick()
594
+
595
+ $.NSTimer.scheduledTimerWithTimeIntervalRepeatsBlock(0.033, true, () => {
596
+ try {
597
+ tick()
598
+ } catch (_) {}
599
+ })
600
+
601
+ app.run()
@@ -0,0 +1,22 @@
1
+ // The `counter` feature entry point — `rippletide counter`.
2
+ // Starts the Codex live counter: a floating sticky window that tallies LLM calls, tokens,
3
+ // and estimated cost in real time. macOS for the window; counting works anywhere.
4
+ import { run } from "./counter.js";
5
+
6
+ const HELP = `counter — a live Codex sticky window: LLM calls, tokens, and estimated cost.
7
+
8
+ Usage: rippletide counter
9
+
10
+ Run it, then use Codex (the desktop app or the CLI). A floating window shows the running
11
+ call count, total tokens, and estimated USD cost. It points Codex's openai_base_url at a
12
+ local proxy while running and restores your Codex config on exit. Ctrl-C (or close the
13
+ window) to stop. macOS only for the window.`;
14
+
15
+ export async function main(argv = []) {
16
+ const sub = argv[0];
17
+ if (sub === "-h" || sub === "--help" || sub === "help") {
18
+ console.log(HELP);
19
+ return 0;
20
+ }
21
+ return await run();
22
+ }