@hirra/vibemeter 0.1.3 → 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.
- package/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +7 -1
- package/.next/build-manifest.json +7 -8
- package/.next/fallback-build-manifest.json +3 -3
- package/.next/prerender-manifest.json +16 -12
- package/.next/required-server-files.js +5 -4
- package/.next/required-server-files.json +5 -4
- package/.next/routes-manifest.json +36 -0
- package/.next/server/app/_global-error/page/build-manifest.json +4 -5
- package/.next/server/app/_global-error/page.js +4 -4
- package/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page/build-manifest.json +4 -5
- package/.next/server/app/_not-found/page.js +5 -4
- package/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/admin/page/build-manifest.json +4 -5
- package/.next/server/app/admin/page.js +7 -5
- package/.next/server/app/admin/page.js.nft.json +1 -1
- package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
- package/.next/server/app/api/codex-accounts/route.js +1 -1
- package/.next/server/app/api/codex-accounts/route.js.nft.json +1 -1
- package/.next/server/app/api/float/route/app-paths-manifest.json +3 -0
- package/.next/server/app/api/float/route/build-manifest.json +9 -0
- package/.next/server/app/api/float/route/server-reference-manifest.json +4 -0
- package/.next/server/app/api/float/route.js +8 -0
- package/.next/server/app/api/float/route.js.map +5 -0
- package/.next/server/app/api/float/route.js.nft.json +1 -0
- package/.next/server/app/api/float/route_client-reference-manifest.js +3 -0
- package/.next/server/app/api/import-sessions/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/tags/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/tags/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/route.js +1 -1
- package/.next/server/app/api/sessions/route.js.nft.json +1 -1
- package/.next/server/app/api/settings/alerts/route/app-paths-manifest.json +3 -0
- package/.next/server/app/api/settings/alerts/route/build-manifest.json +9 -0
- package/.next/server/app/api/settings/alerts/route/server-reference-manifest.json +4 -0
- package/.next/server/app/api/settings/alerts/route.js +9 -0
- package/.next/server/app/api/settings/alerts/route.js.map +5 -0
- package/.next/server/app/api/settings/alerts/route.js.nft.json +1 -0
- package/.next/server/app/api/settings/alerts/route_client-reference-manifest.js +3 -0
- package/.next/server/app/api/settings/notify/route/app-paths-manifest.json +3 -0
- package/.next/server/app/api/settings/notify/route/build-manifest.json +9 -0
- package/.next/server/app/api/settings/notify/route/server-reference-manifest.json +4 -0
- package/.next/server/app/api/settings/notify/route.js +7 -0
- package/.next/server/app/api/settings/notify/route.js.map +5 -0
- package/.next/server/app/api/settings/notify/route.js.nft.json +1 -0
- package/.next/server/app/api/settings/notify/route_client-reference-manifest.js +3 -0
- package/.next/server/app/api/usage/route.js +1 -1
- package/.next/server/app/api/usage/route.js.nft.json +1 -1
- package/.next/server/app/float/page/app-paths-manifest.json +3 -0
- package/.next/server/app/float/page/build-manifest.json +16 -0
- package/.next/server/app/float/page/next-font-manifest.json +11 -0
- package/.next/server/app/float/page/react-loadable-manifest.json +1 -0
- package/.next/server/app/float/page/server-reference-manifest.json +4 -0
- package/.next/server/app/float/page.js +15 -0
- package/.next/server/app/float/page.js.map +5 -0
- package/.next/server/app/float/page.js.nft.json +1 -0
- package/.next/server/app/float/page_client-reference-manifest.js +3 -0
- package/.next/server/app/install.sh/route/app-paths-manifest.json +3 -0
- package/.next/server/app/install.sh/route/build-manifest.json +9 -0
- package/.next/server/app/install.sh/route/server-reference-manifest.json +4 -0
- package/.next/server/app/install.sh/route.js +6 -0
- package/.next/server/app/install.sh/route.js.map +5 -0
- package/.next/server/app/install.sh/route.js.nft.json +1 -0
- package/.next/server/app/install.sh/route_client-reference-manifest.js +3 -0
- package/.next/server/app/install.sh.body +108 -0
- package/.next/server/app/install.sh.meta +1 -0
- package/.next/server/app/page/build-manifest.json +4 -5
- package/.next/server/app/page.js +8 -5
- package/.next/server/app/page.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app/settings/page/app-paths-manifest.json +3 -0
- package/.next/server/app/settings/page/build-manifest.json +16 -0
- package/.next/server/app/settings/page/next-font-manifest.json +11 -0
- package/.next/server/app/settings/page/react-loadable-manifest.json +1 -0
- package/.next/server/app/settings/page/server-reference-manifest.json +4 -0
- package/.next/server/app/settings/page.js +17 -0
- package/.next/server/app/settings/page.js.map +5 -0
- package/.next/server/app/settings/page.js.nft.json +1 -0
- package/.next/server/app/settings/page_client-reference-manifest.js +3 -0
- package/.next/server/app-paths-manifest.json +7 -1
- package/.next/server/chunks/[externals]__0_i~3ox._.js +3 -0
- package/.next/server/chunks/[externals]__0_i~3ox._.js.map +1 -0
- package/.next/server/chunks/[externals]__13p_1zh._.js +3 -0
- package/.next/server/chunks/[externals]__13p_1zh._.js.map +1 -0
- package/.next/server/chunks/[root-of-the-server]__01t2c3w._.js +38 -0
- package/.next/server/chunks/[root-of-the-server]__01t2c3w._.js.map +1 -0
- package/.next/server/chunks/{[root-of-the-server]__0g0u0lm._.js → [root-of-the-server]__024yzee._.js} +2 -2
- package/.next/server/chunks/{[root-of-the-server]__00q0o~z._.js → [root-of-the-server]__082iwfa._.js} +2 -2
- package/.next/server/chunks/[root-of-the-server]__0chedn~._.js +1 -1
- package/.next/server/chunks/[root-of-the-server]__0mwf0bn._.js +38 -0
- package/.next/server/chunks/[root-of-the-server]__0mwf0bn._.js.map +1 -0
- package/.next/server/chunks/{[root-of-the-server]__0-74syk._.js → [root-of-the-server]__0ru3_it._.js} +2 -2
- package/.next/server/chunks/[root-of-the-server]__0u6y_k1._.js +111 -0
- package/.next/server/chunks/[root-of-the-server]__0u6y_k1._.js.map +1 -0
- package/.next/server/chunks/{[root-of-the-server]__0866q87._.js → [root-of-the-server]__0xa4dzi._.js} +2 -2
- package/.next/server/chunks/[root-of-the-server]__13415c6._.js +96 -0
- package/.next/server/chunks/[root-of-the-server]__13415c6._.js.map +1 -0
- package/.next/server/chunks/[root-of-the-server]__13j_28o._.js +96 -0
- package/.next/server/chunks/[root-of-the-server]__13j_28o._.js.map +1 -0
- package/.next/server/chunks/_next-internal_server_app_api_float_route_actions_012j~jr.js +3 -0
- package/.next/server/chunks/_next-internal_server_app_api_float_route_actions_012j~jr.js.map +1 -0
- package/.next/server/chunks/_next-internal_server_app_api_settings_alerts_route_actions_0ydcyko.js +3 -0
- package/.next/server/chunks/_next-internal_server_app_api_settings_alerts_route_actions_0ydcyko.js.map +1 -0
- package/.next/server/chunks/_next-internal_server_app_api_settings_notify_route_actions_0-j35mb.js +3 -0
- package/.next/server/chunks/_next-internal_server_app_api_settings_notify_route_actions_0-j35mb.js.map +1 -0
- package/.next/server/chunks/_next-internal_server_app_install_sh_route_actions_0cj-6me.js +3 -0
- package/.next/server/chunks/_next-internal_server_app_install_sh_route_actions_0cj-6me.js.map +1 -0
- package/.next/server/chunks/node_modules_next_06f88ko._.js +19 -0
- package/.next/server/chunks/node_modules_next_06f88ko._.js.map +1 -0
- package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_03jj5jj.js +18 -0
- package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_03jj5jj.js.map +1 -0
- package/.next/server/chunks/src_12i1qk5._.js +3 -0
- package/.next/server/chunks/src_12i1qk5._.js.map +1 -0
- package/.next/server/chunks/src_lib_alerts_ticker_ts_0n6oy1d._.js +169 -0
- package/.next/server/chunks/src_lib_alerts_ticker_ts_0n6oy1d._.js.map +1 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__03mt8o0._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__03mt8o0._.js.map +1 -0
- package/.next/server/chunks/ssr/{[root-of-the-server]__098zro9._.js → [root-of-the-server]__090jxzh._.js} +2 -2
- package/.next/server/chunks/ssr/[root-of-the-server]__090jxzh._.js.map +1 -0
- package/.next/server/chunks/ssr/{[root-of-the-server]__09hgk-7._.js → [root-of-the-server]__0c3j40u._.js} +2 -2
- package/.next/server/chunks/ssr/[root-of-the-server]__0c3j40u._.js.map +1 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0ot4nev._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0ot4nev._.js.map +1 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0q_~l2m._.js +90 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0q_~l2m._.js.map +1 -0
- package/.next/server/chunks/ssr/{[root-of-the-server]__0cqes87._.js → [root-of-the-server]__0qt26ty._.js} +2 -2
- package/.next/server/chunks/ssr/[root-of-the-server]__0qt26ty._.js.map +1 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0s5.uhg._.js +55 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0s5.uhg._.js.map +1 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0x~-phx._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0x~-phx._.js.map +1 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0y_zq8k._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0y_zq8k._.js.map +1 -0
- package/.next/server/chunks/ssr/{[root-of-the-server]__08qr2ji._.js → [root-of-the-server]__0~953ob._.js} +2 -2
- package/.next/server/chunks/ssr/[root-of-the-server]__0~953ob._.js.map +1 -0
- package/.next/server/chunks/ssr/_0-9j34y._.js +70 -0
- package/.next/server/chunks/ssr/_0-9j34y._.js.map +1 -0
- package/.next/server/chunks/ssr/_0jxmm9h._.js +6 -0
- package/.next/server/chunks/ssr/_0jxmm9h._.js.map +1 -0
- package/.next/server/chunks/ssr/_0lfe3wr._.js +3 -0
- package/.next/server/chunks/ssr/_0lfe3wr._.js.map +1 -0
- package/.next/server/chunks/ssr/{node_modules_0i2xw~e._.js → _0nvprxh._.js} +5 -2
- package/.next/server/chunks/ssr/_0nvprxh._.js.map +1 -0
- package/.next/server/chunks/ssr/_0pugb10._.js +6 -0
- package/.next/server/chunks/ssr/_0pugb10._.js.map +1 -0
- package/.next/server/chunks/ssr/_next-internal_server_app_float_page_actions_0x6hm4p.js +3 -0
- package/.next/server/chunks/ssr/_next-internal_server_app_float_page_actions_0x6hm4p.js.map +1 -0
- package/.next/server/chunks/ssr/_next-internal_server_app_settings_page_actions_0mr68ai.js +3 -0
- package/.next/server/chunks/ssr/_next-internal_server_app_settings_page_actions_0mr68ai.js.map +1 -0
- package/.next/server/chunks/ssr/node_modules_@swc_helpers_cjs__interop_require_default_cjs_11~q6fv._.js +3 -0
- package/.next/server/chunks/ssr/node_modules_@swc_helpers_cjs__interop_require_default_cjs_11~q6fv._.js.map +1 -0
- package/.next/server/chunks/ssr/node_modules_next_dist_09jzzl8._.js +3 -0
- package/.next/server/chunks/ssr/node_modules_next_dist_09jzzl8._.js.map +1 -0
- package/.next/server/chunks/ssr/{node_modules_next_dist_0e1izl_._.js → node_modules_next_dist_0h9llsw._.js} +2 -2
- package/.next/server/chunks/ssr/node_modules_next_dist_0h9llsw._.js.map +1 -0
- package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_06b_a87.js +4 -0
- package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_06b_a87.js.map +1 -0
- package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0p_u4px.js +4 -0
- package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0p_u4px.js.map +1 -0
- package/.next/server/chunks/ssr/src_0urkups._.js +3 -0
- package/.next/server/chunks/ssr/src_0urkups._.js.map +1 -0
- package/.next/server/chunks/ssr/src_components_0ox9d~w._.js +3 -0
- package/.next/server/chunks/ssr/src_components_0ox9d~w._.js.map +1 -0
- package/.next/server/chunks/ssr/src_components_CodexAccountsPanel_tsx_0n9xjzi._.js +1 -1
- package/.next/server/chunks/ssr/src_components_CodexAccountsPanel_tsx_0n9xjzi._.js.map +1 -1
- package/.next/server/chunks/ssr/src_components_FloatingWidget_tsx_089.6oo._.js +3 -0
- package/.next/server/chunks/ssr/src_components_FloatingWidget_tsx_089.6oo._.js.map +1 -0
- package/.next/server/chunks/ssr/src_lib_i18n_client_tsx_07pysgz._.js +3 -0
- package/.next/server/chunks/ssr/src_lib_i18n_client_tsx_07pysgz._.js.map +1 -0
- package/.next/server/edge/chunks/_0-m5tdn._.js +3 -0
- package/.next/server/edge/chunks/_0-m5tdn._.js.map +1 -0
- package/.next/server/edge/chunks/node_modules_next_dist_esm_build_templates_edge-wrapper_0mz-5sp.js.map +1 -0
- package/.next/server/edge/chunks/turbopack-node_modules_next_dist_esm_build_templates_edge-wrapper_0mz-5sp.js +3 -0
- package/.next/server/functions-config-manifest.json +4 -1
- package/.next/server/instrumentation/middleware-manifest.json +12 -0
- package/.next/server/instrumentation.js +4 -0
- package/.next/server/instrumentation.js.map +5 -0
- package/.next/server/instrumentation.js.nft.json +1 -0
- package/.next/server/middleware-build-manifest.js +7 -8
- package/.next/server/next-font-manifest.js +1 -1
- package/.next/server/next-font-manifest.json +8 -0
- package/.next/server/pages/500.html +1 -1
- package/.next/server/pages-manifest.json +0 -1
- package/.next/server/server-reference-manifest.js +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/0bymg5d-lqs0o.js +5 -0
- package/.next/static/chunks/0ebdqsutnp8y4.js +1 -0
- package/.next/static/chunks/0i3~mnai-l497.js +1 -0
- package/.next/static/chunks/0idj4tdmyr934.js +1 -0
- package/.next/static/chunks/10eoi4q2wk80z.js +1 -0
- package/.next/static/chunks/10refkmyp9rbl.css +3 -0
- package/.next/static/chunks/{07lhk_q6pmm3r.js → 10u3y4bw1ayzs.js} +1 -1
- package/.next/static/chunks/{00gq-v0e07i1q.js → 115dplafwys-z.js} +1 -1
- package/.next/static/chunks/12-9memveha-v.js +1 -0
- package/.next/static/chunks/163s8y.j70104.js +4 -0
- package/.next/static/chunks/turbopack-0k9twle9a8sh6.js +1 -0
- package/.next/types/routes.d.ts +8 -2
- package/.next/types/validator.ts +54 -0
- package/README.md +57 -17
- package/bin/vibemeter-float.swift +970 -0
- package/bin/vibemeter-notify.sh +172 -0
- package/bin/vibemeter.mjs +385 -7
- package/package.json +2 -1
- package/public/demo1.png +0 -0
- package/public/float-ball.png +0 -0
- package/public/float-collapsed.png +0 -0
- package/public/float-expanded.png +0 -0
- package/public/pay-alipay.jpg +0 -0
- package/.next/server/app/_not-found.html +0 -1
- package/.next/server/app/_not-found.meta +0 -16
- package/.next/server/app/_not-found.rsc +0 -16
- package/.next/server/app/_not-found.segments/_full.segment.rsc +0 -16
- package/.next/server/app/_not-found.segments/_head.segment.rsc +0 -6
- package/.next/server/app/_not-found.segments/_index.segment.rsc +0 -5
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +0 -5
- package/.next/server/app/_not-found.segments/_not-found.segment.rsc +0 -5
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +0 -2
- package/.next/server/chunks/[root-of-the-server]__0y68a5d._.js +0 -96
- package/.next/server/chunks/[root-of-the-server]__0y68a5d._.js.map +0 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__054lv8d._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__054lv8d._.js.map +0 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__08qr2ji._.js.map +0 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__098zro9._.js.map +0 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__09hgk-7._.js.map +0 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0cqes87._.js.map +0 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0l.drdn._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__0l.drdn._.js.map +0 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0v1zqf~._.js +0 -120
- package/.next/server/chunks/ssr/[root-of-the-server]__0v1zqf~._.js.map +0 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0v73tbn._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__0v73tbn._.js.map +0 -1
- package/.next/server/chunks/ssr/_0ye~8el._.js +0 -7
- package/.next/server/chunks/ssr/_0ye~8el._.js.map +0 -1
- package/.next/server/chunks/ssr/node_modules_0ck2~9g._.js +0 -3
- package/.next/server/chunks/ssr/node_modules_0ck2~9g._.js.map +0 -1
- package/.next/server/chunks/ssr/node_modules_0i2xw~e._.js.map +0 -1
- package/.next/server/chunks/ssr/node_modules_next_dist_0e1izl_._.js.map +0 -1
- package/.next/server/chunks/ssr/node_modules_next_dist_0gsjr7_._.js +0 -3
- package/.next/server/chunks/ssr/node_modules_next_dist_0gsjr7_._.js.map +0 -1
- package/.next/server/pages/404.html +0 -1
- package/.next/static/chunks/002~6040mndie.css +0 -3
- package/.next/static/chunks/02g3221oh~3le.js +0 -2
- package/.next/static/chunks/0ljfidstam_7k.js +0 -1
- package/.next/static/chunks/0pqt~8bl3ukh4.js +0 -4
- package/.next/static/chunks/0yz9oprcns4nm.js +0 -2
- package/.next/static/chunks/turbopack-14vio.b1b9i4l.js +0 -1
- /package/.next/server/chunks/{[root-of-the-server]__0g0u0lm._.js.map → [root-of-the-server]__024yzee._.js.map} +0 -0
- /package/.next/server/chunks/{[root-of-the-server]__00q0o~z._.js.map → [root-of-the-server]__082iwfa._.js.map} +0 -0
- /package/.next/server/chunks/{[root-of-the-server]__0-74syk._.js.map → [root-of-the-server]__0ru3_it._.js.map} +0 -0
- /package/.next/server/chunks/{[root-of-the-server]__0866q87._.js.map → [root-of-the-server]__0xa4dzi._.js.map} +0 -0
- /package/.next/static/{DcqlVrgh4gM8jlqrnOCpT → _Y03MiN6NI16Ms86q6vCJ}/_buildManifest.js +0 -0
- /package/.next/static/{DcqlVrgh4gM8jlqrnOCpT → _Y03MiN6NI16Ms86q6vCJ}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{DcqlVrgh4gM8jlqrnOCpT → _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()
|