@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,3435 @@
1
+ import AppKit
2
+ import RunicCore
3
+ import SwiftUI
4
+
5
+ private struct ProviderUsageStatus {
6
+ let text: String
7
+ let style: Style
8
+
9
+ enum Style {
10
+ case success
11
+ case error
12
+ case neutral
13
+ }
14
+ }
15
+
16
+ private enum ProviderListMetrics {
17
+ static let contentInset: CGFloat = 16
18
+ static let listHeaderCornerRadius: CGFloat = RunicCornerRadius.md
19
+ static let listHeaderPadding: EdgeInsets = .init(
20
+ top: RunicSpacing.xs,
21
+ leading: RunicSpacing.md,
22
+ bottom: RunicSpacing.xs,
23
+ trailing: RunicSpacing.md)
24
+ static let listHeaderBackgroundOpacity: Double = 0.26
25
+ static let listHeaderBorderOpacity: Double = 0.2
26
+ static let rowSpacing: CGFloat = RunicSpacing.sm
27
+ static let reorderHandleSize: CGFloat = 12
28
+ static let reorderDotSize: CGFloat = 4
29
+ static let reorderDotSpacing: CGFloat = 4
30
+ static let rowInsets = EdgeInsets(
31
+ top: RunicSpacing.xxs,
32
+ leading: contentInset,
33
+ bottom: RunicSpacing.xxs,
34
+ trailing: contentInset)
35
+ static let sectionEdgeInset: CGFloat = RunicSpacing.md
36
+ static let dividerBottomInset: CGFloat = RunicSpacing.xxs
37
+ static let checkboxSize: CGFloat = 20
38
+ static let iconSize: CGFloat = 30
39
+ static let dividerLeadingInset: CGFloat = contentInset
40
+ static let dividerTrailingInset: CGFloat = contentInset
41
+ static let providerCardPadding = EdgeInsets(
42
+ top: RunicSpacing.sm,
43
+ leading: RunicSpacing.sm,
44
+ bottom: RunicSpacing.sm,
45
+ trailing: RunicSpacing.sm)
46
+ static let providerCardBackgroundOpacity: Double = 0.55
47
+ static let providerCardBorderOpacity: Double = 0.25
48
+ static let providerCardCornerRadius: CGFloat = RunicCornerRadius.md
49
+ static let providerInsightsCardCornerRadius: CGFloat = RunicCornerRadius.sm
50
+ static let providerInsightsGridItemMinWidth: CGFloat = 210
51
+ static let providerInsightsChipCornerRadius: CGFloat = RunicCornerRadius.sm
52
+ static let providerInsightsChipSpacing: CGFloat = RunicSpacing.xxs
53
+ static let providerInsightsChipPadding: CGFloat = RunicSpacing.xs
54
+
55
+ static let supplementalCardPadding = EdgeInsets(
56
+ top: RunicSpacing.sm,
57
+ leading: RunicSpacing.sm,
58
+ bottom: RunicSpacing.sm,
59
+ trailing: RunicSpacing.sm)
60
+ static let supplementalCardBackgroundOpacity: Double = 0.28
61
+ static let supplementalCardBorderOpacity: Double = 0.18
62
+ static let fieldMaxWidth: CGFloat = 420
63
+ static let errorCardPadding: CGFloat = RunicSpacing.sm
64
+ static let statusBadgePaddingH: CGFloat = RunicSpacing.xs
65
+ static let statusBadgePaddingV: CGFloat = RunicSpacing.xxxs
66
+ static let errorCardCornerRadius: CGFloat = RunicCornerRadius.sm
67
+ static let insightsCardPadding: CGFloat = RunicSpacing.xs
68
+ static let insightsLineSpacing: CGFloat = RunicSpacing.xxxs
69
+ static let insightsLabelWidth: CGFloat = 84
70
+ static let sidebarStatusLabelWidth: CGFloat = 62
71
+ static let sidebarCardCornerRadius: CGFloat = RunicCornerRadius.md
72
+ static let sidebarCardPadding: CGFloat = RunicSpacing.md
73
+ static let sidebarCardBackgroundOpacity: Double = 0.36
74
+ static let sidebarCardBorderOpacity: Double = 0.22
75
+ static let sidebarMicroCardCornerRadius: CGFloat = RunicCornerRadius.sm
76
+ static let sidebarMicroCardBackgroundOpacity: Double = 0.52
77
+ static let sidebarMicroCardBorderOpacity: Double = 0.2
78
+ static let sidebarSectionSpacing: CGFloat = RunicSpacing.md
79
+ static let sidebarContentGap: CGFloat = RunicSpacing.sm
80
+ }
81
+
82
+ private struct ProviderInsightLine: Identifiable, Equatable {
83
+ let id: String
84
+ let label: String
85
+ let value: String
86
+ let help: String?
87
+
88
+ init(id: String, label: String, value: String, help: String? = nil) {
89
+ self.id = id
90
+ self.label = label
91
+ self.value = value
92
+ self.help = help
93
+ }
94
+ }
95
+
96
+ @MainActor
97
+ private enum ProviderInsightsComposer {
98
+ static func lines(
99
+ for provider: UsageProvider,
100
+ store: UsageStore,
101
+ maxRows: Int? = nil) -> [ProviderInsightLine]
102
+ {
103
+ var rows: [ProviderInsightLine] = []
104
+ let snapshot = store.snapshot(for: provider)
105
+ let identity = snapshot?.identity(for: provider) ?? snapshot?.identity
106
+ let tokenSnapshot = store.tokenSnapshot(for: provider)
107
+ let attempts = store.fetchAttempts(for: provider)
108
+ let reliability = store.ledgerReliabilityScore(for: provider)
109
+ let anomaly = store.ledgerAnomalySummary(for: provider)
110
+ let spendForecast = store.ledgerSpendForecast(for: provider)
111
+ let topProjectSpendForecast = store.ledgerTopProjectSpendForecast(for: provider)
112
+ let topModel = store.ledgerTopModel(for: provider)
113
+ let topProject = store.ledgerTopProject(for: provider)
114
+ let modelBreakdown = store.ledgerModelBreakdown(for: provider)
115
+ let projectBreakdown = store.ledgerProjectBreakdown(for: provider)
116
+ let coverage = Self.effectiveCoverage(
117
+ provider: provider,
118
+ metadataCoverage: store.metadata(for: provider).usageCoverage,
119
+ topModel: topModel,
120
+ topProject: topProject,
121
+ modelBreakdown: modelBreakdown,
122
+ projectBreakdown: projectBreakdown,
123
+ snapshot: snapshot,
124
+ tokenSnapshot: tokenSnapshot)
125
+ let hasModelBreakdown = coverage.supportsModelBreakdown
126
+ let hasProjectAttribution = coverage.supportsProjectAttribution
127
+
128
+ if let who = self.actorValue(identity: identity) {
129
+ rows.append(ProviderInsightLine(id: "actor", label: "Who", value: who))
130
+ }
131
+ if let planAuth = self.planAuthValue(identity: identity) {
132
+ rows.append(ProviderInsightLine(id: "plan-auth", label: "Plan/Auth", value: planAuth))
133
+ }
134
+ if let fetch = self.fetchHealthValue(attempts) {
135
+ rows.append(ProviderInsightLine(
136
+ id: "fetch",
137
+ label: "Fetch",
138
+ value: fetch,
139
+ help: self.fetchAttemptsHelp(attempts)))
140
+ }
141
+ if store.isStale(provider: provider), let fetchError = self.fetchErrorValue(attempts) {
142
+ rows.append(ProviderInsightLine(
143
+ id: "fetch-error",
144
+ label: "Fetch err",
145
+ value: fetchError,
146
+ help: self.fetchAttemptsHelp(attempts)))
147
+ }
148
+ if let reliabilityValue = self.reliabilityValue(reliability) {
149
+ rows.append(ProviderInsightLine(
150
+ id: "reliability",
151
+ label: "Reliability",
152
+ value: reliabilityValue,
153
+ help: self.reliabilityHelpText(reliability)))
154
+ }
155
+ if let costAlertValue = self.costAnomalyValue(anomaly) {
156
+ rows.append(ProviderInsightLine(
157
+ id: "cost-alert",
158
+ label: "Cost alert",
159
+ value: costAlertValue,
160
+ help: self.costAnomalyHelpText(anomaly)))
161
+ }
162
+
163
+ if hasModelBreakdown, let topModel, topModel.provider == provider {
164
+ rows.append(ProviderInsightLine(
165
+ id: "top-model",
166
+ label: "Top model",
167
+ value: self.topModelValue(topModel)))
168
+ } else if !hasModelBreakdown, let windowModels = self.windowModelsValue(snapshot) {
169
+ rows.append(ProviderInsightLine(
170
+ id: "models",
171
+ label: "Quota windows",
172
+ value: windowModels,
173
+ help: "Live quota windows grouped by provider response window IDs."))
174
+ }
175
+
176
+ if hasProjectAttribution,
177
+ let topProject,
178
+ topProject.provider == provider
179
+ {
180
+ rows.append(ProviderInsightLine(
181
+ id: "top-project",
182
+ label: "Top project",
183
+ value: self.topProjectValue(topProject),
184
+ help: self.projectIdentityHelpText(topProject)))
185
+ }
186
+
187
+ if hasModelBreakdown, let modelMix = self.modelMixValue(modelBreakdown) {
188
+ rows.append(ProviderInsightLine(id: "model-mix", label: "Model mix", value: modelMix))
189
+ }
190
+
191
+ if hasProjectAttribution, let projectMix = self.projectMixValue(projectBreakdown) {
192
+ rows.append(ProviderInsightLine(
193
+ id: "project-mix",
194
+ label: "Project mix",
195
+ value: projectMix,
196
+ help: self.projectMixHelpText(projectBreakdown)))
197
+ }
198
+
199
+ if let usage = self.usageValue(snapshot?.primary) {
200
+ rows.append(ProviderInsightLine(id: "usage", label: "Usage", value: usage))
201
+ }
202
+
203
+ if let spend = self.spendValue(snapshot?.providerCost) {
204
+ rows.append(ProviderInsightLine(id: "spend", label: "Spend", value: spend))
205
+ }
206
+ if let forecast = self.forecastValue(spendForecast) {
207
+ rows.append(ProviderInsightLine(
208
+ id: "forecast",
209
+ label: "Forecast",
210
+ value: forecast,
211
+ help: self.forecastHelpText(spendForecast)))
212
+ }
213
+ if let budget = self.budgetValue(spendForecast) {
214
+ rows.append(ProviderInsightLine(
215
+ id: "budget",
216
+ label: "Budget",
217
+ value: budget,
218
+ help: self.budgetHelpText(spendForecast)))
219
+ }
220
+ if hasProjectAttribution, let projectBudget = self.projectBudgetValue(topProjectSpendForecast) {
221
+ rows.append(ProviderInsightLine(
222
+ id: "project-budget",
223
+ label: "Prj budget",
224
+ value: projectBudget,
225
+ help: self.budgetHelpText(topProjectSpendForecast)))
226
+ }
227
+
228
+ if let today = self.tokenWindowValue(tokens: tokenSnapshot?.sessionTokens, cost: tokenSnapshot?.sessionCostUSD) {
229
+ rows.append(ProviderInsightLine(id: "today", label: "Today", value: today))
230
+ }
231
+
232
+ if let last30 = self.tokenWindowValue(
233
+ tokens: tokenSnapshot?.last30DaysTokens,
234
+ cost: tokenSnapshot?.last30DaysCostUSD)
235
+ {
236
+ rows.append(ProviderInsightLine(id: "last30", label: "30d", value: last30))
237
+ }
238
+
239
+ if let reset = self.resetValue(snapshot?.primary) {
240
+ rows.append(ProviderInsightLine(id: "reset", label: "Reset", value: reset))
241
+ }
242
+
243
+ guard let maxRows, maxRows > 0, rows.count > maxRows else {
244
+ return rows
245
+ }
246
+ return Array(rows.prefix(maxRows))
247
+ }
248
+
249
+ static func coverageSummaryLabel(for provider: UsageProvider, store: UsageStore) -> String? {
250
+ Self.effectiveCoverage(for: provider, store: store).summaryLabel
251
+ }
252
+
253
+ static func effectiveCoverage(
254
+ for provider: UsageProvider,
255
+ store: UsageStore) -> ProviderUsageCoverage
256
+ {
257
+ Self.effectiveCoverage(
258
+ provider: provider,
259
+ metadataCoverage: store.metadata(for: provider).usageCoverage,
260
+ topModel: store.ledgerTopModel(for: provider),
261
+ topProject: store.ledgerTopProject(for: provider),
262
+ modelBreakdown: store.ledgerModelBreakdown(for: provider),
263
+ projectBreakdown: store.ledgerProjectBreakdown(for: provider),
264
+ snapshot: store.snapshot(for: provider),
265
+ tokenSnapshot: store.tokenSnapshot(for: provider))
266
+ }
267
+
268
+ private static func effectiveCoverage(
269
+ provider: UsageProvider,
270
+ metadataCoverage: ProviderUsageCoverage,
271
+ topModel: UsageLedgerModelSummary?,
272
+ topProject: UsageLedgerProjectSummary?,
273
+ modelBreakdown: [UsageLedgerModelSummary],
274
+ projectBreakdown: [UsageLedgerProjectSummary],
275
+ snapshot: UsageSnapshot?,
276
+ tokenSnapshot: CostUsageTokenSnapshot?) -> ProviderUsageCoverage
277
+ {
278
+ let hasModelBreakdown =
279
+ metadataCoverage.supportsModelBreakdown
280
+ || (topModel?.provider == provider)
281
+ || !modelBreakdown.isEmpty
282
+
283
+ let hasProjectAttribution =
284
+ metadataCoverage.supportsProjectAttribution
285
+ || (topProject?.provider == provider)
286
+ || !projectBreakdown.isEmpty
287
+
288
+ let hasTokenMetrics = metadataCoverage.supportsTokenMetrics
289
+ || snapshot?.providerCost != nil
290
+ || tokenSnapshot?.sessionTokens != nil
291
+ || tokenSnapshot?.sessionCostUSD != nil
292
+ || tokenSnapshot?.last30DaysTokens != nil
293
+ || tokenSnapshot?.last30DaysCostUSD != nil
294
+
295
+ return ProviderUsageCoverage(
296
+ supportsModelBreakdown: hasModelBreakdown,
297
+ supportsTokenMetrics: hasTokenMetrics,
298
+ supportsProjectAttribution: hasProjectAttribution)
299
+ }
300
+
301
+ private static func actorValue(identity: ProviderIdentitySnapshot?) -> String? {
302
+ let email = self.trimmed(identity?.accountEmail)
303
+ let organization = self.trimmed(identity?.accountOrganization)
304
+ if let email, let organization {
305
+ if email.caseInsensitiveCompare(organization) == .orderedSame {
306
+ return email
307
+ }
308
+ return "\(email) · \(organization)"
309
+ }
310
+ return email ?? organization
311
+ }
312
+
313
+ private static func planAuthValue(identity: ProviderIdentitySnapshot?) -> String? {
314
+ guard let raw = self.trimmed(identity?.loginMethod) else { return nil }
315
+ let cleaned = UsageFormatter.cleanPlanName(raw)
316
+ return cleaned.isEmpty ? raw : cleaned
317
+ }
318
+
319
+ private static func topModelValue(_ summary: UsageLedgerModelSummary) -> String {
320
+ let model = UsageFormatter.modelDisplayName(summary.model)
321
+ let tokens = UsageFormatter.tokenCountString(summary.totals.totalTokens)
322
+ var parts = [model, "\(tokens) tok", "\(summary.entryCount) req"]
323
+ if let context = UsageFormatter.modelContextLabel(for: summary.model) {
324
+ parts.append(context)
325
+ }
326
+ if let cost = summary.totals.costUSD {
327
+ parts.append(UsageFormatter.usdString(cost))
328
+ if let per1K = UsageFormatter.usdPer1KTokensString(costUSD: cost, tokenCount: summary.totals.totalTokens) {
329
+ parts.append("~\(per1K)")
330
+ }
331
+ if let perReq = UsageFormatter.usdPerRequestString(costUSD: cost, requestCount: summary.entryCount) {
332
+ parts.append("~\(perReq)")
333
+ }
334
+ }
335
+ return parts.joined(separator: " · ")
336
+ }
337
+
338
+ private static func topProjectValue(_ summary: UsageLedgerProjectSummary) -> String {
339
+ let project = self.shortProjectName(summary.displayProjectName)
340
+ let tokens = UsageFormatter.tokenCountString(summary.totals.totalTokens)
341
+ var parts = [project, "\(tokens) tok", "\(summary.entryCount) req"]
342
+ if let cost = summary.totals.costUSD {
343
+ parts.append(UsageFormatter.usdString(cost))
344
+ if let per1K = UsageFormatter.usdPer1KTokensString(costUSD: cost, tokenCount: summary.totals.totalTokens) {
345
+ parts.append("~\(per1K)")
346
+ }
347
+ if let perReq = UsageFormatter.usdPerRequestString(costUSD: cost, requestCount: summary.entryCount) {
348
+ parts.append("~\(perReq)")
349
+ }
350
+ }
351
+ return parts.joined(separator: " · ")
352
+ }
353
+
354
+ private static func modelMixValue(_ summaries: [UsageLedgerModelSummary]) -> String? {
355
+ guard !summaries.isEmpty else { return nil }
356
+ var tokensByModel: [String: Int] = [:]
357
+ for summary in summaries {
358
+ tokensByModel[summary.model, default: 0] += summary.totals.totalTokens
359
+ }
360
+ let ranked = tokensByModel.map { (model: $0.key, tokens: $0.value) }.sorted { lhs, rhs in
361
+ if lhs.tokens == rhs.tokens {
362
+ return UsageFormatter.modelDisplayName(lhs.model) < UsageFormatter.modelDisplayName(rhs.model)
363
+ }
364
+ return lhs.tokens > rhs.tokens
365
+ }
366
+ guard ranked.count > 1 else { return nil }
367
+ let rendered = ranked.prefix(2).map { item in
368
+ let name = UsageFormatter.modelDisplayName(item.model)
369
+ let tokens = UsageFormatter.tokenCountString(item.tokens)
370
+ if let context = UsageFormatter.modelContextLabel(for: item.model) {
371
+ return "\(name) \(context) \(tokens)"
372
+ }
373
+ return "\(name) \(tokens)"
374
+ }
375
+ var value = rendered.joined(separator: " · ")
376
+ if ranked.count > 2 {
377
+ value += " +\(ranked.count - 2) more"
378
+ }
379
+ return value
380
+ }
381
+
382
+ private static func projectMixValue(_ summaries: [UsageLedgerProjectSummary]) -> String? {
383
+ guard summaries.count > 1 else { return nil }
384
+ let ranked = summaries.sorted { lhs, rhs in
385
+ if lhs.totals.totalTokens == rhs.totals.totalTokens {
386
+ return lhs.displayProjectName < rhs.displayProjectName
387
+ }
388
+ return lhs.totals.totalTokens > rhs.totals.totalTokens
389
+ }
390
+ let rendered = ranked.prefix(2).map { summary in
391
+ let project = self.shortProjectName(summary.displayProjectName)
392
+ let tokens = UsageFormatter.tokenCountString(summary.totals.totalTokens)
393
+ return "\(project) \(tokens)"
394
+ }
395
+ var value = rendered.joined(separator: " · ")
396
+ if ranked.count > 2 {
397
+ value += " +\(ranked.count - 2) more"
398
+ }
399
+ return value
400
+ }
401
+
402
+ private static func shortProjectName(_ text: String) -> String {
403
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
404
+ let maxLength = 44
405
+ guard trimmed.count > maxLength else { return trimmed }
406
+ let prefixLength = 28
407
+ let suffixLength = 12
408
+ guard trimmed.count > (prefixLength + suffixLength + 1) else {
409
+ let index = trimmed.index(trimmed.startIndex, offsetBy: maxLength)
410
+ return "\(trimmed[..<index])…"
411
+ }
412
+ let prefixEnd = trimmed.index(trimmed.startIndex, offsetBy: prefixLength)
413
+ let suffixStart = trimmed.index(trimmed.endIndex, offsetBy: -suffixLength)
414
+ return "\(trimmed[..<prefixEnd])…\(trimmed[suffixStart...])"
415
+ }
416
+
417
+ private static func projectMixHelpText(_ summaries: [UsageLedgerProjectSummary]) -> String? {
418
+ guard summaries.count > 1 else { return nil }
419
+ let ranked = summaries.sorted { lhs, rhs in
420
+ if lhs.totals.totalTokens == rhs.totals.totalTokens {
421
+ return lhs.displayProjectName < rhs.displayProjectName
422
+ }
423
+ return lhs.totals.totalTokens > rhs.totals.totalTokens
424
+ }
425
+ let details = ranked.prefix(3).map { summary in
426
+ let tokens = UsageFormatter.tokenCountString(summary.totals.totalTokens)
427
+ let project = summary.displayProjectName
428
+ return "\(project): \(tokens) tok"
429
+ }
430
+ return details.isEmpty ? nil : details.joined(separator: "\n")
431
+ }
432
+
433
+ private static func projectIdentityHelpText(_ summary: UsageLedgerProjectSummary) -> String? {
434
+ let displayName = summary.displayProjectName
435
+ let source = summary.projectNameSource ?? .unknown
436
+ let confidence = summary.projectNameConfidence ?? .none
437
+ let shouldAnnotateSource = source != .projectName && source != .budgetOverride
438
+ let shouldAnnotateConfidence = confidence != .high
439
+ let isUnknown = displayName == "Unknown project"
440
+
441
+ var details: [String] = []
442
+ if shouldAnnotateSource {
443
+ details.append("source: \(self.projectSourceLabel(source))")
444
+ }
445
+ if shouldAnnotateConfidence {
446
+ details.append("confidence: \(self.projectConfidenceLabel(confidence))")
447
+ }
448
+ if isUnknown, let fingerprint = self.projectIDFingerprint(summary.projectID) {
449
+ details.append("id: \(fingerprint)")
450
+ }
451
+ if let provenance = self.trimmed(summary.projectNameProvenance) {
452
+ details.append("via: \(provenance)")
453
+ }
454
+ guard !details.isEmpty else { return nil }
455
+ return ([displayName] + details).joined(separator: "\n")
456
+ }
457
+
458
+ private static func projectSourceLabel(_ source: UsageLedgerProjectNameSource) -> String {
459
+ switch source {
460
+ case .projectName: return "project name"
461
+ case .projectID: return "project id"
462
+ case .inferredFromPath: return "path-derived"
463
+ case .inferredFromName: return "name-derived"
464
+ case .budgetOverride: return "budget override"
465
+ case .unknown: return "unknown"
466
+ }
467
+ }
468
+
469
+ private static func projectConfidenceLabel(_ confidence: UsageLedgerProjectNameConfidence) -> String {
470
+ switch confidence {
471
+ case .high: return "high"
472
+ case .medium: return "medium"
473
+ case .low: return "low"
474
+ case .none: return "none"
475
+ }
476
+ }
477
+
478
+ private static func projectIDFingerprint(_ projectID: String?) -> String? {
479
+ guard let projectID = projectID?.trimmingCharacters(in: .whitespacesAndNewlines), !projectID.isEmpty else {
480
+ return nil
481
+ }
482
+
483
+ var hash: UInt64 = 0xcbf29ce484222325
484
+ for byte in projectID.lowercased().utf8 {
485
+ hash ^= UInt64(byte)
486
+ hash = hash &* 0x100000001b3
487
+ }
488
+ return String(format: "%08llx", hash)
489
+ }
490
+
491
+ private static func trimmed(_ value: String?) -> String? {
492
+ guard let value else { return nil }
493
+ let cleaned = value.trimmingCharacters(in: .whitespacesAndNewlines)
494
+ return cleaned.isEmpty ? nil : cleaned
495
+ }
496
+
497
+ private static func usageValue(_ window: RateWindow?) -> String? {
498
+ guard let window else { return nil }
499
+ var parts = ["\(Int(window.usedPercent.rounded()))% used"]
500
+ if let minutes = window.windowMinutes, minutes > 0 {
501
+ parts.append("\(minutes)m window")
502
+ }
503
+ return parts.joined(separator: " · ")
504
+ }
505
+
506
+ private static func spendValue(_ providerCost: ProviderCostSnapshot?) -> String? {
507
+ guard let providerCost else { return nil }
508
+ let used = UsageFormatter.currencyString(providerCost.used, currencyCode: providerCost.currencyCode)
509
+ var value: String
510
+ if providerCost.limit > 0 {
511
+ let limitText = UsageFormatter.currencyString(providerCost.limit, currencyCode: providerCost.currencyCode)
512
+ value = "\(used) / \(limitText)"
513
+ } else {
514
+ value = used
515
+ }
516
+ if let period = self.trimmed(providerCost.period) {
517
+ value += " · \(period)"
518
+ }
519
+ return value
520
+ }
521
+
522
+ private static func forecastValue(_ forecast: UsageLedgerSpendForecast?) -> String? {
523
+ guard let forecast else { return nil }
524
+ var parts = [UsageFormatter.usdString(forecast.projected30DayCostUSD), "/ 30d"]
525
+ if forecast.averageDailyCostUSD.isFinite, forecast.averageDailyCostUSD >= 0 {
526
+ parts.append("· \(UsageFormatter.usdString(forecast.averageDailyCostUSD))/day")
527
+ }
528
+ return parts.joined(separator: " ")
529
+ }
530
+
531
+ private static func budgetValue(_ forecast: UsageLedgerSpendForecast?) -> String? {
532
+ guard let forecast, let limit = forecast.budgetLimitUSD, limit > 0 else { return nil }
533
+ let limitText = UsageFormatter.usdString(limit)
534
+ if let eta = forecast.budgetETAInDays {
535
+ return "\(limitText) · \(self.budgetBreachETAText(days: eta))"
536
+ }
537
+ if forecast.budgetWillBreach {
538
+ return "\(limitText) · Breach risk"
539
+ }
540
+ return "\(limitText) · On track"
541
+ }
542
+
543
+ private static func projectBudgetValue(_ forecast: UsageLedgerSpendForecast?) -> String? {
544
+ guard let forecast, let limit = forecast.budgetLimitUSD, limit > 0 else { return nil }
545
+ let name = self.shortProjectName(forecast.projectName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Top project")
546
+ if let eta = forecast.budgetETAInDays {
547
+ return "\(name) · \(self.budgetBreachETAText(days: eta))"
548
+ }
549
+ if forecast.budgetWillBreach {
550
+ return "\(name) · Breach risk"
551
+ }
552
+ return "\(name) · On track"
553
+ }
554
+
555
+ private static func forecastHelpText(_ forecast: UsageLedgerSpendForecast?) -> String? {
556
+ guard let forecast else { return nil }
557
+ var lines: [String] = []
558
+ let observedLabel = forecast.observedDays == 1 ? "day" : "days"
559
+ lines.append("Observed: \(forecast.observedDays) \(observedLabel)")
560
+ lines.append("Projected 30d: \(UsageFormatter.usdString(forecast.projected30DayCostUSD))")
561
+ if let p50 = forecast.projectedCostP50USD {
562
+ lines.append("p50: \(UsageFormatter.usdString(p50))")
563
+ }
564
+ if let p80 = forecast.projectedCostP80USD {
565
+ lines.append("p80: \(UsageFormatter.usdString(p80))")
566
+ }
567
+ if let p95 = forecast.projectedCostP95USD {
568
+ lines.append("p95: \(UsageFormatter.usdString(p95))")
569
+ }
570
+ return lines.joined(separator: "\n")
571
+ }
572
+
573
+ private static func budgetHelpText(_ forecast: UsageLedgerSpendForecast?) -> String? {
574
+ guard let forecast, let limit = forecast.budgetLimitUSD, limit > 0 else { return nil }
575
+ var lines = [
576
+ "Budget limit: \(UsageFormatter.usdString(limit))",
577
+ "Projected 30d: \(UsageFormatter.usdString(forecast.projected30DayCostUSD))"
578
+ ]
579
+ if let eta = forecast.budgetETAInDays {
580
+ lines.append(self.budgetBreachETAText(days: eta))
581
+ } else if forecast.budgetWillBreach {
582
+ lines.append("Breach risk at current pace")
583
+ } else {
584
+ lines.append("No breach at current pace")
585
+ }
586
+ return lines.joined(separator: "\n")
587
+ }
588
+
589
+ private static func budgetBreachETAText(days: Double) -> String {
590
+ guard days.isFinite else { return "Breach ETA unavailable" }
591
+ if days <= 0 { return "Breach now" }
592
+ let now = Date()
593
+ let etaDate = now.addingTimeInterval(days * 24 * 60 * 60)
594
+ let countdown = UsageFormatter.resetCountdownDescription(from: etaDate, now: now)
595
+ if countdown == "now" { return "Breach now" }
596
+ return "Breach \(countdown)"
597
+ }
598
+
599
+ private static func resetValue(_ window: RateWindow?) -> String? {
600
+ guard let window else { return nil }
601
+ if let resetsAt = window.resetsAt {
602
+ return UsageFormatter.resetCountdownDescription(from: resetsAt)
603
+ }
604
+ return self.trimmed(window.resetDescription)
605
+ }
606
+
607
+ private static func tokenWindowValue(tokens: Int?, cost: Double?) -> String? {
608
+ var parts: [String] = []
609
+ if let cost, cost.isFinite, cost >= 0 {
610
+ parts.append(UsageFormatter.usdString(cost))
611
+ }
612
+ if let tokens, tokens >= 0 {
613
+ parts.append("\(UsageFormatter.tokenCountString(tokens)) tok")
614
+ }
615
+ return parts.isEmpty ? nil : parts.joined(separator: " · ")
616
+ }
617
+
618
+ private static func reliabilityValue(_ reliability: UsageLedgerReliabilityScore?) -> String? {
619
+ guard let reliability else { return nil }
620
+ return "\(reliability.grade) · \(reliability.score)/100"
621
+ }
622
+
623
+ private static func reliabilityHelpText(_ reliability: UsageLedgerReliabilityScore?) -> String? {
624
+ guard let reliability else { return nil }
625
+ var lines = [reliability.summary]
626
+ if let primary = self.trimmed(reliability.primarySignal), !primary.isEmpty {
627
+ lines.append(primary)
628
+ }
629
+ for signal in reliability.signals {
630
+ let trimmed = self.trimmed(signal) ?? ""
631
+ guard !trimmed.isEmpty else { continue }
632
+ if lines.contains(trimmed) { continue }
633
+ lines.append(trimmed)
634
+ if lines.count >= 5 { break }
635
+ }
636
+ return lines.joined(separator: "\n")
637
+ }
638
+
639
+ private static func costAnomalyValue(_ anomaly: UsageLedgerAnomalySummary?) -> String? {
640
+ guard let spend = anomaly?.spendAnomaly else { return nil }
641
+ let percent = Int((spend.percentIncrease * 100).rounded())
642
+ return "\(spend.severity.label) spend +\(percent)%"
643
+ }
644
+
645
+ private static func costAnomalyHelpText(_ anomaly: UsageLedgerAnomalySummary?) -> String? {
646
+ guard let anomaly, let spend = anomaly.spendAnomaly else { return nil }
647
+ let percent = Int((spend.percentIncrease * 100).rounded())
648
+ var lines = [
649
+ "Spend today: \(UsageFormatter.usdString(spend.todayValue))",
650
+ "Baseline (\(anomaly.baselineDays)d avg): \(UsageFormatter.usdString(spend.baselineAverage))",
651
+ "Increase: +\(percent)%",
652
+ "Severity: \(spend.severity.label)"
653
+ ]
654
+ if let explanation = anomaly.explanation {
655
+ lines.append(contentsOf: explanation.details.prefix(2))
656
+ }
657
+ return lines.joined(separator: "\n")
658
+ }
659
+
660
+ private static func fetchHealthValue(_ attempts: [ProviderFetchAttempt]) -> String? {
661
+ guard !attempts.isEmpty else { return nil }
662
+ let rendered = attempts.prefix(3).map { attempt in
663
+ let status: String
664
+ if !attempt.wasAvailable {
665
+ status = "unavailable"
666
+ } else if self.trimmed(attempt.errorDescription) != nil {
667
+ status = "failed"
668
+ } else {
669
+ status = "ok"
670
+ }
671
+ return "\(self.fetchStrategyLabel(attempt)) \(status)"
672
+ }
673
+ var value = rendered.joined(separator: " · ")
674
+ if attempts.count > 3 {
675
+ value += " +\(attempts.count - 3) more"
676
+ }
677
+ return value
678
+ }
679
+
680
+ private static func fetchErrorValue(_ attempts: [ProviderFetchAttempt]) -> String? {
681
+ guard let latestError = attempts.reversed().compactMap({ self.trimmed($0.errorDescription) }).first else {
682
+ return nil
683
+ }
684
+ return self.truncated(latestError, maxLength: 88)
685
+ }
686
+
687
+ private static func fetchAttemptsHelp(_ attempts: [ProviderFetchAttempt]) -> String? {
688
+ guard !attempts.isEmpty else { return nil }
689
+ return attempts.map { attempt in
690
+ let status: String
691
+ if !attempt.wasAvailable {
692
+ status = "unavailable"
693
+ } else if let error = self.trimmed(attempt.errorDescription) {
694
+ return "\(attempt.strategyID) (\(self.fetchKindLabel(attempt.kind))) failed: \(error)"
695
+ } else {
696
+ status = "ok"
697
+ }
698
+ return "\(attempt.strategyID) (\(self.fetchKindLabel(attempt.kind))) \(status)"
699
+ }.joined(separator: "\n")
700
+ }
701
+
702
+ private static func fetchStrategyLabel(_ attempt: ProviderFetchAttempt) -> String {
703
+ let raw = attempt.strategyID
704
+ .replacingOccurrences(of: "_", with: " ")
705
+ .replacingOccurrences(of: "-", with: " ")
706
+ let cleaned = UsageFormatter.cleanPlanName(raw).trimmingCharacters(in: .whitespacesAndNewlines)
707
+ if !cleaned.isEmpty {
708
+ return cleaned
709
+ }
710
+ return self.fetchKindLabel(attempt.kind)
711
+ }
712
+
713
+ private static func fetchKindLabel(_ kind: ProviderFetchKind) -> String {
714
+ switch kind {
715
+ case .cli: return "cli"
716
+ case .web: return "web"
717
+ case .oauth: return "oauth"
718
+ case .apiToken: return "api"
719
+ case .api: return "api"
720
+ case .localProbe: return "local"
721
+ case .webDashboard: return "web"
722
+ }
723
+ }
724
+
725
+ private static func truncated(_ text: String, maxLength: Int) -> String {
726
+ guard text.count > maxLength else { return text }
727
+ let index = text.index(text.startIndex, offsetBy: maxLength)
728
+ return "\(text[..<index])…"
729
+ }
730
+
731
+ private static func windowModelsValue(_ snapshot: UsageSnapshot?) -> String? {
732
+ guard let snapshot else { return nil }
733
+ let windows = [snapshot.primary, snapshot.secondary, snapshot.tertiary].compactMap { $0 }
734
+ guard !windows.isEmpty else { return nil }
735
+
736
+ var seen: Set<String> = []
737
+ var items: [String] = []
738
+ for window in windows {
739
+ guard let rawLabel = self.trimmed(window.label) else { continue }
740
+ let normalized = rawLabel.lowercased()
741
+ guard !seen.contains(normalized) else { continue }
742
+ seen.insert(normalized)
743
+
744
+ let label = UsageFormatter.modelDisplayName(rawLabel)
745
+ let used = Int(window.usedPercent.rounded())
746
+ items.append("\(label) \(used)%")
747
+ }
748
+ guard !items.isEmpty else { return nil }
749
+ if items.count > 3 {
750
+ let visible = items.prefix(3).joined(separator: " · ")
751
+ return "\(visible) +\(items.count - 3) more"
752
+ }
753
+ return items.joined(separator: " · ")
754
+ }
755
+ }
756
+
757
+ @MainActor
758
+ struct ProvidersPane: View {
759
+ @Bindable var settings: SettingsStore
760
+ @Bindable var store: UsageStore
761
+ @State private var expandedErrors: Set<UsageProvider> = []
762
+ @State private var settingsStatusTextByID: [String: String] = [:]
763
+ @State private var settingsLastAppActiveRunAtByID: [String: Date] = [:]
764
+ @State private var activeConfirmation: ProviderSettingsConfirmationState?
765
+ @State private var sidebarSelection: UsageProvider?
766
+
767
+ private var providers: [UsageProvider] { self.settings.orderedProviders() }
768
+
769
+ var body: some View {
770
+ Group {
771
+ if self.settings.providersPaneSidebar {
772
+ self.sidebarLayout
773
+ } else {
774
+ self.listLayout
775
+ }
776
+ }
777
+ .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
778
+ self.runSettingsDidBecomeActiveHooks()
779
+ }
780
+ .alert(
781
+ self.activeConfirmation?.title ?? "",
782
+ isPresented: Binding(
783
+ get: { self.activeConfirmation != nil },
784
+ set: { isPresented in
785
+ if !isPresented { self.activeConfirmation = nil }
786
+ }),
787
+ actions: {
788
+ if let active = self.activeConfirmation {
789
+ Button(active.confirmTitle) {
790
+ active.onConfirm()
791
+ self.activeConfirmation = nil
792
+ }
793
+ Button("Cancel", role: .cancel) { self.activeConfirmation = nil }
794
+ }
795
+ },
796
+ message: {
797
+ if let active = self.activeConfirmation {
798
+ Text(active.message)
799
+ }
800
+ })
801
+ }
802
+
803
+ // MARK: - List layout (default)
804
+
805
+ private var listLayout: some View {
806
+ PreferencesListPane(horizontalPadding: 0, verticalPadding: 0) {
807
+ VStack(spacing: ProviderListMetrics.sidebarSectionSpacing) {
808
+ self.listLayoutHeader
809
+
810
+ ProviderListView(
811
+ providers: self.providers,
812
+ store: self.store,
813
+ isEnabled: { provider in self.binding(for: provider) },
814
+ subtitle: { provider in self.providerSubtitle(provider) },
815
+ usageStatus: { provider in self.providerUsageStatus(provider) },
816
+ sourceLabel: { provider in self.providerSourceLabel(provider) },
817
+ statusLabel: { provider in self.providerStatusLabel(provider) },
818
+ settingsToggles: { provider in self.extraSettingsToggles(for: provider) },
819
+ settingsFields: { provider in self.extraSettingsFields(for: provider) },
820
+ errorDisplay: { provider in self.providerErrorDisplay(provider) },
821
+ isErrorExpanded: { provider in self.expandedBinding(for: provider) },
822
+ onCopyError: { text in self.copyToPasteboard(text) },
823
+ moveProviders: { fromOffsets, toOffset in
824
+ self.settings.moveProvider(fromOffsets: fromOffsets, toOffset: toOffset)
825
+ })
826
+ }
827
+ }
828
+ }
829
+
830
+ private var listLayoutHeader: some View {
831
+ HStack(alignment: .top, spacing: RunicSpacing.sm) {
832
+ Image(systemName: "square.grid.2x2")
833
+ .font(.system(size: 15, weight: .medium))
834
+ .foregroundStyle(.secondary)
835
+
836
+ VStack(alignment: .leading, spacing: RunicSpacing.xxs) {
837
+ Text("Built-in providers")
838
+ .font(.subheadline.weight(.semibold))
839
+ .foregroundStyle(.primary)
840
+ Text("Switch to sidebar layout for per-day history cards and model or project drill-down details.")
841
+ .font(.caption)
842
+ .foregroundStyle(.secondary)
843
+ .fixedSize(horizontal: false, vertical: true)
844
+ }
845
+
846
+ Spacer(minLength: 0)
847
+ }
848
+ .padding(ProviderListMetrics.listHeaderPadding)
849
+ .background(
850
+ RoundedRectangle(cornerRadius: ProviderListMetrics.listHeaderCornerRadius, style: .continuous)
851
+ .fill(Color(nsColor: .controlBackgroundColor).opacity(ProviderListMetrics.listHeaderBackgroundOpacity))
852
+ )
853
+ .overlay(
854
+ RoundedRectangle(cornerRadius: ProviderListMetrics.listHeaderCornerRadius, style: .continuous)
855
+ .strokeBorder(
856
+ Color(nsColor: .separatorColor).opacity(ProviderListMetrics.listHeaderBorderOpacity),
857
+ lineWidth: 1)
858
+ )
859
+ .padding(.horizontal, ProviderListMetrics.contentInset)
860
+ }
861
+
862
+ // MARK: - Sidebar layout
863
+
864
+ private var sidebarLayout: some View {
865
+ NavigationSplitView {
866
+ List(selection: self.$sidebarSelection) {
867
+ ForEach(self.providers, id: \.self) { provider in
868
+ ProviderSidebarRow(
869
+ provider: provider,
870
+ store: self.store,
871
+ isEnabled: self.binding(for: provider).wrappedValue,
872
+ isSelected: self.sidebarSelection == provider)
873
+ .tag(provider)
874
+ }
875
+ .onMove { fromOffsets, toOffset in
876
+ self.settings.moveProvider(fromOffsets: fromOffsets, toOffset: toOffset)
877
+ }
878
+ }
879
+ .listStyle(.sidebar)
880
+ .frame(minWidth: 160, idealWidth: 180)
881
+ .scrollContentBackground(.hidden)
882
+ .background(Color(nsColor: .controlBackgroundColor).opacity(0.15))
883
+ .onAppear {
884
+ self.normalizeSidebarSelection()
885
+ }
886
+ .onChange(of: self.providers) { _, _ in
887
+ self.normalizeSidebarSelection()
888
+ }
889
+ } detail: {
890
+ if let selected = self.sidebarSelection {
891
+ ProviderSidebarDetailView(
892
+ provider: selected,
893
+ store: self.store,
894
+ isEnabled: self.binding(for: selected),
895
+ subtitle: self.providerSubtitle(selected),
896
+ usageStatus: self.providerUsageStatus(selected),
897
+ sourceLabel: self.providerSourceLabel(selected),
898
+ statusLabel: self.providerStatusLabel(selected),
899
+ settingsToggles: self.extraSettingsToggles(for: selected),
900
+ settingsFields: self.extraSettingsFields(for: selected),
901
+ errorDisplay: self.providerErrorDisplay(selected),
902
+ isErrorExpanded: self.expandedBinding(for: selected),
903
+ onCopyError: { text in self.copyToPasteboard(text) })
904
+ } else {
905
+ Text("Select a provider")
906
+ .font(.title3)
907
+ .foregroundStyle(.secondary)
908
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
909
+ }
910
+ }
911
+ }
912
+
913
+ private func normalizeSidebarSelection() {
914
+ guard !self.providers.isEmpty else {
915
+ self.sidebarSelection = nil
916
+ return
917
+ }
918
+
919
+ if let selected = self.sidebarSelection, self.providers.contains(selected) {
920
+ return
921
+ }
922
+
923
+ self.sidebarSelection = self.providers.first
924
+ }
925
+
926
+ private func binding(for provider: UsageProvider) -> Binding<Bool> {
927
+ let meta = self.store.metadata(for: provider)
928
+ return Binding(
929
+ get: { self.settings.isProviderEnabled(provider: provider, metadata: meta) },
930
+ set: { self.settings.setProviderEnabled(provider: provider, metadata: meta, enabled: $0) })
931
+ }
932
+
933
+ private func providerSubtitle(_ provider: UsageProvider) -> String {
934
+ let meta = self.store.metadata(for: provider)
935
+ let cliName = meta.cliName
936
+ let coverageSuffix = ProviderInsightsComposer.coverageSummaryLabel(
937
+ for: provider,
938
+ store: self.store).map { " • \($0)" } ?? ""
939
+ let version = self.store.version(for: provider)
940
+ var versionText = version ?? "not detected"
941
+ if provider == .claude, let parenRange = versionText.range(of: "(") {
942
+ versionText = versionText[..<parenRange.lowerBound].trimmingCharacters(in: .whitespaces)
943
+ }
944
+
945
+ if cliName == "codex" {
946
+ return "\(versionText)\(coverageSuffix)"
947
+ }
948
+
949
+ // Cursor is web-based, no CLI version to detect
950
+ if provider == .cursor || provider == .minimax {
951
+ return "web\(coverageSuffix)"
952
+ }
953
+ let apiBackedProviders: Set<UsageProvider> = [
954
+ .zai,
955
+ .openrouter,
956
+ .groq,
957
+ .deepseek,
958
+ .fireworks,
959
+ .mistral,
960
+ .perplexity,
961
+ .kimi,
962
+ .auggie,
963
+ .together,
964
+ .cohere,
965
+ .xai,
966
+ .cerebras,
967
+ .sambanova,
968
+ .azure,
969
+ .bedrock,
970
+ ]
971
+ if apiBackedProviders.contains(provider) {
972
+ return "api\(coverageSuffix)"
973
+ }
974
+
975
+ var detail = "\(cliName) \(versionText)"
976
+ if provider == .antigravity {
977
+ detail += " • experimental"
978
+ }
979
+ return "\(detail)\(coverageSuffix)"
980
+ }
981
+
982
+ private func providerUsageStatus(_ provider: UsageProvider) -> ProviderUsageStatus {
983
+ if let snapshot = self.store.snapshot(for: provider) {
984
+ let relative = snapshot.updatedAt.relativeDescription()
985
+ return ProviderUsageStatus(text: "usage fetched \(relative)", style: .success)
986
+ } else if self.store.isStale(provider: provider) {
987
+ return ProviderUsageStatus(text: "last fetch failed", style: .error)
988
+ } else {
989
+ return ProviderUsageStatus(text: "usage not fetched yet", style: .neutral)
990
+ }
991
+ }
992
+
993
+ private func providerSourceLabel(_ provider: UsageProvider) -> String {
994
+ self.store.sourceLabel(for: provider)
995
+ }
996
+
997
+ private func providerStatusLabel(_ provider: UsageProvider) -> String {
998
+ if let snapshot = self.store.snapshot(for: provider) {
999
+ return snapshot.updatedAt.formatted(date: .abbreviated, time: .shortened)
1000
+ }
1001
+ if self.store.isStale(provider: provider) {
1002
+ return "failed"
1003
+ }
1004
+ return "not yet"
1005
+ }
1006
+
1007
+ private func providerErrorDisplay(_ provider: UsageProvider) -> ProviderErrorDisplay? {
1008
+ guard self.store.isStale(provider: provider), let raw = self.store.error(for: provider) else { return nil }
1009
+ return ProviderErrorDisplay(
1010
+ preview: self.truncated(raw, prefix: ""),
1011
+ full: raw)
1012
+ }
1013
+
1014
+ private func extraSettingsToggles(for provider: UsageProvider) -> [ProviderSettingsToggleDescriptor] {
1015
+ guard let impl = ProviderCatalog.implementation(for: provider) else { return [] }
1016
+ let context = self.makeSettingsContext(provider: provider)
1017
+ return impl.settingsToggles(context: context)
1018
+ .filter { $0.isVisible?() ?? true }
1019
+ }
1020
+
1021
+ private func extraSettingsFields(for provider: UsageProvider) -> [ProviderSettingsFieldDescriptor] {
1022
+ guard let impl = ProviderCatalog.implementation(for: provider) else { return [] }
1023
+ let context = self.makeSettingsContext(provider: provider)
1024
+ return impl.settingsFields(context: context)
1025
+ .filter { $0.isVisible?() ?? true }
1026
+ }
1027
+
1028
+ private func makeSettingsContext(provider: UsageProvider) -> ProviderSettingsContext {
1029
+ ProviderSettingsContext(
1030
+ provider: provider,
1031
+ settings: self.settings,
1032
+ store: self.store,
1033
+ boolBinding: { keyPath in
1034
+ Binding(
1035
+ get: { self.settings[keyPath: keyPath] },
1036
+ set: { self.settings[keyPath: keyPath] = $0 })
1037
+ },
1038
+ stringBinding: { keyPath in
1039
+ Binding(
1040
+ get: { self.settings[keyPath: keyPath] },
1041
+ set: { self.settings[keyPath: keyPath] = $0 })
1042
+ },
1043
+ statusText: { id in
1044
+ self.settingsStatusTextByID[id]
1045
+ },
1046
+ setStatusText: { id, text in
1047
+ if let text {
1048
+ self.settingsStatusTextByID[id] = text
1049
+ } else {
1050
+ self.settingsStatusTextByID.removeValue(forKey: id)
1051
+ }
1052
+ },
1053
+ lastAppActiveRunAt: { id in
1054
+ self.settingsLastAppActiveRunAtByID[id]
1055
+ },
1056
+ setLastAppActiveRunAt: { id, date in
1057
+ if let date {
1058
+ self.settingsLastAppActiveRunAtByID[id] = date
1059
+ } else {
1060
+ self.settingsLastAppActiveRunAtByID.removeValue(forKey: id)
1061
+ }
1062
+ },
1063
+ requestConfirmation: { confirmation in
1064
+ self.activeConfirmation = ProviderSettingsConfirmationState(confirmation: confirmation)
1065
+ })
1066
+ }
1067
+
1068
+ private func runSettingsDidBecomeActiveHooks() {
1069
+ for provider in UsageProvider.allCases {
1070
+ for toggle in self.extraSettingsToggles(for: provider) {
1071
+ guard let hook = toggle.onAppDidBecomeActive else { continue }
1072
+ Task { @MainActor in
1073
+ await hook()
1074
+ }
1075
+ }
1076
+ }
1077
+ }
1078
+
1079
+ private func truncated(_ text: String, prefix: String, maxLength: Int = 160) -> String {
1080
+ var message = text.trimmingCharacters(in: .whitespacesAndNewlines)
1081
+ if message.count > maxLength {
1082
+ let idx = message.index(message.startIndex, offsetBy: maxLength)
1083
+ message = "\(message[..<idx])…"
1084
+ }
1085
+ return prefix + message
1086
+ }
1087
+
1088
+ private func expandedBinding(for provider: UsageProvider) -> Binding<Bool> {
1089
+ Binding(
1090
+ get: { self.expandedErrors.contains(provider) },
1091
+ set: { expanded in
1092
+ if expanded {
1093
+ self.expandedErrors.insert(provider)
1094
+ } else {
1095
+ self.expandedErrors.remove(provider)
1096
+ }
1097
+ })
1098
+ }
1099
+
1100
+ private func copyToPasteboard(_ text: String) {
1101
+ let pb = NSPasteboard.general
1102
+ pb.clearContents()
1103
+ pb.setString(text, forType: .string)
1104
+ }
1105
+ }
1106
+
1107
+ @MainActor
1108
+ private struct ProviderListView: View {
1109
+ let providers: [UsageProvider]
1110
+ @Bindable var store: UsageStore
1111
+ let isEnabled: (UsageProvider) -> Binding<Bool>
1112
+ let subtitle: (UsageProvider) -> String
1113
+ let usageStatus: (UsageProvider) -> ProviderUsageStatus
1114
+ let sourceLabel: (UsageProvider) -> String
1115
+ let statusLabel: (UsageProvider) -> String
1116
+ let settingsToggles: (UsageProvider) -> [ProviderSettingsToggleDescriptor]
1117
+ let settingsFields: (UsageProvider) -> [ProviderSettingsFieldDescriptor]
1118
+ let errorDisplay: (UsageProvider) -> ProviderErrorDisplay?
1119
+ let isErrorExpanded: (UsageProvider) -> Binding<Bool>
1120
+ let onCopyError: (String) -> Void
1121
+ let moveProviders: (IndexSet, Int) -> Void
1122
+
1123
+ var body: some View {
1124
+ List {
1125
+ ForEach(self.providers, id: \.self) { provider in
1126
+ let fields = self.settingsFields(provider)
1127
+ let toggles = self.settingsToggles(provider)
1128
+ let isEnabled = self.isEnabled(provider).wrappedValue
1129
+ let isFirstProvider = provider == self.providers.first
1130
+ let isLastProvider = provider == self.providers.last
1131
+ let shouldShowDivider = provider != self.providers.last
1132
+ let showDividerOnProviderRow = shouldShowDivider &&
1133
+ (!isEnabled || (fields.isEmpty && toggles.isEmpty))
1134
+ let providerAddsBottomPadding = isLastProvider && (!isEnabled || (fields.isEmpty && toggles.isEmpty))
1135
+
1136
+ ProviderListProviderRowView(
1137
+ provider: provider,
1138
+ store: self.store,
1139
+ isEnabled: self.isEnabled(provider),
1140
+ subtitle: self.subtitle(provider),
1141
+ usageStatus: self.usageStatus(provider),
1142
+ sourceLabel: self.sourceLabel(provider),
1143
+ statusLabel: self.statusLabel(provider),
1144
+ errorDisplay: self.isEnabled(provider).wrappedValue ? self.errorDisplay(provider) : nil,
1145
+ isErrorExpanded: self.isErrorExpanded(provider),
1146
+ onCopyError: self.onCopyError)
1147
+ .padding(.bottom, showDividerOnProviderRow ? ProviderListMetrics.dividerBottomInset : 0)
1148
+ .listRowInsets(self.rowInsets(
1149
+ withDivider: showDividerOnProviderRow,
1150
+ addTopPadding: isFirstProvider,
1151
+ addBottomPadding: providerAddsBottomPadding))
1152
+ .listRowSeparator(.hidden)
1153
+ .providerSectionDivider(isVisible: showDividerOnProviderRow)
1154
+
1155
+ if isEnabled {
1156
+ let lastFieldID = fields.last?.id
1157
+ ForEach(fields) { field in
1158
+ let isLastField = field.id == lastFieldID
1159
+ let showDivider = shouldShowDivider && toggles.isEmpty && isLastField
1160
+ let fieldAddsBottomPadding = isLastProvider && toggles.isEmpty && isLastField
1161
+
1162
+ ProviderListFieldRowView(provider: provider, field: field)
1163
+ .id(self.rowID(provider: provider, suffix: field.id))
1164
+ .padding(.bottom, showDivider ? ProviderListMetrics.dividerBottomInset : 0)
1165
+ .listRowInsets(self.rowInsets(
1166
+ withDivider: showDivider,
1167
+ addTopPadding: false,
1168
+ addBottomPadding: fieldAddsBottomPadding))
1169
+ .listRowSeparator(.hidden)
1170
+ .providerSectionDivider(isVisible: showDivider)
1171
+ }
1172
+ let lastToggleID = toggles.last?.id
1173
+ ForEach(toggles) { toggle in
1174
+ let isLastToggle = toggle.id == lastToggleID
1175
+ let showDivider = shouldShowDivider && isLastToggle
1176
+ let toggleAddsBottomPadding = isLastProvider && isLastToggle
1177
+
1178
+ ProviderListToggleRowView(provider: provider, toggle: toggle)
1179
+ .id(self.rowID(provider: provider, suffix: toggle.id))
1180
+ .padding(.bottom, showDivider ? ProviderListMetrics.dividerBottomInset : 0)
1181
+ .listRowInsets(self.rowInsets(
1182
+ withDivider: showDivider,
1183
+ addTopPadding: false,
1184
+ addBottomPadding: toggleAddsBottomPadding))
1185
+ .listRowSeparator(.hidden)
1186
+ .providerSectionDivider(isVisible: showDivider)
1187
+ }
1188
+ }
1189
+ }
1190
+ .onMove { fromOffsets, toOffset in
1191
+ self.moveProviders(fromOffsets, toOffset)
1192
+ }
1193
+ }
1194
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
1195
+ .listStyle(.plain)
1196
+ .scrollContentBackground(.hidden)
1197
+ .background(ProviderListScrollInsetFixer())
1198
+ }
1199
+
1200
+ private func rowInsets(withDivider: Bool, addTopPadding: Bool, addBottomPadding: Bool) -> EdgeInsets {
1201
+ let base = ProviderListMetrics.rowInsets
1202
+ let topInset = addTopPadding ? ProviderListMetrics.sectionEdgeInset : base.top
1203
+ let bottomInset = addBottomPadding
1204
+ ? ProviderListMetrics.sectionEdgeInset
1205
+ : (withDivider ? ProviderListMetrics.dividerBottomInset : base.bottom)
1206
+ return EdgeInsets(
1207
+ top: topInset,
1208
+ leading: base.leading,
1209
+ bottom: bottomInset,
1210
+ trailing: base.trailing)
1211
+ }
1212
+
1213
+ private func rowID(provider: UsageProvider, suffix: String) -> String {
1214
+ "\(provider.rawValue)-\(suffix)"
1215
+ }
1216
+ }
1217
+
1218
+ @MainActor
1219
+ private struct ProviderListBrandIcon: View {
1220
+ let provider: UsageProvider
1221
+
1222
+ var body: some View {
1223
+ Group {
1224
+ if let brand = ProviderBrandIcon.image(for: self.provider, size: ProviderListMetrics.iconSize) {
1225
+ Image(nsImage: brand)
1226
+ .resizable()
1227
+ .scaledToFit()
1228
+ .frame(width: ProviderListMetrics.iconSize, height: ProviderListMetrics.iconSize)
1229
+ } else {
1230
+ let descriptor = ProviderDescriptorRegistry.descriptor(for: self.provider)
1231
+ let initial = String(descriptor.metadata.displayName.prefix(1)).uppercased()
1232
+ let brandColor = Color(
1233
+ red: Double(descriptor.branding.color.red),
1234
+ green: Double(descriptor.branding.color.green),
1235
+ blue: Double(descriptor.branding.color.blue))
1236
+ ZStack {
1237
+ RoundedRectangle(cornerRadius: RunicCornerRadius.sm, style: .continuous)
1238
+ .fill(brandColor.opacity(0.18))
1239
+ Text(initial)
1240
+ .font(.system(size: ProviderListMetrics.iconSize * 0.5, weight: .bold, design: .rounded))
1241
+ .foregroundStyle(brandColor)
1242
+ }
1243
+ .frame(width: ProviderListMetrics.iconSize, height: ProviderListMetrics.iconSize)
1244
+ }
1245
+ }
1246
+ .shadow(color: .black.opacity(0.1), radius: 2, y: 1)
1247
+ .accessibilityHidden(true)
1248
+ }
1249
+ }
1250
+
1251
+ @MainActor
1252
+ private struct ProviderInsightsView: View {
1253
+ let lines: [ProviderInsightLine]
1254
+
1255
+ var body: some View {
1256
+ LazyVGrid(
1257
+ columns: [
1258
+ GridItem(
1259
+ .adaptive(minimum: ProviderListMetrics.providerInsightsGridItemMinWidth),
1260
+ spacing: ProviderListMetrics.providerInsightsChipSpacing)
1261
+ ],
1262
+ alignment: .leading,
1263
+ spacing: ProviderListMetrics.providerInsightsChipSpacing)
1264
+ {
1265
+ ForEach(self.lines) { line in
1266
+ ProviderInsightChip(line: line)
1267
+ }
1268
+ }
1269
+ .padding(ProviderListMetrics.insightsCardPadding)
1270
+ .frame(maxWidth: .infinity, alignment: .leading)
1271
+ .background(
1272
+ RoundedRectangle(
1273
+ cornerRadius: ProviderListMetrics.providerInsightsCardCornerRadius,
1274
+ style: .continuous)
1275
+ .fill(Color(nsColor: .controlBackgroundColor).opacity(0.42))
1276
+ )
1277
+ .overlay {
1278
+ RoundedRectangle(
1279
+ cornerRadius: ProviderListMetrics.providerInsightsCardCornerRadius,
1280
+ style: .continuous)
1281
+ .strokeBorder(Color(nsColor: .separatorColor).opacity(0.22), lineWidth: 1)
1282
+ }
1283
+ }
1284
+ }
1285
+
1286
+ private struct ProviderInsightChip: View {
1287
+ let line: ProviderInsightLine
1288
+
1289
+ var body: some View {
1290
+ VStack(alignment: .leading, spacing: RunicSpacing.xxxs) {
1291
+ Text(line.label.uppercased())
1292
+ .font(.caption2.weight(.semibold))
1293
+ .tracking(0.2)
1294
+ .foregroundStyle(.tertiary)
1295
+ Text(line.value)
1296
+ .font(.caption)
1297
+ .foregroundStyle(.secondary)
1298
+ .lineLimit(2)
1299
+ .fixedSize(horizontal: false, vertical: true)
1300
+ .textSelection(.enabled)
1301
+ .help(line.help ?? "")
1302
+ }
1303
+ .padding(ProviderListMetrics.providerInsightsChipPadding)
1304
+ .frame(maxWidth: .infinity, alignment: .leading)
1305
+ .background(
1306
+ RoundedRectangle(cornerRadius: ProviderListMetrics.providerInsightsChipCornerRadius, style: .continuous)
1307
+ .fill(Color(nsColor: .textBackgroundColor).opacity(0.45))
1308
+ )
1309
+ .overlay(
1310
+ RoundedRectangle(cornerRadius: ProviderListMetrics.providerInsightsChipCornerRadius, style: .continuous)
1311
+ .strokeBorder(Color(nsColor: .separatorColor).opacity(0.2), lineWidth: 1)
1312
+ )
1313
+ .help(line.help ?? "")
1314
+ .accessibilityLabel("\(line.label): \(line.value)")
1315
+ .accessibilityHint(line.help ?? "")
1316
+ }
1317
+ }
1318
+
1319
+ @MainActor
1320
+ private struct ProviderListProviderRowView: View {
1321
+ let provider: UsageProvider
1322
+ @Bindable var store: UsageStore
1323
+ @Binding var isEnabled: Bool
1324
+ let subtitle: String
1325
+ let usageStatus: ProviderUsageStatus
1326
+ let sourceLabel: String
1327
+ let statusLabel: String
1328
+ let errorDisplay: ProviderErrorDisplay?
1329
+ @Binding var isErrorExpanded: Bool
1330
+ let onCopyError: (String) -> Void
1331
+ @State private var isHovering = false
1332
+ @FocusState private var isToggleFocused: Bool
1333
+
1334
+ var body: some View {
1335
+ let isRefreshing = self.store.refreshingProviders.contains(self.provider)
1336
+ let showReorderHandle = self.isHovering || self.isToggleFocused
1337
+ let metadata = self.store.metadata(for: self.provider)
1338
+ let insightLines = ProviderInsightsComposer.lines(for: self.provider, store: self.store, maxRows: 4)
1339
+
1340
+ HStack(alignment: .top, spacing: ProviderListMetrics.rowSpacing) {
1341
+ Toggle("", isOn: self.$isEnabled)
1342
+ .labelsHidden()
1343
+ .toggleStyle(.checkbox)
1344
+ .alignmentGuide(.top) { d in d[VerticalAlignment.center] }
1345
+ .focused(self.$isToggleFocused)
1346
+
1347
+ VStack(alignment: .leading, spacing: RunicSpacing.sm) {
1348
+ VStack(alignment: .leading, spacing: RunicSpacing.sm) {
1349
+ HStack(alignment: .top, spacing: RunicSpacing.sm) {
1350
+ ProviderListBrandIcon(provider: self.provider)
1351
+ VStack(alignment: .leading, spacing: RunicSpacing.xxs) {
1352
+ Text(metadata.displayName)
1353
+ .font(.headline.weight(.semibold))
1354
+ .foregroundStyle(.primary)
1355
+ Text(self.subtitle)
1356
+ .font(.footnote)
1357
+ .foregroundStyle(.secondary)
1358
+ .fixedSize(horizontal: false, vertical: true)
1359
+ }
1360
+ Spacer(minLength: RunicSpacing.xs)
1361
+ }
1362
+
1363
+ HStack(alignment: .center, spacing: RunicSpacing.xs) {
1364
+ self.sourceBadge
1365
+ Text(self.statusLabel)
1366
+ .font(.caption2)
1367
+ .foregroundStyle(.tertiary)
1368
+ .lineLimit(1)
1369
+
1370
+ if isRefreshing {
1371
+ ProgressView()
1372
+ .controlSize(.small)
1373
+ .alignmentGuide(.firstTextBaseline) { d in d[VerticalAlignment.center] }
1374
+ Text("Refreshing…")
1375
+ .font(.caption2)
1376
+ .foregroundStyle(.secondary)
1377
+ } else {
1378
+ self.usageStatusBadge
1379
+ .alignmentGuide(.firstTextBaseline) { d in d[VerticalAlignment.center] }
1380
+ }
1381
+ }
1382
+ }
1383
+ .contentShape(Rectangle())
1384
+ .onTapGesture { self.isEnabled.toggle() }
1385
+
1386
+ if !insightLines.isEmpty {
1387
+ ProviderInsightsView(lines: insightLines)
1388
+ .padding(.top, RunicSpacing.xxs)
1389
+ }
1390
+
1391
+ if let errorDisplay {
1392
+ ProviderErrorView(
1393
+ title: "Last \(metadata.displayName) fetch failed:",
1394
+ display: errorDisplay,
1395
+ isExpanded: self.$isErrorExpanded,
1396
+ onCopy: { self.onCopyError(errorDisplay.full) })
1397
+ .padding(.top, RunicSpacing.xxs)
1398
+ }
1399
+ }
1400
+ .frame(maxWidth: .infinity, alignment: .leading)
1401
+ }
1402
+ .padding(ProviderListMetrics.providerCardPadding)
1403
+ .background(self.cardBackground)
1404
+ .overlay {
1405
+ RoundedRectangle(cornerRadius: ProviderListMetrics.providerCardCornerRadius, style: .continuous)
1406
+ .strokeBorder(self.cardBorderColor, lineWidth: 1)
1407
+ }
1408
+ .overlay(alignment: .topLeading) {
1409
+ ProviderListReorderHandle(isVisible: showReorderHandle)
1410
+ .offset(
1411
+ x: -(ProviderListMetrics.reorderHandleSize + RunicSpacing.xs),
1412
+ y: RunicSpacing.sm)
1413
+ }
1414
+ .onHover { isHovering in
1415
+ self.isHovering = isHovering
1416
+ }
1417
+ }
1418
+
1419
+ private var usageStatusBadge: some View {
1420
+ let (color, backgroundColor) = self.usageStatusColors
1421
+
1422
+ return Text(self.usageStatus.text)
1423
+ .font(.caption2.weight(.medium))
1424
+ .padding(.horizontal, ProviderListMetrics.statusBadgePaddingH)
1425
+ .padding(.vertical, ProviderListMetrics.statusBadgePaddingV)
1426
+ .background(Capsule(style: .continuous).fill(backgroundColor))
1427
+ .foregroundStyle(color)
1428
+ }
1429
+
1430
+ private var sourceBadge: some View {
1431
+ Text(self.sourceLabel)
1432
+ .font(.caption2.weight(.medium))
1433
+ .foregroundStyle(.secondary)
1434
+ .padding(.horizontal, RunicSpacing.xs)
1435
+ .padding(.vertical, RunicSpacing.xxxs)
1436
+ .background(
1437
+ Capsule(style: .continuous)
1438
+ .fill(Color(nsColor: .controlBackgroundColor).opacity(0.7))
1439
+ )
1440
+ }
1441
+
1442
+ private var usageStatusColors: (Color, Color) {
1443
+ switch self.usageStatus.style {
1444
+ case .success:
1445
+ return (.green, Color.green.opacity(0.15))
1446
+ case .error:
1447
+ return (.red, Color.red.opacity(0.15))
1448
+ case .neutral:
1449
+ return (.secondary, Color(nsColor: .controlBackgroundColor))
1450
+ }
1451
+ }
1452
+
1453
+ private var rowBackgroundColor: Color {
1454
+ if self.isHovering {
1455
+ return Color(nsColor: .controlBackgroundColor).opacity(0.72)
1456
+ } else if self.isEnabled {
1457
+ return Color(nsColor: .controlBackgroundColor).opacity(ProviderListMetrics.providerCardBackgroundOpacity)
1458
+ }
1459
+ return Color(nsColor: .controlBackgroundColor).opacity(0.35)
1460
+ }
1461
+
1462
+ private var cardBorderColor: Color {
1463
+ if self.isHovering {
1464
+ return Color.accentColor.opacity(0.35)
1465
+ }
1466
+ if self.isEnabled {
1467
+ return Color(nsColor: .separatorColor).opacity(ProviderListMetrics.providerCardBorderOpacity)
1468
+ }
1469
+ return Color(nsColor: .separatorColor).opacity(0.18)
1470
+ }
1471
+
1472
+ private var cardBackground: some View {
1473
+ RoundedRectangle(cornerRadius: ProviderListMetrics.providerCardCornerRadius, style: .continuous)
1474
+ .fill(self.rowBackgroundColor)
1475
+ }
1476
+ }
1477
+
1478
+ @MainActor
1479
+ private struct ProviderListReorderHandle: View {
1480
+ let isVisible: Bool
1481
+
1482
+ var body: some View {
1483
+ VStack(spacing: ProviderListMetrics.reorderDotSpacing) {
1484
+ ForEach(0..<3, id: \.self) { _ in
1485
+ HStack(spacing: ProviderListMetrics.reorderDotSpacing) {
1486
+ Circle()
1487
+ .frame(
1488
+ width: ProviderListMetrics.reorderDotSize,
1489
+ height: ProviderListMetrics.reorderDotSize)
1490
+ Circle()
1491
+ .frame(
1492
+ width: ProviderListMetrics.reorderDotSize,
1493
+ height: ProviderListMetrics.reorderDotSize)
1494
+ }
1495
+ }
1496
+ }
1497
+ .frame(width: ProviderListMetrics.reorderHandleSize, height: ProviderListMetrics.reorderHandleSize)
1498
+ .foregroundStyle(.tertiary)
1499
+ .opacity(self.isVisible ? 1 : 0)
1500
+ .animation(.easeInOut(duration: 0.15), value: self.isVisible)
1501
+ .help("Drag to reorder")
1502
+ .accessibilityHidden(true)
1503
+ .allowsHitTesting(false)
1504
+ }
1505
+ }
1506
+
1507
+ @MainActor
1508
+ private struct ProviderListSectionDividerView: View {
1509
+ var body: some View {
1510
+ Rectangle()
1511
+ .fill(Color(nsColor: .separatorColor).opacity(0.5))
1512
+ .frame(height: 1)
1513
+ .padding(.leading, ProviderListMetrics.dividerLeadingInset)
1514
+ .padding(.trailing, ProviderListMetrics.dividerTrailingInset)
1515
+ }
1516
+ }
1517
+
1518
+ extension View {
1519
+ @ViewBuilder
1520
+ fileprivate func providerSectionDivider(isVisible: Bool) -> some View {
1521
+ overlay(alignment: .bottom) {
1522
+ if isVisible {
1523
+ ProviderListSectionDividerView()
1524
+ }
1525
+ }
1526
+ }
1527
+ }
1528
+
1529
+ @MainActor
1530
+ private struct ProviderListToggleRowView: View {
1531
+ let provider: UsageProvider
1532
+ let toggle: ProviderSettingsToggleDescriptor
1533
+
1534
+ var body: some View {
1535
+ HStack(alignment: .top, spacing: ProviderListMetrics.rowSpacing) {
1536
+ Toggle("", isOn: self.toggle.binding)
1537
+ .labelsHidden()
1538
+ .toggleStyle(.checkbox)
1539
+ .alignmentGuide(.top) { d in d[VerticalAlignment.center] }
1540
+
1541
+ VStack(alignment: .leading, spacing: RunicSpacing.sm) {
1542
+ VStack(alignment: .leading, spacing: RunicSpacing.xxs) {
1543
+ Text(self.toggle.title)
1544
+ .font(.callout.weight(.semibold))
1545
+ Text(self.toggle.subtitle)
1546
+ .font(.footnote)
1547
+ .foregroundStyle(.secondary)
1548
+ .fixedSize(horizontal: false, vertical: true)
1549
+ }
1550
+
1551
+ if self.toggle.binding.wrappedValue {
1552
+ if let status = self.toggle.statusText?(), !status.isEmpty {
1553
+ Text(status)
1554
+ .font(.caption)
1555
+ .foregroundStyle(.secondary)
1556
+ .lineLimit(4)
1557
+ .fixedSize(horizontal: false, vertical: true)
1558
+ .padding(RunicSpacing.xs)
1559
+ .frame(maxWidth: .infinity, alignment: .leading)
1560
+ .background(
1561
+ RoundedRectangle(cornerRadius: RunicCornerRadius.sm, style: .continuous)
1562
+ .fill(Color(nsColor: .controlBackgroundColor).opacity(0.55))
1563
+ )
1564
+ .overlay {
1565
+ RoundedRectangle(cornerRadius: RunicCornerRadius.sm, style: .continuous)
1566
+ .strokeBorder(Color(nsColor: .separatorColor).opacity(0.2), lineWidth: 1)
1567
+ }
1568
+ }
1569
+
1570
+ let actions = self.toggle.actions.filter { $0.isVisible?() ?? true }
1571
+ if !actions.isEmpty {
1572
+ HStack(spacing: RunicSpacing.xs) {
1573
+ ForEach(actions) { action in
1574
+ Button(action.title) {
1575
+ Task { @MainActor in
1576
+ await action.perform()
1577
+ }
1578
+ }
1579
+ .applyProviderSettingsButtonStyle(action.style)
1580
+ .controlSize(.small)
1581
+ }
1582
+ }
1583
+ }
1584
+ }
1585
+ }
1586
+ .padding(.leading, ProviderListMetrics.iconSize + RunicSpacing.sm)
1587
+ .frame(maxWidth: .infinity, alignment: .leading)
1588
+ }
1589
+ .padding(ProviderListMetrics.supplementalCardPadding)
1590
+ .background(self.supplementalCardBackground)
1591
+ .overlay {
1592
+ RoundedRectangle(cornerRadius: ProviderListMetrics.providerCardCornerRadius, style: .continuous)
1593
+ .strokeBorder(self.supplementalCardBorderColor, lineWidth: 1)
1594
+ }
1595
+ .onChange(of: self.toggle.binding.wrappedValue) { _, enabled in
1596
+ guard let onChange = self.toggle.onChange else { return }
1597
+ Task { @MainActor in
1598
+ await onChange(enabled)
1599
+ }
1600
+ }
1601
+ .task(id: self.toggle.binding.wrappedValue) {
1602
+ guard self.toggle.binding.wrappedValue else { return }
1603
+ guard let onAppear = self.toggle.onAppearWhenEnabled else { return }
1604
+ await onAppear()
1605
+ }
1606
+ }
1607
+
1608
+ private var supplementalCardBackground: some View {
1609
+ RoundedRectangle(cornerRadius: ProviderListMetrics.providerCardCornerRadius, style: .continuous)
1610
+ .fill(Color(nsColor: .controlBackgroundColor).opacity(ProviderListMetrics.supplementalCardBackgroundOpacity))
1611
+ }
1612
+
1613
+ private var supplementalCardBorderColor: Color {
1614
+ Color(nsColor: .separatorColor).opacity(ProviderListMetrics.supplementalCardBorderOpacity)
1615
+ }
1616
+ }
1617
+
1618
+ @MainActor
1619
+ private struct ProviderListFieldRowView: View {
1620
+ let provider: UsageProvider
1621
+ let field: ProviderSettingsFieldDescriptor
1622
+
1623
+ var body: some View {
1624
+ HStack(alignment: .top, spacing: ProviderListMetrics.rowSpacing) {
1625
+ Color.clear
1626
+ .frame(width: ProviderListMetrics.checkboxSize, height: ProviderListMetrics.checkboxSize)
1627
+ .alignmentGuide(.top) { d in d[VerticalAlignment.center] }
1628
+
1629
+ VStack(alignment: .leading, spacing: RunicSpacing.sm) {
1630
+ VStack(alignment: .leading, spacing: RunicSpacing.xxs) {
1631
+ Text(self.field.title)
1632
+ .font(.callout.weight(.semibold))
1633
+ Text(self.field.subtitle)
1634
+ .font(.footnote)
1635
+ .foregroundStyle(.secondary)
1636
+ .fixedSize(horizontal: false, vertical: true)
1637
+ }
1638
+
1639
+ switch self.field.kind {
1640
+ case .plain:
1641
+ TextField(self.field.placeholder ?? "", text: self.field.binding)
1642
+ .textFieldStyle(.roundedBorder)
1643
+ .font(.callout)
1644
+ .frame(maxWidth: ProviderListMetrics.fieldMaxWidth, alignment: .leading)
1645
+ case .secure:
1646
+ SecureField(self.field.placeholder ?? "", text: self.field.binding)
1647
+ .textFieldStyle(.roundedBorder)
1648
+ .font(.callout)
1649
+ .frame(maxWidth: ProviderListMetrics.fieldMaxWidth, alignment: .leading)
1650
+ }
1651
+
1652
+ let actions = self.field.actions.filter { $0.isVisible?() ?? true }
1653
+ if !actions.isEmpty {
1654
+ HStack(spacing: RunicSpacing.xs) {
1655
+ ForEach(actions) { action in
1656
+ Button(action.title) {
1657
+ Task { @MainActor in
1658
+ await action.perform()
1659
+ }
1660
+ }
1661
+ .applyProviderSettingsButtonStyle(action.style)
1662
+ .controlSize(.small)
1663
+ }
1664
+ }
1665
+ }
1666
+ }
1667
+ .padding(.leading, ProviderListMetrics.iconSize + RunicSpacing.sm)
1668
+ .frame(maxWidth: .infinity, alignment: .leading)
1669
+ }
1670
+ .padding(ProviderListMetrics.supplementalCardPadding)
1671
+ .background(self.supplementalCardBackground)
1672
+ .overlay {
1673
+ RoundedRectangle(cornerRadius: ProviderListMetrics.providerCardCornerRadius, style: .continuous)
1674
+ .strokeBorder(self.supplementalCardBorderColor, lineWidth: 1)
1675
+ }
1676
+ }
1677
+
1678
+ private var supplementalCardBackground: some View {
1679
+ RoundedRectangle(cornerRadius: ProviderListMetrics.providerCardCornerRadius, style: .continuous)
1680
+ .fill(Color(nsColor: .controlBackgroundColor).opacity(ProviderListMetrics.supplementalCardBackgroundOpacity))
1681
+ }
1682
+
1683
+ private var supplementalCardBorderColor: Color {
1684
+ Color(nsColor: .separatorColor).opacity(0.18)
1685
+ }
1686
+ }
1687
+
1688
+ extension View {
1689
+ @ViewBuilder
1690
+ fileprivate func applyProviderSettingsButtonStyle(_ style: ProviderSettingsActionDescriptor.Style) -> some View {
1691
+ switch style {
1692
+ case .bordered:
1693
+ self.buttonStyle(.bordered)
1694
+ case .link:
1695
+ self.buttonStyle(.link)
1696
+ }
1697
+ }
1698
+ }
1699
+
1700
+ private struct ProviderErrorDisplay: Sendable {
1701
+ let preview: String
1702
+ let full: String
1703
+ }
1704
+
1705
+ @MainActor
1706
+ private struct ProviderListScrollInsetFixer: NSViewRepresentable {
1707
+ private final class HitTestIgnoringView: NSView {
1708
+ override func hitTest(_ point: NSPoint) -> NSView? { nil }
1709
+ }
1710
+
1711
+ func makeNSView(context: Context) -> NSView {
1712
+ HitTestIgnoringView()
1713
+ }
1714
+
1715
+ func updateNSView(_ nsView: NSView, context: Context) {
1716
+ Task { @MainActor in
1717
+ guard let scrollView = Self.findScrollView(from: nsView) else { return }
1718
+ if scrollView.automaticallyAdjustsContentInsets {
1719
+ scrollView.automaticallyAdjustsContentInsets = false
1720
+ }
1721
+ let zeroInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
1722
+ let currentContentInsets = scrollView.contentInsets
1723
+ if currentContentInsets.top != 0 || currentContentInsets.left != 0 ||
1724
+ currentContentInsets.bottom != 0 || currentContentInsets.right != 0 {
1725
+ scrollView.contentInsets = zeroInsets
1726
+ }
1727
+ let currentScrollerInsets = scrollView.scrollerInsets
1728
+ if currentScrollerInsets.top != 0 || currentScrollerInsets.left != 0 ||
1729
+ currentScrollerInsets.bottom != 0 || currentScrollerInsets.right != 0 {
1730
+ scrollView.scrollerInsets = zeroInsets
1731
+ }
1732
+ }
1733
+ }
1734
+
1735
+ private static func findScrollView(from view: NSView) -> NSScrollView? {
1736
+ var current: NSView? = view
1737
+ while let candidate = current {
1738
+ if let scroll = candidate as? NSScrollView { return scroll }
1739
+ if let found = candidate.subviews.compactMap({ $0 as? NSScrollView }).first {
1740
+ return found
1741
+ }
1742
+ current = candidate.superview
1743
+ }
1744
+ return nil
1745
+ }
1746
+ }
1747
+
1748
+ @MainActor
1749
+ private struct ProviderErrorView: View {
1750
+ let title: String
1751
+ let display: ProviderErrorDisplay
1752
+ @Binding var isExpanded: Bool
1753
+ let onCopy: () -> Void
1754
+
1755
+ var body: some View {
1756
+ VStack(alignment: .leading, spacing: RunicSpacing.xs) {
1757
+ HStack(alignment: .center, spacing: RunicSpacing.xs) {
1758
+ Image(systemName: "exclamationmark.triangle.fill")
1759
+ .font(.caption)
1760
+ .foregroundStyle(.orange)
1761
+ .alignmentGuide(.firstTextBaseline) { d in d[VerticalAlignment.center] }
1762
+ Text(self.title)
1763
+ .font(.callout.weight(.semibold))
1764
+ .foregroundStyle(.orange)
1765
+ Spacer()
1766
+ Button {
1767
+ self.onCopy()
1768
+ } label: {
1769
+ HStack(alignment: .center, spacing: RunicSpacing.xxs) {
1770
+ Image(systemName: "doc.on.doc")
1771
+ .font(.caption)
1772
+ Text("Copy")
1773
+ .font(.caption)
1774
+ }
1775
+ }
1776
+ .buttonStyle(.bordered)
1777
+ .controlSize(.small)
1778
+ .help("Copy error to clipboard")
1779
+ .alignmentGuide(.firstTextBaseline) { d in d[VerticalAlignment.center] }
1780
+ }
1781
+
1782
+ Text(self.display.preview)
1783
+ .font(.callout)
1784
+ .foregroundStyle(.secondary)
1785
+ .lineLimit(3)
1786
+ .fixedSize(horizontal: false, vertical: true)
1787
+ .padding(RunicSpacing.xs)
1788
+ .frame(maxWidth: .infinity, alignment: .leading)
1789
+ .background(
1790
+ RoundedRectangle(cornerRadius: ProviderListMetrics.errorCardCornerRadius, style: .continuous)
1791
+ .fill(Color.orange.opacity(0.08))
1792
+ )
1793
+
1794
+ if self.display.preview != self.display.full {
1795
+ Button(self.isExpanded ? "Hide details" : "Show details") { self.isExpanded.toggle() }
1796
+ .buttonStyle(.link)
1797
+ .font(.callout)
1798
+ }
1799
+
1800
+ if self.isExpanded {
1801
+ ScrollView(.horizontal, showsIndicators: true) {
1802
+ Text(self.display.full)
1803
+ .font(.system(.caption, design: .monospaced))
1804
+ .foregroundStyle(.primary)
1805
+ .textSelection(.enabled)
1806
+ .fixedSize(horizontal: false, vertical: true)
1807
+ .padding(ProviderListMetrics.errorCardPadding)
1808
+ }
1809
+ .frame(maxHeight: 200)
1810
+ .background(
1811
+ RoundedRectangle(cornerRadius: ProviderListMetrics.errorCardCornerRadius, style: .continuous)
1812
+ .fill(Color(nsColor: .textBackgroundColor))
1813
+ )
1814
+ .overlay(
1815
+ RoundedRectangle(cornerRadius: ProviderListMetrics.errorCardCornerRadius, style: .continuous)
1816
+ .stroke(Color.orange.opacity(0.3), lineWidth: 1)
1817
+ )
1818
+ }
1819
+ }
1820
+ .padding(ProviderListMetrics.errorCardPadding)
1821
+ .background(
1822
+ RoundedRectangle(cornerRadius: ProviderListMetrics.errorCardCornerRadius, style: .continuous)
1823
+ .fill(Color.orange.opacity(0.03))
1824
+ )
1825
+ }
1826
+ }
1827
+
1828
+ @MainActor
1829
+ private struct ProviderSettingsConfirmationState: Identifiable {
1830
+ let id = UUID()
1831
+ let title: String
1832
+ let message: String
1833
+ let confirmTitle: String
1834
+ let onConfirm: () -> Void
1835
+
1836
+ init(confirmation: ProviderSettingsConfirmation) {
1837
+ self.title = confirmation.title
1838
+ self.message = confirmation.message
1839
+ self.confirmTitle = confirmation.confirmTitle
1840
+ self.onConfirm = confirmation.onConfirm
1841
+ }
1842
+ }
1843
+
1844
+ // MARK: - Sidebar layout views
1845
+
1846
+ @MainActor
1847
+ private struct ProviderSidebarRow: View {
1848
+ let provider: UsageProvider
1849
+ @Bindable var store: UsageStore
1850
+ let isEnabled: Bool
1851
+ let isSelected: Bool
1852
+
1853
+ var body: some View {
1854
+ HStack(spacing: RunicSpacing.xs) {
1855
+ if let brand = ProviderBrandIcon.image(for: self.provider, size: 20) {
1856
+ Image(nsImage: brand)
1857
+ .resizable()
1858
+ .scaledToFit()
1859
+ .frame(width: 20, height: 20)
1860
+ .foregroundStyle(self.isEnabled ? .primary : .tertiary)
1861
+ } else {
1862
+ Image(systemName: "circle.dotted")
1863
+ .font(.system(size: 16))
1864
+ .foregroundStyle(.tertiary)
1865
+ .frame(width: 20, height: 20)
1866
+ }
1867
+ Text(self.store.metadata(for: self.provider).displayName)
1868
+ .font(.body)
1869
+ .foregroundStyle(self.isEnabled ? .primary : .secondary)
1870
+ .lineLimit(1)
1871
+ .layoutPriority(1)
1872
+ }
1873
+ .padding(.horizontal, RunicSpacing.xs)
1874
+ .padding(.vertical, RunicSpacing.sm)
1875
+ .frame(maxWidth: .infinity, alignment: .leading)
1876
+ .background(
1877
+ RoundedRectangle(cornerRadius: RunicCornerRadius.sm, style: .continuous)
1878
+ .fill(self.isSelected ? Color.accentColor.opacity(0.14) : .clear))
1879
+ .overlay(
1880
+ RoundedRectangle(cornerRadius: RunicCornerRadius.sm, style: .continuous)
1881
+ .stroke(
1882
+ self.isSelected ? Color.accentColor.opacity(0.45) : Color.clear,
1883
+ lineWidth: 1))
1884
+ .contentShape(Rectangle())
1885
+ .opacity(self.isEnabled ? 1 : 0.6)
1886
+ }
1887
+ }
1888
+
1889
+ private struct ProviderSidebarSectionCard<Content: View>: View {
1890
+ private let content: Content
1891
+
1892
+ init(@ViewBuilder content: () -> Content) {
1893
+ self.content = content()
1894
+ }
1895
+
1896
+ var body: some View {
1897
+ self.content
1898
+ .padding(ProviderListMetrics.sidebarCardPadding)
1899
+ .frame(maxWidth: .infinity, alignment: .leading)
1900
+ .background(
1901
+ RoundedRectangle(
1902
+ cornerRadius: ProviderListMetrics.sidebarCardCornerRadius,
1903
+ style: .continuous)
1904
+ .fill(Color(nsColor: .controlBackgroundColor).opacity(ProviderListMetrics.sidebarCardBackgroundOpacity))
1905
+ )
1906
+ .overlay(
1907
+ RoundedRectangle(
1908
+ cornerRadius: ProviderListMetrics.sidebarCardCornerRadius,
1909
+ style: .continuous)
1910
+ .strokeBorder(Color(nsColor: .separatorColor).opacity(ProviderListMetrics.sidebarCardBorderOpacity), lineWidth: 1)
1911
+ )
1912
+ }
1913
+ }
1914
+
1915
+ private struct ProviderHistoryNavigationButton: View {
1916
+ let systemName: String
1917
+ let enabled: Bool
1918
+ let help: String
1919
+ let action: () -> Void
1920
+
1921
+ var body: some View {
1922
+ Button(action: self.action) {
1923
+ Image(systemName: self.systemName)
1924
+ .font(.caption.weight(.semibold))
1925
+ .foregroundStyle(self.enabled ? .secondary : .tertiary)
1926
+ .frame(width: 26, height: 26)
1927
+ }
1928
+ .buttonStyle(.plain)
1929
+ .disabled(!self.enabled)
1930
+ .background(
1931
+ Circle()
1932
+ .fill(Color(nsColor: .controlBackgroundColor).opacity(self.enabled ? 0.48 : 0.2))
1933
+ .overlay(
1934
+ Circle()
1935
+ .strokeBorder(
1936
+ Color(
1937
+ nsColor: .separatorColor).opacity(self.enabled ? 0.28 : 0.1),
1938
+ lineWidth: 1))
1939
+ )
1940
+ .help(self.help)
1941
+ .accessibilityLabel(help)
1942
+ }
1943
+ }
1944
+
1945
+ private struct ProviderSidebarSectionHeader: View {
1946
+ let title: String
1947
+
1948
+ var body: some View {
1949
+ Text(self.title)
1950
+ .font(.subheadline.weight(.semibold))
1951
+ .foregroundStyle(.secondary)
1952
+ }
1953
+ }
1954
+
1955
+ @MainActor
1956
+ private struct ProviderSidebarKeyValueRow: View {
1957
+ let label: String
1958
+ let value: String
1959
+ let helpText: String?
1960
+
1961
+ var body: some View {
1962
+ HStack(alignment: .firstTextBaseline, spacing: RunicSpacing.xs) {
1963
+ Text("\(self.label):")
1964
+ .font(.caption)
1965
+ .foregroundStyle(.tertiary)
1966
+ .frame(width: ProviderListMetrics.sidebarStatusLabelWidth, alignment: .leading)
1967
+ if let helpText = self.helpText {
1968
+ Text(self.value)
1969
+ .font(.caption)
1970
+ .foregroundStyle(.secondary)
1971
+ .lineLimit(2)
1972
+ .fixedSize(horizontal: false, vertical: true)
1973
+ .textSelection(.enabled)
1974
+ .help(helpText)
1975
+ } else {
1976
+ Text(self.value)
1977
+ .font(.caption)
1978
+ .foregroundStyle(.secondary)
1979
+ .lineLimit(2)
1980
+ .fixedSize(horizontal: false, vertical: true)
1981
+ .textSelection(.enabled)
1982
+ }
1983
+ }
1984
+ }
1985
+ }
1986
+
1987
+ @MainActor
1988
+ private struct ProviderSidebarMetricChip: View {
1989
+ let title: String
1990
+ let value: String
1991
+ let helpText: String?
1992
+
1993
+ var body: some View {
1994
+ VStack(alignment: .leading, spacing: RunicSpacing.xxxs) {
1995
+ Text(self.title)
1996
+ .font(.caption2.weight(.semibold))
1997
+ .foregroundStyle(.tertiary)
1998
+ .textCase(.uppercase)
1999
+ Text(self.value)
2000
+ .font(.caption.weight(.medium))
2001
+ .foregroundStyle(.secondary)
2002
+ .lineLimit(2)
2003
+ .fixedSize(horizontal: false, vertical: true)
2004
+ .textSelection(.enabled)
2005
+ }
2006
+ .frame(maxWidth: .infinity, alignment: .leading)
2007
+ .padding(.horizontal, RunicSpacing.xs)
2008
+ .padding(.vertical, RunicSpacing.xs)
2009
+ .background(
2010
+ RoundedRectangle(
2011
+ cornerRadius: ProviderListMetrics.sidebarMicroCardCornerRadius,
2012
+ style: .continuous)
2013
+ .fill(Color(nsColor: .textBackgroundColor).opacity(ProviderListMetrics.sidebarMicroCardBackgroundOpacity))
2014
+ )
2015
+ .overlay(
2016
+ RoundedRectangle(
2017
+ cornerRadius: ProviderListMetrics.sidebarMicroCardCornerRadius,
2018
+ style: .continuous)
2019
+ .strokeBorder(Color(
2020
+ nsColor: .separatorColor).opacity(ProviderListMetrics.sidebarMicroCardBorderOpacity), lineWidth: 1)
2021
+ )
2022
+ .help(self.helpText ?? "")
2023
+ }
2024
+ }
2025
+
2026
+ private enum ProviderDetailSubview: String, CaseIterable, Identifiable {
2027
+ case overview = "Overview"
2028
+ case history = "History"
2029
+
2030
+ var id: String { self.rawValue }
2031
+ }
2032
+
2033
+ private enum ProviderHistoryMetricMode: String, CaseIterable, Identifiable {
2034
+ case tokens = "Tokens"
2035
+ case cost = "Cost"
2036
+ case requests = "Requests"
2037
+
2038
+ var id: String { self.rawValue }
2039
+ }
2040
+
2041
+ private enum ProviderHistoryDayDetailMode: String, CaseIterable, Identifiable {
2042
+ case summary = "Summary"
2043
+ case models = "Models"
2044
+ case projects = "Projects"
2045
+
2046
+ var id: String { self.rawValue }
2047
+ }
2048
+
2049
+ @MainActor
2050
+ private struct ProviderHistoryCalendarDayCell: View {
2051
+ let dayNumber: Int
2052
+ let isInMonth: Bool
2053
+ let isSelected: Bool
2054
+ let hasActivity: Bool
2055
+ let intensity: Double
2056
+ let action: () -> Void
2057
+
2058
+ var body: some View {
2059
+ Button(action: self.action) {
2060
+ VStack(alignment: .leading, spacing: RunicSpacing.xxxs) {
2061
+ Text("\(self.dayNumber)")
2062
+ .font(.caption.weight(self.isSelected ? .semibold : .regular))
2063
+ .foregroundStyle(self.isInMonth ? .primary : .tertiary)
2064
+ Spacer(minLength: 0)
2065
+ if self.hasActivity {
2066
+ Capsule(style: .continuous)
2067
+ .fill(Color.accentColor.opacity(0.35 + (0.45 * self.intensity)))
2068
+ .frame(height: 4)
2069
+ } else {
2070
+ Capsule(style: .continuous)
2071
+ .fill(Color(nsColor: .separatorColor).opacity(self.isInMonth ? 0.14 : 0.08))
2072
+ .frame(height: 2)
2073
+ }
2074
+ }
2075
+ .padding(RunicSpacing.xs)
2076
+ .frame(maxWidth: .infinity, minHeight: 46, alignment: .topLeading)
2077
+ .background(
2078
+ RoundedRectangle(cornerRadius: RunicCornerRadius.sm, style: .continuous)
2079
+ .fill(self.backgroundColor)
2080
+ )
2081
+ .overlay(
2082
+ RoundedRectangle(cornerRadius: RunicCornerRadius.sm, style: .continuous)
2083
+ .strokeBorder(self.borderColor, lineWidth: self.isSelected ? 1.5 : 1)
2084
+ )
2085
+ .opacity(self.isInMonth ? 1 : 0.52)
2086
+ }
2087
+ .buttonStyle(.plain)
2088
+ }
2089
+
2090
+ private var backgroundColor: Color {
2091
+ if self.isSelected {
2092
+ return Color.accentColor.opacity(0.18)
2093
+ }
2094
+ if self.hasActivity {
2095
+ return Color.accentColor.opacity(0.06 + (0.18 * self.intensity))
2096
+ }
2097
+ return Color(nsColor: .textBackgroundColor).opacity(self.isInMonth ? 0.55 : 0.32)
2098
+ }
2099
+
2100
+ private var borderColor: Color {
2101
+ if self.isSelected {
2102
+ return Color.accentColor.opacity(0.75)
2103
+ }
2104
+ return Color(nsColor: .separatorColor).opacity(self.isInMonth ? 0.22 : 0.12)
2105
+ }
2106
+ }
2107
+
2108
+ @MainActor
2109
+ private struct ProviderSidebarDetailView: View {
2110
+ let provider: UsageProvider
2111
+ @Bindable var store: UsageStore
2112
+ @Binding var isEnabled: Bool
2113
+ let subtitle: String
2114
+ let usageStatus: ProviderUsageStatus
2115
+ let sourceLabel: String
2116
+ let statusLabel: String
2117
+ let settingsToggles: [ProviderSettingsToggleDescriptor]
2118
+ let settingsFields: [ProviderSettingsFieldDescriptor]
2119
+ let errorDisplay: ProviderErrorDisplay?
2120
+ @Binding var isErrorExpanded: Bool
2121
+ let onCopyError: (String) -> Void
2122
+ @State private var diagnosticsCopyStatus: String?
2123
+ @State private var selectedSubview: ProviderDetailSubview = .overview
2124
+ @State private var historyMetricMode: ProviderHistoryMetricMode = .tokens
2125
+ @State private var historyMonthStart: Date = Self.monthStart(for: Date())
2126
+ @State private var historySnapshot: ProviderHistoryMonthSnapshot?
2127
+ @State private var historySelectedDay: Date?
2128
+ @State private var historyDayDetailMode: ProviderHistoryDayDetailMode = .summary
2129
+ @State private var historyIsLoading = false
2130
+ @State private var historyError: String?
2131
+
2132
+ var body: some View {
2133
+ let insightLines = ProviderInsightsComposer.lines(for: self.provider, store: self.store)
2134
+ let topModelLines = self.topModelLines
2135
+ let topProjectLines = self.topProjectLines
2136
+
2137
+ ScrollView {
2138
+ VStack(alignment: .leading, spacing: ProviderListMetrics.sidebarSectionSpacing) {
2139
+ ProviderSidebarSectionCard {
2140
+ VStack(alignment: .leading, spacing: RunicSpacing.md) {
2141
+ HStack(alignment: .top, spacing: RunicSpacing.sm) {
2142
+ ProviderListBrandIcon(provider: self.provider)
2143
+ VStack(alignment: .leading, spacing: RunicSpacing.xxs) {
2144
+ Text(self.store.metadata(for: self.provider).displayName)
2145
+ .font(.title2.weight(.semibold))
2146
+ Text(self.subtitle)
2147
+ .font(.caption)
2148
+ .foregroundStyle(.secondary)
2149
+ }
2150
+ Spacer()
2151
+ Toggle("Enabled", isOn: self.$isEnabled)
2152
+ .toggleStyle(.switch)
2153
+ .labelsHidden()
2154
+ }
2155
+ Divider()
2156
+
2157
+ Picker("View", selection: self.$selectedSubview) {
2158
+ ForEach(ProviderDetailSubview.allCases) { view in
2159
+ Text(view.rawValue).tag(view)
2160
+ }
2161
+ }
2162
+ .pickerStyle(.segmented)
2163
+ }
2164
+ }
2165
+
2166
+ if self.selectedSubview == .overview {
2167
+ ProviderSidebarSectionCard {
2168
+ VStack(alignment: .leading, spacing: RunicSpacing.xs) {
2169
+ VStack(alignment: .leading, spacing: RunicSpacing.xxs) {
2170
+ ProviderSidebarSectionHeader(title: "Overview")
2171
+ ProviderSidebarKeyValueRow(label: "Source", value: self.sourceLabel, helpText: nil)
2172
+ ProviderSidebarKeyValueRow(label: "Updated", value: self.statusLabel, helpText: nil)
2173
+ if let runtimeMetrics = self.runtimeMetrics {
2174
+ ProviderSidebarKeyValueRow(
2175
+ label: "Runtime",
2176
+ value: runtimeMetrics.lineText,
2177
+ helpText: runtimeMetrics.hoverText)
2178
+ }
2179
+ self.statusBadge
2180
+ }
2181
+
2182
+ if !self.quickMetrics.isEmpty {
2183
+ LazyVGrid(
2184
+ columns: [
2185
+ GridItem(.flexible(minimum: 120), spacing: RunicSpacing.xs),
2186
+ GridItem(.flexible(minimum: 120), spacing: RunicSpacing.xs),
2187
+ ],
2188
+ alignment: .leading,
2189
+ spacing: RunicSpacing.xs)
2190
+ {
2191
+ ForEach(self.quickMetrics) { metric in
2192
+ ProviderSidebarMetricChip(
2193
+ title: metric.title,
2194
+ value: metric.value,
2195
+ helpText: metric.helpText)
2196
+ }
2197
+ }
2198
+ }
2199
+
2200
+ HStack(spacing: RunicSpacing.xs) {
2201
+ Button("Copy diagnostics") {
2202
+ self.copyDiagnostics()
2203
+ }
2204
+ .buttonStyle(.bordered)
2205
+ .controlSize(.small)
2206
+ .help("Copy fetch path, reliability, anomaly, and budget/forecast details.")
2207
+
2208
+ if let diagnosticsCopyStatus {
2209
+ Text(diagnosticsCopyStatus)
2210
+ .font(.caption2)
2211
+ .foregroundStyle(.tertiary)
2212
+ }
2213
+ }
2214
+ }
2215
+ }
2216
+
2217
+ if !insightLines.isEmpty {
2218
+ ProviderSidebarSectionCard {
2219
+ VStack(alignment: .leading, spacing: RunicSpacing.xxs) {
2220
+ ProviderSidebarSectionHeader(title: "Insights")
2221
+ ProviderInsightsView(lines: insightLines)
2222
+ }
2223
+ }
2224
+ }
2225
+
2226
+ if !topModelLines.isEmpty || !topProjectLines.isEmpty {
2227
+ ProviderSidebarSectionCard {
2228
+ VStack(alignment: .leading, spacing: RunicSpacing.xxs) {
2229
+ ProviderSidebarSectionHeader(title: "Activity leaders")
2230
+ HStack(alignment: .top, spacing: RunicSpacing.md) {
2231
+ if !topModelLines.isEmpty {
2232
+ VStack(alignment: .leading, spacing: RunicSpacing.xxs) {
2233
+ Text(self.modelSectionTitle)
2234
+ .font(.caption2.weight(.semibold))
2235
+ .foregroundStyle(.secondary)
2236
+ ForEach(Array(topModelLines.enumerated()), id: \.offset) { index, line in
2237
+ Text("\(index + 1). \(line)")
2238
+ .font(.caption)
2239
+ .foregroundStyle(.secondary)
2240
+ .textSelection(.enabled)
2241
+ }
2242
+ }
2243
+ .frame(maxWidth: .infinity, alignment: .leading)
2244
+ }
2245
+
2246
+ if !topProjectLines.isEmpty {
2247
+ VStack(alignment: .leading, spacing: RunicSpacing.xxs) {
2248
+ Text("Projects")
2249
+ .font(.caption2.weight(.semibold))
2250
+ .foregroundStyle(.secondary)
2251
+ ForEach(Array(topProjectLines.enumerated()), id: \.offset) { index, line in
2252
+ Text("\(index + 1). \(line)")
2253
+ .font(.caption)
2254
+ .foregroundStyle(.secondary)
2255
+ .textSelection(.enabled)
2256
+ }
2257
+ }
2258
+ .frame(maxWidth: .infinity, alignment: .leading)
2259
+ }
2260
+ }
2261
+ }
2262
+ }
2263
+ }
2264
+ } else {
2265
+ self.historyContent
2266
+ }
2267
+
2268
+ if let errorDisplay, self.isEnabled {
2269
+ ProviderErrorView(
2270
+ title: "Last fetch failed:",
2271
+ display: errorDisplay,
2272
+ isExpanded: self.$isErrorExpanded,
2273
+ onCopy: { self.onCopyError(errorDisplay.full) })
2274
+ }
2275
+
2276
+ if self.isEnabled, !self.settingsFields.isEmpty {
2277
+ ProviderSidebarSectionCard {
2278
+ VStack(alignment: .leading, spacing: RunicSpacing.sm) {
2279
+ ProviderSidebarSectionHeader(title: "Settings fields")
2280
+ ForEach(self.settingsFields) { field in
2281
+ VStack(alignment: .leading, spacing: RunicSpacing.xxs) {
2282
+ Text(field.title)
2283
+ .font(.body.weight(.medium))
2284
+ Text(field.subtitle)
2285
+ .font(.caption)
2286
+ .foregroundStyle(.secondary)
2287
+ switch field.kind {
2288
+ case .plain:
2289
+ TextField(field.placeholder ?? "", text: field.binding)
2290
+ .textFieldStyle(.roundedBorder)
2291
+ .font(.callout)
2292
+ case .secure:
2293
+ SecureField(field.placeholder ?? "", text: field.binding)
2294
+ .textFieldStyle(.roundedBorder)
2295
+ .font(.callout)
2296
+ }
2297
+ self.fieldActions(field.actions)
2298
+ }
2299
+ }
2300
+ }
2301
+ }
2302
+ }
2303
+
2304
+ if self.isEnabled, !self.settingsToggles.isEmpty {
2305
+ ProviderSidebarSectionCard {
2306
+ VStack(alignment: .leading, spacing: RunicSpacing.sm) {
2307
+ ProviderSidebarSectionHeader(title: "Settings toggles")
2308
+ ForEach(self.settingsToggles) { toggle in
2309
+ VStack(alignment: .leading, spacing: RunicSpacing.xxs) {
2310
+ Toggle(isOn: toggle.binding) {
2311
+ VStack(alignment: .leading, spacing: RunicSpacing.xxs) {
2312
+ Text(toggle.title)
2313
+ .font(.body.weight(.medium))
2314
+ Text(toggle.subtitle)
2315
+ .font(.caption)
2316
+ .foregroundStyle(.secondary)
2317
+ }
2318
+ }
2319
+ .toggleStyle(.checkbox)
2320
+
2321
+ if toggle.binding.wrappedValue {
2322
+ if let status = toggle.statusText?(), !status.isEmpty {
2323
+ Text(status)
2324
+ .font(.caption)
2325
+ .foregroundStyle(.secondary)
2326
+ .padding(RunicSpacing.xs)
2327
+ .frame(maxWidth: .infinity, alignment: .leading)
2328
+ .background(
2329
+ RoundedRectangle(
2330
+ cornerRadius: ProviderListMetrics.sidebarMicroCardCornerRadius,
2331
+ style: .continuous)
2332
+ .fill(Color(
2333
+ nsColor: .controlBackgroundColor).opacity(
2334
+ ProviderListMetrics.sidebarMicroCardBackgroundOpacity)))
2335
+ }
2336
+ self.toggleActions(toggle.actions)
2337
+ }
2338
+ }
2339
+ }
2340
+ }
2341
+ }
2342
+ }
2343
+ }
2344
+ .padding(RunicSpacing.lg)
2345
+ }
2346
+ .frame(maxWidth: .infinity, minHeight: 0, alignment: .topLeading)
2347
+ .clipped()
2348
+ .task(id: self.historyTaskID) {
2349
+ guard self.selectedSubview == .history else { return }
2350
+ await self.loadHistoryMonth()
2351
+ }
2352
+ .onChange(of: self.provider) { _, _ in
2353
+ self.selectedSubview = .overview
2354
+ self.historyMetricMode = .tokens
2355
+ self.historyMonthStart = Self.monthStart(for: Date())
2356
+ self.historySnapshot = nil
2357
+ self.historySelectedDay = nil
2358
+ self.historyDayDetailMode = .summary
2359
+ self.historyError = nil
2360
+ self.historyIsLoading = false
2361
+ }
2362
+ }
2363
+
2364
+ private var statusBadge: some View {
2365
+ let (color, bg) = self.statusColors
2366
+ return Text(self.usageStatus.text)
2367
+ .font(.caption2.weight(.medium))
2368
+ .padding(.horizontal, RunicSpacing.xs)
2369
+ .padding(.vertical, RunicSpacing.xxs)
2370
+ .background(bg)
2371
+ .foregroundStyle(color)
2372
+ .clipShape(.capsule)
2373
+ }
2374
+
2375
+ private var statusColors: (Color, Color) {
2376
+ switch self.usageStatus.style {
2377
+ case .success: return (.green, Color.green.opacity(0.15))
2378
+ case .error: return (.red, Color.red.opacity(0.15))
2379
+ case .neutral: return (.secondary, Color(nsColor: .controlBackgroundColor))
2380
+ }
2381
+ }
2382
+
2383
+ private var historyTaskID: String {
2384
+ let monthKey = Int(self.historyMonthStart.timeIntervalSince1970)
2385
+ return "\(self.provider.rawValue)-\(self.selectedSubview.rawValue)-\(monthKey)"
2386
+ }
2387
+
2388
+ private var historyContent: some View {
2389
+ ProviderSidebarSectionCard {
2390
+ VStack(alignment: .leading, spacing: RunicSpacing.sm) {
2391
+ HStack(alignment: .center, spacing: RunicSpacing.xs) {
2392
+ ProviderHistoryNavigationButton(
2393
+ systemName: "chevron.left",
2394
+ enabled: true,
2395
+ help: "Previous month") {
2396
+ self.shiftHistoryMonth(by: -1)
2397
+ }
2398
+
2399
+ Text(self.historyMonthTitle)
2400
+ .font(.caption.weight(.semibold))
2401
+ .foregroundStyle(.secondary)
2402
+ .frame(minWidth: 130, alignment: .leading)
2403
+
2404
+ ProviderHistoryNavigationButton(
2405
+ systemName: "chevron.right",
2406
+ enabled: self.canShiftHistoryForward,
2407
+ help: "Next month") {
2408
+ self.shiftHistoryMonth(by: 1)
2409
+ }
2410
+
2411
+ Spacer()
2412
+
2413
+ Picker("Metric", selection: self.$historyMetricMode) {
2414
+ ForEach(ProviderHistoryMetricMode.allCases) { mode in
2415
+ Text(mode.rawValue).tag(mode)
2416
+ }
2417
+ }
2418
+ .pickerStyle(.segmented)
2419
+ .frame(width: 220)
2420
+ }
2421
+
2422
+ if self.historyIsLoading, self.historySnapshot == nil {
2423
+ HStack(spacing: RunicSpacing.xs) {
2424
+ ProgressView()
2425
+ .controlSize(.small)
2426
+ Text("Loading history…")
2427
+ .font(.caption)
2428
+ .foregroundStyle(.secondary)
2429
+ }
2430
+ .padding(.vertical, RunicSpacing.xs)
2431
+ } else if let snapshot = self.historySnapshot {
2432
+ if !snapshot.isSupported {
2433
+ Text(snapshot.note ?? "History is not available for this provider yet.")
2434
+ .font(.caption)
2435
+ .foregroundStyle(.secondary)
2436
+ .padding(.vertical, RunicSpacing.xs)
2437
+ .frame(maxWidth: .infinity, alignment: .leading)
2438
+ .background(
2439
+ RoundedRectangle(
2440
+ cornerRadius: ProviderListMetrics.sidebarMicroCardCornerRadius,
2441
+ style: .continuous)
2442
+ .fill(Color(nsColor: .controlBackgroundColor).opacity(ProviderListMetrics.sidebarCardBackgroundOpacity))
2443
+ )
2444
+ } else {
2445
+ if let note = snapshot.note, !note.isEmpty {
2446
+ Text(note)
2447
+ .font(.caption2)
2448
+ .foregroundStyle(.tertiary)
2449
+ }
2450
+ Text(snapshot.days.count == 1 ? "1 active day in \(self.historyMonthTitle)." : "\(snapshot.days.count) active days in \(self.historyMonthTitle).")
2451
+ .font(.caption2)
2452
+ .foregroundStyle(.secondary)
2453
+ .padding(.bottom, RunicSpacing.xxs)
2454
+
2455
+ self.historyCalendarGrid
2456
+ self.historyDayDetailCard
2457
+ }
2458
+ } else {
2459
+ Text("History is empty for this period.")
2460
+ .font(.caption)
2461
+ .foregroundStyle(.secondary)
2462
+ .padding(.vertical, RunicSpacing.xs)
2463
+ }
2464
+
2465
+ if let historyError = self.historyError {
2466
+ VStack(alignment: .leading, spacing: RunicSpacing.xxs) {
2467
+ Text("History load failed")
2468
+ .font(.caption.weight(.semibold))
2469
+ .foregroundStyle(.red)
2470
+ Text(historyError)
2471
+ .font(.caption)
2472
+ .foregroundStyle(.secondary)
2473
+ .textSelection(.enabled)
2474
+ Button("Retry") {
2475
+ Task { await self.loadHistoryMonth(forceRefresh: true) }
2476
+ }
2477
+ .buttonStyle(.bordered)
2478
+ .controlSize(.small)
2479
+ }
2480
+ .padding(RunicSpacing.sm)
2481
+ .frame(maxWidth: .infinity, alignment: .leading)
2482
+ .background(
2483
+ RoundedRectangle(
2484
+ cornerRadius: ProviderListMetrics.sidebarMicroCardCornerRadius,
2485
+ style: .continuous)
2486
+ .fill(Color.red.opacity(0.06))
2487
+ )
2488
+ .overlay(
2489
+ RoundedRectangle(
2490
+ cornerRadius: ProviderListMetrics.sidebarMicroCardCornerRadius,
2491
+ style: .continuous)
2492
+ .strokeBorder(Color.red.opacity(0.28), lineWidth: 1)
2493
+ )
2494
+ }
2495
+
2496
+ Text("Local aggregated history only. Prompts, cookies, API keys, and raw payloads are never shown here.")
2497
+ .font(.caption2)
2498
+ .foregroundStyle(.tertiary)
2499
+ }
2500
+ }
2501
+ }
2502
+
2503
+ private var historyCalendarGrid: some View {
2504
+ VStack(alignment: .leading, spacing: RunicSpacing.xs) {
2505
+ let weekdays = self.weekdaySymbols
2506
+ LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: RunicSpacing.xxs), count: 7), spacing: RunicSpacing.xxs) {
2507
+ ForEach(weekdays, id: \.self) { symbol in
2508
+ Text(symbol)
2509
+ .font(.caption2.weight(.semibold))
2510
+ .foregroundStyle(.tertiary)
2511
+ .frame(maxWidth: .infinity)
2512
+ }
2513
+ }
2514
+
2515
+ LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: RunicSpacing.xxs), count: 7), spacing: RunicSpacing.xxs) {
2516
+ ForEach(self.calendarDaysForMonth, id: \.self) { day in
2517
+ let inMonth = self.historyCalendar.isDate(day, equalTo: self.historyMonthStart, toGranularity: .month)
2518
+ let normalizedDay = self.historyCalendar.startOfDay(for: day)
2519
+ let summary = self.historySummaryByDay[normalizedDay]
2520
+ let dayNumber = self.historyCalendar.component(.day, from: day)
2521
+ let isSelected = self.historySelectedDay.map { self.historyCalendar.isDate($0, inSameDayAs: day) } ?? false
2522
+ ProviderHistoryCalendarDayCell(
2523
+ dayNumber: dayNumber,
2524
+ isInMonth: inMonth,
2525
+ isSelected: isSelected,
2526
+ hasActivity: summary != nil,
2527
+ intensity: self.historyIntensity(for: summary),
2528
+ action: { self.historySelectedDay = normalizedDay })
2529
+ .help(self.historyDayHelp(for: day, summary: summary))
2530
+ }
2531
+ }
2532
+ }
2533
+ }
2534
+
2535
+ private var historyDayDetailCard: some View {
2536
+ ProviderSidebarSectionCard {
2537
+ VStack(alignment: .leading, spacing: RunicSpacing.sm) {
2538
+ HStack(alignment: .firstTextBaseline, spacing: RunicSpacing.xs) {
2539
+ Text("Day details")
2540
+ .font(.caption.weight(.semibold))
2541
+ .foregroundStyle(.tertiary)
2542
+ .textCase(.uppercase)
2543
+ .tracking(0.25)
2544
+ Spacer()
2545
+ if self.selectedHistoryDaySummary != nil {
2546
+ Picker("History detail", selection: self.$historyDayDetailMode) {
2547
+ ForEach(ProviderHistoryDayDetailMode.allCases) { mode in
2548
+ Text(mode.rawValue).tag(mode)
2549
+ }
2550
+ }
2551
+ .pickerStyle(.segmented)
2552
+ .controlSize(.mini)
2553
+ .frame(maxWidth: 240)
2554
+ }
2555
+ }
2556
+
2557
+ if let selected = self.selectedHistoryDaySummary {
2558
+ Text(selected.dayStart.formatted(.dateTime.weekday(.wide).month(.abbreviated).day().year()))
2559
+ .font(.caption.weight(.semibold))
2560
+ .foregroundStyle(.secondary)
2561
+
2562
+ if self.historyDayDetailMode == .summary {
2563
+ LazyVGrid(
2564
+ columns: [
2565
+ GridItem(.flexible(minimum: 90), spacing: RunicSpacing.xs),
2566
+ GridItem(.flexible(minimum: 90), spacing: RunicSpacing.xs),
2567
+ GridItem(.flexible(minimum: 90), spacing: RunicSpacing.xs),
2568
+ ],
2569
+ alignment: .leading,
2570
+ spacing: RunicSpacing.xs)
2571
+ {
2572
+ ProviderSidebarMetricChip(
2573
+ title: "Requests",
2574
+ value: self.decimalString(selected.requestCount),
2575
+ helpText: "Count of requests recorded in local ledger logs.")
2576
+ ProviderSidebarMetricChip(
2577
+ title: "Tokens",
2578
+ value: UsageFormatter.tokenSummaryString(selected.totals),
2579
+ helpText: "Input, output, and cache token composition.")
2580
+ if let spend = selected.totals.costUSD {
2581
+ ProviderSidebarMetricChip(
2582
+ title: "Spend",
2583
+ value: UsageFormatter.usdString(spend),
2584
+ helpText: "Estimated day spend from ledger pricing.")
2585
+ }
2586
+ }
2587
+
2588
+ if self.hasModelBreakdown, let topModel = selected.topModel {
2589
+ Text("Top model: \(self.usageLine(title: UsageFormatter.modelDisplayName(topModel.model), totals: topModel.totals, requests: topModel.entryCount, model: topModel.model))")
2590
+ .font(.caption)
2591
+ .foregroundStyle(.secondary)
2592
+ .textSelection(.enabled)
2593
+ }
2594
+
2595
+ if self.hasProjectAttribution, let topProject = selected.topProject {
2596
+ let project = self.projectDisplay(topProject)
2597
+ Text("Top project: \(project.title)")
2598
+ .font(.caption)
2599
+ .foregroundStyle(.secondary)
2600
+ .textSelection(.enabled)
2601
+ .help(project.helpText ?? "")
2602
+ }
2603
+ }
2604
+
2605
+ if self.historyDayDetailMode == .models {
2606
+ if self.hasModelBreakdown, !selected.modelSummaries.isEmpty {
2607
+ Text("Models used")
2608
+ .font(.caption2.weight(.medium))
2609
+ .foregroundStyle(.secondary)
2610
+ ForEach(Array(selected.modelSummaries.prefix(12).enumerated()), id: \.offset) { _, summary in
2611
+ Text("• \(self.historyModelLine(summary))")
2612
+ .font(.caption2)
2613
+ .foregroundStyle(.tertiary)
2614
+ .textSelection(.enabled)
2615
+ .help(self.historyModelLine(summary))
2616
+ }
2617
+ } else if self.hasModelBreakdown, !selected.modelsUsed.isEmpty {
2618
+ Text("Models used: \(self.renderedModelsList(selected.modelsUsed).joined(separator: ", "))")
2619
+ .font(.caption2)
2620
+ .foregroundStyle(.tertiary)
2621
+ .textSelection(.enabled)
2622
+ } else {
2623
+ Text(self.hasModelBreakdown ? "No models recorded for this day." : "Model attribution is not available for this provider.")
2624
+ .font(.caption)
2625
+ .foregroundStyle(.tertiary)
2626
+ }
2627
+ }
2628
+
2629
+ if self.historyDayDetailMode == .projects {
2630
+ if self.hasProjectAttribution, !selected.projectSummaries.isEmpty {
2631
+ Text("Top projects")
2632
+ .font(.caption2.weight(.medium))
2633
+ .foregroundStyle(.secondary)
2634
+ ForEach(Array(selected.projectSummaries.prefix(12).enumerated()), id: \.offset) { _, summary in
2635
+ let project = self.projectDisplay(summary)
2636
+ Text("• \(self.historyProjectLine(summary))")
2637
+ .font(.caption2)
2638
+ .foregroundStyle(.tertiary)
2639
+ .textSelection(.enabled)
2640
+ .help(project.helpText ?? "")
2641
+ }
2642
+ } else {
2643
+ Text(self.hasProjectAttribution ? "No projects recorded for this day." : "Project attribution is not available for this provider.")
2644
+ .font(.caption)
2645
+ .foregroundStyle(.tertiary)
2646
+ }
2647
+ }
2648
+ } else {
2649
+ Text(self.historySelectedDay?.formatted(.dateTime.weekday(.wide).month(.abbreviated).day().year()) ?? "No day selected")
2650
+ .font(.caption.weight(.semibold))
2651
+ .foregroundStyle(.secondary)
2652
+ Text("No recorded activity for this day.")
2653
+ .font(.caption)
2654
+ .foregroundStyle(.tertiary)
2655
+ }
2656
+ }
2657
+ }
2658
+ }
2659
+
2660
+ private var historyMonthTitle: String {
2661
+ self.historyMonthStart.formatted(.dateTime.month(.wide).year())
2662
+ }
2663
+
2664
+ private var canShiftHistoryForward: Bool {
2665
+ self.historyMonthStart < Self.monthStart(for: Date())
2666
+ }
2667
+
2668
+ private var historyCalendar: Calendar {
2669
+ var calendar = Calendar(identifier: .gregorian)
2670
+ calendar.timeZone = TimeZone.current
2671
+ return calendar
2672
+ }
2673
+
2674
+ private var weekdaySymbols: [String] {
2675
+ let symbols = self.historyCalendar.veryShortStandaloneWeekdaySymbols
2676
+ guard !symbols.isEmpty else { return [] }
2677
+ let start = max(0, min(symbols.count - 1, self.historyCalendar.firstWeekday - 1))
2678
+ return Array(symbols[start...]) + Array(symbols[..<start])
2679
+ }
2680
+
2681
+ private var calendarDaysForMonth: [Date] {
2682
+ guard let monthInterval = self.historyCalendar.dateInterval(of: .month, for: self.historyMonthStart),
2683
+ let firstWeek = self.historyCalendar.dateInterval(of: .weekOfMonth, for: monthInterval.start),
2684
+ let lastDayOfMonth = self.historyCalendar.date(byAdding: .day, value: -1, to: monthInterval.end),
2685
+ let lastWeek = self.historyCalendar.dateInterval(of: .weekOfMonth, for: lastDayOfMonth)
2686
+ else {
2687
+ return []
2688
+ }
2689
+
2690
+ var days: [Date] = []
2691
+ var cursor = firstWeek.start
2692
+ while cursor < lastWeek.end {
2693
+ days.append(cursor)
2694
+ guard let next = self.historyCalendar.date(byAdding: .day, value: 1, to: cursor) else { break }
2695
+ cursor = next
2696
+ }
2697
+ return days
2698
+ }
2699
+
2700
+ private var historySummaryByDay: [Date: ProviderHistoryDaySnapshot] {
2701
+ guard let snapshot = self.historySnapshot else { return [:] }
2702
+ var map: [Date: ProviderHistoryDaySnapshot] = [:]
2703
+ for day in snapshot.days {
2704
+ map[self.historyCalendar.startOfDay(for: day.dayStart)] = day
2705
+ }
2706
+ return map
2707
+ }
2708
+
2709
+ private var selectedHistoryDaySummary: ProviderHistoryDaySnapshot? {
2710
+ guard let snapshot = self.historySnapshot else { return nil }
2711
+ guard let selectedDay = self.historySelectedDay else { return snapshot.days.last }
2712
+ return snapshot.days.first { self.historyCalendar.isDate($0.dayStart, inSameDayAs: selectedDay) }
2713
+ }
2714
+
2715
+ private var historyMaxMetricValue: Double {
2716
+ guard let snapshot = self.historySnapshot, !snapshot.days.isEmpty else { return 0 }
2717
+ return snapshot.days.reduce(0) { max($0, self.historyMetricValue(for: $1)) }
2718
+ }
2719
+
2720
+ private func historyMetricValue(for day: ProviderHistoryDaySnapshot) -> Double {
2721
+ switch self.historyMetricMode {
2722
+ case .tokens:
2723
+ return Double(day.totals.totalTokens)
2724
+ case .cost:
2725
+ return max(0, day.totals.costUSD ?? 0)
2726
+ case .requests:
2727
+ return Double(day.requestCount)
2728
+ }
2729
+ }
2730
+
2731
+ private func historyIntensity(for day: ProviderHistoryDaySnapshot?) -> Double {
2732
+ guard let day else { return 0 }
2733
+ let maxValue = self.historyMaxMetricValue
2734
+ guard maxValue > 0 else { return 0.15 }
2735
+ return min(1, max(0.15, self.historyMetricValue(for: day) / maxValue))
2736
+ }
2737
+
2738
+ private func historyDayHelp(for day: Date, summary: ProviderHistoryDaySnapshot?) -> String {
2739
+ var lines: [String] = [day.formatted(.dateTime.weekday(.wide).month(.abbreviated).day().year())]
2740
+ guard let summary else {
2741
+ lines.append("No recorded activity")
2742
+ return lines.joined(separator: "\n")
2743
+ }
2744
+ lines.append("Requests: \(self.decimalString(summary.requestCount))")
2745
+ lines.append("Tokens: \(UsageFormatter.tokenSummaryString(summary.totals))")
2746
+ if let spend = summary.totals.costUSD {
2747
+ lines.append("Spend: \(UsageFormatter.usdString(spend))")
2748
+ }
2749
+ if self.hasModelBreakdown, let topModel = summary.topModel {
2750
+ var modelLine = "Top model: \(UsageFormatter.modelDisplayName(topModel.model))"
2751
+ if let context = UsageFormatter.modelContextLabel(for: topModel.model) {
2752
+ modelLine += " · \(context)"
2753
+ }
2754
+ lines.append(modelLine)
2755
+ }
2756
+ if self.hasModelBreakdown, !summary.modelSummaries.isEmpty {
2757
+ let modelCount = min(3, summary.modelSummaries.count)
2758
+ for summary in summary.modelSummaries.prefix(modelCount) {
2759
+ var line = "Model: \(UsageFormatter.modelDisplayName(summary.model)) · \(UsageFormatter.tokenCountString(summary.totals.totalTokens)) tok"
2760
+ if let context = UsageFormatter.modelContextLabel(for: summary.model) {
2761
+ line += " · \(context)"
2762
+ }
2763
+ lines.append(line)
2764
+ }
2765
+ }
2766
+ if self.hasProjectAttribution, let topProject = summary.topProject {
2767
+ var projectLine = "Top project: \(self.projectDisplay(topProject).title)"
2768
+ if let cost = topProject.totals.costUSD {
2769
+ projectLine += " · \(UsageFormatter.usdString(cost))"
2770
+ }
2771
+ lines.append(projectLine)
2772
+ }
2773
+ return lines.joined(separator: "\n")
2774
+ }
2775
+
2776
+ private func renderedModelsList(_ modelsUsed: [String]) -> [String] {
2777
+ var seen: Set<String> = []
2778
+ var rendered: [String] = []
2779
+ for model in modelsUsed {
2780
+ let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
2781
+ guard !trimmed.isEmpty, seen.insert(trimmed).inserted else { continue }
2782
+ var text = UsageFormatter.modelDisplayName(trimmed)
2783
+ if let context = UsageFormatter.modelContextLabel(for: trimmed) {
2784
+ text += " \(context)"
2785
+ }
2786
+ rendered.append(text)
2787
+ }
2788
+ return rendered
2789
+ }
2790
+
2791
+ private func historyModelLine(_ summary: UsageLedgerModelSummary) -> String {
2792
+ self.usageLine(
2793
+ title: UsageFormatter.modelDisplayName(summary.model),
2794
+ totals: summary.totals,
2795
+ requests: summary.entryCount,
2796
+ model: summary.model)
2797
+ }
2798
+
2799
+ private func historyProjectLine(_ summary: UsageLedgerProjectSummary) -> String {
2800
+ let project = self.projectDisplay(summary)
2801
+ return self.usageLine(
2802
+ title: project.title,
2803
+ totals: summary.totals,
2804
+ requests: summary.entryCount)
2805
+ }
2806
+
2807
+ private func shiftHistoryMonth(by delta: Int) {
2808
+ guard delta != 0 else { return }
2809
+ guard let shifted = self.historyCalendar.date(byAdding: .month, value: delta, to: self.historyMonthStart) else {
2810
+ return
2811
+ }
2812
+ let candidate = Self.monthStart(for: shifted)
2813
+ if delta > 0, candidate > Self.monthStart(for: Date()) {
2814
+ return
2815
+ }
2816
+ self.historyMonthStart = candidate
2817
+ self.historySnapshot = nil
2818
+ self.historySelectedDay = nil
2819
+ self.historyError = nil
2820
+ }
2821
+
2822
+ private static func monthStart(for date: Date) -> Date {
2823
+ var calendar = Calendar(identifier: .gregorian)
2824
+ calendar.timeZone = TimeZone.current
2825
+ let components = calendar.dateComponents([.year, .month], from: date)
2826
+ return calendar.date(from: components) ?? calendar.startOfDay(for: date)
2827
+ }
2828
+
2829
+ private func loadHistoryMonth(forceRefresh: Bool = false) async {
2830
+ guard !self.historyIsLoading else { return }
2831
+ self.historyIsLoading = true
2832
+ self.historyError = nil
2833
+ let snapshot = await self.store.providerHistoryMonth(
2834
+ provider: self.provider,
2835
+ monthStart: self.historyMonthStart,
2836
+ forceRefresh: forceRefresh)
2837
+ self.historySnapshot = snapshot
2838
+ self.historyError = snapshot.error
2839
+ self.historyIsLoading = false
2840
+ self.selectDefaultHistoryDay(from: snapshot)
2841
+ }
2842
+
2843
+ private func selectDefaultHistoryDay(from snapshot: ProviderHistoryMonthSnapshot) {
2844
+ guard !snapshot.days.isEmpty else {
2845
+ self.historySelectedDay = self.historyCalendar.startOfDay(for: self.historyMonthStart)
2846
+ return
2847
+ }
2848
+ if let selected = self.historySelectedDay,
2849
+ snapshot.days.contains(where: { self.historyCalendar.isDate($0.dayStart, inSameDayAs: selected) })
2850
+ {
2851
+ self.historySelectedDay = self.historyCalendar.startOfDay(for: selected)
2852
+ return
2853
+ }
2854
+ self.historySelectedDay = snapshot.days.map(\.dayStart).max()
2855
+ }
2856
+
2857
+ private struct ProjectDisplay {
2858
+ let title: String
2859
+ let helpText: String?
2860
+ }
2861
+
2862
+ private func projectDisplay(_ summary: UsageLedgerProjectSummary) -> ProjectDisplay {
2863
+ let displayName = summary.displayProjectName
2864
+ let source = summary.projectNameSource ?? .unknown
2865
+ let confidence = summary.projectNameConfidence ?? .none
2866
+ let shouldAnnotateSource = source != .projectName && source != .budgetOverride
2867
+ let shouldAnnotateConfidence = confidence != .high
2868
+ let isUnknown = displayName == "Unknown project"
2869
+
2870
+ var details: [String] = []
2871
+ if shouldAnnotateSource {
2872
+ details.append("source: \(self.projectSourceLabel(source))")
2873
+ }
2874
+ if shouldAnnotateConfidence {
2875
+ details.append("confidence: \(self.projectConfidenceLabel(confidence))")
2876
+ }
2877
+ if isUnknown, let fingerprint = self.projectIDFingerprint(summary.projectID) {
2878
+ details.append("id: \(fingerprint)")
2879
+ }
2880
+ if let provenance = self.trimmed(summary.projectNameProvenance) {
2881
+ details.append("via: \(provenance)")
2882
+ }
2883
+ return ProjectDisplay(
2884
+ title: displayName,
2885
+ helpText: details.isEmpty ? nil : details.joined(separator: "\n"))
2886
+ }
2887
+
2888
+ private func projectSourceLabel(_ source: UsageLedgerProjectNameSource) -> String {
2889
+ switch source {
2890
+ case .projectName: return "project name"
2891
+ case .projectID: return "project id"
2892
+ case .inferredFromPath: return "path-derived"
2893
+ case .inferredFromName: return "name-derived"
2894
+ case .budgetOverride: return "budget override"
2895
+ case .unknown: return "unknown"
2896
+ }
2897
+ }
2898
+
2899
+ private func projectConfidenceLabel(_ confidence: UsageLedgerProjectNameConfidence) -> String {
2900
+ switch confidence {
2901
+ case .high: return "high"
2902
+ case .medium: return "medium"
2903
+ case .low: return "low"
2904
+ case .none: return "none"
2905
+ }
2906
+ }
2907
+
2908
+ private func projectIDFingerprint(_ projectID: String?) -> String? {
2909
+ guard let projectID = projectID?.trimmingCharacters(in: .whitespacesAndNewlines), !projectID.isEmpty else {
2910
+ return nil
2911
+ }
2912
+ var hash: UInt64 = 0xcbf29ce484222325
2913
+ for byte in projectID.lowercased().utf8 {
2914
+ hash ^= UInt64(byte)
2915
+ hash = hash &* 0x100000001b3
2916
+ }
2917
+ return String(format: "%08llx", hash)
2918
+ }
2919
+
2920
+ @ViewBuilder
2921
+ private func fieldActions(_ actions: [ProviderSettingsActionDescriptor]) -> some View {
2922
+ let visible = actions.filter { $0.isVisible?() ?? true }
2923
+ if !visible.isEmpty {
2924
+ HStack(spacing: RunicSpacing.xs) {
2925
+ ForEach(visible) { action in
2926
+ Button(action.title) {
2927
+ Task { @MainActor in await action.perform() }
2928
+ }
2929
+ .applyProviderSettingsButtonStyle(action.style)
2930
+ .controlSize(.small)
2931
+ }
2932
+ }
2933
+ }
2934
+ }
2935
+
2936
+ @ViewBuilder
2937
+ private func toggleActions(_ actions: [ProviderSettingsActionDescriptor]) -> some View {
2938
+ let visible = actions.filter { $0.isVisible?() ?? true }
2939
+ if !visible.isEmpty {
2940
+ HStack(spacing: RunicSpacing.xs) {
2941
+ ForEach(visible) { action in
2942
+ Button(action.title) {
2943
+ Task { @MainActor in await action.perform() }
2944
+ }
2945
+ .applyProviderSettingsButtonStyle(action.style)
2946
+ .controlSize(.small)
2947
+ }
2948
+ }
2949
+ }
2950
+ }
2951
+
2952
+ private struct QuickMetricItem: Identifiable {
2953
+ let id: String
2954
+ let title: String
2955
+ let value: String
2956
+ let helpText: String?
2957
+ }
2958
+
2959
+ private var quickMetrics: [QuickMetricItem] {
2960
+ var items: [QuickMetricItem] = []
2961
+ let snapshot = self.store.snapshot(for: self.provider)
2962
+ let tokenSnapshot = self.store.tokenSnapshot(for: self.provider)
2963
+ let hasModelBreakdown = self.hasModelBreakdown
2964
+ let hasProjectAttribution = self.hasProjectAttribution
2965
+
2966
+ if let today = self.tokenWindowValue(tokens: tokenSnapshot?.sessionTokens, cost: tokenSnapshot?.sessionCostUSD) {
2967
+ items.append(QuickMetricItem(id: "today", title: "Today", value: today, helpText: "Session cost and tokens."))
2968
+ }
2969
+ if let last30 = self.tokenWindowValue(
2970
+ tokens: tokenSnapshot?.last30DaysTokens,
2971
+ cost: tokenSnapshot?.last30DaysCostUSD)
2972
+ {
2973
+ items.append(QuickMetricItem(id: "30d", title: "30d", value: last30, helpText: "Last 30 days cost and tokens."))
2974
+ }
2975
+ if let spend = self.providerSpendValue(snapshot?.providerCost) {
2976
+ items.append(QuickMetricItem(id: "spend", title: "Spend", value: spend, helpText: "Provider billing usage."))
2977
+ }
2978
+ if hasModelBreakdown, let topModel = self.store.ledgerTopModel(for: self.provider) {
2979
+ let modelName = UsageFormatter.modelDisplayName(topModel.model)
2980
+ var value = self.modelLineValue(
2981
+ title: modelName,
2982
+ totals: topModel.totals,
2983
+ requests: topModel.entryCount)
2984
+ if let context = UsageFormatter.modelContextLabel(for: topModel.model) {
2985
+ value += " · \(context)"
2986
+ }
2987
+ items.append(QuickMetricItem(
2988
+ id: "top-model",
2989
+ title: "Top model",
2990
+ value: value,
2991
+ helpText: "Highest token usage model in the active insights window."))
2992
+ } else if !hasModelBreakdown, let windowModel = self.topWindowModel {
2993
+ let modelName = UsageFormatter.modelDisplayName(windowModel.label)
2994
+ let used = Int(windowModel.window.usedPercent.rounded())
2995
+ items.append(QuickMetricItem(
2996
+ id: "top-model-window",
2997
+ title: hasModelBreakdown ? "Top model" : "Top window",
2998
+ value: "\(modelName) · \(used)% used",
2999
+ helpText: hasModelBreakdown ?
3000
+ "Most constrained model/category from live quota windows."
3001
+ : "Top quota window from live fetch response."))
3002
+ }
3003
+ if hasProjectAttribution, let topProject = self.store.ledgerTopProject(for: self.provider) {
3004
+ let project = self.projectDisplay(topProject)
3005
+ let value = self.topProjectSummaryValue(topProject)
3006
+ items.append(QuickMetricItem(
3007
+ id: "top-project",
3008
+ title: "Top project",
3009
+ value: "\(project.title) · \(value)",
3010
+ helpText: "Highest token usage project in the active insights window."))
3011
+ }
3012
+ if let coverage = ProviderInsightsComposer.coverageSummaryLabel(for: self.provider, store: self.store) {
3013
+ let value = coverage.replacingOccurrences(of: "usage: ", with: "")
3014
+ items.append(QuickMetricItem(
3015
+ id: "coverage",
3016
+ title: "Data",
3017
+ value: value,
3018
+ helpText: "Provider coverage for model-level usage, token metrics, and project attribution."))
3019
+ }
3020
+ return items
3021
+ }
3022
+
3023
+ private func tokenWindowValue(tokens: Int?, cost: Double?) -> String? {
3024
+ var parts: [String] = []
3025
+ if let cost, cost.isFinite, cost >= 0 {
3026
+ parts.append(UsageFormatter.usdString(cost))
3027
+ }
3028
+ if let tokens, tokens >= 0 {
3029
+ parts.append("\(UsageFormatter.tokenCountString(tokens)) tok")
3030
+ }
3031
+ guard !parts.isEmpty else { return nil }
3032
+ return parts.joined(separator: " · ")
3033
+ }
3034
+
3035
+ private func providerSpendValue(_ providerCost: ProviderCostSnapshot?) -> String? {
3036
+ guard let providerCost else { return nil }
3037
+ let used = UsageFormatter.currencyString(providerCost.used, currencyCode: providerCost.currencyCode)
3038
+ if providerCost.limit > 0 {
3039
+ let limitText = UsageFormatter.currencyString(providerCost.limit, currencyCode: providerCost.currencyCode)
3040
+ return "\(used) / \(limitText)"
3041
+ }
3042
+ return used
3043
+ }
3044
+
3045
+ private var topModelLines: [String] {
3046
+ let ranked = self.store.ledgerModelBreakdown(for: self.provider).sorted { lhs, rhs in
3047
+ if lhs.totals.totalTokens == rhs.totals.totalTokens {
3048
+ return UsageFormatter.modelDisplayName(lhs.model) < UsageFormatter.modelDisplayName(rhs.model)
3049
+ }
3050
+ return lhs.totals.totalTokens > rhs.totals.totalTokens
3051
+ }
3052
+ if !ranked.isEmpty {
3053
+ guard self.hasModelBreakdown else {
3054
+ return []
3055
+ }
3056
+ return ranked.prefix(3).map { summary in
3057
+ let name = UsageFormatter.modelDisplayName(summary.model)
3058
+ return self.usageLine(
3059
+ title: name,
3060
+ totals: summary.totals,
3061
+ requests: summary.entryCount,
3062
+ model: summary.model)
3063
+ }
3064
+ }
3065
+ guard !self.hasModelBreakdown else {
3066
+ return []
3067
+ }
3068
+ return self.windowModelLines
3069
+ }
3070
+
3071
+ private var modelSectionTitle: String {
3072
+ self.hasModelBreakdown ? "Models" : "Quota windows"
3073
+ }
3074
+
3075
+ private var effectiveUsageCoverage: ProviderUsageCoverage {
3076
+ ProviderInsightsComposer.effectiveCoverage(for: self.provider, store: self.store)
3077
+ }
3078
+
3079
+ private var hasModelBreakdown: Bool {
3080
+ self.effectiveUsageCoverage.supportsModelBreakdown
3081
+ }
3082
+
3083
+ private var hasProjectAttribution: Bool {
3084
+ self.effectiveUsageCoverage.supportsProjectAttribution
3085
+ }
3086
+
3087
+ private var topProjectLines: [String] {
3088
+ guard self.hasProjectAttribution else {
3089
+ return []
3090
+ }
3091
+ let ranked = self.store.ledgerProjectBreakdown(for: self.provider).sorted { lhs, rhs in
3092
+ if lhs.totals.totalTokens == rhs.totals.totalTokens {
3093
+ return lhs.displayProjectName < rhs.displayProjectName
3094
+ }
3095
+ return lhs.totals.totalTokens > rhs.totals.totalTokens
3096
+ }
3097
+ return ranked.prefix(3).map { summary in
3098
+ let project = summary.displayProjectName
3099
+ return self.usageLine(title: project, totals: summary.totals, requests: summary.entryCount)
3100
+ }
3101
+ }
3102
+
3103
+ private func usageLine(
3104
+ title: String,
3105
+ totals: UsageLedgerTotals,
3106
+ requests: Int,
3107
+ model: String? = nil) -> String
3108
+ {
3109
+ let tokens = UsageFormatter.tokenSummaryString(totals)
3110
+ var parts = ["\(title)", "\(tokens)", "\(requests) req"]
3111
+ if let cost = totals.costUSD {
3112
+ parts.append(UsageFormatter.usdString(cost))
3113
+ if let per1K = UsageFormatter.usdPer1KTokensString(costUSD: cost, tokenCount: totals.totalTokens) {
3114
+ parts.append("~\(per1K)")
3115
+ }
3116
+ if let perReq = UsageFormatter.usdPerRequestString(costUSD: cost, requestCount: requests) {
3117
+ parts.append("~\(perReq)")
3118
+ }
3119
+ }
3120
+ if let model, let context = UsageFormatter.modelContextLabel(for: model) {
3121
+ parts.append(context)
3122
+ }
3123
+ return parts.joined(separator: " · ")
3124
+ }
3125
+
3126
+ private func topProjectSummaryValue(_ summary: UsageLedgerProjectSummary) -> String {
3127
+ var parts: [String] = []
3128
+ parts.append(UsageFormatter.tokenSummaryString(summary.totals))
3129
+ parts.append("\(summary.entryCount) req")
3130
+ if let cost = summary.totals.costUSD {
3131
+ parts.append(UsageFormatter.usdString(cost))
3132
+ if let perReq = UsageFormatter.usdPerRequestString(costUSD: cost, requestCount: summary.entryCount) {
3133
+ parts.append("~\(perReq)")
3134
+ }
3135
+ }
3136
+ return parts.joined(separator: " · ")
3137
+ }
3138
+
3139
+ private func modelLineValue(title: String, totals: UsageLedgerTotals, requests: Int) -> String {
3140
+ var parts: [String] = [title]
3141
+ parts.append(UsageFormatter.tokenSummaryString(totals))
3142
+ parts.append("\(requests) req")
3143
+ if let cost = totals.costUSD {
3144
+ parts.append(UsageFormatter.usdString(cost))
3145
+ if let per1K = UsageFormatter.usdPer1KTokensString(costUSD: cost, tokenCount: totals.totalTokens) {
3146
+ parts.append("~\(per1K)")
3147
+ }
3148
+ if let perReq = UsageFormatter.usdPerRequestString(costUSD: cost, requestCount: requests) {
3149
+ parts.append("~\(perReq)")
3150
+ }
3151
+ }
3152
+ return parts.joined(separator: " · ")
3153
+ }
3154
+
3155
+ private var windowModelLines: [String] {
3156
+ self.labeledQuotaWindows(from: self.store.snapshot(for: self.provider)).prefix(3).map { item in
3157
+ let modelName = UsageFormatter.modelDisplayName(item.label)
3158
+ let used = Int(item.window.usedPercent.rounded())
3159
+ let remaining = Int(item.window.remainingPercent.rounded())
3160
+ var parts = [modelName, "\(used)% used", "\(remaining)% left"]
3161
+ if let resetsAt = item.window.resetsAt {
3162
+ parts.append("reset \(UsageFormatter.resetCountdownDescription(from: resetsAt))")
3163
+ } else if let resetDescription = item.window.resetDescription?
3164
+ .trimmingCharacters(in: .whitespacesAndNewlines),
3165
+ !resetDescription.isEmpty
3166
+ {
3167
+ parts.append(resetDescription)
3168
+ }
3169
+ return parts.joined(separator: " · ")
3170
+ }
3171
+ }
3172
+
3173
+ private var topWindowModel: (label: String, window: RateWindow)? {
3174
+ self.labeledQuotaWindows(from: self.store.snapshot(for: self.provider)).first
3175
+ }
3176
+
3177
+ private func labeledQuotaWindows(from snapshot: UsageSnapshot?) -> [(label: String, window: RateWindow)] {
3178
+ guard let snapshot else { return [] }
3179
+ let windows = [snapshot.primary, snapshot.secondary, snapshot.tertiary].compactMap { $0 }
3180
+ var seen: Set<String> = []
3181
+ var labeled: [(label: String, window: RateWindow)] = []
3182
+
3183
+ for window in windows {
3184
+ guard let label = window.label?.trimmingCharacters(in: .whitespacesAndNewlines),
3185
+ !label.isEmpty
3186
+ else {
3187
+ continue
3188
+ }
3189
+ let normalized = label.lowercased()
3190
+ guard seen.insert(normalized).inserted else { continue }
3191
+ labeled.append((label, window))
3192
+ }
3193
+
3194
+ return labeled.sorted { lhs, rhs in
3195
+ if lhs.window.usedPercent == rhs.window.usedPercent {
3196
+ return lhs.label.localizedCaseInsensitiveCompare(rhs.label) == .orderedAscending
3197
+ }
3198
+ return lhs.window.usedPercent > rhs.window.usedPercent
3199
+ }
3200
+ }
3201
+
3202
+ private func decimalString(_ value: Int) -> String {
3203
+ let formatter = NumberFormatter()
3204
+ formatter.numberStyle = .decimal
3205
+ formatter.usesGroupingSeparator = true
3206
+ formatter.locale = Locale(identifier: "en_US_POSIX")
3207
+ return formatter.string(from: NSNumber(value: value)) ?? "\(value)"
3208
+ }
3209
+
3210
+ private struct RuntimeMetricsData {
3211
+ let lineText: String
3212
+ let hoverText: String?
3213
+ }
3214
+
3215
+ private var runtimeMetrics: RuntimeMetricsData? {
3216
+ let attempts = self.store.fetchAttempts(for: self.provider)
3217
+ guard !attempts.isEmpty || self.store.snapshot(for: self.provider) != nil else {
3218
+ return nil
3219
+ }
3220
+
3221
+ var parts: [String] = []
3222
+ if let updatedAt = self.store.snapshot(for: self.provider)?.updatedAt {
3223
+ parts.append("success \(updatedAt.relativeDescription())")
3224
+ } else {
3225
+ parts.append("no success yet")
3226
+ }
3227
+
3228
+ var hoverText: String?
3229
+ if !attempts.isEmpty {
3230
+ let retryCount = self.retryCount(from: attempts)
3231
+ if retryCount > 0 {
3232
+ parts.append("retry \(retryCount)")
3233
+ }
3234
+ if let activeAttempt = self.activeAttempt(from: attempts) {
3235
+ parts.append(Self.fetchKindLabel(activeAttempt.kind))
3236
+ if let strategyID = self.trimmed(activeAttempt.strategyID) {
3237
+ hoverText = "Strategy: \(strategyID)"
3238
+ }
3239
+ }
3240
+ }
3241
+
3242
+ return RuntimeMetricsData(
3243
+ lineText: parts.joined(separator: " · "),
3244
+ hoverText: hoverText)
3245
+ }
3246
+
3247
+ private func retryCount(from attempts: [ProviderFetchAttempt]) -> Int {
3248
+ guard !attempts.isEmpty else { return 0 }
3249
+ if let successIndex = attempts.firstIndex(where: { $0.wasAvailable && self.trimmed($0.errorDescription) == nil }) {
3250
+ return max(0, successIndex)
3251
+ }
3252
+ return max(0, attempts.count - 1)
3253
+ }
3254
+
3255
+ private func activeAttempt(from attempts: [ProviderFetchAttempt]) -> ProviderFetchAttempt? {
3256
+ guard !attempts.isEmpty else { return nil }
3257
+ return attempts.first(where: { $0.wasAvailable && self.trimmed($0.errorDescription) == nil }) ??
3258
+ attempts.last(where: { $0.wasAvailable }) ??
3259
+ attempts.last
3260
+ }
3261
+
3262
+ private func trimmed(_ value: String?) -> String? {
3263
+ guard let value else { return nil }
3264
+ let cleaned = value.trimmingCharacters(in: .whitespacesAndNewlines)
3265
+ return cleaned.isEmpty ? nil : cleaned
3266
+ }
3267
+
3268
+ private static func fetchKindLabel(_ kind: ProviderFetchKind) -> String {
3269
+ switch kind {
3270
+ case .cli: return "cli"
3271
+ case .web: return "web"
3272
+ case .oauth: return "oauth"
3273
+ case .apiToken: return "api"
3274
+ case .api: return "api"
3275
+ case .localProbe: return "local"
3276
+ case .webDashboard: return "web"
3277
+ }
3278
+ }
3279
+
3280
+ private func copyDiagnostics() {
3281
+ let pb = NSPasteboard.general
3282
+ pb.clearContents()
3283
+ pb.setString(self.diagnosticsReport, forType: .string)
3284
+ self.diagnosticsCopyStatus = "Copied"
3285
+ Task { @MainActor in
3286
+ try? await Task.sleep(nanoseconds: 1_500_000_000)
3287
+ self.diagnosticsCopyStatus = nil
3288
+ }
3289
+ }
3290
+
3291
+ private var diagnosticsReport: String {
3292
+ let metadata = self.store.metadata(for: self.provider)
3293
+ let attempts = self.store.fetchAttempts(for: self.provider)
3294
+ let snapshot = self.store.snapshot(for: self.provider)
3295
+ let forecast = self.store.ledgerSpendForecast(for: self.provider)
3296
+ let topProjectForecast = self.store.ledgerTopProjectSpendForecast(for: self.provider)
3297
+ let reliability = self.store.ledgerReliabilityScore(for: self.provider)
3298
+ let anomaly = self.store.ledgerAnomalySummary(for: self.provider)
3299
+ let iso = ISO8601DateFormatter()
3300
+
3301
+ var lines: [String] = []
3302
+ lines.append("# \(metadata.displayName) Diagnostics")
3303
+ lines.append("provider: \(self.provider.rawValue)")
3304
+ lines.append("generated_at: \(iso.string(from: Date()))")
3305
+ lines.append("enabled: \(self.isEnabled ? "true" : "false")")
3306
+ lines.append("source: \(self.sourceLabel)")
3307
+ lines.append("updated: \(self.statusLabel)")
3308
+ if let runtime = self.runtimeMetrics?.lineText {
3309
+ lines.append("runtime: \(runtime)")
3310
+ }
3311
+
3312
+ if let snapshot {
3313
+ lines.append("")
3314
+ lines.append("usage_snapshot:")
3315
+ lines.append("- updated_at: \(iso.string(from: snapshot.updatedAt))")
3316
+ lines.append("- primary_used_percent: \(Int(snapshot.primary.usedPercent.rounded()))")
3317
+ if let minutes = snapshot.primary.windowMinutes, minutes > 0 {
3318
+ lines.append("- primary_window_minutes: \(minutes)")
3319
+ }
3320
+ if let reset = snapshot.primary.resetsAt {
3321
+ lines.append("- primary_resets_at: \(iso.string(from: reset))")
3322
+ }
3323
+ if let cost = snapshot.providerCost {
3324
+ lines.append("- provider_spend_used: \(UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode))")
3325
+ if cost.limit > 0 {
3326
+ lines.append("- provider_spend_limit: \(UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode))")
3327
+ }
3328
+ if let period = self.trimmed(cost.period) {
3329
+ lines.append("- provider_spend_period: \(period)")
3330
+ }
3331
+ }
3332
+ }
3333
+
3334
+ lines.append("")
3335
+ lines.append("fetch_path:")
3336
+ if attempts.isEmpty {
3337
+ lines.append("- none")
3338
+ } else {
3339
+ for attempt in attempts {
3340
+ let state: String
3341
+ if !attempt.wasAvailable {
3342
+ state = "unavailable"
3343
+ } else if let error = self.trimmed(attempt.errorDescription) {
3344
+ state = "failed: \(error)"
3345
+ } else {
3346
+ state = "ok"
3347
+ }
3348
+ lines.append("- \(attempt.strategyID) [\(Self.fetchKindLabel(attempt.kind))] \(state)")
3349
+ }
3350
+ }
3351
+
3352
+ if let forecast {
3353
+ lines.append("")
3354
+ lines.append("provider_forecast:")
3355
+ lines.append("- projected_30d: \(UsageFormatter.usdString(forecast.projected30DayCostUSD))")
3356
+ lines.append("- average_daily: \(UsageFormatter.usdString(forecast.averageDailyCostUSD))")
3357
+ if let p50 = forecast.projectedCostP50USD {
3358
+ lines.append("- p50: \(UsageFormatter.usdString(p50))")
3359
+ }
3360
+ if let p80 = forecast.projectedCostP80USD {
3361
+ lines.append("- p80: \(UsageFormatter.usdString(p80))")
3362
+ }
3363
+ if let p95 = forecast.projectedCostP95USD {
3364
+ lines.append("- p95: \(UsageFormatter.usdString(p95))")
3365
+ }
3366
+ if let limit = forecast.budgetLimitUSD, limit > 0 {
3367
+ lines.append("- budget_limit: \(UsageFormatter.usdString(limit))")
3368
+ lines.append("- budget_status: \(self.budgetStatusText(forecast))")
3369
+ }
3370
+ }
3371
+
3372
+ if let topProjectForecast {
3373
+ lines.append("")
3374
+ lines.append("top_project_forecast:")
3375
+ if let name = self.trimmed(topProjectForecast.projectName) {
3376
+ lines.append("- project: \(name)")
3377
+ }
3378
+ lines.append("- projected_30d: \(UsageFormatter.usdString(topProjectForecast.projected30DayCostUSD))")
3379
+ if let limit = topProjectForecast.budgetLimitUSD, limit > 0 {
3380
+ lines.append("- budget_limit: \(UsageFormatter.usdString(limit))")
3381
+ lines.append("- budget_status: \(self.budgetStatusText(topProjectForecast))")
3382
+ }
3383
+ }
3384
+
3385
+ if let reliability {
3386
+ lines.append("")
3387
+ lines.append("reliability:")
3388
+ lines.append("- score: \(reliability.score)/100")
3389
+ lines.append("- grade: \(reliability.grade)")
3390
+ lines.append("- summary: \(reliability.summary)")
3391
+ if let signal = self.trimmed(reliability.primarySignal) {
3392
+ lines.append("- signal: \(signal)")
3393
+ }
3394
+ }
3395
+
3396
+ if let anomaly {
3397
+ lines.append("")
3398
+ lines.append("anomaly:")
3399
+ if let spend = anomaly.spendAnomaly {
3400
+ lines.append("- spend: \(spend.severity.label) +\(Int((spend.percentIncrease * 100).rounded()))%")
3401
+ }
3402
+ if let token = anomaly.tokenAnomaly {
3403
+ lines.append("- tokens: \(token.severity.label) +\(Int((token.percentIncrease * 100).rounded()))%")
3404
+ }
3405
+ if let explanation = anomaly.explanation {
3406
+ lines.append("- headline: \(explanation.headline)")
3407
+ for detail in explanation.details {
3408
+ lines.append("- detail: \(detail)")
3409
+ }
3410
+ }
3411
+ }
3412
+
3413
+ return lines.joined(separator: "\n")
3414
+ }
3415
+
3416
+ private func budgetStatusText(_ forecast: UsageLedgerSpendForecast) -> String {
3417
+ if let eta = forecast.budgetETAInDays {
3418
+ return self.budgetBreachETAText(days: eta)
3419
+ }
3420
+ if forecast.budgetWillBreach {
3421
+ return "Breach risk"
3422
+ }
3423
+ return "On track"
3424
+ }
3425
+
3426
+ private func budgetBreachETAText(days: Double) -> String {
3427
+ guard days.isFinite else { return "Breach ETA unavailable" }
3428
+ if days <= 0 { return "Breach now" }
3429
+ let now = Date()
3430
+ let etaDate = now.addingTimeInterval(days * 24 * 60 * 60)
3431
+ let countdown = UsageFormatter.resetCountdownDescription(from: etaDate, now: now)
3432
+ if countdown == "now" { return "Breach now" }
3433
+ return "Breach \(countdown)"
3434
+ }
3435
+ }