@hirra/vibemeter 0.1.2 → 0.1.4

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.
Files changed (262) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +7 -1
  3. package/.next/build-manifest.json +7 -8
  4. package/.next/fallback-build-manifest.json +3 -3
  5. package/.next/prerender-manifest.json +16 -12
  6. package/.next/required-server-files.js +5 -4
  7. package/.next/required-server-files.json +5 -4
  8. package/.next/routes-manifest.json +36 -0
  9. package/.next/server/app/_global-error/page/build-manifest.json +4 -5
  10. package/.next/server/app/_global-error/page.js +4 -4
  11. package/.next/server/app/_global-error/page.js.nft.json +1 -1
  12. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  13. package/.next/server/app/_global-error.html +1 -1
  14. package/.next/server/app/_global-error.rsc +1 -1
  15. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  16. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  17. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  18. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  19. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  20. package/.next/server/app/_not-found/page/build-manifest.json +4 -5
  21. package/.next/server/app/_not-found/page.js +5 -4
  22. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  23. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  24. package/.next/server/app/admin/page/build-manifest.json +4 -5
  25. package/.next/server/app/admin/page.js +7 -5
  26. package/.next/server/app/admin/page.js.nft.json +1 -1
  27. package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
  28. package/.next/server/app/api/codex-accounts/route.js +1 -1
  29. package/.next/server/app/api/codex-accounts/route.js.nft.json +1 -1
  30. package/.next/server/app/api/float/route/app-paths-manifest.json +3 -0
  31. package/.next/server/app/api/float/route/build-manifest.json +9 -0
  32. package/.next/server/app/api/float/route/server-reference-manifest.json +4 -0
  33. package/.next/server/app/api/float/route.js +8 -0
  34. package/.next/server/app/api/float/route.js.map +5 -0
  35. package/.next/server/app/api/float/route.js.nft.json +1 -0
  36. package/.next/server/app/api/float/route_client-reference-manifest.js +3 -0
  37. package/.next/server/app/api/import-sessions/route.js.nft.json +1 -1
  38. package/.next/server/app/api/sessions/[id]/tags/route.js +1 -1
  39. package/.next/server/app/api/sessions/[id]/tags/route.js.nft.json +1 -1
  40. package/.next/server/app/api/sessions/route.js +1 -1
  41. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  42. package/.next/server/app/api/settings/alerts/route/app-paths-manifest.json +3 -0
  43. package/.next/server/app/api/settings/alerts/route/build-manifest.json +9 -0
  44. package/.next/server/app/api/settings/alerts/route/server-reference-manifest.json +4 -0
  45. package/.next/server/app/api/settings/alerts/route.js +9 -0
  46. package/.next/server/app/api/settings/alerts/route.js.map +5 -0
  47. package/.next/server/app/api/settings/alerts/route.js.nft.json +1 -0
  48. package/.next/server/app/api/settings/alerts/route_client-reference-manifest.js +3 -0
  49. package/.next/server/app/api/settings/notify/route/app-paths-manifest.json +3 -0
  50. package/.next/server/app/api/settings/notify/route/build-manifest.json +9 -0
  51. package/.next/server/app/api/settings/notify/route/server-reference-manifest.json +4 -0
  52. package/.next/server/app/api/settings/notify/route.js +7 -0
  53. package/.next/server/app/api/settings/notify/route.js.map +5 -0
  54. package/.next/server/app/api/settings/notify/route.js.nft.json +1 -0
  55. package/.next/server/app/api/settings/notify/route_client-reference-manifest.js +3 -0
  56. package/.next/server/app/api/usage/route.js +1 -1
  57. package/.next/server/app/api/usage/route.js.nft.json +1 -1
  58. package/.next/server/app/float/page/app-paths-manifest.json +3 -0
  59. package/.next/server/app/float/page/build-manifest.json +16 -0
  60. package/.next/server/app/float/page/next-font-manifest.json +11 -0
  61. package/.next/server/app/float/page/react-loadable-manifest.json +1 -0
  62. package/.next/server/app/float/page/server-reference-manifest.json +4 -0
  63. package/.next/server/app/float/page.js +15 -0
  64. package/.next/server/app/float/page.js.map +5 -0
  65. package/.next/server/app/float/page.js.nft.json +1 -0
  66. package/.next/server/app/float/page_client-reference-manifest.js +3 -0
  67. package/.next/server/app/install.sh/route/app-paths-manifest.json +3 -0
  68. package/.next/server/app/install.sh/route/build-manifest.json +9 -0
  69. package/.next/server/app/install.sh/route/server-reference-manifest.json +4 -0
  70. package/.next/server/app/install.sh/route.js +6 -0
  71. package/.next/server/app/install.sh/route.js.map +5 -0
  72. package/.next/server/app/install.sh/route.js.nft.json +1 -0
  73. package/.next/server/app/install.sh/route_client-reference-manifest.js +3 -0
  74. package/.next/server/app/install.sh.body +108 -0
  75. package/.next/server/app/install.sh.meta +1 -0
  76. package/.next/server/app/page/build-manifest.json +4 -5
  77. package/.next/server/app/page.js +8 -5
  78. package/.next/server/app/page.js.nft.json +1 -1
  79. package/.next/server/app/page_client-reference-manifest.js +1 -1
  80. package/.next/server/app/settings/page/app-paths-manifest.json +3 -0
  81. package/.next/server/app/settings/page/build-manifest.json +16 -0
  82. package/.next/server/app/settings/page/next-font-manifest.json +11 -0
  83. package/.next/server/app/settings/page/react-loadable-manifest.json +1 -0
  84. package/.next/server/app/settings/page/server-reference-manifest.json +4 -0
  85. package/.next/server/app/settings/page.js +17 -0
  86. package/.next/server/app/settings/page.js.map +5 -0
  87. package/.next/server/app/settings/page.js.nft.json +1 -0
  88. package/.next/server/app/settings/page_client-reference-manifest.js +3 -0
  89. package/.next/server/app-paths-manifest.json +7 -1
  90. package/.next/server/chunks/[externals]__0_i~3ox._.js +3 -0
  91. package/.next/server/chunks/[externals]__0_i~3ox._.js.map +1 -0
  92. package/.next/server/chunks/[externals]__13p_1zh._.js +3 -0
  93. package/.next/server/chunks/[externals]__13p_1zh._.js.map +1 -0
  94. package/.next/server/chunks/[root-of-the-server]__01t2c3w._.js +38 -0
  95. package/.next/server/chunks/[root-of-the-server]__01t2c3w._.js.map +1 -0
  96. package/.next/server/chunks/{[root-of-the-server]__0g0u0lm._.js → [root-of-the-server]__024yzee._.js} +2 -2
  97. package/.next/server/chunks/{[root-of-the-server]__00q0o~z._.js → [root-of-the-server]__082iwfa._.js} +2 -2
  98. package/.next/server/chunks/[root-of-the-server]__0chedn~._.js +1 -1
  99. package/.next/server/chunks/[root-of-the-server]__0mwf0bn._.js +38 -0
  100. package/.next/server/chunks/[root-of-the-server]__0mwf0bn._.js.map +1 -0
  101. package/.next/server/chunks/{[root-of-the-server]__0-74syk._.js → [root-of-the-server]__0ru3_it._.js} +2 -2
  102. package/.next/server/chunks/[root-of-the-server]__0u6y_k1._.js +111 -0
  103. package/.next/server/chunks/[root-of-the-server]__0u6y_k1._.js.map +1 -0
  104. package/.next/server/chunks/{[root-of-the-server]__0866q87._.js → [root-of-the-server]__0xa4dzi._.js} +2 -2
  105. package/.next/server/chunks/[root-of-the-server]__13415c6._.js +96 -0
  106. package/.next/server/chunks/[root-of-the-server]__13415c6._.js.map +1 -0
  107. package/.next/server/chunks/[root-of-the-server]__13j_28o._.js +96 -0
  108. package/.next/server/chunks/[root-of-the-server]__13j_28o._.js.map +1 -0
  109. package/.next/server/chunks/_next-internal_server_app_api_float_route_actions_012j~jr.js +3 -0
  110. package/.next/server/chunks/_next-internal_server_app_api_float_route_actions_012j~jr.js.map +1 -0
  111. package/.next/server/chunks/_next-internal_server_app_api_settings_alerts_route_actions_0ydcyko.js +3 -0
  112. package/.next/server/chunks/_next-internal_server_app_api_settings_alerts_route_actions_0ydcyko.js.map +1 -0
  113. package/.next/server/chunks/_next-internal_server_app_api_settings_notify_route_actions_0-j35mb.js +3 -0
  114. package/.next/server/chunks/_next-internal_server_app_api_settings_notify_route_actions_0-j35mb.js.map +1 -0
  115. package/.next/server/chunks/_next-internal_server_app_install_sh_route_actions_0cj-6me.js +3 -0
  116. package/.next/server/chunks/_next-internal_server_app_install_sh_route_actions_0cj-6me.js.map +1 -0
  117. package/.next/server/chunks/node_modules_next_06f88ko._.js +19 -0
  118. package/.next/server/chunks/node_modules_next_06f88ko._.js.map +1 -0
  119. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_03jj5jj.js +18 -0
  120. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_03jj5jj.js.map +1 -0
  121. package/.next/server/chunks/src_12i1qk5._.js +3 -0
  122. package/.next/server/chunks/src_12i1qk5._.js.map +1 -0
  123. package/.next/server/chunks/src_lib_alerts_ticker_ts_0n6oy1d._.js +169 -0
  124. package/.next/server/chunks/src_lib_alerts_ticker_ts_0n6oy1d._.js.map +1 -0
  125. package/.next/server/chunks/ssr/[root-of-the-server]__03mt8o0._.js +3 -0
  126. package/.next/server/chunks/ssr/[root-of-the-server]__03mt8o0._.js.map +1 -0
  127. package/.next/server/chunks/ssr/{[root-of-the-server]__098zro9._.js → [root-of-the-server]__090jxzh._.js} +2 -2
  128. package/.next/server/chunks/ssr/[root-of-the-server]__090jxzh._.js.map +1 -0
  129. package/.next/server/chunks/ssr/{[root-of-the-server]__09hgk-7._.js → [root-of-the-server]__0c3j40u._.js} +2 -2
  130. package/.next/server/chunks/ssr/[root-of-the-server]__0c3j40u._.js.map +1 -0
  131. package/.next/server/chunks/ssr/[root-of-the-server]__0ot4nev._.js +3 -0
  132. package/.next/server/chunks/ssr/[root-of-the-server]__0ot4nev._.js.map +1 -0
  133. package/.next/server/chunks/ssr/[root-of-the-server]__0q_~l2m._.js +90 -0
  134. package/.next/server/chunks/ssr/[root-of-the-server]__0q_~l2m._.js.map +1 -0
  135. package/.next/server/chunks/ssr/{[root-of-the-server]__0cqes87._.js → [root-of-the-server]__0qt26ty._.js} +2 -2
  136. package/.next/server/chunks/ssr/[root-of-the-server]__0qt26ty._.js.map +1 -0
  137. package/.next/server/chunks/ssr/[root-of-the-server]__0s5.uhg._.js +55 -0
  138. package/.next/server/chunks/ssr/[root-of-the-server]__0s5.uhg._.js.map +1 -0
  139. package/.next/server/chunks/ssr/[root-of-the-server]__0x~-phx._.js +3 -0
  140. package/.next/server/chunks/ssr/[root-of-the-server]__0x~-phx._.js.map +1 -0
  141. package/.next/server/chunks/ssr/[root-of-the-server]__0y_zq8k._.js +3 -0
  142. package/.next/server/chunks/ssr/[root-of-the-server]__0y_zq8k._.js.map +1 -0
  143. package/.next/server/chunks/ssr/{[root-of-the-server]__08qr2ji._.js → [root-of-the-server]__0~953ob._.js} +2 -2
  144. package/.next/server/chunks/ssr/[root-of-the-server]__0~953ob._.js.map +1 -0
  145. package/.next/server/chunks/ssr/_0-9j34y._.js +70 -0
  146. package/.next/server/chunks/ssr/_0-9j34y._.js.map +1 -0
  147. package/.next/server/chunks/ssr/_0jxmm9h._.js +6 -0
  148. package/.next/server/chunks/ssr/_0jxmm9h._.js.map +1 -0
  149. package/.next/server/chunks/ssr/_0lfe3wr._.js +3 -0
  150. package/.next/server/chunks/ssr/_0lfe3wr._.js.map +1 -0
  151. package/.next/server/chunks/ssr/{node_modules_0i2xw~e._.js → _0nvprxh._.js} +5 -2
  152. package/.next/server/chunks/ssr/_0nvprxh._.js.map +1 -0
  153. package/.next/server/chunks/ssr/_0pugb10._.js +6 -0
  154. package/.next/server/chunks/ssr/_0pugb10._.js.map +1 -0
  155. package/.next/server/chunks/ssr/_next-internal_server_app_float_page_actions_0x6hm4p.js +3 -0
  156. package/.next/server/chunks/ssr/_next-internal_server_app_float_page_actions_0x6hm4p.js.map +1 -0
  157. package/.next/server/chunks/ssr/_next-internal_server_app_settings_page_actions_0mr68ai.js +3 -0
  158. package/.next/server/chunks/ssr/_next-internal_server_app_settings_page_actions_0mr68ai.js.map +1 -0
  159. package/.next/server/chunks/ssr/node_modules_@swc_helpers_cjs__interop_require_default_cjs_11~q6fv._.js +3 -0
  160. package/.next/server/chunks/ssr/node_modules_@swc_helpers_cjs__interop_require_default_cjs_11~q6fv._.js.map +1 -0
  161. package/.next/server/chunks/ssr/node_modules_next_dist_09jzzl8._.js +3 -0
  162. package/.next/server/chunks/ssr/node_modules_next_dist_09jzzl8._.js.map +1 -0
  163. package/.next/server/chunks/ssr/{node_modules_next_dist_0e1izl_._.js → node_modules_next_dist_0h9llsw._.js} +2 -2
  164. package/.next/server/chunks/ssr/node_modules_next_dist_0h9llsw._.js.map +1 -0
  165. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_06b_a87.js +4 -0
  166. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_06b_a87.js.map +1 -0
  167. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0p_u4px.js +4 -0
  168. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0p_u4px.js.map +1 -0
  169. package/.next/server/chunks/ssr/src_0urkups._.js +3 -0
  170. package/.next/server/chunks/ssr/src_0urkups._.js.map +1 -0
  171. package/.next/server/chunks/ssr/src_components_0ox9d~w._.js +3 -0
  172. package/.next/server/chunks/ssr/src_components_0ox9d~w._.js.map +1 -0
  173. package/.next/server/chunks/ssr/src_components_CodexAccountsPanel_tsx_0n9xjzi._.js +1 -1
  174. package/.next/server/chunks/ssr/src_components_CodexAccountsPanel_tsx_0n9xjzi._.js.map +1 -1
  175. package/.next/server/chunks/ssr/src_components_FloatingWidget_tsx_089.6oo._.js +3 -0
  176. package/.next/server/chunks/ssr/src_components_FloatingWidget_tsx_089.6oo._.js.map +1 -0
  177. package/.next/server/chunks/ssr/src_lib_i18n_client_tsx_07pysgz._.js +3 -0
  178. package/.next/server/chunks/ssr/src_lib_i18n_client_tsx_07pysgz._.js.map +1 -0
  179. package/.next/server/edge/chunks/_0-m5tdn._.js +3 -0
  180. package/.next/server/edge/chunks/_0-m5tdn._.js.map +1 -0
  181. package/.next/server/edge/chunks/node_modules_next_dist_esm_build_templates_edge-wrapper_0mz-5sp.js.map +1 -0
  182. package/.next/server/edge/chunks/turbopack-node_modules_next_dist_esm_build_templates_edge-wrapper_0mz-5sp.js +3 -0
  183. package/.next/server/functions-config-manifest.json +4 -1
  184. package/.next/server/instrumentation/middleware-manifest.json +12 -0
  185. package/.next/server/instrumentation.js +4 -0
  186. package/.next/server/instrumentation.js.map +5 -0
  187. package/.next/server/instrumentation.js.nft.json +1 -0
  188. package/.next/server/middleware-build-manifest.js +7 -8
  189. package/.next/server/next-font-manifest.js +1 -1
  190. package/.next/server/next-font-manifest.json +8 -0
  191. package/.next/server/pages/500.html +1 -1
  192. package/.next/server/pages-manifest.json +0 -1
  193. package/.next/server/server-reference-manifest.js +1 -1
  194. package/.next/server/server-reference-manifest.json +1 -1
  195. package/.next/static/chunks/0bymg5d-lqs0o.js +5 -0
  196. package/.next/static/chunks/0ebdqsutnp8y4.js +1 -0
  197. package/.next/static/chunks/0i3~mnai-l497.js +1 -0
  198. package/.next/static/chunks/0idj4tdmyr934.js +1 -0
  199. package/.next/static/chunks/10eoi4q2wk80z.js +1 -0
  200. package/.next/static/chunks/10refkmyp9rbl.css +3 -0
  201. package/.next/static/chunks/{07lhk_q6pmm3r.js → 10u3y4bw1ayzs.js} +1 -1
  202. package/.next/static/chunks/{00gq-v0e07i1q.js → 115dplafwys-z.js} +1 -1
  203. package/.next/static/chunks/12-9memveha-v.js +1 -0
  204. package/.next/static/chunks/163s8y.j70104.js +4 -0
  205. package/.next/static/chunks/turbopack-0k9twle9a8sh6.js +1 -0
  206. package/.next/types/routes.d.ts +8 -2
  207. package/.next/types/validator.ts +54 -0
  208. package/README.md +57 -17
  209. package/bin/vibemeter-float.swift +970 -0
  210. package/bin/vibemeter-notify.sh +172 -0
  211. package/bin/vibemeter.mjs +385 -7
  212. package/package.json +2 -1
  213. package/public/demo1.png +0 -0
  214. package/public/float-ball.png +0 -0
  215. package/public/float-collapsed.png +0 -0
  216. package/public/float-expanded.png +0 -0
  217. package/public/pay-alipay.jpg +0 -0
  218. package/.next/server/app/_not-found.html +0 -1
  219. package/.next/server/app/_not-found.meta +0 -16
  220. package/.next/server/app/_not-found.rsc +0 -16
  221. package/.next/server/app/_not-found.segments/_full.segment.rsc +0 -16
  222. package/.next/server/app/_not-found.segments/_head.segment.rsc +0 -6
  223. package/.next/server/app/_not-found.segments/_index.segment.rsc +0 -5
  224. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +0 -5
  225. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +0 -5
  226. package/.next/server/app/_not-found.segments/_tree.segment.rsc +0 -2
  227. package/.next/server/chunks/[root-of-the-server]__0y68a5d._.js +0 -96
  228. package/.next/server/chunks/[root-of-the-server]__0y68a5d._.js.map +0 -1
  229. package/.next/server/chunks/ssr/[root-of-the-server]__054lv8d._.js +0 -3
  230. package/.next/server/chunks/ssr/[root-of-the-server]__054lv8d._.js.map +0 -1
  231. package/.next/server/chunks/ssr/[root-of-the-server]__08qr2ji._.js.map +0 -1
  232. package/.next/server/chunks/ssr/[root-of-the-server]__098zro9._.js.map +0 -1
  233. package/.next/server/chunks/ssr/[root-of-the-server]__09hgk-7._.js.map +0 -1
  234. package/.next/server/chunks/ssr/[root-of-the-server]__0cqes87._.js.map +0 -1
  235. package/.next/server/chunks/ssr/[root-of-the-server]__0l.drdn._.js +0 -3
  236. package/.next/server/chunks/ssr/[root-of-the-server]__0l.drdn._.js.map +0 -1
  237. package/.next/server/chunks/ssr/[root-of-the-server]__0v1zqf~._.js +0 -120
  238. package/.next/server/chunks/ssr/[root-of-the-server]__0v1zqf~._.js.map +0 -1
  239. package/.next/server/chunks/ssr/[root-of-the-server]__0v73tbn._.js +0 -3
  240. package/.next/server/chunks/ssr/[root-of-the-server]__0v73tbn._.js.map +0 -1
  241. package/.next/server/chunks/ssr/_0ye~8el._.js +0 -7
  242. package/.next/server/chunks/ssr/_0ye~8el._.js.map +0 -1
  243. package/.next/server/chunks/ssr/node_modules_0ck2~9g._.js +0 -3
  244. package/.next/server/chunks/ssr/node_modules_0ck2~9g._.js.map +0 -1
  245. package/.next/server/chunks/ssr/node_modules_0i2xw~e._.js.map +0 -1
  246. package/.next/server/chunks/ssr/node_modules_next_dist_0e1izl_._.js.map +0 -1
  247. package/.next/server/chunks/ssr/node_modules_next_dist_0gsjr7_._.js +0 -3
  248. package/.next/server/chunks/ssr/node_modules_next_dist_0gsjr7_._.js.map +0 -1
  249. package/.next/server/pages/404.html +0 -1
  250. package/.next/static/chunks/002~6040mndie.css +0 -3
  251. package/.next/static/chunks/02g3221oh~3le.js +0 -2
  252. package/.next/static/chunks/0erq0bmub6w_z.js +0 -2
  253. package/.next/static/chunks/0ljfidstam_7k.js +0 -1
  254. package/.next/static/chunks/0pqt~8bl3ukh4.js +0 -4
  255. package/.next/static/chunks/turbopack-14vio.b1b9i4l.js +0 -1
  256. /package/.next/server/chunks/{[root-of-the-server]__0g0u0lm._.js.map → [root-of-the-server]__024yzee._.js.map} +0 -0
  257. /package/.next/server/chunks/{[root-of-the-server]__00q0o~z._.js.map → [root-of-the-server]__082iwfa._.js.map} +0 -0
  258. /package/.next/server/chunks/{[root-of-the-server]__0-74syk._.js.map → [root-of-the-server]__0ru3_it._.js.map} +0 -0
  259. /package/.next/server/chunks/{[root-of-the-server]__0866q87._.js.map → [root-of-the-server]__0xa4dzi._.js.map} +0 -0
  260. /package/.next/static/{Ong62ufsHaRU36s0QK5ZZ → _Y03MiN6NI16Ms86q6vCJ}/_buildManifest.js +0 -0
  261. /package/.next/static/{Ong62ufsHaRU36s0QK5ZZ → _Y03MiN6NI16Ms86q6vCJ}/_clientMiddlewareManifest.js +0 -0
  262. /package/.next/static/{Ong62ufsHaRU36s0QK5ZZ → _Y03MiN6NI16Ms86q6vCJ}/_ssgManifest.js +0 -0
@@ -0,0 +1,970 @@
1
+ import Cocoa
2
+ import Foundation
3
+ import UserNotifications
4
+
5
+ struct FloatQuota: Decodable {
6
+ let agent: String
7
+ let label: String
8
+ let accountLabel: String?
9
+ let remaining5h: Double?
10
+ let used5h: Double?
11
+ let remainingWeekly: Double?
12
+ let usedWeekly: Double?
13
+ let resetAt5h: Double?
14
+ let resetAtWeekly: Double?
15
+ let capturedAt: Double?
16
+ }
17
+
18
+ struct ToolCount: Decodable {
19
+ let tool: String
20
+ let count: Int
21
+ }
22
+
23
+ struct LastSession: Decodable {
24
+ let tool: String
25
+ let project: String
26
+ let title: String?
27
+ let startedAt: Double
28
+ }
29
+
30
+ struct LiveSession: Decodable {
31
+ let id: String
32
+ let tool: String
33
+ let project: String
34
+ let title: String?
35
+ let startedAt: Double
36
+ let endedAt: Double?
37
+ let durationMs: Double
38
+ }
39
+
40
+ struct AgentLive: Decodable {
41
+ let agent: String
42
+ let state: String
43
+ let quotaLevel: String
44
+ let activeSession: LiveSession?
45
+ let recentSession: LiveSession?
46
+ }
47
+
48
+ struct FloatStats: Decodable {
49
+ let generatedAt: Double
50
+ let primary: FloatQuota?
51
+ let quotas: [FloatQuota]
52
+ let liveByAgent: [AgentLive]
53
+ let todaySessions: Int
54
+ let totalSessions: Int
55
+ let todayByTool: [ToolCount]
56
+ let lastSession: LastSession?
57
+ }
58
+
59
+ private func menuBarRemainingPercentText(_ remaining: Double) -> String {
60
+ let clamped = max(0, min(100, remaining))
61
+ return "\(clamped >= 100 ? 100 : Int(floor(clamped)))%"
62
+ }
63
+
64
+ final class FloatingPanel: NSPanel {
65
+ override var canBecomeKey: Bool { true }
66
+ override var canBecomeMain: Bool { false }
67
+ }
68
+
69
+ final class FloatView: NSView {
70
+ struct HitRects {
71
+ var ring: NSRect = .zero
72
+ var title: NSRect = .zero
73
+ var refresh: [NSRect] = []
74
+ var close: NSRect = .zero
75
+ var claudeToggle: NSRect = .zero
76
+ var codexToggle: NSRect = .zero
77
+ }
78
+
79
+ static let displayStyleKey = "VMFloatDisplayStyle"
80
+ static let agentDisplayKey = "VMFloatAgentDisplay"
81
+
82
+ var stats: FloatStats?
83
+ var statusText = "loading"
84
+ var isExpanded = false
85
+ var displayStyle = "ball"
86
+ var agentDisplay = "claude-code"
87
+ var onRefresh: (() -> Void)?
88
+ var onOpenDashboard: (() -> Void)?
89
+ var onHide: (() -> Void)?
90
+ var onSettingsChanged: (() -> Void)?
91
+ private var dragStart: NSPoint?
92
+ private var didDrag = false
93
+ private var hitRects = HitRects()
94
+
95
+ override var isFlipped: Bool { true }
96
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
97
+
98
+ func loadSettings() {
99
+ let defaults = UserDefaults.standard
100
+ if let style = defaults.string(forKey: Self.displayStyleKey), style == "ball" || style == "pill" {
101
+ displayStyle = style
102
+ }
103
+ if let agent = defaults.string(forKey: Self.agentDisplayKey),
104
+ agent == "claude-code" || agent == "codex" || agent == "both" {
105
+ agentDisplay = agent
106
+ }
107
+ }
108
+
109
+ func setDisplayStyle(_ value: String) {
110
+ guard displayStyle != value else { return }
111
+ displayStyle = value
112
+ UserDefaults.standard.set(value, forKey: Self.displayStyleKey)
113
+ onSettingsChanged?()
114
+ }
115
+
116
+ func setAgentDisplay(_ value: String) {
117
+ guard agentDisplay != value else { return }
118
+ agentDisplay = value
119
+ UserDefaults.standard.set(value, forKey: Self.agentDisplayKey)
120
+ onSettingsChanged?()
121
+ }
122
+
123
+ private var agentsToShow: [String] {
124
+ if agentDisplay == "both" { return ["claude-code", "codex"] }
125
+ return [agentDisplay]
126
+ }
127
+
128
+ private func quota(for agent: String) -> FloatQuota? {
129
+ stats?.quotas.first(where: { $0.agent == agent })
130
+ }
131
+
132
+ private func quotaWindow(_ quota: FloatQuota?) -> (remaining: Double?, resetAt: Double?, label: String) {
133
+ guard let quota else { return (nil, nil, "no snapshot") }
134
+ if let five = quota.remaining5h { return (five, quota.resetAt5h, "5h remaining") }
135
+ if let weekly = quota.remainingWeekly { return (weekly, quota.resetAtWeekly, "weekly remaining") }
136
+ return (nil, nil, "no quota")
137
+ }
138
+
139
+ private func remainingPercentText(_ remaining: Double?) -> String {
140
+ guard let remaining else { return "--" }
141
+ let clamped = max(0, min(100, remaining))
142
+ return "\(clamped >= 100 ? 100 : Int(floor(clamped)))%"
143
+ }
144
+
145
+ private func live(for agent: String) -> AgentLive? {
146
+ stats?.liveByAgent.first(where: { $0.agent == agent })
147
+ }
148
+
149
+ private var focusAgent: String {
150
+ if agentDisplay == "both" { return "claude-code" }
151
+ return agentDisplay
152
+ }
153
+
154
+ func preferredSize() -> NSSize {
155
+ if isExpanded {
156
+ return NSSize(width: 306, height: agentDisplay == "both" ? 372 : 260)
157
+ }
158
+ switch (displayStyle, agentDisplay) {
159
+ case ("pill", "both"):
160
+ return NSSize(width: 280, height: 96)
161
+ case ("pill", _):
162
+ return NSSize(width: 280, height: 56)
163
+ case (_, "both"):
164
+ return NSSize(width: 132, height: 220)
165
+ default:
166
+ return NSSize(width: 112, height: 112)
167
+ }
168
+ }
169
+
170
+ override func draw(_ dirtyRect: NSRect) {
171
+ super.draw(dirtyRect)
172
+ guard let context = NSGraphicsContext.current?.cgContext else { return }
173
+ hitRects = HitRects()
174
+
175
+ let rect = bounds.insetBy(dx: 8, dy: 8)
176
+ drawPanel(in: rect)
177
+
178
+ if !isExpanded {
179
+ if displayStyle == "pill" {
180
+ drawPillsCollapsed(context: context, in: rect)
181
+ } else if agentDisplay == "both" {
182
+ drawDualBallCollapsed(context: context, in: rect)
183
+ } else {
184
+ drawBallCollapsed(context: context, in: rect, agent: agentDisplay)
185
+ }
186
+ return
187
+ }
188
+
189
+ drawHeader(in: rect)
190
+ if agentDisplay == "both" {
191
+ drawRingBlock(context: context, in: rect, agent: "claude-code", yOffset: -8)
192
+ drawRingBlock(context: context, in: rect, agent: "codex", yOffset: 86)
193
+ } else {
194
+ drawRingBlock(context: context, in: rect, agent: agentDisplay, yOffset: 0)
195
+ }
196
+ drawStats(in: rect)
197
+ drawFooter(in: rect)
198
+ }
199
+
200
+ override func mouseDown(with event: NSEvent) {
201
+ dragStart = event.locationInWindow
202
+ didDrag = false
203
+ if event.clickCount == 2 {
204
+ onOpenDashboard?()
205
+ }
206
+ }
207
+
208
+ override func mouseDragged(with event: NSEvent) {
209
+ guard let window, let start = dragStart else { return }
210
+ let current = event.locationInWindow
211
+ var origin = window.frame.origin
212
+ origin.x += current.x - start.x
213
+ origin.y += current.y - start.y
214
+ if abs(current.x - start.x) > 2 || abs(current.y - start.y) > 2 {
215
+ didDrag = true
216
+ }
217
+ window.setFrameOrigin(origin)
218
+ }
219
+
220
+ override func mouseUp(with event: NSEvent) {
221
+ dragStart = nil
222
+ if didDrag { return }
223
+ if event.clickCount != 1 { return }
224
+
225
+ let point = convert(event.locationInWindow, from: nil)
226
+
227
+ if hitRects.refresh.contains(where: { $0.contains(point) }) {
228
+ onRefresh?()
229
+ return
230
+ }
231
+ if isExpanded {
232
+ if hitRects.close.contains(point) {
233
+ onHide?()
234
+ return
235
+ }
236
+ if hitRects.title.contains(point) {
237
+ onOpenDashboard?()
238
+ return
239
+ }
240
+ if hitRects.claudeToggle != .zero && hitRects.claudeToggle.contains(point) {
241
+ setAgentDisplay("claude-code")
242
+ return
243
+ }
244
+ if hitRects.codexToggle != .zero && hitRects.codexToggle.contains(point) {
245
+ setAgentDisplay("codex")
246
+ return
247
+ }
248
+ if hitRects.ring != .zero && hitRects.ring.contains(point) {
249
+ isExpanded = false
250
+ applyWindowSize()
251
+ needsDisplay = true
252
+ return
253
+ }
254
+ return
255
+ }
256
+
257
+ // collapsed: any click expands
258
+ isExpanded = true
259
+ applyWindowSize()
260
+ needsDisplay = true
261
+ }
262
+
263
+ override func rightMouseUp(with event: NSEvent) {
264
+ let menu = NSMenu()
265
+ let toggle = NSMenuItem(title: isExpanded ? "Collapse" : "Expand", action: #selector(toggleFromMenu), keyEquivalent: "e")
266
+ toggle.target = self
267
+ menu.addItem(toggle)
268
+ let refresh = NSMenuItem(title: "Refresh", action: #selector(refreshFromMenu), keyEquivalent: "r")
269
+ refresh.target = self
270
+ menu.addItem(refresh)
271
+ let dash = NSMenuItem(title: "Open Dashboard", action: #selector(openDashboardFromMenu), keyEquivalent: "o")
272
+ dash.target = self
273
+ menu.addItem(dash)
274
+ menu.addItem(NSMenuItem.separator())
275
+ menu.addItem(buildDisplayStyleMenuItem())
276
+ menu.addItem(buildAgentDisplayMenuItem())
277
+ menu.addItem(NSMenuItem.separator())
278
+ let quit = NSMenuItem(title: "Quit Vibemeter Float", action: #selector(quitFromMenu), keyEquivalent: "q")
279
+ quit.target = self
280
+ menu.addItem(quit)
281
+ NSMenu.popUpContextMenu(menu, with: event, for: self)
282
+ }
283
+
284
+ func buildDisplayStyleMenuItem() -> NSMenuItem {
285
+ let item = NSMenuItem(title: "Display Style", action: nil, keyEquivalent: "")
286
+ let sub = NSMenu()
287
+ for (title, value) in [("Ball", "ball"), ("Pill (horizontal)", "pill")] {
288
+ let mi = NSMenuItem(title: title, action: #selector(setDisplayStyleFromMenu(_:)), keyEquivalent: "")
289
+ mi.target = self
290
+ mi.representedObject = value
291
+ mi.state = displayStyle == value ? .on : .off
292
+ sub.addItem(mi)
293
+ }
294
+ item.submenu = sub
295
+ return item
296
+ }
297
+
298
+ func buildAgentDisplayMenuItem() -> NSMenuItem {
299
+ let item = NSMenuItem(title: "Show Agents", action: nil, keyEquivalent: "")
300
+ let sub = NSMenu()
301
+ let options: [(String, String)] = [
302
+ ("Claude only", "claude-code"),
303
+ ("Codex only", "codex"),
304
+ ("Both", "both"),
305
+ ]
306
+ for (title, value) in options {
307
+ let mi = NSMenuItem(title: title, action: #selector(setAgentDisplayFromMenu(_:)), keyEquivalent: "")
308
+ mi.target = self
309
+ mi.representedObject = value
310
+ mi.state = agentDisplay == value ? .on : .off
311
+ sub.addItem(mi)
312
+ }
313
+ item.submenu = sub
314
+ return item
315
+ }
316
+
317
+ @objc private func toggleFromMenu() {
318
+ isExpanded.toggle()
319
+ applyWindowSize()
320
+ needsDisplay = true
321
+ }
322
+ @objc private func refreshFromMenu() { onRefresh?() }
323
+ @objc private func openDashboardFromMenu() { onOpenDashboard?() }
324
+ @objc private func quitFromMenu() { NSApp.terminate(nil) }
325
+
326
+ @objc private func setDisplayStyleFromMenu(_ sender: NSMenuItem) {
327
+ guard let value = sender.representedObject as? String else { return }
328
+ setDisplayStyle(value)
329
+ applyWindowSize()
330
+ needsDisplay = true
331
+ }
332
+
333
+ @objc private func setAgentDisplayFromMenu(_ sender: NSMenuItem) {
334
+ guard let value = sender.representedObject as? String else { return }
335
+ setAgentDisplay(value)
336
+ applyWindowSize()
337
+ needsDisplay = true
338
+ }
339
+
340
+ func applyWindowSize() {
341
+ resizeWindowKeepingTopRight(preferredSize())
342
+ }
343
+
344
+ private func drawPanel(in rect: NSRect) {
345
+ let shadow = NSShadow()
346
+ shadow.shadowBlurRadius = 22
347
+ shadow.shadowOffset = NSSize(width: 0, height: -8)
348
+ shadow.shadowColor = NSColor.black.withAlphaComponent(0.45)
349
+ shadow.set()
350
+
351
+ NSColor(calibratedRed: 0.055, green: 0.057, blue: 0.067, alpha: 0.94).setFill()
352
+ let path = NSBezierPath(roundedRect: rect, xRadius: 26, yRadius: 26)
353
+ path.fill()
354
+
355
+ NSColor.white.withAlphaComponent(0.08).setStroke()
356
+ path.lineWidth = 1
357
+ path.stroke()
358
+ }
359
+
360
+ private func drawHeader(in rect: NSRect) {
361
+ let title = NSRect(x: rect.minX + 20, y: rect.minY + 18, width: 94, height: 18)
362
+ drawText("Vibemeter", rect: title, size: 13, weight: .semibold, color: NSColor.white.withAlphaComponent(0.94))
363
+ hitRects.title = title
364
+
365
+ if agentDisplay != "both" {
366
+ let switchRect = NSRect(x: rect.maxX - 180, y: rect.minY + 10, width: 114, height: 27)
367
+ drawAgentSwitch(in: switchRect)
368
+ hitRects.claudeToggle = NSRect(x: switchRect.minX, y: switchRect.minY, width: switchRect.width / 2, height: switchRect.height)
369
+ hitRects.codexToggle = NSRect(x: switchRect.midX, y: switchRect.minY, width: switchRect.width / 2, height: switchRect.height)
370
+ }
371
+
372
+ let refreshRect = NSRect(x: rect.maxX - 59, y: rect.minY + 10, width: 27, height: 27)
373
+ drawIconButton("↻", rect: refreshRect, active: true)
374
+ hitRects.refresh.append(refreshRect)
375
+
376
+ let closeGlyph = NSRect(x: rect.maxX - 22, y: rect.minY + 14, width: 14, height: 16)
377
+ drawText("×", rect: closeGlyph, size: 14, weight: .medium, color: NSColor.white.withAlphaComponent(0.54), alignment: .center)
378
+ hitRects.close = closeGlyph.insetBy(dx: -8, dy: -6)
379
+ }
380
+
381
+ private func drawAgentSwitch(in rect: NSRect) {
382
+ NSColor.black.withAlphaComponent(0.22).setFill()
383
+ NSBezierPath(roundedRect: rect, xRadius: 13.5, yRadius: 13.5).fill()
384
+ let isCodex = agentDisplay == "codex"
385
+ let activeRect = isCodex
386
+ ? NSRect(x: rect.midX, y: rect.minY + 2, width: rect.width / 2 - 2, height: rect.height - 4)
387
+ : NSRect(x: rect.minX + 2, y: rect.minY + 2, width: rect.width / 2 - 2, height: rect.height - 4)
388
+ NSColor(calibratedRed: 0.40, green: 0.28, blue: 0.74, alpha: 0.65).setFill()
389
+ NSBezierPath(roundedRect: activeRect, xRadius: 11.5, yRadius: 11.5).fill()
390
+ NSColor.white.withAlphaComponent(0.08).setStroke()
391
+ NSBezierPath(roundedRect: rect, xRadius: 13.5, yRadius: 13.5).stroke()
392
+ drawText("Claude", rect: NSRect(x: rect.minX, y: rect.minY + 7, width: rect.width / 2, height: 12), size: 9.5, weight: .medium, color: NSColor.white.withAlphaComponent(agentDisplay == "claude-code" ? 0.92 : 0.52), alignment: .center)
393
+ drawText("Codex", rect: NSRect(x: rect.midX, y: rect.minY + 7, width: rect.width / 2, height: 12), size: 9.5, weight: .medium, color: NSColor.white.withAlphaComponent(isCodex ? 0.92 : 0.52), alignment: .center)
394
+ }
395
+
396
+ private func drawBallCollapsed(context: CGContext, in rect: NSRect, agent: String) {
397
+ let q = quota(for: agent)
398
+ let remaining = quotaWindow(q).remaining
399
+ let progress = CGFloat(max(0, min(100, remaining ?? 0)) / 100)
400
+ let center = CGPoint(x: rect.midX, y: rect.midY)
401
+ let radius: CGFloat = min(rect.width, rect.height) / 2 - 15
402
+ let color = ringColor(for: agent, remaining: remaining)
403
+
404
+ context.setLineWidth(8)
405
+ context.setLineCap(.round)
406
+ context.setStrokeColor(NSColor.white.withAlphaComponent(0.10).cgColor)
407
+ context.addArc(center: center, radius: radius, startAngle: -.pi / 2, endAngle: 1.5 * .pi, clockwise: false)
408
+ context.strokePath()
409
+
410
+ context.setStrokeColor(color.cgColor)
411
+ context.addArc(center: center, radius: radius, startAngle: -.pi / 2, endAngle: -.pi / 2 + progress * 2 * .pi, clockwise: false)
412
+ context.strokePath()
413
+
414
+ let value = remainingPercentText(remaining)
415
+ drawText(value, rect: NSRect(x: rect.minX + 12, y: center.y - 15, width: rect.width - 24, height: 30), size: 24, weight: .bold, color: .white, alignment: .center)
416
+ hitRects.ring = rect
417
+ }
418
+
419
+ private func drawDualBallCollapsed(context: CGContext, in rect: NSRect) {
420
+ let half = rect.height / 2
421
+ let topRect = NSRect(x: rect.minX, y: rect.minY, width: rect.width, height: half)
422
+ let bottomRect = NSRect(x: rect.minX, y: rect.minY + half, width: rect.width, height: half)
423
+ drawSmallBall(context: context, in: topRect, agent: "claude-code")
424
+ drawSmallBall(context: context, in: bottomRect, agent: "codex")
425
+ hitRects.ring = rect
426
+ }
427
+
428
+ private func drawSmallBall(context: CGContext, in rect: NSRect, agent: String) {
429
+ let q = quota(for: agent)
430
+ let remaining = quotaWindow(q).remaining
431
+ let progress = CGFloat(max(0, min(100, remaining ?? 0)) / 100)
432
+ let center = CGPoint(x: rect.midX, y: rect.midY)
433
+ let radius: CGFloat = min(rect.width, rect.height) / 2 - 14
434
+ let color = ringColor(for: agent, remaining: remaining)
435
+
436
+ context.setLineWidth(6)
437
+ context.setLineCap(.round)
438
+ context.setStrokeColor(NSColor.white.withAlphaComponent(0.10).cgColor)
439
+ context.addArc(center: center, radius: radius, startAngle: -.pi / 2, endAngle: 1.5 * .pi, clockwise: false)
440
+ context.strokePath()
441
+
442
+ context.setStrokeColor(color.cgColor)
443
+ context.addArc(center: center, radius: radius, startAngle: -.pi / 2, endAngle: -.pi / 2 + progress * 2 * .pi, clockwise: false)
444
+ context.strokePath()
445
+
446
+ let value = remainingPercentText(remaining)
447
+ drawText(value, rect: NSRect(x: rect.minX, y: center.y - 13, width: rect.width, height: 20), size: 15, weight: .bold, color: .white, alignment: .center)
448
+ let letter = agent == "claude-code" ? "C" : "X"
449
+ drawText(letter, rect: NSRect(x: rect.minX, y: center.y + 8, width: rect.width, height: 12), size: 9, weight: .semibold, color: NSColor.white.withAlphaComponent(0.55), alignment: .center)
450
+ }
451
+
452
+ private func drawPillsCollapsed(context: CGContext, in rect: NSRect) {
453
+ let agents = agentsToShow
454
+ let pillHeight: CGFloat = 36
455
+ let gap: CGFloat = 8
456
+ let count = CGFloat(agents.count)
457
+ let totalHeight = count * pillHeight + max(0, count - 1) * gap
458
+ let startY = rect.minY + (rect.height - totalHeight) / 2
459
+
460
+ for (i, agent) in agents.enumerated() {
461
+ let y = startY + CGFloat(i) * (pillHeight + gap)
462
+ let pillRect = NSRect(x: rect.minX + 8, y: y, width: rect.width - 16, height: pillHeight)
463
+ drawAgentPill(in: pillRect, agent: agent)
464
+ }
465
+ hitRects.ring = rect
466
+ }
467
+
468
+ private func drawAgentPill(in rect: NSRect, agent: String) {
469
+ let q = quota(for: agent)
470
+ let remaining = quotaWindow(q).remaining
471
+ let color = ringColor(for: agent, remaining: remaining)
472
+
473
+ let radius = rect.height / 2
474
+ NSColor.black.withAlphaComponent(0.30).setFill()
475
+ NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill()
476
+ let stroke = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius)
477
+ NSColor.white.withAlphaComponent(0.08).setStroke()
478
+ stroke.lineWidth = 1
479
+ stroke.stroke()
480
+
481
+ let labelRect = NSRect(x: rect.minX + 14, y: rect.minY + 11, width: 52, height: 14)
482
+ drawText(toolName(agent), rect: labelRect, size: 11, weight: .semibold, color: NSColor.white.withAlphaComponent(0.88))
483
+
484
+ let pctText = remainingPercentText(remaining)
485
+ let pctRect = NSRect(x: rect.minX + 66, y: rect.minY + 10, width: 38, height: 15)
486
+ drawText(pctText, rect: pctRect, size: 12, weight: .bold, color: .white)
487
+
488
+ let iconSize: CGFloat = 22
489
+ let iconRect = NSRect(x: rect.maxX - iconSize - 8, y: rect.minY + (rect.height - iconSize) / 2, width: iconSize, height: iconSize)
490
+
491
+ let barX = rect.minX + 108
492
+ let barRight = iconRect.minX - 8
493
+ let barWidth = max(0, barRight - barX)
494
+ let barHeight: CGFloat = 8
495
+ let barY = rect.minY + (rect.height - barHeight) / 2
496
+ let trackRect = NSRect(x: barX, y: barY, width: barWidth, height: barHeight)
497
+ NSColor.white.withAlphaComponent(0.10).setFill()
498
+ NSBezierPath(roundedRect: trackRect, xRadius: barHeight / 2, yRadius: barHeight / 2).fill()
499
+ let fillWidth = barWidth * CGFloat(max(0, min(100, remaining ?? 0)) / 100)
500
+ if fillWidth > 0.5 {
501
+ color.setFill()
502
+ let fillRect = NSRect(x: barX, y: barY, width: fillWidth, height: barHeight)
503
+ NSBezierPath(roundedRect: fillRect, xRadius: barHeight / 2, yRadius: barHeight / 2).fill()
504
+ }
505
+
506
+ drawIconButton("↻", rect: iconRect, active: true)
507
+ hitRects.refresh.append(iconRect)
508
+ }
509
+
510
+ private func drawRingBlock(context: CGContext, in rect: NSRect, agent: String, yOffset: CGFloat) {
511
+ let dual = agentDisplay == "both"
512
+ let q = quota(for: agent)
513
+ let window = quotaWindow(q)
514
+ let remaining = window.remaining
515
+ let progress = CGFloat(max(0, min(100, remaining ?? 0)) / 100)
516
+ let center = CGPoint(x: rect.minX + 70, y: rect.minY + 99 + yOffset)
517
+ let radius: CGFloat = dual ? 34 : 43
518
+ let color = ringColor(for: agent, remaining: remaining)
519
+
520
+ context.setLineWidth(dual ? 7 : 9)
521
+ context.setLineCap(.round)
522
+ context.setStrokeColor(NSColor.white.withAlphaComponent(0.10).cgColor)
523
+ context.addArc(center: center, radius: radius, startAngle: -.pi / 2, endAngle: 1.5 * .pi, clockwise: false)
524
+ context.strokePath()
525
+
526
+ context.setStrokeColor(color.cgColor)
527
+ context.addArc(center: center, radius: radius, startAngle: -.pi / 2, endAngle: -.pi / 2 + progress * 2 * .pi, clockwise: false)
528
+ context.strokePath()
529
+
530
+ let innerHalf: CGFloat = dual ? 26 : 34
531
+ let inner = NSRect(x: center.x - innerHalf, y: center.y - innerHalf, width: innerHalf * 2, height: innerHalf * 2)
532
+ NSColor(calibratedRed: 0.035, green: 0.037, blue: 0.045, alpha: 1).setFill()
533
+ NSBezierPath(ovalIn: inner).fill()
534
+
535
+ let value = remainingPercentText(remaining)
536
+ let valueSize: CGFloat = dual ? 17 : 23
537
+ drawText(value, rect: NSRect(x: center.x - 36, y: center.y - 13, width: 72, height: 26), size: valueSize, weight: .bold, color: .white, alignment: .center)
538
+
539
+ let x = rect.minX + 134
540
+ let baseY = rect.minY + (dual ? 78 : 72) + yOffset
541
+ drawText(q?.label ?? toolName(agent), rect: NSRect(x: x, y: baseY, width: 140, height: 22), size: dual ? 14 : 18, weight: .semibold, color: .white)
542
+ drawText(q == nil ? statusText : window.label, rect: NSRect(x: x, y: baseY + (dual ? 18 : 25), width: 140, height: 16), size: 11, weight: .regular, color: NSColor.white.withAlphaComponent(0.46))
543
+ drawText(resetText(window.resetAt), rect: NSRect(x: x, y: baseY + (dual ? 35 : 46), width: 140, height: 16), size: 11, weight: .regular, color: NSColor.white.withAlphaComponent(0.64))
544
+ if let account = q?.accountLabel, !account.isEmpty, !dual {
545
+ drawText(account, rect: NSRect(x: x, y: baseY + 67, width: 140, height: 14), size: 10, weight: .regular, color: NSColor.white.withAlphaComponent(0.32))
546
+ }
547
+
548
+ if yOffset <= 0 {
549
+ hitRects.ring = NSRect(x: center.x - radius - 8, y: center.y - radius - 8, width: radius * 2 + 16, height: radius * 2 + 16)
550
+ }
551
+ }
552
+
553
+ private func drawStats(in rect: NSRect) {
554
+ let dual = agentDisplay == "both"
555
+ let offset: CGFloat = dual ? 94 : 0
556
+ let top = rect.minY + 164 + offset
557
+ drawMetric(title: "today", value: "\(stats?.todaySessions ?? 0)", rect: NSRect(x: rect.minX + 20, y: top, width: 74, height: 50))
558
+ drawMetric(title: "total", value: "\(stats?.totalSessions ?? 0)", rect: NSRect(x: rect.minX + 104, y: top, width: 74, height: 50))
559
+ let weeklyRect = NSRect(x: rect.minX + 188, y: top, width: 74, height: 50)
560
+ if dual {
561
+ drawDualWeekly(rect: weeklyRect)
562
+ } else {
563
+ let weekly = quota(for: focusAgent)?.remainingWeekly
564
+ drawMetric(title: "weekly", value: remainingPercentText(weekly), rect: weeklyRect)
565
+ }
566
+ }
567
+
568
+ private func drawDualWeekly(rect: NSRect) {
569
+ NSColor.black.withAlphaComponent(0.20).setFill()
570
+ NSBezierPath(roundedRect: rect, xRadius: 14, yRadius: 14).fill()
571
+ NSColor.white.withAlphaComponent(0.06).setStroke()
572
+ NSBezierPath(roundedRect: rect, xRadius: 14, yRadius: 14).stroke()
573
+ drawText("weekly", rect: NSRect(x: rect.minX, y: rect.minY + 6, width: rect.width, height: 12), size: 9, weight: .medium, color: NSColor.white.withAlphaComponent(0.38), alignment: .center)
574
+ let c = quota(for: "claude-code")?.remainingWeekly
575
+ let x = quota(for: "codex")?.remainingWeekly
576
+ let cText = "C \(remainingPercentText(c))"
577
+ let xText = "X \(remainingPercentText(x))"
578
+ drawText(cText, rect: NSRect(x: rect.minX, y: rect.minY + 22, width: rect.width, height: 13), size: 11, weight: .semibold, color: .white, alignment: .center)
579
+ drawText(xText, rect: NSRect(x: rect.minX, y: rect.minY + 36, width: rect.width, height: 13), size: 11, weight: .semibold, color: NSColor(calibratedRed: 0.66, green: 0.39, blue: 0.95, alpha: 1), alignment: .center)
580
+ }
581
+
582
+ private func drawFooter(in rect: NSRect) {
583
+ if agentDisplay == "both" {
584
+ let topY = rect.maxY - 32
585
+ let bottomY = rect.maxY - 16
586
+ drawFooterLine(agent: "claude-code", rect: NSRect(x: rect.minX + 22, y: topY, width: rect.width - 44, height: 14))
587
+ drawFooterLine(agent: "codex", rect: NSRect(x: rect.minX + 22, y: bottomY, width: rect.width - 44, height: 14))
588
+ return
589
+ }
590
+ let l = live(for: focusAgent)
591
+ let session = l?.activeSession ?? l?.recentSession
592
+ let prefix = l?.state == "active" ? "active" : l?.state == "recent" ? "done" : "latest"
593
+ let agentName = toolName(focusAgent)
594
+ let line = session == nil ? "No recent \(agentName) session" : "\(prefix) · \(session!.project) · \(durationText(session!.durationMs))"
595
+ drawText(line, rect: NSRect(x: rect.minX + 22, y: rect.maxY - 32, width: rect.width - 74, height: 16), size: 11, weight: .medium, color: NSColor.white.withAlphaComponent(0.72))
596
+ if let title = session?.title, !title.isEmpty {
597
+ drawText(title, rect: NSRect(x: rect.minX + 22, y: rect.maxY - 17, width: rect.width - 74, height: 14), size: 10, weight: .regular, color: NSColor.white.withAlphaComponent(0.38))
598
+ } else {
599
+ drawText("double-click dashboard · drag anywhere", rect: NSRect(x: rect.minX + 22, y: rect.maxY - 17, width: rect.width - 74, height: 14), size: 10, weight: .regular, color: NSColor.white.withAlphaComponent(0.35))
600
+ }
601
+ }
602
+
603
+ private func drawFooterLine(agent: String, rect: NSRect) {
604
+ let l = live(for: agent)
605
+ let session = l?.activeSession ?? l?.recentSession
606
+ let prefix = l?.state == "active" ? "active" : l?.state == "recent" ? "done" : "latest"
607
+ let name = toolName(agent)
608
+ let line = session == nil ? "\(name) · no recent session" : "\(name) · \(prefix) · \(session!.project) · \(durationText(session!.durationMs))"
609
+ let labelColor = agent == "codex" ? NSColor(calibratedRed: 0.66, green: 0.39, blue: 0.95, alpha: 0.95) : NSColor.white.withAlphaComponent(0.72)
610
+ drawText(line, rect: rect, size: 11, weight: .medium, color: labelColor)
611
+ }
612
+
613
+ private func drawMetric(title: String, value: String, rect: NSRect) {
614
+ NSColor.black.withAlphaComponent(0.20).setFill()
615
+ NSBezierPath(roundedRect: rect, xRadius: 14, yRadius: 14).fill()
616
+ NSColor.white.withAlphaComponent(0.06).setStroke()
617
+ NSBezierPath(roundedRect: rect, xRadius: 14, yRadius: 14).stroke()
618
+ drawText(title, rect: NSRect(x: rect.minX, y: rect.minY + 9, width: rect.width, height: 13), size: 9, weight: .medium, color: NSColor.white.withAlphaComponent(0.38), alignment: .center)
619
+ drawText(value, rect: NSRect(x: rect.minX, y: rect.minY + 25, width: rect.width, height: 20), size: 15, weight: .semibold, color: .white, alignment: .center)
620
+ }
621
+
622
+ private func drawIconButton(_ text: String, rect: NSRect, active: Bool) {
623
+ (active ? NSColor(calibratedRed: 0.31, green: 0.19, blue: 0.62, alpha: 0.55) : NSColor.white.withAlphaComponent(0.07)).setFill()
624
+ NSBezierPath(ovalIn: rect).fill()
625
+ NSColor.white.withAlphaComponent(active ? 0.12 : 0.08).setStroke()
626
+ NSBezierPath(ovalIn: rect).stroke()
627
+ let textY = rect.minY + (rect.height - 14) / 2
628
+ drawText(text, rect: NSRect(x: rect.minX, y: textY, width: rect.width, height: 14), size: 12, weight: .semibold, color: NSColor.white.withAlphaComponent(0.82), alignment: .center)
629
+ }
630
+
631
+ private func resizeWindowKeepingTopRight(_ size: NSSize) {
632
+ guard let window else { return }
633
+ let frame = window.frame
634
+ let topRight = NSPoint(x: frame.maxX, y: frame.maxY)
635
+ let next = NSRect(
636
+ x: topRight.x - size.width,
637
+ y: topRight.y - size.height,
638
+ width: size.width,
639
+ height: size.height
640
+ )
641
+ window.setFrame(next, display: true, animate: false)
642
+ }
643
+
644
+ private func drawText(_ text: String, rect: NSRect, size: CGFloat, weight: NSFont.Weight, color: NSColor, alignment: NSTextAlignment = .left) {
645
+ let paragraph = NSMutableParagraphStyle()
646
+ paragraph.alignment = alignment
647
+ paragraph.lineBreakMode = .byTruncatingTail
648
+ let attrs: [NSAttributedString.Key: Any] = [
649
+ .font: NSFont.systemFont(ofSize: size, weight: weight),
650
+ .foregroundColor: color,
651
+ .paragraphStyle: paragraph,
652
+ ]
653
+ (text as NSString).draw(in: rect, withAttributes: attrs)
654
+ }
655
+
656
+ private func accentColor(remaining: Double?) -> NSColor {
657
+ guard let remaining else { return NSColor.systemGray }
658
+ if remaining < 20 { return NSColor.systemPink }
659
+ if remaining < 45 { return NSColor.systemOrange }
660
+ return NSColor.systemGreen
661
+ }
662
+
663
+ private func ringColor(for agent: String, remaining: Double?) -> NSColor {
664
+ if agentDisplay == "both" && agent == "codex" {
665
+ return NSColor(calibratedRed: 0.66, green: 0.39, blue: 0.95, alpha: 1)
666
+ }
667
+ return accentColor(remaining: remaining)
668
+ }
669
+
670
+ private func resetText(_ value: Double?) -> String {
671
+ guard let value else { return "no reset time" }
672
+ let diff = (value / 1000) - Date().timeIntervalSince1970
673
+ if diff <= 0 { return "snapshot expired" }
674
+ let hours = Int(diff) / 3600
675
+ let minutes = (Int(diff) % 3600) / 60
676
+ return hours > 0 ? "resets in \(hours)h \(minutes)m" : "resets in \(minutes)m"
677
+ }
678
+
679
+ private func toolName(_ value: String) -> String {
680
+ if value == "claude-code" { return "Claude" }
681
+ if value == "codex" { return "Codex" }
682
+ if value == "cursor" { return "Cursor" }
683
+ return value
684
+ }
685
+
686
+ private func durationText(_ ms: Double) -> String {
687
+ let seconds = max(0, Int(ms / 1000))
688
+ let hours = seconds / 3600
689
+ let minutes = (seconds % 3600) / 60
690
+ if hours > 0 { return "\(hours)h \(minutes)m" }
691
+ return "\(minutes)m"
692
+ }
693
+ }
694
+
695
+ final class FloatingWindowController: NSObject, NSApplicationDelegate {
696
+ private let pageURL: URL
697
+ private let apiURL: URL
698
+ private let importURL: URL
699
+ private var panel: FloatingPanel?
700
+ private var contentView: FloatView?
701
+ private var timer: Timer?
702
+ private var statusItem: NSStatusItem?
703
+ private var refreshInFlight = false
704
+
705
+ init(pageURL: URL) {
706
+ self.pageURL = pageURL
707
+ var components = URLComponents(url: pageURL, resolvingAgainstBaseURL: false)!
708
+ components.path = "/api/float"
709
+ components.query = nil
710
+ self.apiURL = components.url!
711
+ components.path = "/api/import-sessions"
712
+ self.importURL = components.url!
713
+ }
714
+
715
+ func applicationDidFinishLaunching(_ notification: Notification) {
716
+ let view = FloatView(frame: NSRect(x: 0, y: 0, width: 112, height: 112))
717
+ view.loadSettings()
718
+ let initial = view.preferredSize()
719
+
720
+ let panel = FloatingPanel(
721
+ contentRect: NSRect(x: 0, y: 0, width: initial.width, height: initial.height),
722
+ styleMask: [.borderless, .nonactivatingPanel],
723
+ backing: .buffered,
724
+ defer: false
725
+ )
726
+ panel.level = .statusBar
727
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]
728
+ panel.hidesOnDeactivate = false
729
+ panel.isReleasedWhenClosed = false
730
+ panel.isMovableByWindowBackground = true
731
+ panel.backgroundColor = .clear
732
+ panel.isOpaque = false
733
+ panel.hasShadow = false
734
+ panel.setFrameAutosaveName("VibemeterFloatingWindow")
735
+
736
+ view.frame = panel.contentView?.bounds ?? NSRect(origin: .zero, size: initial)
737
+ view.autoresizingMask = [.width, .height]
738
+ view.wantsLayer = true
739
+ view.layer?.backgroundColor = NSColor.clear.cgColor
740
+ view.onRefresh = { [weak self] in self?.refreshNow() }
741
+ view.onSettingsChanged = { [weak self] in
742
+ self?.refreshNow()
743
+ self?.rebuildStatusMenu()
744
+ }
745
+ view.onHide = { [weak self] in self?.hidePanel() }
746
+ view.onOpenDashboard = { [weak self] in
747
+ guard let self else { return }
748
+ NSWorkspace.shared.open(self.pageURL.deletingLastPathComponent())
749
+ }
750
+
751
+ panel.contentView = view
752
+ placeAtTopRight(panel)
753
+ panel.orderFrontRegardless()
754
+ self.panel = panel
755
+ self.contentView = view
756
+ setupStatusItem(initialView: view)
757
+
758
+ refreshNow()
759
+ timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
760
+ self?.refreshNow()
761
+ }
762
+ }
763
+
764
+ private func setupStatusItem(initialView: FloatView) {
765
+ let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
766
+ item.button?.title = "Vibe"
767
+ statusItem = item
768
+ rebuildStatusMenu()
769
+ }
770
+
771
+ private func rebuildStatusMenu() {
772
+ guard let statusItem else { return }
773
+ let menu = NSMenu()
774
+ let show = NSMenuItem(title: "Show Float", action: #selector(showPanelFromMenu), keyEquivalent: "s")
775
+ show.target = self
776
+ menu.addItem(show)
777
+ let refresh = NSMenuItem(title: "Refresh", action: #selector(refreshFromMenu), keyEquivalent: "r")
778
+ refresh.target = self
779
+ menu.addItem(refresh)
780
+ let dash = NSMenuItem(title: "Open Dashboard", action: #selector(openDashboardFromMenu), keyEquivalent: "o")
781
+ dash.target = self
782
+ menu.addItem(dash)
783
+ menu.addItem(NSMenuItem.separator())
784
+ if let view = contentView {
785
+ menu.addItem(view.buildDisplayStyleMenuItem())
786
+ menu.addItem(view.buildAgentDisplayMenuItem())
787
+ menu.addItem(NSMenuItem.separator())
788
+ }
789
+ let quit = NSMenuItem(title: "Quit", action: #selector(quitFromMenu), keyEquivalent: "q")
790
+ quit.target = self
791
+ menu.addItem(quit)
792
+ statusItem.menu = menu
793
+ }
794
+
795
+ private func hidePanel() {
796
+ panel?.orderOut(nil)
797
+ }
798
+
799
+ @objc private func showPanelFromMenu() {
800
+ guard let panel else { return }
801
+ if panel.frame.origin.x < 0 || panel.frame.origin.y < 0 {
802
+ placeAtTopRight(panel)
803
+ }
804
+ panel.orderFrontRegardless()
805
+ refreshNow()
806
+ }
807
+
808
+ @objc private func refreshFromMenu() {
809
+ refreshNow()
810
+ showPanelFromMenu()
811
+ }
812
+
813
+ @objc private func openDashboardFromMenu() {
814
+ NSWorkspace.shared.open(pageURL.deletingLastPathComponent())
815
+ }
816
+
817
+ @objc private func quitFromMenu() {
818
+ NSApp.terminate(nil)
819
+ }
820
+
821
+ private func placeAtTopRight(_ panel: NSPanel) {
822
+ let screen = NSScreen.main ?? NSScreen.screens.first
823
+ guard let frame = screen?.visibleFrame else {
824
+ panel.center()
825
+ return
826
+ }
827
+ let margin: CGFloat = 18
828
+ let origin = NSPoint(
829
+ x: frame.maxX - panel.frame.width - margin,
830
+ y: frame.maxY - panel.frame.height - margin
831
+ )
832
+ panel.setFrameOrigin(origin)
833
+ }
834
+
835
+ private func refreshNow() {
836
+ if refreshInFlight { return }
837
+ refreshInFlight = true
838
+
839
+ var components = URLComponents(url: apiURL, resolvingAgainstBaseURL: false)!
840
+ components.queryItems = [
841
+ URLQueryItem(name: "refresh", value: "usage"),
842
+ URLQueryItem(name: "t", value: "\(Int(Date().timeIntervalSince1970 * 1000))"),
843
+ ]
844
+ var request = URLRequest(url: components.url!)
845
+ request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
846
+
847
+ URLSession.shared.dataTask(with: request) { [weak self] data, _, _ in
848
+ guard let self else { return }
849
+ defer {
850
+ DispatchQueue.main.async {
851
+ self.refreshInFlight = false
852
+ }
853
+ }
854
+ guard let data else {
855
+ DispatchQueue.main.async {
856
+ self.contentView?.statusText = "api unavailable"
857
+ self.contentView?.needsDisplay = true
858
+ }
859
+ return
860
+ }
861
+ do {
862
+ let stats = try JSONDecoder().decode(FloatStats.self, from: data)
863
+ DispatchQueue.main.async {
864
+ self.contentView?.stats = stats
865
+ self.contentView?.statusText = stats.quotas.isEmpty ? "no snapshot" : "loaded"
866
+ self.contentView?.needsDisplay = true
867
+ self.updateStatusItem(stats)
868
+ if self.panel?.isVisible == true {
869
+ self.panel?.orderFrontRegardless()
870
+ }
871
+ }
872
+ } catch {
873
+ DispatchQueue.main.async {
874
+ self.contentView?.statusText = "decode failed"
875
+ self.contentView?.needsDisplay = true
876
+ }
877
+ }
878
+ }.resume()
879
+ }
880
+
881
+ private func updateStatusItem(_ stats: FloatStats) {
882
+ guard let view = contentView else { return }
883
+ if view.agentDisplay == "both" {
884
+ let c = stats.quotas.first(where: { $0.agent == "claude-code" })?.remaining5h
885
+ let x = stats.quotas.first(where: { $0.agent == "codex" })?.remaining5h
886
+ switch (c, x) {
887
+ case let (cv?, xv?):
888
+ statusItem?.button?.title = "C \(menuBarRemainingPercentText(cv)) · X \(menuBarRemainingPercentText(xv))"
889
+ case let (cv?, nil):
890
+ statusItem?.button?.title = "C \(menuBarRemainingPercentText(cv))"
891
+ case let (nil, xv?):
892
+ statusItem?.button?.title = "X \(menuBarRemainingPercentText(xv))"
893
+ default:
894
+ statusItem?.button?.title = "Vibe"
895
+ }
896
+ } else {
897
+ let agent = view.agentDisplay
898
+ let remaining = stats.quotas.first(where: { $0.agent == agent })?.remaining5h
899
+ if let remaining {
900
+ let prefix = agent == "claude-code" ? "C" : "X"
901
+ statusItem?.button?.title = "\(prefix) \(menuBarRemainingPercentText(remaining))"
902
+ } else {
903
+ statusItem?.button?.title = "Vibe"
904
+ }
905
+ }
906
+ }
907
+ }
908
+
909
+ // ── --notify mode ────────────────────────────────────────────────────────
910
+ // Usage: Vibemeter --notify "<title>" "<body>" ["<thread-id>"]
911
+ // Posts a native UNUserNotificationCenter banner from the Vibemeter bundle
912
+ // and exits. Because this binary lives inside Vibemeter.app, the system can
913
+ // resolve our CFBundleIdentifier and accept the request — a plain `swiftc`
914
+ // output without a bundle would be silently dropped.
915
+ func runNotifyMode(title: String, body: String, threadId: String?) -> Never {
916
+ let center = UNUserNotificationCenter.current()
917
+ let group = DispatchGroup()
918
+
919
+ var deliveryFinished = false
920
+
921
+ group.enter()
922
+ center.requestAuthorization(options: [.alert, .sound]) { _, _ in
923
+ let content = UNMutableNotificationContent()
924
+ content.title = title
925
+ if !body.isEmpty { content.body = body }
926
+ if let threadId, !threadId.isEmpty { content.threadIdentifier = threadId }
927
+
928
+ let request = UNNotificationRequest(
929
+ identifier: "vibemeter-notify-\(Int(Date().timeIntervalSince1970 * 1000))",
930
+ content: content,
931
+ trigger: nil
932
+ )
933
+ center.add(request) { _ in
934
+ deliveryFinished = true
935
+ group.leave()
936
+ }
937
+ }
938
+
939
+ // Allow the request to be delivered; exit even if authorization is denied
940
+ // so the calling hook doesn't block forever.
941
+ _ = group.wait(timeout: .now() + 2.0)
942
+ if !deliveryFinished {
943
+ fputs("Vibemeter notify: authorization or delivery timeout\n", stderr)
944
+ exit(2)
945
+ }
946
+ // Give the notification system a brief moment to enqueue before we tear
947
+ // down — exiting too fast occasionally drops the banner.
948
+ Thread.sleep(forTimeInterval: 0.15)
949
+ exit(0)
950
+ }
951
+
952
+ let rawArgs = Array(CommandLine.arguments.dropFirst())
953
+ if let first = rawArgs.first, first == "--notify" {
954
+ let title = rawArgs.count > 1 ? rawArgs[1] : "Vibemeter"
955
+ let body = rawArgs.count > 2 ? rawArgs[2] : ""
956
+ let threadId = rawArgs.count > 3 ? rawArgs[3] : nil
957
+ runNotifyMode(title: title, body: body, threadId: threadId)
958
+ }
959
+
960
+ let urlString = rawArgs.first ?? "http://localhost:9527/float"
961
+ guard let url = URL(string: urlString) else {
962
+ fputs("Invalid Vibemeter float URL: \(urlString)\n", stderr)
963
+ exit(1)
964
+ }
965
+
966
+ let app = NSApplication.shared
967
+ let delegate = FloatingWindowController(pageURL: url)
968
+ app.delegate = delegate
969
+ app.setActivationPolicy(.accessory)
970
+ app.run()