@sriinnu/runic 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (584) hide show
  1. package/.swiftformat +50 -0
  2. package/.swiftlint.yml +160 -0
  3. package/CHANGELOG.md +307 -0
  4. package/CONTRIBUTING.md +456 -0
  5. package/Icon.icns +0 -0
  6. package/Icon.icon/Assets/runic.png +0 -0
  7. package/Icon.icon/icon.json +36 -0
  8. package/LICENSE +21 -0
  9. package/Package.resolved +51 -0
  10. package/Package.swift +136 -0
  11. package/Packages/Helix/LICENSE +21 -0
  12. package/Packages/Helix/Package.swift +29 -0
  13. package/Packages/Helix/README.md +976 -0
  14. package/Packages/Helix/Sources/Helix/Binding.swift +111 -0
  15. package/Packages/Helix/Sources/Helix/CommandParser.swift +160 -0
  16. package/Packages/Helix/Sources/Helix/CommandSignature.swift +117 -0
  17. package/Packages/Helix/Sources/Helix/ExpressibleFromArgument.swift +72 -0
  18. package/Packages/Helix/Sources/Helix/HelpGenerator.swift +175 -0
  19. package/Packages/Helix/Sources/Helix/NameSpecification.swift +98 -0
  20. package/Packages/Helix/Sources/Helix/ParsableCommand.swift +204 -0
  21. package/Packages/Helix/Sources/Helix/Platform/DarwinPlatformContext.swift +44 -0
  22. package/Packages/Helix/Sources/Helix/Platform/LinuxPlatformContext.swift +34 -0
  23. package/Packages/Helix/Sources/Helix/Platform/PlatformContext+Default.swift +91 -0
  24. package/Packages/Helix/Sources/Helix/Platform/PlatformContext.swift +94 -0
  25. package/Packages/Helix/Sources/Helix/Platform/PlatformPath.swift +167 -0
  26. package/Packages/Helix/Sources/Helix/Platform/Stdio.swift +99 -0
  27. package/Packages/Helix/Sources/Helix/Platform/WebPlatformContext.swift +146 -0
  28. package/Packages/Helix/Sources/Helix/Platform/WindowsPlatformContext.swift +97 -0
  29. package/Packages/Helix/Sources/Helix/Program.swift +204 -0
  30. package/Packages/Helix/Sources/Helix/PropertyWrappers.swift +367 -0
  31. package/Packages/Silo/LICENSE +21 -0
  32. package/Packages/Silo/Package.swift +34 -0
  33. package/Packages/Silo/README.md +466 -0
  34. package/Packages/Silo/Sources/Silo/BinaryCookiesReader.swift +169 -0
  35. package/Packages/Silo/Sources/Silo/BrowserCookieAnalytics.swift +365 -0
  36. package/Packages/Silo/Sources/Silo/BrowserCookieBackends.swift +594 -0
  37. package/Packages/Silo/Sources/Silo/BrowserCookieClient.swift +326 -0
  38. package/Packages/Silo/Sources/Silo/BrowserCookieExport.swift +154 -0
  39. package/Packages/Silo/Sources/Silo/BrowserCookieImport.swift +119 -0
  40. package/Packages/Silo/Sources/Silo/BrowserCookieMockStore.swift +121 -0
  41. package/Packages/Silo/Sources/Silo/BrowserCookieModels.swift +391 -0
  42. package/Packages/Silo/Sources/Silo/ChromiumCookieReader.swift +240 -0
  43. package/Packages/Silo/Sources/Silo/CryptoSupport.swift +86 -0
  44. package/Packages/Silo/Sources/Silo/FirefoxCookieReader.swift +172 -0
  45. package/Packages/Silo/Sources/Silo/LinuxChromiumCookieReader.swift +172 -0
  46. package/Packages/Silo/Sources/Silo/LinuxSecretService.swift +105 -0
  47. package/Packages/Silo/Sources/Silo/MacOSChromiumCookieReader.swift +191 -0
  48. package/Packages/Silo/Sources/Silo/SQLiteSnapshot.swift +41 -0
  49. package/Packages/Silo/Sources/Silo/WindowsChromiumCookieReader.swift +110 -0
  50. package/README.md +109 -0
  51. package/RuniciOS/App/RuniciOSApp.swift +105 -0
  52. package/RuniciOS/Core/iOSUsageStore.swift +192 -0
  53. package/RuniciOS/Views/ProviderDetailView.swift +312 -0
  54. package/RuniciOS/Views/ProviderListView.swift +228 -0
  55. package/RuniciOS/Widgets/LargeWidget.swift +367 -0
  56. package/RuniciOS/Widgets/LiveActivity.swift +342 -0
  57. package/RuniciOS/Widgets/LockScreenWidget.swift +273 -0
  58. package/RuniciOS/Widgets/MediumWidget.swift +370 -0
  59. package/RuniciOS/Widgets/RunicWidget.swift +274 -0
  60. package/RuniciOS/Widgets/SmallWidget.swift +281 -0
  61. package/RuniciOS/Widgets/WidgetModels.swift +285 -0
  62. package/RuniciOS/Widgets/WidgetTimeline.swift +270 -0
  63. package/Scripts/CREATION-SUMMARY.md +367 -0
  64. package/Scripts/DIRECTORY-STRUCTURE.md +358 -0
  65. package/Scripts/GETTING-STARTED-CHECKLIST.md +364 -0
  66. package/Scripts/INDEX.md +365 -0
  67. package/Scripts/QUICK-REFERENCE.md +164 -0
  68. package/Scripts/README-SCRIPTS.md +926 -0
  69. package/Scripts/build-android.sh +526 -0
  70. package/Scripts/build-ios.sh +529 -0
  71. package/Scripts/build-macos.sh +603 -0
  72. package/Scripts/build-windows.sh +504 -0
  73. package/Scripts/build_icon.sh +49 -0
  74. package/Scripts/changelog-to-html.sh +89 -0
  75. package/Scripts/check-release-assets.sh +10 -0
  76. package/Scripts/ci-config.yml +474 -0
  77. package/Scripts/compile_and_run.sh +219 -0
  78. package/Scripts/compile_and_run_adhoc.sh +4 -0
  79. package/Scripts/docs-list.mjs +119 -0
  80. package/Scripts/make_appcast.sh +58 -0
  81. package/Scripts/package_app.sh +409 -0
  82. package/Scripts/release-lib.sh +214 -0
  83. package/Scripts/release.sh +76 -0
  84. package/Scripts/setup-all.sh +387 -0
  85. package/Scripts/setup-ios.sh +404 -0
  86. package/Scripts/setup-react-native.sh +523 -0
  87. package/Scripts/sign-and-notarize.sh +122 -0
  88. package/Scripts/test_live_update.sh +38 -0
  89. package/Scripts/validate_changelog.sh +23 -0
  90. package/Scripts/verify_appcast.sh +92 -0
  91. package/Sources/Runic/About.swift +93 -0
  92. package/Sources/Runic/AppNotifications.swift +115 -0
  93. package/Sources/Runic/Controllers/StatusItemController+Actions.swift +657 -0
  94. package/Sources/Runic/Controllers/StatusItemController+Animation.swift +518 -0
  95. package/Sources/Runic/Controllers/StatusItemController+ChartSubmenus.swift +810 -0
  96. package/Sources/Runic/Controllers/StatusItemController+CustomProviders.swift +87 -0
  97. package/Sources/Runic/Controllers/StatusItemController+Menu.swift +1379 -0
  98. package/Sources/Runic/Controllers/StatusItemController+MenuCards.swift +337 -0
  99. package/Sources/Runic/Controllers/StatusItemController+MenuHelpers.swift +395 -0
  100. package/Sources/Runic/Controllers/StatusItemController.swift +360 -0
  101. package/Sources/Runic/Core/BudgetNotificationManager.swift +80 -0
  102. package/Sources/Runic/Core/Login/ClaudeLoginRunner.swift +103 -0
  103. package/Sources/Runic/Core/Login/CodexLoginRunner.swift +154 -0
  104. package/Sources/Runic/Core/Login/CursorLoginRunner.swift +206 -0
  105. package/Sources/Runic/Core/Login/GeminiLoginRunner.swift +95 -0
  106. package/Sources/Runic/Core/Rendering/IconRenderer.swift +1216 -0
  107. package/Sources/Runic/Core/Rendering/IconView.swift +120 -0
  108. package/Sources/Runic/Core/Rendering/ProviderBrandIcon.swift +71 -0
  109. package/Sources/Runic/Core/RunicAnimation.swift +53 -0
  110. package/Sources/Runic/Core/RunicColors.swift +73 -0
  111. package/Sources/Runic/Core/RunicCornerRadius.swift +15 -0
  112. package/Sources/Runic/Core/RunicSpacing.swift +68 -0
  113. package/Sources/Runic/Core/Stores/AuggieTokenStore.swift +143 -0
  114. package/Sources/Runic/Core/Stores/AzureOpenAITokenStore.swift +143 -0
  115. package/Sources/Runic/Core/Stores/CerebrasTokenStore.swift +143 -0
  116. package/Sources/Runic/Core/Stores/CohereTokenStore.swift +143 -0
  117. package/Sources/Runic/Core/Stores/CopilotTokenStore.swift +143 -0
  118. package/Sources/Runic/Core/Stores/CustomProviderStore.swift +158 -0
  119. package/Sources/Runic/Core/Stores/DeepSeekTokenStore.swift +143 -0
  120. package/Sources/Runic/Core/Stores/FireworksTokenStore.swift +143 -0
  121. package/Sources/Runic/Core/Stores/GroqTokenStore.swift +143 -0
  122. package/Sources/Runic/Core/Stores/KimiTokenStore.swift +143 -0
  123. package/Sources/Runic/Core/Stores/MiniMaxCookieHeaderStore.swift +143 -0
  124. package/Sources/Runic/Core/Stores/MiniMaxGroupIDStore.swift +143 -0
  125. package/Sources/Runic/Core/Stores/MiniMaxTokenStore.swift +143 -0
  126. package/Sources/Runic/Core/Stores/MistralTokenStore.swift +143 -0
  127. package/Sources/Runic/Core/Stores/OpenRouterTokenStore.swift +143 -0
  128. package/Sources/Runic/Core/Stores/PerplexityTokenStore.swift +143 -0
  129. package/Sources/Runic/Core/Stores/ProviderToggleStore.swift +30 -0
  130. package/Sources/Runic/Core/Stores/QwenTokenStore.swift +143 -0
  131. package/Sources/Runic/Core/Stores/SambaNovaTokenStore.swift +143 -0
  132. package/Sources/Runic/Core/Stores/SettingsStore.swift +1702 -0
  133. package/Sources/Runic/Core/Stores/TogetherTokenStore.swift +143 -0
  134. package/Sources/Runic/Core/Stores/UsageStore+Accessors.swift +20 -0
  135. package/Sources/Runic/Core/Stores/UsageStore+Status.swift +189 -0
  136. package/Sources/Runic/Core/Stores/UsageStore+TokenCost.swift +33 -0
  137. package/Sources/Runic/Core/Stores/UsageStore+WidgetSnapshot.swift +67 -0
  138. package/Sources/Runic/Core/Stores/UsageStore.swift +3564 -0
  139. package/Sources/Runic/Core/Stores/XAITokenStore.swift +143 -0
  140. package/Sources/Runic/Core/Stores/ZaiTokenStore.swift +143 -0
  141. package/Sources/Runic/Core/UsageExporter.swift +193 -0
  142. package/Sources/Runic/Date+RelativeDescription.swift +21 -0
  143. package/Sources/Runic/HiddenWindowView.swift +32 -0
  144. package/Sources/Runic/InstallOrigin.swift +9 -0
  145. package/Sources/Runic/KeyboardShortcuts+Names.swift +6 -0
  146. package/Sources/Runic/MouseLocationReader.swift +65 -0
  147. package/Sources/Runic/Notifications+Runic.swift +6 -0
  148. package/Sources/Runic/OpenAICreditsPurchaseWindowController.swift +503 -0
  149. package/Sources/Runic/ProviderRegistry.swift +100 -0
  150. package/Sources/Runic/ProviderSwitcherButtons.swift +267 -0
  151. package/Sources/Runic/Providers/Antigravity/AntigravityLoginFlow.swift +11 -0
  152. package/Sources/Runic/Providers/Antigravity/AntigravityProviderImplementation.swift +14 -0
  153. package/Sources/Runic/Providers/Auggie/AuggieProviderImplementation.swift +24 -0
  154. package/Sources/Runic/Providers/Azure/AzureProviderImplementation.swift +52 -0
  155. package/Sources/Runic/Providers/Bedrock/BedrockProviderImplementation.swift +42 -0
  156. package/Sources/Runic/Providers/Cerebras/CerebrasProviderImplementation.swift +24 -0
  157. package/Sources/Runic/Providers/Claude/ClaudeLoginFlow.swift +26 -0
  158. package/Sources/Runic/Providers/Claude/ClaudeProviderImplementation.swift +14 -0
  159. package/Sources/Runic/Providers/Codex/CodexLoginFlow.swift +18 -0
  160. package/Sources/Runic/Providers/Codex/CodexProviderImplementation.swift +15 -0
  161. package/Sources/Runic/Providers/Cohere/CohereProviderImplementation.swift +24 -0
  162. package/Sources/Runic/Providers/Copilot/CopilotLoginFlow.swift +193 -0
  163. package/Sources/Runic/Providers/Copilot/CopilotProviderImplementation.swift +110 -0
  164. package/Sources/Runic/Providers/Cursor/CursorLoginFlow.swift +28 -0
  165. package/Sources/Runic/Providers/Cursor/CursorProviderImplementation.swift +15 -0
  166. package/Sources/Runic/Providers/DeepSeek/DeepSeekProviderImplementation.swift +24 -0
  167. package/Sources/Runic/Providers/Factory/FactoryLoginFlow.swift +12 -0
  168. package/Sources/Runic/Providers/Factory/FactoryProviderImplementation.swift +15 -0
  169. package/Sources/Runic/Providers/Fireworks/FireworksProviderImplementation.swift +24 -0
  170. package/Sources/Runic/Providers/Gemini/GeminiLoginFlow.swift +20 -0
  171. package/Sources/Runic/Providers/Gemini/GeminiProviderImplementation.swift +15 -0
  172. package/Sources/Runic/Providers/Groq/GroqProviderImplementation.swift +24 -0
  173. package/Sources/Runic/Providers/Kimi/KimiProviderImplementation.swift +24 -0
  174. package/Sources/Runic/Providers/MiniMax/MiniMaxProviderImplementation.swift +32 -0
  175. package/Sources/Runic/Providers/Mistral/MistralProviderImplementation.swift +24 -0
  176. package/Sources/Runic/Providers/OpenRouter/OpenRouterProviderImplementation.swift +24 -0
  177. package/Sources/Runic/Providers/Perplexity/PerplexityProviderImplementation.swift +24 -0
  178. package/Sources/Runic/Providers/SambaNova/SambaNovaProviderImplementation.swift +24 -0
  179. package/Sources/Runic/Providers/Shared/ProviderCatalog.swift +15 -0
  180. package/Sources/Runic/Providers/Shared/ProviderImplementation.swift +48 -0
  181. package/Sources/Runic/Providers/Shared/ProviderImplementationRegistry.swift +66 -0
  182. package/Sources/Runic/Providers/Shared/ProviderLoginFlow.swift +12 -0
  183. package/Sources/Runic/Providers/Shared/ProviderSettingsDescriptors.swift +99 -0
  184. package/Sources/Runic/Providers/Shared/SystemSettingsLinks.swift +18 -0
  185. package/Sources/Runic/Providers/Together/TogetherProviderImplementation.swift +24 -0
  186. package/Sources/Runic/Providers/XAI/XAIProviderImplementation.swift +24 -0
  187. package/Sources/Runic/Providers/Zai/ZaiProviderImplementation.swift +24 -0
  188. package/Sources/Runic/Resources/ProviderIcon-antigravity.svg +2 -0
  189. package/Sources/Runic/Resources/ProviderIcon-auggie.svg +3 -0
  190. package/Sources/Runic/Resources/ProviderIcon-azure.svg +5 -0
  191. package/Sources/Runic/Resources/ProviderIcon-bedrock.svg +7 -0
  192. package/Sources/Runic/Resources/ProviderIcon-cerebras.svg +3 -0
  193. package/Sources/Runic/Resources/ProviderIcon-claude.svg +1 -0
  194. package/Sources/Runic/Resources/ProviderIcon-codex.svg +1 -0
  195. package/Sources/Runic/Resources/ProviderIcon-cohere.svg +3 -0
  196. package/Sources/Runic/Resources/ProviderIcon-copilot.svg +1 -0
  197. package/Sources/Runic/Resources/ProviderIcon-cursor.svg +3 -0
  198. package/Sources/Runic/Resources/ProviderIcon-deepseek.svg +4 -0
  199. package/Sources/Runic/Resources/ProviderIcon-factory.svg +3 -0
  200. package/Sources/Runic/Resources/ProviderIcon-fireworks.svg +3 -0
  201. package/Sources/Runic/Resources/ProviderIcon-gemini.svg +1 -0
  202. package/Sources/Runic/Resources/ProviderIcon-groq.svg +1 -0
  203. package/Sources/Runic/Resources/ProviderIcon-kimi.svg +4 -0
  204. package/Sources/Runic/Resources/ProviderIcon-minimax.svg +1 -0
  205. package/Sources/Runic/Resources/ProviderIcon-mistral.svg +5 -0
  206. package/Sources/Runic/Resources/ProviderIcon-openrouter.svg +7 -0
  207. package/Sources/Runic/Resources/ProviderIcon-perplexity.svg +4 -0
  208. package/Sources/Runic/Resources/ProviderIcon-qwen.svg +9 -0
  209. package/Sources/Runic/Resources/ProviderIcon-sambanova.svg +4 -0
  210. package/Sources/Runic/Resources/ProviderIcon-together.svg +5 -0
  211. package/Sources/Runic/Resources/ProviderIcon-vertexai.svg +17 -0
  212. package/Sources/Runic/Resources/ProviderIcon-xai.svg +3 -0
  213. package/Sources/Runic/Resources/ProviderIcon-zai.svg +5 -0
  214. package/Sources/Runic/Resources/RunicMenubarIcon.svg +7 -0
  215. package/Sources/Runic/RunicApp.swift +357 -0
  216. package/Sources/Runic/SessionQuotaNotifications.swift +58 -0
  217. package/Sources/Runic/UsagePaceText.swift +52 -0
  218. package/Sources/Runic/UsageProgressBar.swift +224 -0
  219. package/Sources/Runic/Utilities/DisplayLink.swift +96 -0
  220. package/Sources/Runic/Utilities/LoadingPattern.swift +60 -0
  221. package/Sources/Runic/Utilities/PerformanceConstants.swift +85 -0
  222. package/Sources/Runic/Views/Menu/AlertsMenuView.swift +256 -0
  223. package/Sources/Runic/Views/Menu/ChartPanelStyle.swift +22 -0
  224. package/Sources/Runic/Views/Menu/CostComparativeChartMenuView.swift +275 -0
  225. package/Sources/Runic/Views/Menu/CostHistoryChartMenuView.swift +377 -0
  226. package/Sources/Runic/Views/Menu/CreditsHistoryChartMenuView.swift +312 -0
  227. package/Sources/Runic/Views/Menu/EfficiencyMetricsMenuView.swift +312 -0
  228. package/Sources/Runic/Views/Menu/HeroStatCardsMenuView.swift +228 -0
  229. package/Sources/Runic/Views/Menu/HourlyActivityChartMenuView.swift +228 -0
  230. package/Sources/Runic/Views/Menu/InlineUsageChartView.swift +162 -0
  231. package/Sources/Runic/Views/Menu/MenuCardEntranceModifier.swift +64 -0
  232. package/Sources/Runic/Views/Menu/MenuCardSidebarView.swift +268 -0
  233. package/Sources/Runic/Views/Menu/MenuCardSkeletonView.swift +104 -0
  234. package/Sources/Runic/Views/Menu/MenuCardView.swift +1900 -0
  235. package/Sources/Runic/Views/Menu/MenuContent.swift +150 -0
  236. package/Sources/Runic/Views/Menu/MenuDescriptor.swift +369 -0
  237. package/Sources/Runic/Views/Menu/MenuHighlightStyle.swift +42 -0
  238. package/Sources/Runic/Views/Menu/ModelBreakdownMenuView.swift +235 -0
  239. package/Sources/Runic/Views/Menu/OverviewMenuView.swift +141 -0
  240. package/Sources/Runic/Views/Menu/ProjectBreakdownMenuView.swift +209 -0
  241. package/Sources/Runic/Views/Menu/ProjectBudgetMenuView.swift +220 -0
  242. package/Sources/Runic/Views/Menu/ProviderTabBarView.swift +88 -0
  243. package/Sources/Runic/Views/Menu/SubscriptionUtilizationChartMenuView.swift +201 -0
  244. package/Sources/Runic/Views/Menu/UsageBreakdownChartMenuView.swift +398 -0
  245. package/Sources/Runic/Views/Menu/UsageHeatmapMenuView.swift +232 -0
  246. package/Sources/Runic/Views/Menu/UsageTimelineChartMenuView.swift +417 -0
  247. package/Sources/Runic/Views/Menu/UsageWindowComparisonChartMenuView.swift +190 -0
  248. package/Sources/Runic/Views/Menu/WeeklyActivityChartMenuView.swift +132 -0
  249. package/Sources/Runic/Views/Performance/PerformanceDashboardView.swift +618 -0
  250. package/Sources/Runic/Views/Preferences/LiquidComponents.swift +305 -0
  251. package/Sources/Runic/Views/Preferences/PreferencesAboutPane.swift +271 -0
  252. package/Sources/Runic/Views/Preferences/PreferencesAdvancedPane.swift +268 -0
  253. package/Sources/Runic/Views/Preferences/PreferencesAlertsPane.swift +422 -0
  254. package/Sources/Runic/Views/Preferences/PreferencesAnalyticsPane.swift +641 -0
  255. package/Sources/Runic/Views/Preferences/PreferencesBudgetsPane.swift +344 -0
  256. package/Sources/Runic/Views/Preferences/PreferencesComponents.swift +230 -0
  257. package/Sources/Runic/Views/Preferences/PreferencesCustomProvidersPane.swift +322 -0
  258. package/Sources/Runic/Views/Preferences/PreferencesDebugPane.swift +469 -0
  259. package/Sources/Runic/Views/Preferences/PreferencesGeneralPane.swift +231 -0
  260. package/Sources/Runic/Views/Preferences/PreferencesHelpPane.swift +50 -0
  261. package/Sources/Runic/Views/Preferences/PreferencesIntegrationsPane.swift +497 -0
  262. package/Sources/Runic/Views/Preferences/PreferencesPerformancePane.swift +288 -0
  263. package/Sources/Runic/Views/Preferences/PreferencesProvidersPane.swift +3435 -0
  264. package/Sources/Runic/Views/Preferences/PreferencesSelection.swift +25 -0
  265. package/Sources/Runic/Views/Preferences/PreferencesSyncPane.swift +59 -0
  266. package/Sources/Runic/Views/Preferences/PreferencesView.swift +206 -0
  267. package/Sources/Runic/Views/Rating/QualityRatingView.swift +238 -0
  268. package/Sources/Runic/Views/Team/TeamDashboardView.swift +371 -0
  269. package/Sources/Runic/Views/Team/TeamInviteSheet.swift +175 -0
  270. package/Sources/Runic/Views/Team/TeamManagementView.swift +761 -0
  271. package/Sources/Runic/Views/Team/TeamMemberRow.swift +181 -0
  272. package/Sources/RunicCLI/CLIEntry.swift +942 -0
  273. package/Sources/RunicCLI/CLIRenderer.swift +87 -0
  274. package/Sources/RunicCLI/Commands/EnhancedUsageCommand.swift +588 -0
  275. package/Sources/RunicCLI/Commands/ModelsCommand.swift +362 -0
  276. package/Sources/RunicCLI/Commands/ProjectsCommand.swift +439 -0
  277. package/Sources/RunicCLI/Commands/ResetCommand.swift +429 -0
  278. package/Sources/RunicCLI/Commands/UsageLedgerSourceFactory.swift +168 -0
  279. package/Sources/RunicClaudeWatchdog/main.swift +122 -0
  280. package/Sources/RunicClaudeWebProbe/main.swift +62 -0
  281. package/Sources/RunicCore/AlertRuleStore.swift +187 -0
  282. package/Sources/RunicCore/BrowserCookieImportOrder.swift +25 -0
  283. package/Sources/RunicCore/CopilotUsageModels.swift +84 -0
  284. package/Sources/RunicCore/CostUsageFetcher.swift +119 -0
  285. package/Sources/RunicCore/CostUsageModels.swift +479 -0
  286. package/Sources/RunicCore/CreditsModels.swift +50 -0
  287. package/Sources/RunicCore/CustomProviders/CustomProviderModels.swift +269 -0
  288. package/Sources/RunicCore/CustomProviders/GenericProviderFetcher.swift +622 -0
  289. package/Sources/RunicCore/CustomProviders/ProviderTemplates.swift +500 -0
  290. package/Sources/RunicCore/Host/PTY/TTYCommandRunner.swift +679 -0
  291. package/Sources/RunicCore/Host/Process/SubprocessRunner.swift +135 -0
  292. package/Sources/RunicCore/Integrations/GitHubIntegration.swift +173 -0
  293. package/Sources/RunicCore/Logging/JSONStderrLogHandler.swift +79 -0
  294. package/Sources/RunicCore/Logging/OSLogLogHandler.swift +86 -0
  295. package/Sources/RunicCore/Logging/RunicLog.swift +123 -0
  296. package/Sources/RunicCore/Models/EnhancedUsageModels.swift +371 -0
  297. package/Sources/RunicCore/OpenAIDashboardModels.swift +210 -0
  298. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift +676 -0
  299. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardFetcher.swift +492 -0
  300. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift +51 -0
  301. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardParser.swift +483 -0
  302. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift +558 -0
  303. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +204 -0
  304. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardWebsiteDataStore.swift +78 -0
  305. package/Sources/RunicCore/PathEnvironment.swift +482 -0
  306. package/Sources/RunicCore/Performance/PerformanceModels.swift +371 -0
  307. package/Sources/RunicCore/Performance/PerformanceStorageImpl.swift +409 -0
  308. package/Sources/RunicCore/Policy/UsagePolicyEngine.swift +178 -0
  309. package/Sources/RunicCore/ProjectBudgetStore.swift +99 -0
  310. package/Sources/RunicCore/ProviderCostSnapshot.swift +29 -0
  311. package/Sources/RunicCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +66 -0
  312. package/Sources/RunicCore/Providers/Antigravity/AntigravityStatusProbe.swift +685 -0
  313. package/Sources/RunicCore/Providers/Auggie/AuggieProviderDescriptor.swift +83 -0
  314. package/Sources/RunicCore/Providers/Auggie/AuggieUsageFetcher.swift +142 -0
  315. package/Sources/RunicCore/Providers/Azure/AzureProviderDescriptor.swift +141 -0
  316. package/Sources/RunicCore/Providers/Azure/AzureUsageFetcher.swift +186 -0
  317. package/Sources/RunicCore/Providers/Bedrock/BedrockProviderDescriptor.swift +126 -0
  318. package/Sources/RunicCore/Providers/Bedrock/BedrockUsageFetcher.swift +168 -0
  319. package/Sources/RunicCore/Providers/Cerebras/CerebrasProviderDescriptor.swift +84 -0
  320. package/Sources/RunicCore/Providers/Cerebras/CerebrasUsageFetcher.swift +75 -0
  321. package/Sources/RunicCore/Providers/Claude/ClaudeCLISession.swift +332 -0
  322. package/Sources/RunicCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +180 -0
  323. package/Sources/RunicCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +137 -0
  324. package/Sources/RunicCore/Providers/Claude/ClaudeProviderDescriptor.swift +207 -0
  325. package/Sources/RunicCore/Providers/Claude/ClaudeStatusProbe.swift +654 -0
  326. package/Sources/RunicCore/Providers/Claude/ClaudeUsageDataSource.swift +28 -0
  327. package/Sources/RunicCore/Providers/Claude/ClaudeUsageFetcher.swift +480 -0
  328. package/Sources/RunicCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +780 -0
  329. package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +146 -0
  330. package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +269 -0
  331. package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift +100 -0
  332. package/Sources/RunicCore/Providers/Codex/CodexProviderDescriptor.swift +243 -0
  333. package/Sources/RunicCore/Providers/Codex/CodexStatusProbe.swift +125 -0
  334. package/Sources/RunicCore/Providers/Codex/CodexUsageDataSource.swift +24 -0
  335. package/Sources/RunicCore/Providers/Codex/CodexWebDashboardStrategy.swift +181 -0
  336. package/Sources/RunicCore/Providers/Cohere/CohereProviderDescriptor.swift +83 -0
  337. package/Sources/RunicCore/Providers/Cohere/CohereUsageFetcher.swift +85 -0
  338. package/Sources/RunicCore/Providers/Copilot/CopilotDeviceFlow.swift +123 -0
  339. package/Sources/RunicCore/Providers/Copilot/CopilotGitHubCLITokenReader.swift +114 -0
  340. package/Sources/RunicCore/Providers/Copilot/CopilotProviderDescriptor.swift +100 -0
  341. package/Sources/RunicCore/Providers/Copilot/CopilotUsageFetcher.swift +291 -0
  342. package/Sources/RunicCore/Providers/Copilot/CopilotVSCodeTokenReader.swift +386 -0
  343. package/Sources/RunicCore/Providers/Cursor/CursorProviderDescriptor.swift +65 -0
  344. package/Sources/RunicCore/Providers/Cursor/CursorStatusProbe.swift +594 -0
  345. package/Sources/RunicCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift +85 -0
  346. package/Sources/RunicCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift +108 -0
  347. package/Sources/RunicCore/Providers/Factory/FactoryLocalStorageImporter.swift +398 -0
  348. package/Sources/RunicCore/Providers/Factory/FactoryProviderDescriptor.swift +65 -0
  349. package/Sources/RunicCore/Providers/Factory/FactoryStatusProbe.swift +1275 -0
  350. package/Sources/RunicCore/Providers/Fireworks/FireworksProviderDescriptor.swift +84 -0
  351. package/Sources/RunicCore/Providers/Fireworks/FireworksUsageFetcher.swift +75 -0
  352. package/Sources/RunicCore/Providers/Gemini/GeminiProviderDescriptor.swift +65 -0
  353. package/Sources/RunicCore/Providers/Gemini/GeminiStatusProbe.swift +835 -0
  354. package/Sources/RunicCore/Providers/Groq/GroqProviderDescriptor.swift +83 -0
  355. package/Sources/RunicCore/Providers/Groq/GroqUsageFetcher.swift +71 -0
  356. package/Sources/RunicCore/Providers/Kimi/KimiProviderDescriptor.swift +83 -0
  357. package/Sources/RunicCore/Providers/Kimi/KimiUsageFetcher.swift +77 -0
  358. package/Sources/RunicCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +92 -0
  359. package/Sources/RunicCore/Providers/MiniMax/MiniMaxSettingsReader.swift +54 -0
  360. package/Sources/RunicCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +171 -0
  361. package/Sources/RunicCore/Providers/MiniMax/MiniMaxWebUsageFetcher.swift +911 -0
  362. package/Sources/RunicCore/Providers/Mistral/MistralProviderDescriptor.swift +84 -0
  363. package/Sources/RunicCore/Providers/Mistral/MistralUsageFetcher.swift +75 -0
  364. package/Sources/RunicCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift +87 -0
  365. package/Sources/RunicCore/Providers/OpenRouter/OpenRouterUsageFetcher.swift +207 -0
  366. package/Sources/RunicCore/Providers/Perplexity/PerplexityProviderDescriptor.swift +84 -0
  367. package/Sources/RunicCore/Providers/Perplexity/PerplexityUsageFetcher.swift +75 -0
  368. package/Sources/RunicCore/Providers/ProviderBranding.swift +25 -0
  369. package/Sources/RunicCore/Providers/ProviderCLIConfig.swift +17 -0
  370. package/Sources/RunicCore/Providers/ProviderDescriptor.swift +129 -0
  371. package/Sources/RunicCore/Providers/ProviderFetchPlan.swift +263 -0
  372. package/Sources/RunicCore/Providers/ProviderSettingsSnapshot.swift +98 -0
  373. package/Sources/RunicCore/Providers/ProviderTokenResolver.swift +501 -0
  374. package/Sources/RunicCore/Providers/ProviderVersionDetector.swift +68 -0
  375. package/Sources/RunicCore/Providers/Providers.swift +184 -0
  376. package/Sources/RunicCore/Providers/Qwen/QwenProviderDescriptor.swift +84 -0
  377. package/Sources/RunicCore/Providers/Qwen/QwenSettingsReader.swift +30 -0
  378. package/Sources/RunicCore/Providers/Qwen/QwenUsageStats.swift +317 -0
  379. package/Sources/RunicCore/Providers/SambaNova/SambaNovaProviderDescriptor.swift +84 -0
  380. package/Sources/RunicCore/Providers/SambaNova/SambaNovaUsageFetcher.swift +75 -0
  381. package/Sources/RunicCore/Providers/Together/TogetherProviderDescriptor.swift +83 -0
  382. package/Sources/RunicCore/Providers/Together/TogetherUsageFetcher.swift +75 -0
  383. package/Sources/RunicCore/Providers/VertexAI/VertexAIProviderDescriptor.swift +123 -0
  384. package/Sources/RunicCore/Providers/VertexAI/VertexAISettingsReader.swift +48 -0
  385. package/Sources/RunicCore/Providers/VertexAI/VertexAIUsageFetcher.swift +144 -0
  386. package/Sources/RunicCore/Providers/XAI/XAIProviderDescriptor.swift +83 -0
  387. package/Sources/RunicCore/Providers/XAI/XAIUsageFetcher.swift +75 -0
  388. package/Sources/RunicCore/Providers/Zai/ZaiProviderDescriptor.swift +72 -0
  389. package/Sources/RunicCore/Providers/Zai/ZaiSettingsReader.swift +41 -0
  390. package/Sources/RunicCore/Providers/Zai/ZaiUsageStats.swift +724 -0
  391. package/Sources/RunicCore/Sync/BackgroundSyncManager.swift +385 -0
  392. package/Sources/RunicCore/Sync/SyncConflictResolver.swift +310 -0
  393. package/Sources/RunicCore/Sync/SyncProtocol.swift +288 -0
  394. package/Sources/RunicCore/Sync/SyncRecord.swift +405 -0
  395. package/Sources/RunicCore/Sync/SyncUsageExample.swift +294 -0
  396. package/Sources/RunicCore/Sync/TeamSyncRecords.swift +775 -0
  397. package/Sources/RunicCore/Sync/iCloudSyncEngine.swift +333 -0
  398. package/Sources/RunicCore/Team/TeamModels.swift +583 -0
  399. package/Sources/RunicCore/Team/TeamShowbackExporter.swift +495 -0
  400. package/Sources/RunicCore/Team/TeamStore.swift +792 -0
  401. package/Sources/RunicCore/TextParsing.swift +55 -0
  402. package/Sources/RunicCore/UsageFetcher.swift +694 -0
  403. package/Sources/RunicCore/UsageFormatter.swift +410 -0
  404. package/Sources/RunicCore/UsageLedger/ClaudeUsageLogSource.swift +329 -0
  405. package/Sources/RunicCore/UsageLedger/CodexUsageLogSource.swift +448 -0
  406. package/Sources/RunicCore/UsageLedger/OTelGenAILedgerAdapter.swift +550 -0
  407. package/Sources/RunicCore/UsageLedger/UsageLedger.swift +29 -0
  408. package/Sources/RunicCore/UsageLedger/UsageLedgerAggregator.swift +660 -0
  409. package/Sources/RunicCore/UsageLedger/UsageLedgerInsightsAdvisor.swift +209 -0
  410. package/Sources/RunicCore/UsageLedger/UsageLedgerModels.swift +417 -0
  411. package/Sources/RunicCore/UsageLedger/UsageLedgerProjectIdentity.swift +245 -0
  412. package/Sources/RunicCore/UsagePace.swift +81 -0
  413. package/Sources/RunicCore/Vendored/CostUsage/CostUsageCache.swift +73 -0
  414. package/Sources/RunicCore/Vendored/CostUsage/CostUsageJsonl.swift +74 -0
  415. package/Sources/RunicCore/Vendored/CostUsage/CostUsagePricing.swift +208 -0
  416. package/Sources/RunicCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +455 -0
  417. package/Sources/RunicCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift +132 -0
  418. package/Sources/RunicCore/Vendored/CostUsage/CostUsageScanner.swift +589 -0
  419. package/Sources/RunicCore/WidgetSnapshot.swift +183 -0
  420. package/Sources/RunicMacroSupport/ProviderRegistrationMacros.swift +14 -0
  421. package/Sources/RunicMacros/ProviderRegistrationMacros.swift +216 -0
  422. package/Sources/RunicWidget/RunicWidgetBundle.swift +79 -0
  423. package/Sources/RunicWidget/RunicWidgetProvider.swift +283 -0
  424. package/Sources/RunicWidget/RunicWidgetViews.swift +680 -0
  425. package/Tests/RunicTests/AntigravityStatusProbeTests.swift +50 -0
  426. package/Tests/RunicTests/AppDelegateTests.swift +43 -0
  427. package/Tests/RunicTests/BrowserCookieOrderLabelTests.swift +22 -0
  428. package/Tests/RunicTests/CLIArgumentParsingTests.swift +70 -0
  429. package/Tests/RunicTests/CLICostTests.swift +65 -0
  430. package/Tests/RunicTests/CLIProviderSelectionTests.swift +22 -0
  431. package/Tests/RunicTests/CLISnapshotTests.swift +72 -0
  432. package/Tests/RunicTests/CLIWebFallbackTests.swift +58 -0
  433. package/Tests/RunicTests/ClaudeOAuthTests.swift +164 -0
  434. package/Tests/RunicTests/ClaudeResilienceTests.swift +30 -0
  435. package/Tests/RunicTests/ClaudeUsageTests.swift +370 -0
  436. package/Tests/RunicTests/CodexOAuthTests.swift +122 -0
  437. package/Tests/RunicTests/CodexUsageLogSourceTests.swift +82 -0
  438. package/Tests/RunicTests/CopilotTokenResolverTests.swift +75 -0
  439. package/Tests/RunicTests/CopilotVSCodeTokenReaderTests.swift +75 -0
  440. package/Tests/RunicTests/CostUsageDecodingTests.swift +394 -0
  441. package/Tests/RunicTests/CostUsagePricingTests.swift +50 -0
  442. package/Tests/RunicTests/CostUsageScannerTests.swift +700 -0
  443. package/Tests/RunicTests/CursorStatusProbeTests.swift +323 -0
  444. package/Tests/RunicTests/FactoryStatusProbeTests.swift +69 -0
  445. package/Tests/RunicTests/GeminiAPITestHelpers.swift +94 -0
  446. package/Tests/RunicTests/GeminiLoginAlertTests.swift +28 -0
  447. package/Tests/RunicTests/GeminiStatusProbeAPITests.swift +283 -0
  448. package/Tests/RunicTests/GeminiStatusProbePlanTests.swift +279 -0
  449. package/Tests/RunicTests/GeminiStatusProbeTests.swift +233 -0
  450. package/Tests/RunicTests/GeminiTestEnvironment.swift +87 -0
  451. package/Tests/RunicTests/GoogleWorkspaceStatusTests.swift +73 -0
  452. package/Tests/RunicTests/InstallOriginTests.swift +18 -0
  453. package/Tests/RunicTests/KeyboardShortcutsBundleTests.swift +9 -0
  454. package/Tests/RunicTests/LiveAccountTests.swift +24 -0
  455. package/Tests/RunicTests/LoadingPatternTests.swift +35 -0
  456. package/Tests/RunicTests/MenuCardModelTests.swift +545 -0
  457. package/Tests/RunicTests/MiniMaxCookieHeaderStoreTestSupport.swift +8 -0
  458. package/Tests/RunicTests/MiniMaxGroupIDStoreTestSupport.swift +8 -0
  459. package/Tests/RunicTests/MiniMaxParsingTests.swift +68 -0
  460. package/Tests/RunicTests/MiniMaxTokenStoreTestSupport.swift +8 -0
  461. package/Tests/RunicTests/OTelGenAILedgerAdapterTests.swift +112 -0
  462. package/Tests/RunicTests/OpenAIDashboardBrowserCookieImporterTests.swift +17 -0
  463. package/Tests/RunicTests/OpenAIDashboardFetcherCreditsWaitTests.swift +75 -0
  464. package/Tests/RunicTests/OpenAIDashboardOffscreenHostTests.swift +27 -0
  465. package/Tests/RunicTests/OpenAIDashboardParserTests.swift +134 -0
  466. package/Tests/RunicTests/OpenAIWebAccountSwitchTests.swift +53 -0
  467. package/Tests/RunicTests/OpenRouterTokenStoreTestSupport.swift +92 -0
  468. package/Tests/RunicTests/PathBuilderTests.swift +237 -0
  469. package/Tests/RunicTests/ProviderFetchPipelineTests.swift +160 -0
  470. package/Tests/RunicTests/ProviderIconResourcesTests.swift +38 -0
  471. package/Tests/RunicTests/ProviderMetadataStatusLinkTests.swift +16 -0
  472. package/Tests/RunicTests/ProviderRegistryTests.swift +29 -0
  473. package/Tests/RunicTests/ProviderSettingsDescriptorTests.swift +202 -0
  474. package/Tests/RunicTests/ProviderToggleStoreTests.swift +51 -0
  475. package/Tests/RunicTests/RunicTests.swift +120 -0
  476. package/Tests/RunicTests/SessionQuotaNotificationLogicTests.swift +36 -0
  477. package/Tests/RunicTests/SettingsStoreTests.swift +161 -0
  478. package/Tests/RunicTests/StatusItemAnimationTests.swift +49 -0
  479. package/Tests/RunicTests/StatusMenuTests.swift +434 -0
  480. package/Tests/RunicTests/StatusProbeTests.swift +377 -0
  481. package/Tests/RunicTests/SubprocessRunnerTests.swift +20 -0
  482. package/Tests/RunicTests/SubscriptionDetectionTests.swift +65 -0
  483. package/Tests/RunicTests/TTYCommandRunnerTests.swift +148 -0
  484. package/Tests/RunicTests/TTYIntegrationTests.swift +41 -0
  485. package/Tests/RunicTests/TeamShowbackExporterTests.swift +159 -0
  486. package/Tests/RunicTests/TextParsingTests.swift +12 -0
  487. package/Tests/RunicTests/UsageFormatterTests.swift +112 -0
  488. package/Tests/RunicTests/UsageLedgerAnomalyDetectionTests.swift +172 -0
  489. package/Tests/RunicTests/UsageLedgerForecastTests.swift +213 -0
  490. package/Tests/RunicTests/UsageLedgerInsightsAdvisorTests.swift +83 -0
  491. package/Tests/RunicTests/UsageLedgerProjectNameTests.swift +170 -0
  492. package/Tests/RunicTests/UsagePaceTests.swift +77 -0
  493. package/Tests/RunicTests/UsagePaceTextTests.swift +109 -0
  494. package/Tests/RunicTests/UsagePolicyEngineTests.swift +91 -0
  495. package/Tests/RunicTests/WidgetSnapshotTests.swift +45 -0
  496. package/Tests/RunicTests/ZaiProviderTests.swift +61 -0
  497. package/Tests/RunicTests/ZaiTokenStoreTestSupport.swift +6 -0
  498. package/TestsLinux/PlatformGatingTests.swift +46 -0
  499. package/appcast.xml +105 -0
  500. package/assets/logo-concept-stack.svg +24 -0
  501. package/assets/logo-minimal-bars.svg +15 -0
  502. package/assets/logo.svg +20 -0
  503. package/assets/logo2.svg +43 -0
  504. package/bin/docs-list +5 -0
  505. package/bin/install-runic-cli.sh +32 -0
  506. package/build-and-run.sh +369 -0
  507. package/docs/.nojekyll +1 -0
  508. package/docs/CNAME +1 -0
  509. package/docs/antigravity.md +75 -0
  510. package/docs/architecture.md +32 -0
  511. package/docs/claude.md +93 -0
  512. package/docs/cli-commands/integration_guide.md +371 -0
  513. package/docs/cli-commands/quick_reference.md +362 -0
  514. package/docs/cli-commands/readme.md +361 -0
  515. package/docs/cli.md +156 -0
  516. package/docs/cloudkit_sync_guide.md +495 -0
  517. package/docs/codebase_review.md +288 -0
  518. package/docs/codex-oauth.md +635 -0
  519. package/docs/codex.md +104 -0
  520. package/docs/codexbar.png +0 -0
  521. package/docs/copilot.md +44 -0
  522. package/docs/cursor.md +46 -0
  523. package/docs/design/designcomparisonchart.md +510 -0
  524. package/docs/design/implementationguide.md +792 -0
  525. package/docs/design/menubaricondesignconcepts.md +796 -0
  526. package/docs/design/mockups/hybridiconconcept.svg +62 -0
  527. package/docs/design/mockups/infinityiconconcept.svg +41 -0
  528. package/docs/design/mockups/infinityiconstates.svg +129 -0
  529. package/docs/design/mockups/wavelogorefined.svg +48 -0
  530. package/docs/design/quick_summary.md +426 -0
  531. package/docs/design/readme.md +478 -0
  532. package/docs/design/sizespecifications.md +575 -0
  533. package/docs/design-system.md +836 -0
  534. package/docs/factory.md +103 -0
  535. package/docs/gemini.md +73 -0
  536. package/docs/icon.md +37 -0
  537. package/docs/icon.png +0 -0
  538. package/docs/index.html +175 -0
  539. package/docs/keychain_fix.md +274 -0
  540. package/docs/minimax.md +71 -0
  541. package/docs/packaging.md +25 -0
  542. package/docs/progress-bar-improvements.md +240 -0
  543. package/docs/progress-bar-quick-reference.md +283 -0
  544. package/docs/progress-bar-technical-details.md +432 -0
  545. package/docs/progress-bar-visual-test.swift +375 -0
  546. package/docs/provider.md +202 -0
  547. package/docs/providers.md +86 -0
  548. package/docs/refactor/macros.md +35 -0
  549. package/docs/refresh-loop.md +22 -0
  550. package/docs/releasing-homebrew.md +35 -0
  551. package/docs/releasing.md +121 -0
  552. package/docs/research/concrete_todo_2026-02-22.md +47 -0
  553. package/docs/research/core-feature-research.md +248 -0
  554. package/docs/research/feature_uiux_roadmap_2026-02-22.md +202 -0
  555. package/docs/research/parity_matrix.md +49 -0
  556. package/docs/research/provider_expansion_candidates_2026-02-22.md +80 -0
  557. package/docs/research/provider_model_token_project_matrix_2026-02-23.md +46 -0
  558. package/docs/research/release_gate_checklist.md +57 -0
  559. package/docs/site.css +505 -0
  560. package/docs/sparkle.md +28 -0
  561. package/docs/status.md +28 -0
  562. package/docs/sync/implementation.md +393 -0
  563. package/docs/sync/readme.md +317 -0
  564. package/docs/sync_files_summary.md +391 -0
  565. package/docs/sync_quick_reference.md +225 -0
  566. package/docs/todo.md +11 -0
  567. package/docs/ui.md +29 -0
  568. package/docs/web-integration.md +9 -0
  569. package/docs/widgets.md +85 -0
  570. package/docs/workflows/error-handling-improvements.md +434 -0
  571. package/docs/workflows/folder_structure.md +299 -0
  572. package/docs/workflows/loading_states.md +446 -0
  573. package/docs/workflows/loading_states_examples.md +565 -0
  574. package/docs/workflows/loading_states_flow.md +430 -0
  575. package/docs/workflows/performance_optimizations.md +457 -0
  576. package/docs/workflows/phase1-cli-testing-report.md +313 -0
  577. package/docs/workflows/quick_reference.md +228 -0
  578. package/docs/workflows/readme.md +59 -0
  579. package/docs/workflows/recent_improvements.md +275 -0
  580. package/docs/workflows/sync_enablement_summary.md +400 -0
  581. package/docs/zai.md +43 -0
  582. package/package.json +36 -0
  583. package/runic.png +0 -0
  584. package/version.env +2 -0
@@ -0,0 +1,1900 @@
1
+ import AppKit
2
+ import RunicCore
3
+ import SwiftUI
4
+
5
+ /// **Menu Card Layout Metrics**
6
+ /// Defines all spacing and padding constants for the menu card UI.
7
+ /// These values are carefully tuned for readability and visual hierarchy.
8
+ enum MenuCardMetrics {
9
+ /// Horizontal padding on left/right edges of card content
10
+ static let horizontalPadding: CGFloat = RunicSpacing.sm // 12
11
+
12
+ /// Top padding for the header section (provider name, email)
13
+ static let headerTopPadding: CGFloat = RunicSpacing.xxs // 4
14
+
15
+ /// Bottom padding for the header section
16
+ static let headerBottomPadding: CGFloat = RunicSpacing.xxs // 4
17
+
18
+ /// Top padding for content sections (usage, credits, cost)
19
+ static let sectionTopPadding: CGFloat = RunicSpacing.xs // 8
20
+
21
+ /// Bottom padding for content sections
22
+ static let sectionBottomPadding: CGFloat = RunicSpacing.xxs // 4
23
+
24
+ /// Vertical spacing between major sections (e.g., Session vs Weekly)
25
+ static let sectionSpacing: CGFloat = RunicSpacing.xs // 8
26
+
27
+ /// Spacing between related blocks of content
28
+ static let blockSpacing: CGFloat = RunicSpacing.xs // 8
29
+
30
+ /// Spacing between individual text lines within a section
31
+ static let lineSpacing: CGFloat = RunicSpacing.xxs // 4
32
+
33
+ /// Base padding for menu items
34
+ static let menuItemBasePadding: CGFloat = RunicSpacing.xxs // 4
35
+
36
+ /// Additional padding for text with descenders (y, g, p, q, j)
37
+ static let menuItemDescenderPadding: CGFloat = RunicSpacing.xxxs // 2
38
+
39
+ /// Tail padding at the end of sections
40
+ static let tailPadding: CGFloat = RunicSpacing.xxs // 4
41
+ static let metricCardPadding: CGFloat = RunicSpacing.sm // 8
42
+ static let metricCardCornerRadius: CGFloat = RunicCornerRadius.sm
43
+ }
44
+
45
+ /// SwiftUI card used inside the NSMenu to mirror Apple's rich menu panels.
46
+ struct UsageMenuCardView: View {
47
+ struct Model {
48
+ enum PercentStyle: String, Sendable {
49
+ case left
50
+ case used
51
+
52
+ var labelSuffix: String {
53
+ switch self {
54
+ case .left: "left"
55
+ case .used: "used"
56
+ }
57
+ }
58
+
59
+ var accessibilityLabel: String {
60
+ switch self {
61
+ case .left: "Usage remaining"
62
+ case .used: "Usage used"
63
+ }
64
+ }
65
+ }
66
+
67
+ struct Metric: Identifiable {
68
+ let id: String
69
+ let title: String
70
+ let percent: Double
71
+ let percentStyle: PercentStyle
72
+ let resetText: String?
73
+ let detailText: String?
74
+
75
+ var percentLabel: String {
76
+ String(format: "%.0f%% %@", self.percent, self.percentStyle.labelSuffix)
77
+ }
78
+ }
79
+
80
+ enum SubtitleStyle {
81
+ case info
82
+ case loading
83
+ case error
84
+ }
85
+
86
+ struct TokenUsageSection: Sendable {
87
+ let sessionLine: String
88
+ let sessionDetailLine: String?
89
+ let monthLine: String
90
+ let monthDetailLine: String?
91
+ let updatedLine: String
92
+ let hintLine: String?
93
+ let errorLine: String?
94
+ let errorCopyText: String?
95
+ }
96
+
97
+ struct ProviderCostSection: Sendable {
98
+ let title: String
99
+ let percentUsed: Double
100
+ let spendLine: String
101
+ }
102
+
103
+ struct InsightsSection: Sendable {
104
+ let title: String
105
+ let todayLine: String?
106
+ let todayDetail: String?
107
+ let forecastLine: String?
108
+ let blockLine: String?
109
+ let blockDetail: String?
110
+ let modelLine: String?
111
+ let projectLine: String?
112
+ let projectDetail: String?
113
+ let anomalyLine: String?
114
+ let anomalyDetail: String?
115
+ let reliabilityLine: String?
116
+ let reliabilityDetail: String?
117
+ let routingLine: String?
118
+ let routingDetail: String?
119
+ let updatedLine: String?
120
+ let errorLine: String?
121
+ }
122
+
123
+ enum HeaderBadgeStyle {
124
+ case info
125
+ case warning
126
+ case error
127
+ }
128
+
129
+ struct HeaderBadge {
130
+ let text: String
131
+ let style: HeaderBadgeStyle
132
+ }
133
+
134
+ let provider: UsageProvider
135
+ let providerName: String
136
+ let email: String
137
+ let subtitleText: String
138
+ let subtitleStyle: SubtitleStyle
139
+ let planText: String?
140
+ let topModelLine: String?
141
+ let headerBadge: HeaderBadge?
142
+ let metrics: [Metric]
143
+ let usageMetricDisplayMode: UsageMetricDisplayMode
144
+ let menuMode: MenuMode
145
+ let creditsText: String?
146
+ let creditsRemaining: Double?
147
+ let creditsHintText: String?
148
+ let creditsHintCopyText: String?
149
+ let providerCost: ProviderCostSection?
150
+ let tokenUsage: TokenUsageSection?
151
+ let insights: InsightsSection?
152
+ let placeholder: String?
153
+ let progressColor: Color
154
+ }
155
+
156
+ let model: Model
157
+ let width: CGFloat
158
+ @Environment(\.menuItemHighlighted) private var isHighlighted
159
+
160
+ init(model: Model, width: CGFloat) {
161
+ self.model = model
162
+ self.width = width
163
+ }
164
+
165
+ var body: some View {
166
+ VStack(alignment: .leading, spacing: RunicSpacing.xxs) {
167
+ UsageMenuCardHeaderView(model: self.model)
168
+
169
+ if self.hasDetails {
170
+ Divider()
171
+ .padding(.vertical, RunicSpacing.xxs)
172
+ }
173
+
174
+ if self.model.metrics.isEmpty {
175
+ if let placeholder = self.model.placeholder {
176
+ MenuEmptyStateView(
177
+ providerName: self.model.providerName,
178
+ placeholder: placeholder,
179
+ isHighlighted: self.isHighlighted)
180
+ }
181
+ } else {
182
+ let hasUsage = !self.model.metrics.isEmpty
183
+ let includeSummarySections = self.model.menuMode != .glance
184
+ let includeInsightSections = self.model.menuMode == .`operator`
185
+ let hasCredits = includeSummarySections && self.model.creditsText != nil
186
+ let hasExtraUsage = includeSummarySections && self.model.providerCost != nil
187
+ let hasTokenCost = includeSummarySections && self.model.tokenUsage != nil
188
+ let hasCost = hasExtraUsage || hasTokenCost
189
+ let hasInsights = includeInsightSections && self.model.insights != nil
190
+
191
+ VStack(alignment: .leading, spacing: MenuCardMetrics.sectionSpacing) {
192
+ if hasUsage {
193
+ VStack(alignment: .leading, spacing: MenuCardMetrics.sectionSpacing) {
194
+ ForEach(self.model.metrics) { metric in
195
+ let displayMode = self.model.usageMetricDisplayMode
196
+ UsageMenuMetricCard(
197
+ metric: metric,
198
+ displayMode: displayMode,
199
+ tint: self.model.progressColor,
200
+ isHighlighted: self.isHighlighted)
201
+ }
202
+ }
203
+ }
204
+ if hasUsage, hasCredits || hasCost || hasInsights {
205
+ Divider()
206
+ .padding(.vertical, RunicSpacing.xxs)
207
+ }
208
+ if hasCredits, let credits = self.model.creditsText {
209
+ CreditsBarContent(
210
+ creditsText: credits,
211
+ creditsRemaining: self.model.creditsRemaining,
212
+ hintText: self.model.creditsHintText,
213
+ hintCopyText: self.model.creditsHintCopyText,
214
+ progressColor: self.model.progressColor)
215
+ }
216
+ if hasCredits, hasCost || hasInsights {
217
+ Divider()
218
+ .padding(.vertical, RunicSpacing.xxs)
219
+ }
220
+ if hasExtraUsage, let providerCost = self.model.providerCost {
221
+ ProviderCostContent(
222
+ section: providerCost,
223
+ progressColor: self.model.progressColor)
224
+ }
225
+ if hasExtraUsage, hasTokenCost || hasInsights {
226
+ Divider()
227
+ .padding(.vertical, RunicSpacing.xxs)
228
+ }
229
+ if hasTokenCost, let tokenUsage = self.model.tokenUsage {
230
+ VStack(alignment: .leading, spacing: MenuCardMetrics.lineSpacing) {
231
+ Text("Cost")
232
+ .font(.body)
233
+ .fontWeight(.medium)
234
+ Text(tokenUsage.sessionLine)
235
+ .font(.footnote)
236
+ if let sessionDetail = tokenUsage.sessionDetailLine {
237
+ Text(sessionDetail)
238
+ .font(.footnote)
239
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
240
+ }
241
+ Text(tokenUsage.monthLine)
242
+ .font(.footnote)
243
+ if let monthDetail = tokenUsage.monthDetailLine {
244
+ Text(monthDetail)
245
+ .font(.footnote)
246
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
247
+ }
248
+ Text(tokenUsage.updatedLine)
249
+ .font(.caption)
250
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
251
+ if let hint = tokenUsage.hintLine, !hint.isEmpty {
252
+ Text(hint)
253
+ .font(.footnote)
254
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
255
+ .lineLimit(4)
256
+ .fixedSize(horizontal: false, vertical: true)
257
+ }
258
+ if let error = tokenUsage.errorLine, !error.isEmpty {
259
+ HStack(alignment: .top, spacing: RunicSpacing.xs) {
260
+ Image(systemName: "exclamationmark.triangle.fill")
261
+ .font(.footnote)
262
+ .foregroundStyle(RunicColors.error)
263
+ Text(error)
264
+ .font(.footnote)
265
+ .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted))
266
+ .lineLimit(4)
267
+ .fixedSize(horizontal: false, vertical: true)
268
+ }
269
+ .padding(RunicSpacing.xs)
270
+ .background(
271
+ RoundedRectangle(cornerRadius: RunicCornerRadius.sm, style: .continuous)
272
+ .fill(RunicColors.error.opacity(RunicColors.Opacity.subtle)))
273
+ .overlay {
274
+ ClickToCopyOverlay(copyText: tokenUsage.errorCopyText ?? error)
275
+ }
276
+ }
277
+ }
278
+ }
279
+ if hasCost, hasInsights {
280
+ Divider()
281
+ .padding(.vertical, RunicSpacing.xxs)
282
+ }
283
+ if hasInsights, let insights = self.model.insights {
284
+ InsightsContent(section: insights)
285
+ }
286
+ }
287
+ .padding(.bottom, self.model.creditsText == nil ? MenuCardMetrics.tailPadding : 0)
288
+ }
289
+ }
290
+ .padding(.horizontal, MenuCardMetrics.horizontalPadding)
291
+ .padding(.top, MenuCardMetrics.headerTopPadding)
292
+ .padding(.bottom, MenuCardMetrics.headerBottomPadding)
293
+ .frame(width: self.width, alignment: .leading)
294
+ }
295
+
296
+ private var hasDetails: Bool {
297
+ let includeSummarySections = self.model.menuMode != .glance
298
+ let includeInsightSections = self.model.menuMode == .`operator`
299
+ return !self.model.metrics.isEmpty || self.model.placeholder != nil || (includeSummarySections && (
300
+ self.model.tokenUsage != nil || self.model.providerCost != nil)) || (includeInsightSections &&
301
+ self.model.insights != nil)
302
+ }
303
+ }
304
+
305
+ private struct UsageMenuCardHeaderView: View {
306
+ let model: UsageMenuCardView.Model
307
+ @Environment(\.menuItemHighlighted) private var isHighlighted
308
+
309
+ var body: some View {
310
+ VStack(alignment: .leading, spacing: RunicSpacing.xs) {
311
+ HStack(alignment: .center, spacing: RunicSpacing.sm) {
312
+ ProviderAvatarView(provider: self.model.provider)
313
+
314
+ VStack(alignment: .leading, spacing: RunicSpacing.xxxs) {
315
+ Text(self.model.providerName)
316
+ .font(.headline)
317
+ .fontWeight(.semibold)
318
+ if !self.model.email.isEmpty {
319
+ ProfilePill(
320
+ text: self.model.email,
321
+ systemImage: "person.crop.circle",
322
+ tint: self.brandAccent)
323
+ .lineLimit(1)
324
+ }
325
+ }
326
+
327
+ Spacer()
328
+
329
+ VStack(alignment: .trailing, spacing: RunicSpacing.xxxs) {
330
+ if let badge = self.model.headerBadge {
331
+ MenuHeaderBadgeView(badge: badge, isHighlighted: self.isHighlighted)
332
+ }
333
+ if let plan = self.model.planText {
334
+ ProfilePill(
335
+ text: plan,
336
+ systemImage: "sparkles",
337
+ tint: self.brandAccent,
338
+ style: .plan)
339
+ .lineLimit(1)
340
+ }
341
+ }
342
+ }
343
+
344
+ let subtitleAlignment: VerticalAlignment = self.model.subtitleStyle == .error ? .top : .firstTextBaseline
345
+ HStack(alignment: subtitleAlignment) {
346
+ Text(self.model.subtitleText)
347
+ .font(.footnote.weight(.medium))
348
+ .foregroundStyle(self.subtitleColor)
349
+ .lineLimit(self.model.subtitleStyle == .error ? 4 : 1)
350
+ .multilineTextAlignment(.leading)
351
+ .fixedSize(horizontal: false, vertical: true)
352
+ .layoutPriority(1)
353
+ .padding(.bottom, self.model.subtitleStyle == .error ? 4 : 0)
354
+ Spacer()
355
+ if self.model.subtitleStyle == .error, !self.model.subtitleText.isEmpty {
356
+ CopyIconButton(copyText: self.model.subtitleText, isHighlighted: self.isHighlighted)
357
+ }
358
+ }
359
+
360
+ if let topModelLine = self.model.topModelLine {
361
+ Text(topModelLine)
362
+ .font(.footnote)
363
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
364
+ .lineLimit(1)
365
+ }
366
+ }
367
+ .padding(.horizontal, RunicSpacing.xs)
368
+ .padding(.vertical, RunicSpacing.xs)
369
+ .background(self.headerBackground)
370
+ .overlay(self.headerBorder)
371
+ }
372
+
373
+ private var subtitleColor: Color {
374
+ switch self.model.subtitleStyle {
375
+ case .info: MenuHighlightStyle.secondary(self.isHighlighted)
376
+ case .loading: MenuHighlightStyle.secondary(self.isHighlighted)
377
+ case .error: MenuHighlightStyle.error(self.isHighlighted)
378
+ }
379
+ }
380
+
381
+ private var brandAccent: Color {
382
+ let color = ProviderDescriptorRegistry.descriptor(for: self.model.provider).branding.color
383
+ return Color(red: color.red, green: color.green, blue: color.blue)
384
+ }
385
+
386
+ private var headerBackground: some View {
387
+ let base = self.brandNSColor
388
+ let top = base.blended(withFraction: 0.35, of: .white) ?? base
389
+ let bottom = base.blended(withFraction: 0.25, of: .black) ?? base
390
+ let start = Color(nsColor: top).opacity(RunicColors.Opacity.medium)
391
+ let end = Color(nsColor: bottom).opacity(RunicColors.Opacity.light)
392
+ return RoundedRectangle(cornerRadius: RunicCornerRadius.lg, style: .continuous)
393
+ .fill(LinearGradient(colors: [start, end], startPoint: .topLeading, endPoint: .bottomTrailing))
394
+ }
395
+
396
+ private var headerBorder: some View {
397
+ return RoundedRectangle(cornerRadius: RunicCornerRadius.lg, style: .continuous)
398
+ .stroke(Color.white.opacity(RunicColors.Opacity.medium), lineWidth: 0.7)
399
+ }
400
+
401
+ private var brandNSColor: NSColor {
402
+ let color = ProviderDescriptorRegistry.descriptor(for: self.model.provider).branding.color
403
+ return NSColor(calibratedRed: CGFloat(color.red), green: CGFloat(color.green), blue: CGFloat(color.blue), alpha: 1)
404
+ }
405
+ }
406
+
407
+ private struct ProviderAvatarView: View {
408
+ let provider: UsageProvider
409
+
410
+ var body: some View {
411
+ let size: CGFloat = 40
412
+ let base = self.brandNSColor
413
+ let top = base.blended(withFraction: 0.35, of: .white) ?? base
414
+ let mid = base.blended(withFraction: 0.15, of: .black) ?? base
415
+ let bottom = base.blended(withFraction: 0.35, of: .black) ?? base
416
+
417
+ let coreGradient = LinearGradient(
418
+ colors: [Color(nsColor: top), Color(nsColor: mid), Color(nsColor: bottom)],
419
+ startPoint: .topLeading,
420
+ endPoint: .bottomTrailing)
421
+
422
+ let ringGradient = AngularGradient(
423
+ colors: [
424
+ Color.white.opacity(RunicColors.Opacity.vivid),
425
+ Color(nsColor: top).opacity(RunicColors.Opacity.emphasis),
426
+ Color.white.opacity(RunicColors.Opacity.medium),
427
+ Color(nsColor: bottom).opacity(RunicColors.Opacity.prominent),
428
+ Color.white.opacity(RunicColors.Opacity.vivid),
429
+ ],
430
+ center: .center)
431
+
432
+ ZStack {
433
+ RoundedRectangle(cornerRadius: RunicCornerRadius.lg, style: .continuous)
434
+ .fill(coreGradient)
435
+ .shadow(color: Color(nsColor: base).opacity(0.25), radius: 6, x: 0, y: 4)
436
+
437
+ RoundedRectangle(cornerRadius: RunicCornerRadius.lg, style: .continuous)
438
+ .stroke(ringGradient, lineWidth: 1.2)
439
+
440
+ RoundedRectangle(cornerRadius: RunicCornerRadius.lg - 2, style: .continuous)
441
+ .fill(
442
+ LinearGradient(
443
+ colors: [Color.white.opacity(0.45), .clear],
444
+ startPoint: .topLeading,
445
+ endPoint: .bottomTrailing))
446
+ .padding(RunicSpacing.xxxs)
447
+ .blendMode(.screen)
448
+
449
+ if let icon = ProviderBrandIcon.templateImage(for: self.provider, size: 18) {
450
+ Image(nsImage: icon)
451
+ .resizable()
452
+ .scaledToFit()
453
+ .frame(width: 18, height: 18)
454
+ .foregroundStyle(Color.white.opacity(0.95))
455
+ .shadow(color: Color.black.opacity(0.18), radius: 1, x: 0, y: 1)
456
+ } else {
457
+ Text(self.fallbackInitials)
458
+ .font(.caption.weight(.semibold))
459
+ .foregroundStyle(Color.white.opacity(0.95))
460
+ }
461
+ }
462
+ .frame(width: size, height: size)
463
+ .overlay(
464
+ RoundedRectangle(cornerRadius: RunicCornerRadius.lg, style: .continuous)
465
+ .stroke(Color.white.opacity(RunicColors.Opacity.medium), lineWidth: 0.6))
466
+ }
467
+
468
+ private var brandNSColor: NSColor {
469
+ let color = ProviderDescriptorRegistry.descriptor(for: self.provider).branding.color
470
+ return NSColor(calibratedRed: CGFloat(color.red), green: CGFloat(color.green), blue: CGFloat(color.blue), alpha: 1)
471
+ }
472
+
473
+ private var fallbackInitials: String {
474
+ let name = ProviderDescriptorRegistry.descriptor(for: self.provider).metadata.displayName
475
+ let parts = name.split(separator: " ")
476
+ if parts.count >= 2 {
477
+ return "\(parts[0].prefix(1))\(parts[1].prefix(1))"
478
+ }
479
+ return String(name.prefix(2)).uppercased()
480
+ }
481
+ }
482
+
483
+ private struct ProfilePill: View {
484
+ enum Style { case email, plan }
485
+
486
+ let text: String
487
+ let systemImage: String
488
+ let tint: Color
489
+ var style: Style = .email
490
+
491
+ var body: some View {
492
+ HStack(spacing: RunicSpacing.xxxs) {
493
+ Image(systemName: self.systemImage)
494
+ .font(.caption2.weight(.semibold))
495
+ Text(self.text)
496
+ .font(.caption.weight(.medium))
497
+ }
498
+ .padding(.horizontal, RunicSpacing.compact)
499
+ .padding(.vertical, RunicSpacing.xxxs)
500
+ .foregroundStyle(self.foregroundColor)
501
+ .background(
502
+ RoundedRectangle(cornerRadius: RunicCornerRadius.sm, style: .continuous)
503
+ .fill(self.backgroundColor))
504
+ .overlay(
505
+ RoundedRectangle(cornerRadius: RunicCornerRadius.sm, style: .continuous)
506
+ .stroke(self.borderColor, lineWidth: 0.5))
507
+ }
508
+
509
+ private var foregroundColor: Color {
510
+ switch self.style {
511
+ case .email:
512
+ return .secondary
513
+ case .plan:
514
+ return self.tint.opacity(RunicColors.Opacity.vivid)
515
+ }
516
+ }
517
+
518
+ private var backgroundColor: Color {
519
+ switch self.style {
520
+ case .email:
521
+ return Color(nsColor: .quaternaryLabelColor).opacity(RunicColors.Opacity.emphasis)
522
+ case .plan:
523
+ return self.tint.opacity(RunicColors.Opacity.light)
524
+ }
525
+ }
526
+
527
+ private var borderColor: Color {
528
+ switch self.style {
529
+ case .email:
530
+ return Color(nsColor: .separatorColor).opacity(RunicColors.Opacity.strong)
531
+ case .plan:
532
+ return self.tint.opacity(RunicColors.Opacity.medium)
533
+ }
534
+ }
535
+ }
536
+
537
+
538
+ private struct MenuHeaderBadgeView: View {
539
+ let badge: UsageMenuCardView.Model.HeaderBadge
540
+ let isHighlighted: Bool
541
+
542
+ var body: some View {
543
+ HStack(spacing: RunicSpacing.xxs) {
544
+ if self.badge.style == .info {
545
+ ProgressView()
546
+ .controlSize(.mini)
547
+ .scaleEffect(0.7)
548
+ .frame(width: 10, height: 10)
549
+ } else {
550
+ Image(systemName: self.badgeIcon)
551
+ .font(.caption2.weight(.semibold))
552
+ }
553
+ Text(self.badge.text)
554
+ .font(.caption2.weight(.semibold))
555
+ }
556
+ .padding(.horizontal, RunicSpacing.compact)
557
+ .padding(.vertical, RunicSpacing.xxxs)
558
+ .foregroundStyle(self.foregroundColor)
559
+ .background(
560
+ RoundedRectangle(cornerRadius: RunicCornerRadius.sm, style: .continuous)
561
+ .fill(self.backgroundColor))
562
+ .overlay(
563
+ RoundedRectangle(cornerRadius: RunicCornerRadius.sm, style: .continuous)
564
+ .stroke(self.borderColor, lineWidth: 0.5))
565
+ }
566
+
567
+ private var badgeIcon: String {
568
+ switch self.badge.style {
569
+ case .info: return "arrow.triangle.2.circlepath"
570
+ case .warning: return "exclamationmark.triangle.fill"
571
+ case .error: return "xmark.circle.fill"
572
+ }
573
+ }
574
+
575
+ private var backgroundColor: Color {
576
+ switch self.badge.style {
577
+ case .info:
578
+ return Color(nsColor: .systemBlue).opacity(self.isHighlighted ? 0.25 : 0.12)
579
+ case .warning:
580
+ return Color(nsColor: .systemOrange).opacity(self.isHighlighted ? 0.35 : 0.15)
581
+ case .error:
582
+ return Color(nsColor: .systemRed).opacity(self.isHighlighted ? 0.35 : 0.15)
583
+ }
584
+ }
585
+
586
+ private var foregroundColor: Color {
587
+ switch self.badge.style {
588
+ case .info:
589
+ return Color(nsColor: .systemBlue).opacity(RunicColors.Opacity.vivid)
590
+ case .warning:
591
+ return Color(nsColor: .systemOrange)
592
+ case .error:
593
+ return Color(nsColor: .systemRed)
594
+ }
595
+ }
596
+
597
+ private var borderColor: Color {
598
+ switch self.badge.style {
599
+ case .info:
600
+ return Color(nsColor: .systemBlue).opacity(RunicColors.Opacity.medium)
601
+ case .warning:
602
+ return Color(nsColor: .systemOrange).opacity(RunicColors.Opacity.strong)
603
+ case .error:
604
+ return Color(nsColor: .systemRed).opacity(RunicColors.Opacity.strong)
605
+ }
606
+ }
607
+ }
608
+
609
+ private struct MenuEmptyStateView: View {
610
+ let providerName: String
611
+ let placeholder: String
612
+ let isHighlighted: Bool
613
+
614
+ var body: some View {
615
+ VStack(alignment: .leading, spacing: RunicSpacing.xxs) {
616
+ HStack(spacing: RunicSpacing.xxs) {
617
+ Image(systemName: "sparkles")
618
+ .font(.caption.weight(.semibold))
619
+ Text("Connect \(self.providerName)")
620
+ .font(.subheadline.weight(.semibold))
621
+ }
622
+ Text(self.placeholder)
623
+ .font(.footnote)
624
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
625
+ Text("Open Settings → Providers, add credentials, then refresh.")
626
+ .font(.footnote)
627
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
628
+ .fixedSize(horizontal: false, vertical: true)
629
+ }
630
+ }
631
+ }
632
+
633
+ private struct CopyIconButtonStyle: ButtonStyle {
634
+ let isHighlighted: Bool
635
+
636
+ func makeBody(configuration: Configuration) -> some View {
637
+ configuration.label
638
+ .padding(RunicSpacing.xxs)
639
+ .background {
640
+ RoundedRectangle(cornerRadius: RunicCornerRadius.xs, style: .continuous)
641
+ .fill(MenuHighlightStyle.secondary(self.isHighlighted).opacity(configuration.isPressed ? 0.18 : 0))
642
+ }
643
+ .scaleEffect(configuration.isPressed ? 0.94 : 1)
644
+ .animation(RunicAnimation.highlight, value: configuration.isPressed)
645
+ }
646
+ }
647
+
648
+ private struct CopyIconButton: View {
649
+ let copyText: String
650
+ let isHighlighted: Bool
651
+
652
+ @State private var didCopy = false
653
+ @State private var resetTask: Task<Void, Never>?
654
+
655
+ var body: some View {
656
+ Button {
657
+ self.copyToPasteboard()
658
+ withAnimation(RunicAnimation.highlight) {
659
+ self.didCopy = true
660
+ }
661
+ self.resetTask?.cancel()
662
+ self.resetTask = Task { @MainActor in
663
+ try? await Task.sleep(for: .seconds(0.9))
664
+ withAnimation(.easeOut(duration: 0.2)) {
665
+ self.didCopy = false
666
+ }
667
+ }
668
+ } label: {
669
+ Image(systemName: self.didCopy ? "checkmark" : "doc.on.doc")
670
+ .font(.caption2.weight(.semibold))
671
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
672
+ .frame(width: 18, height: 18)
673
+ }
674
+ .buttonStyle(CopyIconButtonStyle(isHighlighted: self.isHighlighted))
675
+ .accessibilityLabel(self.didCopy ? "Copied" : "Copy error")
676
+ }
677
+
678
+ private func copyToPasteboard() {
679
+ let pb = NSPasteboard.general
680
+ pb.clearContents()
681
+ pb.setString(self.copyText, forType: .string)
682
+ }
683
+ }
684
+
685
+ private struct ProviderCostContent: View {
686
+ let section: UsageMenuCardView.Model.ProviderCostSection
687
+ let progressColor: Color
688
+ @Environment(\.menuItemHighlighted) private var isHighlighted
689
+
690
+ var body: some View {
691
+ VStack(alignment: .leading, spacing: MenuCardMetrics.lineSpacing) {
692
+ Text(self.section.title)
693
+ .font(.body)
694
+ .fontWeight(.medium)
695
+ UsageProgressBar(
696
+ percent: self.section.percentUsed,
697
+ tint: self.progressColor,
698
+ accessibilityLabel: "Extra usage spent")
699
+ HStack(alignment: .firstTextBaseline) {
700
+ Text(self.section.spendLine)
701
+ .font(.footnote)
702
+ Spacer()
703
+ Text(String(format: "%.0f%% used", min(100, max(0, self.section.percentUsed))))
704
+ .font(.system(.footnote, design: .rounded))
705
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
706
+ }
707
+ }
708
+ }
709
+ }
710
+
711
+ private struct InsightsContent: View {
712
+ let section: UsageMenuCardView.Model.InsightsSection
713
+ @Environment(\.menuItemHighlighted) private var isHighlighted
714
+
715
+ var body: some View {
716
+ VStack(alignment: .leading, spacing: MenuCardMetrics.lineSpacing) {
717
+ Text(self.section.title)
718
+ .font(.body)
719
+ .fontWeight(.medium)
720
+ if let today = self.section.todayLine {
721
+ Text(today)
722
+ .font(.footnote)
723
+ }
724
+ if let detail = self.section.todayDetail {
725
+ Text(detail)
726
+ .font(.footnote)
727
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
728
+ }
729
+ if let forecastLine = self.section.forecastLine {
730
+ Text(forecastLine)
731
+ .font(.footnote)
732
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
733
+ }
734
+ if let block = self.section.blockLine {
735
+ Text(block)
736
+ .font(.footnote)
737
+ }
738
+ if let blockDetail = self.section.blockDetail {
739
+ Text(blockDetail)
740
+ .font(.footnote)
741
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
742
+ }
743
+ if let modelLine = self.section.modelLine {
744
+ Text(modelLine)
745
+ .font(.footnote)
746
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
747
+ }
748
+ if let projectLine = self.section.projectLine {
749
+ Text(projectLine)
750
+ .font(.footnote)
751
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
752
+ }
753
+ if let projectDetail = self.section.projectDetail {
754
+ Text(projectDetail)
755
+ .font(.footnote)
756
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
757
+ }
758
+ if let anomalyLine = self.section.anomalyLine {
759
+ Text(anomalyLine)
760
+ .font(.footnote)
761
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
762
+ }
763
+ if let anomalyDetail = self.section.anomalyDetail {
764
+ Text(anomalyDetail)
765
+ .font(.footnote)
766
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
767
+ }
768
+ if let reliabilityLine = self.section.reliabilityLine {
769
+ Text(reliabilityLine)
770
+ .font(.footnote)
771
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
772
+ }
773
+ if let reliabilityDetail = self.section.reliabilityDetail {
774
+ Text(reliabilityDetail)
775
+ .font(.footnote)
776
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
777
+ }
778
+ if let routingLine = self.section.routingLine {
779
+ Text(routingLine)
780
+ .font(.footnote)
781
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
782
+ }
783
+ if let routingDetail = self.section.routingDetail {
784
+ Text(routingDetail)
785
+ .font(.footnote)
786
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
787
+ }
788
+ if let updated = self.section.updatedLine {
789
+ Text(updated)
790
+ .font(.caption)
791
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
792
+ }
793
+ if let error = self.section.errorLine {
794
+ HStack(alignment: .top, spacing: RunicSpacing.xs) {
795
+ Image(systemName: "exclamationmark.triangle.fill")
796
+ .font(.footnote)
797
+ .foregroundStyle(RunicColors.error)
798
+ Text(error)
799
+ .font(.footnote)
800
+ .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted))
801
+ .lineLimit(4)
802
+ .fixedSize(horizontal: false, vertical: true)
803
+ }
804
+ .padding(RunicSpacing.xs)
805
+ .background(
806
+ RoundedRectangle(cornerRadius: RunicCornerRadius.sm, style: .continuous)
807
+ .fill(RunicColors.error.opacity(RunicColors.Opacity.subtle)))
808
+ }
809
+ }
810
+ }
811
+ }
812
+
813
+ struct UsageMenuCardHeaderSectionView: View {
814
+ let model: UsageMenuCardView.Model
815
+ let showDivider: Bool
816
+ let width: CGFloat
817
+
818
+ var body: some View {
819
+ VStack(alignment: .leading, spacing: RunicSpacing.xxs) {
820
+ UsageMenuCardHeaderView(model: self.model)
821
+
822
+ if self.showDivider {
823
+ Divider()
824
+ .padding(.vertical, RunicSpacing.xxs)
825
+ }
826
+ }
827
+ .padding(.horizontal, MenuCardMetrics.horizontalPadding)
828
+ .padding(.top, MenuCardMetrics.headerTopPadding)
829
+ .padding(.bottom, self.model.subtitleStyle == .error ? MenuCardMetrics.headerBottomPadding : 0)
830
+ .frame(width: self.width, alignment: .leading)
831
+ }
832
+ }
833
+
834
+ private struct UsageMenuMetricCard: View {
835
+ let metric: UsageMenuCardView.Model.Metric
836
+ let displayMode: UsageMetricDisplayMode
837
+ let tint: Color
838
+ let isHighlighted: Bool
839
+
840
+ var body: some View {
841
+ VStack(alignment: .leading, spacing: MenuCardMetrics.lineSpacing) {
842
+ HStack(alignment: .firstTextBaseline, spacing: RunicSpacing.xs) {
843
+ Text(self.metric.title)
844
+ .font(.caption.weight(.semibold))
845
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
846
+ Spacer(minLength: RunicSpacing.xs)
847
+ if let reset = self.metric.resetText {
848
+ Text(reset)
849
+ .font(.caption2)
850
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
851
+ }
852
+ }
853
+
854
+ if self.displayMode.showsBars {
855
+ UsageProgressBar(
856
+ percent: self.metric.percent,
857
+ tint: self.tint,
858
+ accessibilityLabel: self.metric.percentStyle.accessibilityLabel)
859
+ }
860
+
861
+ if self.displayMode.showsPercent {
862
+ HStack(alignment: .firstTextBaseline, spacing: RunicSpacing.xs) {
863
+ Text(self.metric.percentLabel)
864
+ .font(.system(.caption, design: .rounded))
865
+ .fontWeight(.medium)
866
+ Spacer()
867
+ }
868
+ }
869
+
870
+ if let detail = self.metric.detailText {
871
+ Text(detail)
872
+ .font(.caption)
873
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
874
+ .lineLimit(1)
875
+ .textCase(.none)
876
+ }
877
+ }
878
+ .padding(MenuCardMetrics.metricCardPadding)
879
+ .background(
880
+ RoundedRectangle(
881
+ cornerRadius: MenuCardMetrics.metricCardCornerRadius,
882
+ style: .continuous)
883
+ .fill(Color(nsColor: .controlBackgroundColor).opacity(self.isHighlighted ? 0.2 : 0.14))
884
+ )
885
+ .overlay(
886
+ RoundedRectangle(
887
+ cornerRadius: MenuCardMetrics.metricCardCornerRadius,
888
+ style: .continuous)
889
+ .strokeBorder(Color(nsColor: .separatorColor).opacity(0.25), lineWidth: 1)
890
+ )
891
+ }
892
+ }
893
+
894
+ struct UsageMenuCardUsageSectionView: View {
895
+ let model: UsageMenuCardView.Model
896
+ let showBottomDivider: Bool
897
+ let bottomPadding: CGFloat
898
+ let width: CGFloat
899
+ @Environment(\.menuItemHighlighted) private var isHighlighted
900
+
901
+ var body: some View {
902
+ VStack(alignment: .leading, spacing: MenuCardMetrics.sectionSpacing) {
903
+ if self.model.metrics.isEmpty {
904
+ if let placeholder = self.model.placeholder {
905
+ MenuEmptyStateView(
906
+ providerName: self.model.providerName,
907
+ placeholder: placeholder,
908
+ isHighlighted: self.isHighlighted)
909
+ }
910
+ } else {
911
+ ForEach(self.model.metrics) { metric in
912
+ let displayMode = self.model.usageMetricDisplayMode
913
+ UsageMenuMetricCard(
914
+ metric: metric,
915
+ displayMode: displayMode,
916
+ tint: self.model.progressColor,
917
+ isHighlighted: self.isHighlighted)
918
+ }
919
+ }
920
+ if self.showBottomDivider {
921
+ Divider()
922
+ .padding(.vertical, RunicSpacing.xxs)
923
+ }
924
+ }
925
+ .padding(.horizontal, MenuCardMetrics.horizontalPadding)
926
+ .padding(.top, MenuCardMetrics.sectionTopPadding)
927
+ .padding(.bottom, self.bottomPadding)
928
+ .frame(width: self.width, alignment: .leading)
929
+ }
930
+ }
931
+
932
+ struct UsageMenuCardCreditsSectionView: View {
933
+ let model: UsageMenuCardView.Model
934
+ let showBottomDivider: Bool
935
+ let topPadding: CGFloat
936
+ let bottomPadding: CGFloat
937
+ let width: CGFloat
938
+
939
+ init(
940
+ model: UsageMenuCardView.Model,
941
+ showBottomDivider: Bool,
942
+ topPadding: CGFloat,
943
+ bottomPadding: CGFloat,
944
+ width: CGFloat)
945
+ {
946
+ self.model = model
947
+ self.showBottomDivider = showBottomDivider
948
+ self.topPadding = topPadding
949
+ self.bottomPadding = bottomPadding
950
+ self.width = width
951
+ }
952
+
953
+ var body: some View {
954
+ if let credits = self.model.creditsText {
955
+ VStack(alignment: .leading, spacing: MenuCardMetrics.lineSpacing) {
956
+ CreditsBarContent(
957
+ creditsText: credits,
958
+ creditsRemaining: self.model.creditsRemaining,
959
+ hintText: self.model.creditsHintText,
960
+ hintCopyText: self.model.creditsHintCopyText,
961
+ progressColor: self.model.progressColor)
962
+ if self.showBottomDivider {
963
+ Divider()
964
+ .padding(.vertical, RunicSpacing.xxs)
965
+ }
966
+ }
967
+ .padding(.horizontal, MenuCardMetrics.horizontalPadding)
968
+ .padding(.top, self.topPadding)
969
+ .padding(.bottom, self.bottomPadding)
970
+ .frame(width: self.width, alignment: .leading)
971
+ }
972
+ }
973
+ }
974
+
975
+ private struct CreditsBarContent: View {
976
+ private static let fullScaleTokens: Double = 1000
977
+
978
+ let creditsText: String
979
+ let creditsRemaining: Double?
980
+ let hintText: String?
981
+ let hintCopyText: String?
982
+ let progressColor: Color
983
+ @Environment(\.menuItemHighlighted) private var isHighlighted
984
+
985
+ private var percentLeft: Double? {
986
+ guard let creditsRemaining else { return nil }
987
+ let percent = (creditsRemaining / Self.fullScaleTokens) * 100
988
+ return min(100, max(0, percent))
989
+ }
990
+
991
+ private var scaleText: String {
992
+ let scale = UsageFormatter.tokenCountString(Int(Self.fullScaleTokens))
993
+ return "\(scale) tokens"
994
+ }
995
+
996
+ var body: some View {
997
+ VStack(alignment: .leading, spacing: MenuCardMetrics.lineSpacing) {
998
+ Text("Credits")
999
+ .font(.body)
1000
+ .fontWeight(.medium)
1001
+ if let percentLeft {
1002
+ UsageProgressBar(
1003
+ percent: percentLeft,
1004
+ tint: self.progressColor,
1005
+ accessibilityLabel: "Credits remaining")
1006
+ HStack(alignment: .firstTextBaseline) {
1007
+ Text(self.creditsText)
1008
+ .font(.system(.caption, design: .rounded))
1009
+ Spacer()
1010
+ Text(self.scaleText)
1011
+ .font(.system(.caption, design: .rounded))
1012
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
1013
+ }
1014
+ } else {
1015
+ Text(self.creditsText)
1016
+ .font(.system(.caption, design: .rounded))
1017
+ }
1018
+ if let hintText, !hintText.isEmpty {
1019
+ Text(hintText)
1020
+ .font(.footnote)
1021
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
1022
+ .lineLimit(4)
1023
+ .fixedSize(horizontal: false, vertical: true)
1024
+ .overlay {
1025
+ ClickToCopyOverlay(copyText: self.hintCopyText ?? hintText)
1026
+ }
1027
+ }
1028
+ }
1029
+ }
1030
+ }
1031
+
1032
+ struct UsageMenuCardInsightsSectionView: View {
1033
+ let model: UsageMenuCardView.Model
1034
+ let topPadding: CGFloat
1035
+ let bottomPadding: CGFloat
1036
+ let width: CGFloat
1037
+
1038
+ var body: some View {
1039
+ if let insights = self.model.insights {
1040
+ InsightsContent(section: insights)
1041
+ .padding(.horizontal, MenuCardMetrics.horizontalPadding)
1042
+ .padding(.top, self.topPadding)
1043
+ .padding(.bottom, self.bottomPadding)
1044
+ .frame(width: self.width, alignment: .leading)
1045
+ }
1046
+ }
1047
+ }
1048
+
1049
+ struct UsageMenuCardCostSectionView: View {
1050
+ let model: UsageMenuCardView.Model
1051
+ let topPadding: CGFloat
1052
+ let bottomPadding: CGFloat
1053
+ let width: CGFloat
1054
+ @Environment(\.menuItemHighlighted) private var isHighlighted
1055
+
1056
+ var body: some View {
1057
+ let hasTokenCost = self.model.tokenUsage != nil
1058
+ return Group {
1059
+ if hasTokenCost {
1060
+ VStack(alignment: .leading, spacing: MenuCardMetrics.blockSpacing) {
1061
+ if let tokenUsage = self.model.tokenUsage {
1062
+ VStack(alignment: .leading, spacing: MenuCardMetrics.lineSpacing) {
1063
+ Text("Cost")
1064
+ .font(.body)
1065
+ .fontWeight(.medium)
1066
+ Text(tokenUsage.sessionLine)
1067
+ .font(.system(.caption, design: .rounded))
1068
+ if let sessionDetail = tokenUsage.sessionDetailLine {
1069
+ Text(sessionDetail)
1070
+ .font(.footnote)
1071
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
1072
+ }
1073
+ Text(tokenUsage.monthLine)
1074
+ .font(.system(.caption, design: .rounded))
1075
+ if let monthDetail = tokenUsage.monthDetailLine {
1076
+ Text(monthDetail)
1077
+ .font(.footnote)
1078
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
1079
+ }
1080
+ Text(tokenUsage.updatedLine)
1081
+ .font(.caption)
1082
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
1083
+ if let hint = tokenUsage.hintLine, !hint.isEmpty {
1084
+ Text(hint)
1085
+ .font(.footnote)
1086
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
1087
+ .lineLimit(4)
1088
+ .fixedSize(horizontal: false, vertical: true)
1089
+ }
1090
+ if let error = tokenUsage.errorLine, !error.isEmpty {
1091
+ HStack(alignment: .top, spacing: RunicSpacing.xs) {
1092
+ Image(systemName: "exclamationmark.triangle.fill")
1093
+ .font(.footnote)
1094
+ .foregroundStyle(RunicColors.error)
1095
+ Text(error)
1096
+ .font(.footnote)
1097
+ .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted))
1098
+ .lineLimit(4)
1099
+ .fixedSize(horizontal: false, vertical: true)
1100
+ }
1101
+ .padding(RunicSpacing.xs)
1102
+ .background(
1103
+ RoundedRectangle(cornerRadius: RunicCornerRadius.sm, style: .continuous)
1104
+ .fill(RunicColors.error.opacity(RunicColors.Opacity.subtle)))
1105
+ .overlay {
1106
+ ClickToCopyOverlay(copyText: tokenUsage.errorCopyText ?? error)
1107
+ }
1108
+ }
1109
+ }
1110
+ }
1111
+ }
1112
+ .padding(.horizontal, MenuCardMetrics.horizontalPadding)
1113
+ .padding(.top, self.topPadding)
1114
+ .padding(.bottom, self.bottomPadding)
1115
+ .frame(width: self.width, alignment: .leading)
1116
+ }
1117
+ }
1118
+ }
1119
+ }
1120
+
1121
+ struct UsageMenuCardExtraUsageSectionView: View {
1122
+ let model: UsageMenuCardView.Model
1123
+ let topPadding: CGFloat
1124
+ let bottomPadding: CGFloat
1125
+ let width: CGFloat
1126
+
1127
+ var body: some View {
1128
+ Group {
1129
+ if let providerCost = self.model.providerCost {
1130
+ ProviderCostContent(
1131
+ section: providerCost,
1132
+ progressColor: self.model.progressColor)
1133
+ .padding(.horizontal, MenuCardMetrics.horizontalPadding)
1134
+ .padding(.top, self.topPadding)
1135
+ .padding(.bottom, self.bottomPadding)
1136
+ .frame(width: self.width, alignment: .leading)
1137
+ }
1138
+ }
1139
+ }
1140
+ }
1141
+
1142
+ // MARK: - Model factory
1143
+
1144
+ extension UsageMenuCardView.Model {
1145
+ struct Input {
1146
+ let provider: UsageProvider
1147
+ let metadata: ProviderMetadata
1148
+ let snapshot: UsageSnapshot?
1149
+ let credits: CreditsSnapshot?
1150
+ let creditsError: String?
1151
+ let dashboard: OpenAIDashboardSnapshot?
1152
+ let dashboardError: String?
1153
+ let tokenSnapshot: CostUsageTokenSnapshot?
1154
+ let tokenError: String?
1155
+ let ledgerDaily: UsageLedgerDailySummary?
1156
+ let ledgerActiveBlock: UsageLedgerBlockSummary?
1157
+ let ledgerTopModel: UsageLedgerModelSummary?
1158
+ let ledgerTopProject: UsageLedgerProjectSummary?
1159
+ let ledgerSpendForecast: UsageLedgerSpendForecast?
1160
+ let ledgerTopProjectSpendForecast: UsageLedgerSpendForecast?
1161
+ let ledgerAnomaly: UsageLedgerAnomalySummary?
1162
+ let ledgerReliability: UsageLedgerReliabilityScore?
1163
+ let ledgerRouting: UsageLedgerRoutingRecommendation?
1164
+ let ledgerError: String?
1165
+ let ledgerUpdatedAt: Date?
1166
+ let account: AccountInfo
1167
+ let isRefreshing: Bool
1168
+ let lastError: String?
1169
+ let usageBarsShowUsed: Bool
1170
+ let usageMetricDisplayMode: UsageMetricDisplayMode
1171
+ let menuMode: MenuMode
1172
+ let tokenCostUsageEnabled: Bool
1173
+ let showOptionalCreditsAndExtraUsage: Bool
1174
+ let now: Date
1175
+ }
1176
+
1177
+ static func make(_ input: Input) -> UsageMenuCardView.Model {
1178
+ let trimmedError = input.lastError?.trimmingCharacters(in: .whitespacesAndNewlines)
1179
+ let normalizedError = (trimmedError?.isEmpty ?? true) ? nil : trimmedError
1180
+ let email = Self.email(
1181
+ for: input.provider,
1182
+ snapshot: input.snapshot,
1183
+ account: input.account,
1184
+ metadata: input.metadata)
1185
+ let planText = Self.plan(
1186
+ for: input.provider,
1187
+ snapshot: input.snapshot,
1188
+ account: input.account,
1189
+ metadata: input.metadata)
1190
+ let metrics = Self.metrics(input: input)
1191
+ let creditsText: String? = if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage {
1192
+ nil
1193
+ } else {
1194
+ Self.creditsLine(metadata: input.metadata, credits: input.credits, error: input.creditsError)
1195
+ }
1196
+ let creditsHintText = Self.dashboardHint(provider: input.provider, error: input.dashboardError)
1197
+ let providerCost: ProviderCostSection? = if input.provider == .claude, !input.showOptionalCreditsAndExtraUsage {
1198
+ nil
1199
+ } else {
1200
+ Self.providerCostSection(provider: input.provider, cost: input.snapshot?.providerCost)
1201
+ }
1202
+ let tokenUsage = Self.tokenUsageSection(
1203
+ provider: input.provider,
1204
+ enabled: input.tokenCostUsageEnabled,
1205
+ snapshot: input.tokenSnapshot,
1206
+ error: input.tokenError)
1207
+ let topModelLine = Self.topModelLine(input.ledgerTopModel)
1208
+ let insights = Self.removingModelLine(from: Self.insightsSection(input: input), when: topModelLine != nil)
1209
+ let subtitle = Self.subtitle(
1210
+ snapshot: input.snapshot,
1211
+ isRefreshing: input.isRefreshing,
1212
+ lastError: normalizedError)
1213
+ let headerBadge: HeaderBadge? = if input.isRefreshing {
1214
+ HeaderBadge(text: "Refreshing", style: .info)
1215
+ } else if normalizedError != nil {
1216
+ HeaderBadge(text: "Issue", style: .error)
1217
+ } else {
1218
+ nil
1219
+ }
1220
+ let placeholder = input.snapshot == nil && !input.isRefreshing && normalizedError == nil ? "No usage yet" : nil
1221
+
1222
+ return UsageMenuCardView.Model(
1223
+ provider: input.provider,
1224
+ providerName: input.metadata.displayName,
1225
+ email: email,
1226
+ subtitleText: subtitle.text,
1227
+ subtitleStyle: subtitle.style,
1228
+ planText: planText,
1229
+ topModelLine: topModelLine,
1230
+ headerBadge: headerBadge,
1231
+ metrics: metrics,
1232
+ usageMetricDisplayMode: input.usageMetricDisplayMode,
1233
+ menuMode: input.menuMode,
1234
+ creditsText: creditsText,
1235
+ creditsRemaining: input.credits?.remaining,
1236
+ creditsHintText: creditsHintText,
1237
+ creditsHintCopyText: (input.dashboardError?.isEmpty ?? true) ? nil : input.dashboardError,
1238
+ providerCost: providerCost,
1239
+ tokenUsage: tokenUsage,
1240
+ insights: insights,
1241
+ placeholder: placeholder,
1242
+ progressColor: Self.progressColor(for: input.provider))
1243
+ }
1244
+
1245
+ private static func topModelLine(_ summary: UsageLedgerModelSummary?) -> String? {
1246
+ guard let summary else { return nil }
1247
+ let modelName = UsageFormatter.modelDisplayName(summary.model)
1248
+ let tokens = UsageFormatter.tokenCountString(summary.totals.totalTokens)
1249
+ var parts = ["Top model: \(modelName)", "\(tokens) tokens", "\(summary.entryCount) req"]
1250
+ if let context = UsageFormatter.modelContextLabel(for: summary.model) {
1251
+ parts.append(context)
1252
+ }
1253
+ if let cost = summary.totals.costUSD {
1254
+ parts.append(UsageFormatter.usdString(cost))
1255
+ if let per1K = UsageFormatter.usdPer1KTokensString(
1256
+ costUSD: cost,
1257
+ tokenCount: summary.totals.totalTokens)
1258
+ {
1259
+ parts.append(per1K)
1260
+ }
1261
+ }
1262
+ return parts.joined(separator: " · ")
1263
+ }
1264
+
1265
+ private static func removingModelLine(
1266
+ from section: InsightsSection?,
1267
+ when remove: Bool) -> InsightsSection?
1268
+ {
1269
+ guard remove, let section else { return section }
1270
+ return InsightsSection(
1271
+ title: section.title,
1272
+ todayLine: section.todayLine,
1273
+ todayDetail: section.todayDetail,
1274
+ forecastLine: section.forecastLine,
1275
+ blockLine: section.blockLine,
1276
+ blockDetail: section.blockDetail,
1277
+ modelLine: nil,
1278
+ projectLine: section.projectLine,
1279
+ projectDetail: section.projectDetail,
1280
+ anomalyLine: section.anomalyLine,
1281
+ anomalyDetail: section.anomalyDetail,
1282
+ reliabilityLine: section.reliabilityLine,
1283
+ reliabilityDetail: section.reliabilityDetail,
1284
+ routingLine: section.routingLine,
1285
+ routingDetail: section.routingDetail,
1286
+ updatedLine: section.updatedLine,
1287
+ errorLine: section.errorLine)
1288
+ }
1289
+
1290
+ private static func email(
1291
+ for provider: UsageProvider,
1292
+ snapshot: UsageSnapshot?,
1293
+ account: AccountInfo,
1294
+ metadata: ProviderMetadata) -> String
1295
+ {
1296
+ if let email = snapshot?.accountEmail(for: provider), !email.isEmpty { return email }
1297
+ if metadata.usesAccountFallback,
1298
+ let email = account.email, !email.isEmpty
1299
+ {
1300
+ return email
1301
+ }
1302
+ return ""
1303
+ }
1304
+
1305
+ private static func plan(
1306
+ for provider: UsageProvider,
1307
+ snapshot: UsageSnapshot?,
1308
+ account: AccountInfo,
1309
+ metadata: ProviderMetadata) -> String?
1310
+ {
1311
+ if let plan = snapshot?.loginMethod(for: provider), !plan.isEmpty {
1312
+ return self.planDisplay(plan)
1313
+ }
1314
+ if metadata.usesAccountFallback,
1315
+ let plan = account.plan, !plan.isEmpty
1316
+ {
1317
+ return Self.planDisplay(plan)
1318
+ }
1319
+ return nil
1320
+ }
1321
+
1322
+ private static func planDisplay(_ text: String) -> String {
1323
+ let cleaned = UsageFormatter.cleanPlanName(text)
1324
+ return cleaned.isEmpty ? text : cleaned
1325
+ }
1326
+
1327
+ private static func subtitle(
1328
+ snapshot: UsageSnapshot?,
1329
+ isRefreshing: Bool,
1330
+ lastError: String?) -> (text: String, style: SubtitleStyle)
1331
+ {
1332
+ if let lastError, !lastError.isEmpty {
1333
+ return (lastError.trimmingCharacters(in: .whitespacesAndNewlines), .error)
1334
+ }
1335
+
1336
+ if isRefreshing, snapshot == nil {
1337
+ return ("Refreshing...", .loading)
1338
+ }
1339
+
1340
+ if let updated = snapshot?.updatedAt {
1341
+ return (UsageFormatter.updatedString(from: updated), .info)
1342
+ }
1343
+
1344
+ return ("Not fetched yet", .info)
1345
+ }
1346
+
1347
+ private static func metrics(input: Input) -> [Metric] {
1348
+ guard let snapshot = input.snapshot else { return [] }
1349
+ var metrics: [Metric] = []
1350
+ let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left
1351
+ let zaiUsage = input.provider == .zai ? snapshot.zaiUsage : nil
1352
+ let zaiTokenDetail = Self.zaiLimitDetailText(limit: zaiUsage?.tokenLimit)
1353
+ let zaiTimeDetail = Self.zaiLimitDetailText(limit: zaiUsage?.timeLimit)
1354
+ metrics.append(Metric(
1355
+ id: "primary",
1356
+ title: input.metadata.sessionLabel,
1357
+ percent: Self.clamped(
1358
+ input.usageBarsShowUsed ? snapshot.primary.usedPercent : snapshot.primary.remainingPercent),
1359
+ percentStyle: percentStyle,
1360
+ resetText: Self.resetText(for: snapshot.primary, prefersCountdown: true),
1361
+ detailText: input.provider == .zai ? zaiTokenDetail : nil))
1362
+ if let weekly = snapshot.secondary {
1363
+ let paceText = UsagePaceText.weekly(provider: input.provider, window: weekly, now: input.now)
1364
+ metrics.append(Metric(
1365
+ id: "secondary",
1366
+ title: input.metadata.weeklyLabel,
1367
+ percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent),
1368
+ percentStyle: percentStyle,
1369
+ resetText: Self.resetText(for: weekly, prefersCountdown: true),
1370
+ detailText: input.provider == .zai ? zaiTimeDetail : paceText))
1371
+ }
1372
+ if input.metadata.supportsOpus, let opus = snapshot.tertiary {
1373
+ metrics.append(Metric(
1374
+ id: "tertiary",
1375
+ title: input.metadata.opusLabel ?? "Sonnet",
1376
+ percent: Self.clamped(input.usageBarsShowUsed ? opus.usedPercent : opus.remainingPercent),
1377
+ percentStyle: percentStyle,
1378
+ resetText: Self.resetText(for: opus, prefersCountdown: true),
1379
+ detailText: nil))
1380
+ }
1381
+
1382
+ if input.provider == .codex, let remaining = input.dashboard?.codeReviewRemainingPercent {
1383
+ let percent = input.usageBarsShowUsed ? (100 - remaining) : remaining
1384
+ metrics.append(Metric(
1385
+ id: "code-review",
1386
+ title: "Code review",
1387
+ percent: Self.clamped(percent),
1388
+ percentStyle: percentStyle,
1389
+ resetText: nil,
1390
+ detailText: nil))
1391
+ }
1392
+ return metrics
1393
+ }
1394
+
1395
+ private static func zaiLimitDetailText(limit: ZaiLimitEntry?) -> String? {
1396
+ guard let limit else { return nil }
1397
+ let currentStr = UsageFormatter.tokenCountString(limit.currentValue)
1398
+ let usageStr = UsageFormatter.tokenCountString(limit.usage)
1399
+ let remainingStr = UsageFormatter.tokenCountString(limit.remaining)
1400
+ return "\(currentStr) / \(usageStr) (\(remainingStr) remaining)"
1401
+ }
1402
+
1403
+ private static func creditsLine(
1404
+ metadata: ProviderMetadata,
1405
+ credits: CreditsSnapshot?,
1406
+ error: String?) -> String?
1407
+ {
1408
+ guard metadata.supportsCredits else { return nil }
1409
+ if let credits {
1410
+ return UsageFormatter.creditsString(from: credits.remaining)
1411
+ }
1412
+ if let error, !error.isEmpty {
1413
+ return error.trimmingCharacters(in: .whitespacesAndNewlines)
1414
+ }
1415
+ return metadata.creditsHint
1416
+ }
1417
+
1418
+ private static func dashboardHint(provider: UsageProvider, error: String?) -> String? {
1419
+ guard provider == .codex else { return nil }
1420
+ guard let error, !error.isEmpty else { return nil }
1421
+ return error
1422
+ }
1423
+
1424
+ private static func tokenUsageSection(
1425
+ provider: UsageProvider,
1426
+ enabled: Bool,
1427
+ snapshot: CostUsageTokenSnapshot?,
1428
+ error: String?) -> TokenUsageSection?
1429
+ {
1430
+ guard provider == .codex || provider == .claude else { return nil }
1431
+ guard enabled else { return nil }
1432
+ guard let snapshot else { return nil }
1433
+
1434
+ let sessionCostValue = snapshot.sessionCostUSD
1435
+ let sessionCost = sessionCostValue.map { UsageFormatter.usdString($0) } ?? "—"
1436
+ let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) }
1437
+ let sessionLine: String = {
1438
+ if let sessionTokens {
1439
+ return "Today: \(sessionCost) · \(sessionTokens) tokens"
1440
+ }
1441
+ return "Today: \(sessionCost)"
1442
+ }()
1443
+ let sessionDetailLine: String? = {
1444
+ guard let cost = sessionCostValue,
1445
+ let tokens = snapshot.sessionTokens,
1446
+ let per1K = UsageFormatter.usdPer1KTokensString(costUSD: cost, tokenCount: tokens)
1447
+ else {
1448
+ return nil
1449
+ }
1450
+ return "Today efficiency: \(per1K)"
1451
+ }()
1452
+
1453
+ let fallbackCost = snapshot.daily.compactMap(\.costUSD).reduce(0, +)
1454
+ let monthCostValue = snapshot.last30DaysCostUSD ?? (fallbackCost > 0 ? fallbackCost : nil)
1455
+ let monthCost = monthCostValue.map { UsageFormatter.usdString($0) } ?? "—"
1456
+ let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +)
1457
+ let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil)
1458
+ let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) }
1459
+ let monthLine: String = {
1460
+ if let monthTokens {
1461
+ return "Last 30 days: \(monthCost) · \(monthTokens) tokens"
1462
+ }
1463
+ return "Last 30 days: \(monthCost)"
1464
+ }()
1465
+ let monthDetailLine: String? = {
1466
+ var parts: [String] = []
1467
+ if let cost = monthCostValue,
1468
+ let tokens = monthTokensValue,
1469
+ let per1K = UsageFormatter.usdPer1KTokensString(costUSD: cost, tokenCount: tokens)
1470
+ {
1471
+ parts.append(per1K)
1472
+ }
1473
+
1474
+ if let cost = monthCostValue, let tokens = monthTokensValue, tokens > 0 {
1475
+ let days = Self.observedUsageDays(snapshot)
1476
+ let avgCostPerDay = cost / Double(days)
1477
+ let avgTokensPerDay = Int((Double(tokens) / Double(days)).rounded())
1478
+ parts.append("Avg \(UsageFormatter.usdRateString(avgCostPerDay))/day")
1479
+ parts.append("Avg \(UsageFormatter.tokenCountString(avgTokensPerDay)) tok/day")
1480
+ }
1481
+
1482
+ return parts.isEmpty ? nil : parts.joined(separator: " · ")
1483
+ }()
1484
+ let updatedLine = UsageFormatter.updatedString(from: snapshot.updatedAt)
1485
+ let err = (error?.isEmpty ?? true) ? nil : error
1486
+ let hintLine = err == nil ? "Token totals are estimates and may lag provider dashboards." : nil
1487
+ return TokenUsageSection(
1488
+ sessionLine: sessionLine,
1489
+ sessionDetailLine: sessionDetailLine,
1490
+ monthLine: monthLine,
1491
+ monthDetailLine: monthDetailLine,
1492
+ updatedLine: updatedLine,
1493
+ hintLine: hintLine,
1494
+ errorLine: err,
1495
+ errorCopyText: (error?.isEmpty ?? true) ? nil : error)
1496
+ }
1497
+
1498
+ private static func observedUsageDays(_ snapshot: CostUsageTokenSnapshot) -> Int {
1499
+ let nonEmptyDays = snapshot.daily.filter { entry in
1500
+ (entry.totalTokens ?? 0) > 0 || (entry.costUSD ?? 0) > 0
1501
+ }.count
1502
+ if nonEmptyDays > 0 {
1503
+ return min(30, nonEmptyDays)
1504
+ }
1505
+ if !snapshot.daily.isEmpty {
1506
+ return min(30, snapshot.daily.count)
1507
+ }
1508
+ return 30
1509
+ }
1510
+
1511
+ private static func providerCostSection(
1512
+ provider: UsageProvider,
1513
+ cost: ProviderCostSnapshot?) -> ProviderCostSection?
1514
+ {
1515
+ guard provider == .claude else { return nil }
1516
+ guard let cost else { return nil }
1517
+ guard cost.limit > 0 else { return nil }
1518
+
1519
+ let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
1520
+ let limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode)
1521
+ let percentUsed = Self.clamped((cost.used / cost.limit) * 100)
1522
+
1523
+ return ProviderCostSection(
1524
+ title: "Extra usage",
1525
+ percentUsed: percentUsed,
1526
+ spendLine: "This month: \(used) / \(limit)")
1527
+ }
1528
+
1529
+ private static func insightsSection(input: Input) -> InsightsSection? {
1530
+ let error = input.ledgerError?.trimmingCharacters(in: .whitespacesAndNewlines)
1531
+ let daily = input.ledgerDaily?.provider == input.provider ? input.ledgerDaily : nil
1532
+ let block = input.ledgerActiveBlock?.provider == input.provider ? input.ledgerActiveBlock : nil
1533
+ let topModel = input.ledgerTopModel?.provider == input.provider ? input.ledgerTopModel : nil
1534
+ let topProject = input.ledgerTopProject?.provider == input.provider ? input.ledgerTopProject : nil
1535
+ let spendForecast = input.ledgerSpendForecast?.provider == input.provider ? input.ledgerSpendForecast : nil
1536
+ let topProjectSpendForecast = input.ledgerTopProjectSpendForecast?.provider == input.provider
1537
+ ? input.ledgerTopProjectSpendForecast
1538
+ : nil
1539
+ let anomaly = input.ledgerAnomaly?.provider == input.provider ? input.ledgerAnomaly : nil
1540
+ let hasData = daily != nil || block != nil || topModel != nil || topProject != nil || spendForecast != nil || anomaly != nil
1541
+ if !hasData && (error?.isEmpty ?? true) {
1542
+ return nil
1543
+ }
1544
+
1545
+ var todayLine: String?
1546
+ var todayDetail: String?
1547
+ if let daily {
1548
+ let tokens = UsageFormatter.tokenCountString(daily.totals.totalTokens)
1549
+ todayLine = "Today: \(tokens) tokens"
1550
+ var details: [String] = []
1551
+ let input = UsageFormatter.tokenCountString(daily.totals.inputTokens)
1552
+ let output = UsageFormatter.tokenCountString(daily.totals.outputTokens)
1553
+ var flowParts = ["In \(input)", "Out \(output)"]
1554
+ if daily.totals.cacheReadTokens > 0 {
1555
+ let cacheRead = UsageFormatter.tokenCountString(daily.totals.cacheReadTokens)
1556
+ flowParts.append("Cache read \(cacheRead)")
1557
+ }
1558
+ if daily.totals.cacheCreationTokens > 0 {
1559
+ let cacheWrite = UsageFormatter.tokenCountString(daily.totals.cacheCreationTokens)
1560
+ flowParts.append("Cache write \(cacheWrite)")
1561
+ }
1562
+ details.append(flowParts.joined(separator: " · "))
1563
+ if let cost = daily.totals.costUSD {
1564
+ var spendParts = ["Spend \(UsageFormatter.usdString(cost))"]
1565
+ if let per1K = UsageFormatter.usdPer1KTokensString(
1566
+ costUSD: cost,
1567
+ tokenCount: daily.totals.totalTokens)
1568
+ {
1569
+ spendParts.append(per1K)
1570
+ }
1571
+ details.append(spendParts.joined(separator: " · "))
1572
+ }
1573
+ if !details.isEmpty {
1574
+ todayDetail = details.joined(separator: "\n")
1575
+ }
1576
+ }
1577
+
1578
+ var forecastLine: String?
1579
+ if let spendForecast {
1580
+ let projected = UsageFormatter.usdString(spendForecast.projected30DayCostUSD)
1581
+ let observedDayLabel = spendForecast.observedDays == 1 ? "day" : "days"
1582
+ var parts = ["Month-end forecast: \(projected)"]
1583
+ if let p50 = spendForecast.projectedCostP50USD,
1584
+ let p80 = spendForecast.projectedCostP80USD,
1585
+ let p95 = spendForecast.projectedCostP95USD
1586
+ {
1587
+ let p50Text = UsageFormatter.usdString(p50)
1588
+ let p80Text = UsageFormatter.usdString(p80)
1589
+ let p95Text = UsageFormatter.usdString(p95)
1590
+ parts.append("p50 \(p50Text) · p80 \(p80Text) · p95 \(p95Text)")
1591
+ }
1592
+ parts.append("\(spendForecast.observedDays) observed \(observedDayLabel)")
1593
+ forecastLine = parts.joined(separator: " · ")
1594
+ }
1595
+
1596
+ var blockLine: String?
1597
+ var blockDetail: String?
1598
+ if let block, block.isActive {
1599
+ let tokens = UsageFormatter.tokenCountString(block.totals.totalTokens)
1600
+ blockLine = "Active block: \(tokens) tokens · \(block.entryCount) req"
1601
+ var details: [String] = []
1602
+ details.append("Ends \(UsageFormatter.resetCountdownDescription(from: block.end, now: input.now))")
1603
+ if let rate = block.tokensPerMinute {
1604
+ let rateText = UsageFormatter.tokenCountString(Int(rate.rounded()))
1605
+ details.append("\(rateText) tok/min")
1606
+ }
1607
+ if let projected = block.projectedTotalTokens {
1608
+ details.append("Proj \(UsageFormatter.tokenCountString(projected))")
1609
+ }
1610
+ let inputTokens = UsageFormatter.tokenCountString(block.totals.inputTokens)
1611
+ let outputTokens = UsageFormatter.tokenCountString(block.totals.outputTokens)
1612
+ details.append("In \(inputTokens) · Out \(outputTokens)")
1613
+ if let cost = block.totals.costUSD {
1614
+ var spendParts = ["Spend \(UsageFormatter.usdString(cost))"]
1615
+ if let per1K = UsageFormatter.usdPer1KTokensString(
1616
+ costUSD: cost,
1617
+ tokenCount: block.totals.totalTokens)
1618
+ {
1619
+ spendParts.append(per1K)
1620
+ }
1621
+ if let perRequest = UsageFormatter.usdPerRequestString(
1622
+ costUSD: cost,
1623
+ requestCount: block.entryCount)
1624
+ {
1625
+ spendParts.append(perRequest)
1626
+ }
1627
+ if let burnPerHour = UsageFormatter.usdPerHourFromTokensString(
1628
+ costUSD: cost,
1629
+ tokenCount: block.totals.totalTokens,
1630
+ tokensPerMinute: block.tokensPerMinute)
1631
+ {
1632
+ spendParts.append("Burn \(burnPerHour)")
1633
+ }
1634
+ details.append(spendParts.joined(separator: " · "))
1635
+ }
1636
+ blockDetail = details.joined(separator: "\n")
1637
+ }
1638
+
1639
+ var modelLine: String?
1640
+ if let topModel {
1641
+ let tokens = UsageFormatter.tokenCountString(topModel.totals.totalTokens)
1642
+ let modelName = UsageFormatter.modelDisplayName(topModel.model)
1643
+ var parts = ["Top model: \(modelName) · \(tokens) tokens · \(topModel.entryCount) req"]
1644
+ if let context = UsageFormatter.modelContextLabel(for: topModel.model) {
1645
+ parts.append(context)
1646
+ }
1647
+ if let cost = topModel.totals.costUSD {
1648
+ parts.append(UsageFormatter.usdString(cost))
1649
+ if let per1K = UsageFormatter.usdPer1KTokensString(
1650
+ costUSD: cost,
1651
+ tokenCount: topModel.totals.totalTokens)
1652
+ {
1653
+ parts.append(per1K)
1654
+ }
1655
+ }
1656
+ modelLine = parts.joined(separator: " · ")
1657
+ }
1658
+
1659
+ var projectLine: String?
1660
+ var projectDetail: String?
1661
+ if let topProject {
1662
+ let name = Self.insightsProjectDisplayName(topProject)
1663
+ let tokens = UsageFormatter.tokenCountString(topProject.totals.totalTokens)
1664
+ var parts = ["Top project: \(name) · \(tokens) tokens · \(topProject.entryCount) req"]
1665
+ if let cost = topProject.totals.costUSD {
1666
+ parts.append(UsageFormatter.usdString(cost))
1667
+ if let per1K = UsageFormatter.usdPer1KTokensString(
1668
+ costUSD: cost,
1669
+ tokenCount: topProject.totals.totalTokens)
1670
+ {
1671
+ parts.append(per1K)
1672
+ }
1673
+ }
1674
+ projectLine = parts.joined(separator: " · ")
1675
+
1676
+ if let projectForecast = topProjectSpendForecast {
1677
+ var detailParts = ["30d forecast \(UsageFormatter.usdString(projectForecast.projected30DayCostUSD))"]
1678
+ if let budgetLimit = projectForecast.budgetLimitUSD {
1679
+ detailParts.append("Budget \(UsageFormatter.usdString(budgetLimit))")
1680
+ if let budgetETAInDays = projectForecast.budgetETAInDays {
1681
+ detailParts.append(Self.budgetBreachETAText(days: budgetETAInDays, now: input.now))
1682
+ } else if !projectForecast.budgetWillBreach {
1683
+ detailParts.append("No breach at current pace")
1684
+ }
1685
+ }
1686
+ projectDetail = detailParts.joined(separator: " · ")
1687
+ }
1688
+ }
1689
+
1690
+ var anomalyLine: String?
1691
+ var anomalyDetail: String?
1692
+ if let anomaly, let primary = anomaly.primaryAnomaly {
1693
+ anomalyLine = "Anomaly: \(primary.severity.label) \(primary.metric.label) spike"
1694
+ var details = [Self.anomalyMetricDetail(primary, baselineDays: anomaly.baselineDays)]
1695
+ if let secondary = anomaly.secondaryAnomaly(excluding: primary.metric) {
1696
+ details.append(Self.anomalyMetricDetail(secondary, baselineDays: anomaly.baselineDays))
1697
+ }
1698
+ anomalyDetail = details.joined(separator: "\n")
1699
+ }
1700
+
1701
+ var reliabilityLine: String?
1702
+ var reliabilityDetail: String?
1703
+ if let reliability = input.ledgerReliability {
1704
+ reliabilityLine = "Reliability: \(reliability.score)/100 · \(reliability.grade)"
1705
+ reliabilityDetail = reliability.primarySignal ?? reliability.summary
1706
+ }
1707
+
1708
+ var routingLine: String?
1709
+ var routingDetail: String?
1710
+ if let routing = input.ledgerRouting {
1711
+ let from = UsageFormatter.modelDisplayName(routing.fromModel)
1712
+ let to = UsageFormatter.modelDisplayName(routing.toModel)
1713
+ routingLine = "Routing advisor: shift \(routing.shiftPercent)% \(from) -> \(to)"
1714
+ let confidenceText = "\(Int((routing.confidence * 100).rounded()))%"
1715
+ routingDetail = "Estimated savings: \(UsageFormatter.usdString(routing.estimatedSavingsUSD)) · confidence \(confidenceText)"
1716
+ }
1717
+
1718
+ let updatedLine = input.ledgerUpdatedAt.map { UsageFormatter.updatedString(from: $0, now: input.now) }
1719
+
1720
+ return InsightsSection(
1721
+ title: "Insights",
1722
+ todayLine: todayLine,
1723
+ todayDetail: todayDetail,
1724
+ forecastLine: forecastLine,
1725
+ blockLine: blockLine,
1726
+ blockDetail: blockDetail,
1727
+ modelLine: modelLine,
1728
+ projectLine: projectLine,
1729
+ projectDetail: projectDetail,
1730
+ anomalyLine: anomalyLine,
1731
+ anomalyDetail: anomalyDetail,
1732
+ reliabilityLine: reliabilityLine,
1733
+ reliabilityDetail: reliabilityDetail,
1734
+ routingLine: routingLine,
1735
+ routingDetail: routingDetail,
1736
+ updatedLine: updatedLine,
1737
+ errorLine: (error?.isEmpty ?? true) ? nil : error)
1738
+ }
1739
+
1740
+ private static func anomalyMetricDetail(
1741
+ _ anomaly: UsageLedgerAnomalySummary.MetricAnomaly,
1742
+ baselineDays: Int) -> String
1743
+ {
1744
+ let percentText = "\(Int((anomaly.percentIncrease * 100).rounded()))%"
1745
+ let baselineLabel = "\(baselineDays)d avg"
1746
+ switch anomaly.metric {
1747
+ case .tokens:
1748
+ let todayTokens = UsageFormatter.tokenCountString(Int(anomaly.todayValue.rounded()))
1749
+ let baselineTokens = UsageFormatter.tokenCountString(Int(anomaly.baselineAverage.rounded()))
1750
+ return "Tokens \(todayTokens) today · +\(percentText) vs \(baselineLabel) \(baselineTokens)"
1751
+ case .spend:
1752
+ let todaySpend = UsageFormatter.usdString(anomaly.todayValue)
1753
+ let baselineSpend = UsageFormatter.usdString(anomaly.baselineAverage)
1754
+ return "Spend \(todaySpend) today · +\(percentText) vs \(baselineLabel) \(baselineSpend)"
1755
+ }
1756
+ }
1757
+
1758
+ private static func insightsProjectDisplayName(_ summary: UsageLedgerProjectSummary) -> String {
1759
+ let displayName = summary.displayProjectName
1760
+ guard let annotation = self.projectIdentityAnnotation(
1761
+ displayName: displayName,
1762
+ projectID: summary.projectID,
1763
+ confidence: summary.projectNameConfidence,
1764
+ source: summary.projectNameSource)
1765
+ else {
1766
+ return displayName
1767
+ }
1768
+ return "\(displayName) [\(annotation)]"
1769
+ }
1770
+
1771
+ private static func projectIdentityAnnotation(
1772
+ displayName: String,
1773
+ projectID: String?,
1774
+ confidence: UsageLedgerProjectNameConfidence?,
1775
+ source: UsageLedgerProjectNameSource?) -> String?
1776
+ {
1777
+ let normalizedSource = source ?? .unknown
1778
+ let normalizedConfidence = confidence ?? .none
1779
+
1780
+ let shouldAnnotateSource = normalizedSource != .projectName && normalizedSource != .budgetOverride
1781
+ let shouldAnnotateConfidence = normalizedConfidence != .high
1782
+ let isUnknown = displayName == "Unknown project"
1783
+ guard shouldAnnotateSource || shouldAnnotateConfidence || isUnknown else { return nil }
1784
+
1785
+ var parts: [String] = []
1786
+ if shouldAnnotateSource {
1787
+ parts.append("source \(self.projectSourceLabel(normalizedSource))")
1788
+ }
1789
+ if shouldAnnotateConfidence {
1790
+ parts.append("confidence \(self.projectConfidenceLabel(normalizedConfidence))")
1791
+ }
1792
+ if isUnknown, let fingerprint = self.projectIDFingerprint(projectID) {
1793
+ parts.append("id \(fingerprint)")
1794
+ }
1795
+ return parts.isEmpty ? nil : parts.joined(separator: " · ")
1796
+ }
1797
+
1798
+ private static func projectSourceLabel(_ source: UsageLedgerProjectNameSource) -> String {
1799
+ switch source {
1800
+ case .projectName: "project name"
1801
+ case .projectID: "project id"
1802
+ case .inferredFromPath: "path-derived"
1803
+ case .inferredFromName: "name-derived"
1804
+ case .budgetOverride: "budget override"
1805
+ case .unknown: "unknown"
1806
+ }
1807
+ }
1808
+
1809
+ private static func projectConfidenceLabel(_ confidence: UsageLedgerProjectNameConfidence) -> String {
1810
+ switch confidence {
1811
+ case .high: "high"
1812
+ case .medium: "medium"
1813
+ case .low: "low"
1814
+ case .none: "none"
1815
+ }
1816
+ }
1817
+
1818
+ private static func projectIDFingerprint(_ projectID: String?) -> String? {
1819
+ guard let projectID = projectID?.trimmingCharacters(in: .whitespacesAndNewlines), !projectID.isEmpty else {
1820
+ return nil
1821
+ }
1822
+
1823
+ var hash: UInt64 = 0xcbf29ce484222325
1824
+ for byte in projectID.lowercased().utf8 {
1825
+ hash ^= UInt64(byte)
1826
+ hash = hash &* 0x100000001b3
1827
+ }
1828
+ return String(format: "%08llx", hash)
1829
+ }
1830
+
1831
+ private static func budgetBreachETAText(days: Double, now: Date) -> String {
1832
+ guard days.isFinite else { return "Breach ETA unavailable" }
1833
+ if days <= 0 { return "Breach now" }
1834
+ let etaDate = now.addingTimeInterval(days * 24 * 60 * 60)
1835
+ let countdown = UsageFormatter.resetCountdownDescription(from: etaDate, now: now)
1836
+ if countdown == "now" { return "Breach now" }
1837
+ return "Breach \(countdown)"
1838
+ }
1839
+
1840
+ private static func clamped(_ value: Double) -> Double {
1841
+ min(100, max(0, value))
1842
+ }
1843
+
1844
+ private static func progressColor(for provider: UsageProvider) -> Color {
1845
+ let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color
1846
+ return Color(red: color.red, green: color.green, blue: color.blue)
1847
+ }
1848
+
1849
+ private static func resetText(for window: RateWindow, prefersCountdown: Bool) -> String? {
1850
+ if let date = window.resetsAt {
1851
+ if prefersCountdown {
1852
+ return "Resets \(UsageFormatter.resetCountdownDescription(from: date))"
1853
+ }
1854
+ return "Resets \(UsageFormatter.resetDescription(from: date))"
1855
+ }
1856
+
1857
+ if let desc = window.resetDescription, !desc.isEmpty {
1858
+ return desc
1859
+ }
1860
+ return nil
1861
+ }
1862
+ }
1863
+
1864
+ // MARK: - Copy-on-click overlay
1865
+
1866
+ private struct ClickToCopyOverlay: NSViewRepresentable {
1867
+ let copyText: String
1868
+
1869
+ func makeNSView(context: Context) -> ClickToCopyView {
1870
+ ClickToCopyView(copyText: self.copyText)
1871
+ }
1872
+
1873
+ func updateNSView(_ nsView: ClickToCopyView, context: Context) {
1874
+ nsView.copyText = self.copyText
1875
+ }
1876
+ }
1877
+
1878
+ private final class ClickToCopyView: NSView {
1879
+ var copyText: String
1880
+
1881
+ init(copyText: String) {
1882
+ self.copyText = copyText
1883
+ super.init(frame: .zero)
1884
+ self.wantsLayer = false
1885
+ }
1886
+
1887
+ @available(*, unavailable)
1888
+ required init?(coder: NSCoder) {
1889
+ fatalError("init(coder:) has not been implemented")
1890
+ }
1891
+
1892
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
1893
+
1894
+ override func mouseDown(with event: NSEvent) {
1895
+ _ = event
1896
+ let pb = NSPasteboard.general
1897
+ pb.clearContents()
1898
+ pb.setString(self.copyText, forType: .string)
1899
+ }
1900
+ }