@sriinnu/runic 1.2.0 → 2.11.1

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 (586) hide show
  1. package/CHANGELOG.md +267 -0
  2. package/LICENSE +373 -21
  3. package/README.md +98 -84
  4. package/THIRD-PARTY-NOTICES.md +651 -0
  5. package/package.json +14 -4
  6. package/.swiftformat +0 -50
  7. package/.swiftlint.yml +0 -160
  8. package/CONTRIBUTING.md +0 -456
  9. package/Icon.icns +0 -0
  10. package/Icon.icon/Assets/runic.png +0 -0
  11. package/Icon.icon/icon.json +0 -36
  12. package/Package.resolved +0 -51
  13. package/Package.swift +0 -136
  14. package/Packages/Helix/LICENSE +0 -21
  15. package/Packages/Helix/Package.swift +0 -29
  16. package/Packages/Helix/README.md +0 -976
  17. package/Packages/Helix/Sources/Helix/Binding.swift +0 -111
  18. package/Packages/Helix/Sources/Helix/CommandParser.swift +0 -160
  19. package/Packages/Helix/Sources/Helix/CommandSignature.swift +0 -117
  20. package/Packages/Helix/Sources/Helix/ExpressibleFromArgument.swift +0 -72
  21. package/Packages/Helix/Sources/Helix/HelpGenerator.swift +0 -175
  22. package/Packages/Helix/Sources/Helix/NameSpecification.swift +0 -98
  23. package/Packages/Helix/Sources/Helix/ParsableCommand.swift +0 -204
  24. package/Packages/Helix/Sources/Helix/Platform/DarwinPlatformContext.swift +0 -44
  25. package/Packages/Helix/Sources/Helix/Platform/LinuxPlatformContext.swift +0 -34
  26. package/Packages/Helix/Sources/Helix/Platform/PlatformContext+Default.swift +0 -91
  27. package/Packages/Helix/Sources/Helix/Platform/PlatformContext.swift +0 -94
  28. package/Packages/Helix/Sources/Helix/Platform/PlatformPath.swift +0 -167
  29. package/Packages/Helix/Sources/Helix/Platform/Stdio.swift +0 -99
  30. package/Packages/Helix/Sources/Helix/Platform/WebPlatformContext.swift +0 -146
  31. package/Packages/Helix/Sources/Helix/Platform/WindowsPlatformContext.swift +0 -97
  32. package/Packages/Helix/Sources/Helix/Program.swift +0 -204
  33. package/Packages/Helix/Sources/Helix/PropertyWrappers.swift +0 -367
  34. package/Packages/Silo/LICENSE +0 -21
  35. package/Packages/Silo/Package.swift +0 -34
  36. package/Packages/Silo/README.md +0 -466
  37. package/Packages/Silo/Sources/Silo/BinaryCookiesReader.swift +0 -169
  38. package/Packages/Silo/Sources/Silo/BrowserCookieAnalytics.swift +0 -365
  39. package/Packages/Silo/Sources/Silo/BrowserCookieBackends.swift +0 -594
  40. package/Packages/Silo/Sources/Silo/BrowserCookieClient.swift +0 -326
  41. package/Packages/Silo/Sources/Silo/BrowserCookieExport.swift +0 -154
  42. package/Packages/Silo/Sources/Silo/BrowserCookieImport.swift +0 -119
  43. package/Packages/Silo/Sources/Silo/BrowserCookieMockStore.swift +0 -121
  44. package/Packages/Silo/Sources/Silo/BrowserCookieModels.swift +0 -391
  45. package/Packages/Silo/Sources/Silo/ChromiumCookieReader.swift +0 -240
  46. package/Packages/Silo/Sources/Silo/CryptoSupport.swift +0 -86
  47. package/Packages/Silo/Sources/Silo/FirefoxCookieReader.swift +0 -172
  48. package/Packages/Silo/Sources/Silo/LinuxChromiumCookieReader.swift +0 -172
  49. package/Packages/Silo/Sources/Silo/LinuxSecretService.swift +0 -105
  50. package/Packages/Silo/Sources/Silo/MacOSChromiumCookieReader.swift +0 -191
  51. package/Packages/Silo/Sources/Silo/SQLiteSnapshot.swift +0 -41
  52. package/Packages/Silo/Sources/Silo/WindowsChromiumCookieReader.swift +0 -110
  53. package/RuniciOS/App/RuniciOSApp.swift +0 -105
  54. package/RuniciOS/Core/iOSUsageStore.swift +0 -192
  55. package/RuniciOS/Views/ProviderDetailView.swift +0 -312
  56. package/RuniciOS/Views/ProviderListView.swift +0 -228
  57. package/RuniciOS/Widgets/LargeWidget.swift +0 -367
  58. package/RuniciOS/Widgets/LiveActivity.swift +0 -342
  59. package/RuniciOS/Widgets/LockScreenWidget.swift +0 -273
  60. package/RuniciOS/Widgets/MediumWidget.swift +0 -370
  61. package/RuniciOS/Widgets/RunicWidget.swift +0 -274
  62. package/RuniciOS/Widgets/SmallWidget.swift +0 -281
  63. package/RuniciOS/Widgets/WidgetModels.swift +0 -285
  64. package/RuniciOS/Widgets/WidgetTimeline.swift +0 -270
  65. package/Scripts/CREATION-SUMMARY.md +0 -367
  66. package/Scripts/DIRECTORY-STRUCTURE.md +0 -358
  67. package/Scripts/GETTING-STARTED-CHECKLIST.md +0 -364
  68. package/Scripts/INDEX.md +0 -365
  69. package/Scripts/QUICK-REFERENCE.md +0 -164
  70. package/Scripts/README-SCRIPTS.md +0 -926
  71. package/Scripts/build-android.sh +0 -526
  72. package/Scripts/build-ios.sh +0 -529
  73. package/Scripts/build-macos.sh +0 -603
  74. package/Scripts/build-windows.sh +0 -504
  75. package/Scripts/build_icon.sh +0 -49
  76. package/Scripts/changelog-to-html.sh +0 -89
  77. package/Scripts/check-release-assets.sh +0 -10
  78. package/Scripts/ci-config.yml +0 -474
  79. package/Scripts/compile_and_run.sh +0 -219
  80. package/Scripts/compile_and_run_adhoc.sh +0 -4
  81. package/Scripts/docs-list.mjs +0 -119
  82. package/Scripts/make_appcast.sh +0 -58
  83. package/Scripts/package_app.sh +0 -409
  84. package/Scripts/release-lib.sh +0 -214
  85. package/Scripts/release.sh +0 -76
  86. package/Scripts/setup-all.sh +0 -387
  87. package/Scripts/setup-ios.sh +0 -404
  88. package/Scripts/setup-react-native.sh +0 -523
  89. package/Scripts/sign-and-notarize.sh +0 -122
  90. package/Scripts/test_live_update.sh +0 -38
  91. package/Scripts/validate_changelog.sh +0 -23
  92. package/Scripts/verify_appcast.sh +0 -92
  93. package/Sources/Runic/About.swift +0 -93
  94. package/Sources/Runic/AppNotifications.swift +0 -115
  95. package/Sources/Runic/Controllers/StatusItemController+Actions.swift +0 -666
  96. package/Sources/Runic/Controllers/StatusItemController+Animation.swift +0 -518
  97. package/Sources/Runic/Controllers/StatusItemController+ChartSubmenus.swift +0 -810
  98. package/Sources/Runic/Controllers/StatusItemController+CustomProviders.swift +0 -87
  99. package/Sources/Runic/Controllers/StatusItemController+Menu.swift +0 -1407
  100. package/Sources/Runic/Controllers/StatusItemController+MenuCards.swift +0 -337
  101. package/Sources/Runic/Controllers/StatusItemController+MenuHelpers.swift +0 -395
  102. package/Sources/Runic/Controllers/StatusItemController.swift +0 -360
  103. package/Sources/Runic/Core/BudgetNotificationManager.swift +0 -80
  104. package/Sources/Runic/Core/Login/ClaudeLoginRunner.swift +0 -103
  105. package/Sources/Runic/Core/Login/CodexLoginRunner.swift +0 -154
  106. package/Sources/Runic/Core/Login/CursorLoginRunner.swift +0 -206
  107. package/Sources/Runic/Core/Login/GeminiLoginRunner.swift +0 -92
  108. package/Sources/Runic/Core/Rendering/IconRenderer.swift +0 -1216
  109. package/Sources/Runic/Core/Rendering/IconView.swift +0 -120
  110. package/Sources/Runic/Core/Rendering/ProviderBrandIcon.swift +0 -71
  111. package/Sources/Runic/Core/RunicAnimation.swift +0 -53
  112. package/Sources/Runic/Core/RunicColors.swift +0 -73
  113. package/Sources/Runic/Core/RunicCornerRadius.swift +0 -15
  114. package/Sources/Runic/Core/RunicSpacing.swift +0 -68
  115. package/Sources/Runic/Core/Stores/AuggieTokenStore.swift +0 -119
  116. package/Sources/Runic/Core/Stores/AzureOpenAITokenStore.swift +0 -119
  117. package/Sources/Runic/Core/Stores/CerebrasTokenStore.swift +0 -119
  118. package/Sources/Runic/Core/Stores/CohereTokenStore.swift +0 -119
  119. package/Sources/Runic/Core/Stores/CopilotTokenStore.swift +0 -119
  120. package/Sources/Runic/Core/Stores/CustomProviderStore.swift +0 -158
  121. package/Sources/Runic/Core/Stores/DeepSeekTokenStore.swift +0 -119
  122. package/Sources/Runic/Core/Stores/FireworksTokenStore.swift +0 -119
  123. package/Sources/Runic/Core/Stores/GroqTokenStore.swift +0 -119
  124. package/Sources/Runic/Core/Stores/KimiTokenStore.swift +0 -119
  125. package/Sources/Runic/Core/Stores/MiniMaxCookieHeaderStore.swift +0 -119
  126. package/Sources/Runic/Core/Stores/MiniMaxGroupIDStore.swift +0 -119
  127. package/Sources/Runic/Core/Stores/MiniMaxTokenStore.swift +0 -119
  128. package/Sources/Runic/Core/Stores/MistralTokenStore.swift +0 -119
  129. package/Sources/Runic/Core/Stores/OpenRouterTokenStore.swift +0 -119
  130. package/Sources/Runic/Core/Stores/PerplexityTokenStore.swift +0 -119
  131. package/Sources/Runic/Core/Stores/ProviderToggleStore.swift +0 -30
  132. package/Sources/Runic/Core/Stores/QwenTokenStore.swift +0 -119
  133. package/Sources/Runic/Core/Stores/SambaNovaTokenStore.swift +0 -119
  134. package/Sources/Runic/Core/Stores/SettingsStore.swift +0 -1711
  135. package/Sources/Runic/Core/Stores/TogetherTokenStore.swift +0 -119
  136. package/Sources/Runic/Core/Stores/UsageStore+Accessors.swift +0 -20
  137. package/Sources/Runic/Core/Stores/UsageStore+Status.swift +0 -189
  138. package/Sources/Runic/Core/Stores/UsageStore+TokenCost.swift +0 -33
  139. package/Sources/Runic/Core/Stores/UsageStore+WidgetSnapshot.swift +0 -67
  140. package/Sources/Runic/Core/Stores/UsageStore.swift +0 -3605
  141. package/Sources/Runic/Core/Stores/XAITokenStore.swift +0 -119
  142. package/Sources/Runic/Core/Stores/ZaiTokenStore.swift +0 -118
  143. package/Sources/Runic/Core/UsageExporter.swift +0 -193
  144. package/Sources/Runic/Date+RelativeDescription.swift +0 -21
  145. package/Sources/Runic/HiddenWindowView.swift +0 -32
  146. package/Sources/Runic/InstallOrigin.swift +0 -9
  147. package/Sources/Runic/KeyboardShortcuts+Names.swift +0 -6
  148. package/Sources/Runic/MouseLocationReader.swift +0 -65
  149. package/Sources/Runic/Notifications+Runic.swift +0 -6
  150. package/Sources/Runic/OpenAICreditsPurchaseWindowController.swift +0 -503
  151. package/Sources/Runic/ProviderRegistry.swift +0 -100
  152. package/Sources/Runic/ProviderSwitcherButtons.swift +0 -267
  153. package/Sources/Runic/Providers/Antigravity/AntigravityLoginFlow.swift +0 -11
  154. package/Sources/Runic/Providers/Antigravity/AntigravityProviderImplementation.swift +0 -14
  155. package/Sources/Runic/Providers/Auggie/AuggieProviderImplementation.swift +0 -24
  156. package/Sources/Runic/Providers/Azure/AzureProviderImplementation.swift +0 -52
  157. package/Sources/Runic/Providers/Bedrock/BedrockProviderImplementation.swift +0 -42
  158. package/Sources/Runic/Providers/Cerebras/CerebrasProviderImplementation.swift +0 -24
  159. package/Sources/Runic/Providers/Claude/ClaudeLoginFlow.swift +0 -26
  160. package/Sources/Runic/Providers/Claude/ClaudeProviderImplementation.swift +0 -14
  161. package/Sources/Runic/Providers/Codex/CodexLoginFlow.swift +0 -18
  162. package/Sources/Runic/Providers/Codex/CodexProviderImplementation.swift +0 -15
  163. package/Sources/Runic/Providers/Cohere/CohereProviderImplementation.swift +0 -24
  164. package/Sources/Runic/Providers/Copilot/CopilotLoginFlow.swift +0 -193
  165. package/Sources/Runic/Providers/Copilot/CopilotProviderImplementation.swift +0 -110
  166. package/Sources/Runic/Providers/Cursor/CursorLoginFlow.swift +0 -28
  167. package/Sources/Runic/Providers/Cursor/CursorProviderImplementation.swift +0 -15
  168. package/Sources/Runic/Providers/DeepSeek/DeepSeekProviderImplementation.swift +0 -24
  169. package/Sources/Runic/Providers/Factory/FactoryLoginFlow.swift +0 -12
  170. package/Sources/Runic/Providers/Factory/FactoryProviderImplementation.swift +0 -15
  171. package/Sources/Runic/Providers/Fireworks/FireworksProviderImplementation.swift +0 -24
  172. package/Sources/Runic/Providers/Gemini/GeminiLoginFlow.swift +0 -20
  173. package/Sources/Runic/Providers/Gemini/GeminiProviderImplementation.swift +0 -15
  174. package/Sources/Runic/Providers/Groq/GroqProviderImplementation.swift +0 -24
  175. package/Sources/Runic/Providers/Kimi/KimiProviderImplementation.swift +0 -24
  176. package/Sources/Runic/Providers/MiniMax/MiniMaxProviderImplementation.swift +0 -32
  177. package/Sources/Runic/Providers/Mistral/MistralProviderImplementation.swift +0 -24
  178. package/Sources/Runic/Providers/OpenRouter/OpenRouterProviderImplementation.swift +0 -24
  179. package/Sources/Runic/Providers/Perplexity/PerplexityProviderImplementation.swift +0 -24
  180. package/Sources/Runic/Providers/SambaNova/SambaNovaProviderImplementation.swift +0 -24
  181. package/Sources/Runic/Providers/Shared/ProviderCatalog.swift +0 -15
  182. package/Sources/Runic/Providers/Shared/ProviderImplementation.swift +0 -48
  183. package/Sources/Runic/Providers/Shared/ProviderImplementationRegistry.swift +0 -66
  184. package/Sources/Runic/Providers/Shared/ProviderLoginFlow.swift +0 -12
  185. package/Sources/Runic/Providers/Shared/ProviderSettingsDescriptors.swift +0 -99
  186. package/Sources/Runic/Providers/Shared/SystemSettingsLinks.swift +0 -18
  187. package/Sources/Runic/Providers/Together/TogetherProviderImplementation.swift +0 -24
  188. package/Sources/Runic/Providers/XAI/XAIProviderImplementation.swift +0 -24
  189. package/Sources/Runic/Providers/Zai/ZaiProviderImplementation.swift +0 -24
  190. package/Sources/Runic/Resources/ProviderIcon-antigravity.svg +0 -2
  191. package/Sources/Runic/Resources/ProviderIcon-auggie.svg +0 -3
  192. package/Sources/Runic/Resources/ProviderIcon-azure.svg +0 -5
  193. package/Sources/Runic/Resources/ProviderIcon-bedrock.svg +0 -7
  194. package/Sources/Runic/Resources/ProviderIcon-cerebras.svg +0 -3
  195. package/Sources/Runic/Resources/ProviderIcon-claude.svg +0 -1
  196. package/Sources/Runic/Resources/ProviderIcon-codex.svg +0 -1
  197. package/Sources/Runic/Resources/ProviderIcon-cohere.svg +0 -3
  198. package/Sources/Runic/Resources/ProviderIcon-copilot.svg +0 -1
  199. package/Sources/Runic/Resources/ProviderIcon-cursor.svg +0 -3
  200. package/Sources/Runic/Resources/ProviderIcon-deepseek.svg +0 -4
  201. package/Sources/Runic/Resources/ProviderIcon-factory.svg +0 -3
  202. package/Sources/Runic/Resources/ProviderIcon-fireworks.svg +0 -3
  203. package/Sources/Runic/Resources/ProviderIcon-gemini.svg +0 -1
  204. package/Sources/Runic/Resources/ProviderIcon-groq.svg +0 -1
  205. package/Sources/Runic/Resources/ProviderIcon-kimi.svg +0 -4
  206. package/Sources/Runic/Resources/ProviderIcon-minimax.svg +0 -1
  207. package/Sources/Runic/Resources/ProviderIcon-mistral.svg +0 -5
  208. package/Sources/Runic/Resources/ProviderIcon-openrouter.svg +0 -7
  209. package/Sources/Runic/Resources/ProviderIcon-perplexity.svg +0 -4
  210. package/Sources/Runic/Resources/ProviderIcon-qwen.svg +0 -9
  211. package/Sources/Runic/Resources/ProviderIcon-sambanova.svg +0 -4
  212. package/Sources/Runic/Resources/ProviderIcon-together.svg +0 -5
  213. package/Sources/Runic/Resources/ProviderIcon-vertexai.svg +0 -17
  214. package/Sources/Runic/Resources/ProviderIcon-xai.svg +0 -3
  215. package/Sources/Runic/Resources/ProviderIcon-zai.svg +0 -5
  216. package/Sources/Runic/Resources/RunicMenubarIcon.svg +0 -7
  217. package/Sources/Runic/Resources/provider-context-windows.json +0 -20
  218. package/Sources/Runic/RunicApp.swift +0 -357
  219. package/Sources/Runic/SessionQuotaNotifications.swift +0 -58
  220. package/Sources/Runic/UsagePaceText.swift +0 -52
  221. package/Sources/Runic/UsageProgressBar.swift +0 -224
  222. package/Sources/Runic/Utilities/DisplayLink.swift +0 -96
  223. package/Sources/Runic/Utilities/LoadingPattern.swift +0 -60
  224. package/Sources/Runic/Utilities/PerformanceConstants.swift +0 -85
  225. package/Sources/Runic/Views/Menu/AlertsMenuView.swift +0 -256
  226. package/Sources/Runic/Views/Menu/ChartPanelStyle.swift +0 -22
  227. package/Sources/Runic/Views/Menu/CostComparativeChartMenuView.swift +0 -275
  228. package/Sources/Runic/Views/Menu/CostHistoryChartMenuView.swift +0 -377
  229. package/Sources/Runic/Views/Menu/CreditsHistoryChartMenuView.swift +0 -312
  230. package/Sources/Runic/Views/Menu/EfficiencyMetricsMenuView.swift +0 -312
  231. package/Sources/Runic/Views/Menu/HeroStatCardsMenuView.swift +0 -228
  232. package/Sources/Runic/Views/Menu/HourlyActivityChartMenuView.swift +0 -228
  233. package/Sources/Runic/Views/Menu/InlineUsageChartView.swift +0 -155
  234. package/Sources/Runic/Views/Menu/MenuCardEntranceModifier.swift +0 -64
  235. package/Sources/Runic/Views/Menu/MenuCardSidebarView.swift +0 -268
  236. package/Sources/Runic/Views/Menu/MenuCardSkeletonView.swift +0 -104
  237. package/Sources/Runic/Views/Menu/MenuCardView.swift +0 -1900
  238. package/Sources/Runic/Views/Menu/MenuContent.swift +0 -150
  239. package/Sources/Runic/Views/Menu/MenuDescriptor.swift +0 -369
  240. package/Sources/Runic/Views/Menu/MenuHighlightStyle.swift +0 -42
  241. package/Sources/Runic/Views/Menu/ModelBreakdownMenuView.swift +0 -235
  242. package/Sources/Runic/Views/Menu/OverviewMenuView.swift +0 -278
  243. package/Sources/Runic/Views/Menu/ProjectBreakdownMenuView.swift +0 -209
  244. package/Sources/Runic/Views/Menu/ProjectBudgetMenuView.swift +0 -220
  245. package/Sources/Runic/Views/Menu/ProviderTabBarView.swift +0 -88
  246. package/Sources/Runic/Views/Menu/SubscriptionUtilizationChartMenuView.swift +0 -201
  247. package/Sources/Runic/Views/Menu/UsageBreakdownChartMenuView.swift +0 -398
  248. package/Sources/Runic/Views/Menu/UsageHeatmapMenuView.swift +0 -232
  249. package/Sources/Runic/Views/Menu/UsageTimelineChartMenuView.swift +0 -364
  250. package/Sources/Runic/Views/Menu/UsageWindowComparisonChartMenuView.swift +0 -190
  251. package/Sources/Runic/Views/Menu/WeeklyActivityChartMenuView.swift +0 -132
  252. package/Sources/Runic/Views/Performance/PerformanceDashboardView.swift +0 -618
  253. package/Sources/Runic/Views/Preferences/LiquidComponents.swift +0 -305
  254. package/Sources/Runic/Views/Preferences/PreferencesAboutPane.swift +0 -271
  255. package/Sources/Runic/Views/Preferences/PreferencesAdvancedPane.swift +0 -268
  256. package/Sources/Runic/Views/Preferences/PreferencesAlertsPane.swift +0 -422
  257. package/Sources/Runic/Views/Preferences/PreferencesAnalyticsPane.swift +0 -641
  258. package/Sources/Runic/Views/Preferences/PreferencesBudgetsPane.swift +0 -344
  259. package/Sources/Runic/Views/Preferences/PreferencesComponents.swift +0 -230
  260. package/Sources/Runic/Views/Preferences/PreferencesCustomProvidersPane.swift +0 -322
  261. package/Sources/Runic/Views/Preferences/PreferencesDebugPane.swift +0 -469
  262. package/Sources/Runic/Views/Preferences/PreferencesGeneralPane.swift +0 -231
  263. package/Sources/Runic/Views/Preferences/PreferencesHelpPane.swift +0 -50
  264. package/Sources/Runic/Views/Preferences/PreferencesIntegrationsPane.swift +0 -567
  265. package/Sources/Runic/Views/Preferences/PreferencesPerformancePane.swift +0 -288
  266. package/Sources/Runic/Views/Preferences/PreferencesProvidersPane.swift +0 -3435
  267. package/Sources/Runic/Views/Preferences/PreferencesSelection.swift +0 -25
  268. package/Sources/Runic/Views/Preferences/PreferencesSyncPane.swift +0 -59
  269. package/Sources/Runic/Views/Preferences/PreferencesView.swift +0 -206
  270. package/Sources/Runic/Views/Rating/QualityRatingView.swift +0 -238
  271. package/Sources/Runic/Views/Team/TeamDashboardView.swift +0 -371
  272. package/Sources/Runic/Views/Team/TeamInviteSheet.swift +0 -175
  273. package/Sources/Runic/Views/Team/TeamManagementView.swift +0 -761
  274. package/Sources/Runic/Views/Team/TeamMemberRow.swift +0 -181
  275. package/Sources/RunicCLI/CLIEntry.swift +0 -942
  276. package/Sources/RunicCLI/CLIRenderer.swift +0 -87
  277. package/Sources/RunicCLI/Commands/EnhancedUsageCommand.swift +0 -588
  278. package/Sources/RunicCLI/Commands/ModelsCommand.swift +0 -362
  279. package/Sources/RunicCLI/Commands/ProjectsCommand.swift +0 -439
  280. package/Sources/RunicCLI/Commands/ResetCommand.swift +0 -429
  281. package/Sources/RunicCLI/Commands/UsageLedgerSourceFactory.swift +0 -168
  282. package/Sources/RunicClaudeWatchdog/main.swift +0 -122
  283. package/Sources/RunicClaudeWebProbe/main.swift +0 -62
  284. package/Sources/RunicCore/AlertRuleStore.swift +0 -187
  285. package/Sources/RunicCore/BrowserCookieImportOrder.swift +0 -25
  286. package/Sources/RunicCore/CopilotUsageModels.swift +0 -84
  287. package/Sources/RunicCore/CostUsageFetcher.swift +0 -119
  288. package/Sources/RunicCore/CostUsageModels.swift +0 -479
  289. package/Sources/RunicCore/CreditsModels.swift +0 -50
  290. package/Sources/RunicCore/CustomProviders/CustomProviderModels.swift +0 -269
  291. package/Sources/RunicCore/CustomProviders/GenericProviderFetcher.swift +0 -633
  292. package/Sources/RunicCore/CustomProviders/ProviderTemplates.swift +0 -500
  293. package/Sources/RunicCore/Host/PTY/TTYCommandRunner.swift +0 -679
  294. package/Sources/RunicCore/Host/Process/SubprocessRunner.swift +0 -135
  295. package/Sources/RunicCore/Integrations/GitHubIntegration.swift +0 -173
  296. package/Sources/RunicCore/Logging/JSONStderrLogHandler.swift +0 -79
  297. package/Sources/RunicCore/Logging/OSLogLogHandler.swift +0 -86
  298. package/Sources/RunicCore/Logging/RunicLog.swift +0 -123
  299. package/Sources/RunicCore/Models/EnhancedUsageModels.swift +0 -371
  300. package/Sources/RunicCore/OpenAIDashboardModels.swift +0 -210
  301. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift +0 -676
  302. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardFetcher.swift +0 -492
  303. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift +0 -51
  304. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardParser.swift +0 -483
  305. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift +0 -558
  306. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +0 -204
  307. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardWebsiteDataStore.swift +0 -78
  308. package/Sources/RunicCore/PathEnvironment.swift +0 -482
  309. package/Sources/RunicCore/Performance/PerformanceModels.swift +0 -371
  310. package/Sources/RunicCore/Performance/PerformanceStorageImpl.swift +0 -409
  311. package/Sources/RunicCore/Policy/UsagePolicyEngine.swift +0 -178
  312. package/Sources/RunicCore/ProjectBudgetStore.swift +0 -99
  313. package/Sources/RunicCore/ProviderCostSnapshot.swift +0 -29
  314. package/Sources/RunicCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +0 -66
  315. package/Sources/RunicCore/Providers/Antigravity/AntigravityStatusProbe.swift +0 -685
  316. package/Sources/RunicCore/Providers/Auggie/AuggieProviderDescriptor.swift +0 -83
  317. package/Sources/RunicCore/Providers/Auggie/AuggieUsageFetcher.swift +0 -142
  318. package/Sources/RunicCore/Providers/Azure/AzureProviderDescriptor.swift +0 -141
  319. package/Sources/RunicCore/Providers/Azure/AzureUsageFetcher.swift +0 -188
  320. package/Sources/RunicCore/Providers/Bedrock/BedrockProviderDescriptor.swift +0 -126
  321. package/Sources/RunicCore/Providers/Bedrock/BedrockUsageFetcher.swift +0 -168
  322. package/Sources/RunicCore/Providers/Cerebras/CerebrasProviderDescriptor.swift +0 -84
  323. package/Sources/RunicCore/Providers/Cerebras/CerebrasUsageFetcher.swift +0 -75
  324. package/Sources/RunicCore/Providers/Claude/ClaudeCLISession.swift +0 -332
  325. package/Sources/RunicCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +0 -180
  326. package/Sources/RunicCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +0 -137
  327. package/Sources/RunicCore/Providers/Claude/ClaudeProviderDescriptor.swift +0 -207
  328. package/Sources/RunicCore/Providers/Claude/ClaudeStatusProbe.swift +0 -654
  329. package/Sources/RunicCore/Providers/Claude/ClaudeUsageDataSource.swift +0 -28
  330. package/Sources/RunicCore/Providers/Claude/ClaudeUsageFetcher.swift +0 -480
  331. package/Sources/RunicCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +0 -780
  332. package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +0 -147
  333. package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +0 -280
  334. package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift +0 -100
  335. package/Sources/RunicCore/Providers/Codex/CodexProviderDescriptor.swift +0 -243
  336. package/Sources/RunicCore/Providers/Codex/CodexStatusProbe.swift +0 -125
  337. package/Sources/RunicCore/Providers/Codex/CodexUsageDataSource.swift +0 -24
  338. package/Sources/RunicCore/Providers/Codex/CodexWebDashboardStrategy.swift +0 -181
  339. package/Sources/RunicCore/Providers/Cohere/CohereProviderDescriptor.swift +0 -83
  340. package/Sources/RunicCore/Providers/Cohere/CohereUsageFetcher.swift +0 -85
  341. package/Sources/RunicCore/Providers/Copilot/CopilotDeviceFlow.swift +0 -123
  342. package/Sources/RunicCore/Providers/Copilot/CopilotGitHubCLITokenReader.swift +0 -114
  343. package/Sources/RunicCore/Providers/Copilot/CopilotProviderDescriptor.swift +0 -100
  344. package/Sources/RunicCore/Providers/Copilot/CopilotUsageFetcher.swift +0 -291
  345. package/Sources/RunicCore/Providers/Copilot/CopilotVSCodeTokenReader.swift +0 -386
  346. package/Sources/RunicCore/Providers/Cursor/CursorProviderDescriptor.swift +0 -65
  347. package/Sources/RunicCore/Providers/Cursor/CursorStatusProbe.swift +0 -595
  348. package/Sources/RunicCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift +0 -85
  349. package/Sources/RunicCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift +0 -108
  350. package/Sources/RunicCore/Providers/Factory/FactoryLocalStorageImporter.swift +0 -398
  351. package/Sources/RunicCore/Providers/Factory/FactoryProviderDescriptor.swift +0 -65
  352. package/Sources/RunicCore/Providers/Factory/FactoryStatusProbe.swift +0 -1276
  353. package/Sources/RunicCore/Providers/Fireworks/FireworksProviderDescriptor.swift +0 -84
  354. package/Sources/RunicCore/Providers/Fireworks/FireworksUsageFetcher.swift +0 -75
  355. package/Sources/RunicCore/Providers/Gemini/GeminiProviderDescriptor.swift +0 -65
  356. package/Sources/RunicCore/Providers/Gemini/GeminiStatusProbe.swift +0 -836
  357. package/Sources/RunicCore/Providers/Groq/GroqProviderDescriptor.swift +0 -83
  358. package/Sources/RunicCore/Providers/Groq/GroqUsageFetcher.swift +0 -71
  359. package/Sources/RunicCore/Providers/Kimi/KimiProviderDescriptor.swift +0 -83
  360. package/Sources/RunicCore/Providers/Kimi/KimiUsageFetcher.swift +0 -77
  361. package/Sources/RunicCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +0 -92
  362. package/Sources/RunicCore/Providers/MiniMax/MiniMaxSettingsReader.swift +0 -54
  363. package/Sources/RunicCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +0 -168
  364. package/Sources/RunicCore/Providers/MiniMax/MiniMaxWebUsageFetcher.swift +0 -911
  365. package/Sources/RunicCore/Providers/Mistral/MistralProviderDescriptor.swift +0 -84
  366. package/Sources/RunicCore/Providers/Mistral/MistralUsageFetcher.swift +0 -75
  367. package/Sources/RunicCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift +0 -87
  368. package/Sources/RunicCore/Providers/OpenRouter/OpenRouterUsageFetcher.swift +0 -203
  369. package/Sources/RunicCore/Providers/Perplexity/PerplexityProviderDescriptor.swift +0 -84
  370. package/Sources/RunicCore/Providers/Perplexity/PerplexityUsageFetcher.swift +0 -75
  371. package/Sources/RunicCore/Providers/ProviderBranding.swift +0 -25
  372. package/Sources/RunicCore/Providers/ProviderCLIConfig.swift +0 -17
  373. package/Sources/RunicCore/Providers/ProviderDescriptor.swift +0 -129
  374. package/Sources/RunicCore/Providers/ProviderFetchPlan.swift +0 -263
  375. package/Sources/RunicCore/Providers/ProviderSettingsSnapshot.swift +0 -98
  376. package/Sources/RunicCore/Providers/ProviderTokenResolver.swift +0 -501
  377. package/Sources/RunicCore/Providers/ProviderVersionDetector.swift +0 -68
  378. package/Sources/RunicCore/Providers/Providers.swift +0 -184
  379. package/Sources/RunicCore/Providers/Qwen/QwenProviderDescriptor.swift +0 -84
  380. package/Sources/RunicCore/Providers/Qwen/QwenSettingsReader.swift +0 -30
  381. package/Sources/RunicCore/Providers/Qwen/QwenUsageStats.swift +0 -315
  382. package/Sources/RunicCore/Providers/SambaNova/SambaNovaProviderDescriptor.swift +0 -84
  383. package/Sources/RunicCore/Providers/SambaNova/SambaNovaUsageFetcher.swift +0 -75
  384. package/Sources/RunicCore/Providers/Together/TogetherProviderDescriptor.swift +0 -83
  385. package/Sources/RunicCore/Providers/Together/TogetherUsageFetcher.swift +0 -75
  386. package/Sources/RunicCore/Providers/VertexAI/VertexAIProviderDescriptor.swift +0 -123
  387. package/Sources/RunicCore/Providers/VertexAI/VertexAISettingsReader.swift +0 -48
  388. package/Sources/RunicCore/Providers/VertexAI/VertexAIUsageFetcher.swift +0 -144
  389. package/Sources/RunicCore/Providers/XAI/XAIProviderDescriptor.swift +0 -83
  390. package/Sources/RunicCore/Providers/XAI/XAIUsageFetcher.swift +0 -75
  391. package/Sources/RunicCore/Providers/Zai/ZaiProviderDescriptor.swift +0 -72
  392. package/Sources/RunicCore/Providers/Zai/ZaiSettingsReader.swift +0 -41
  393. package/Sources/RunicCore/Providers/Zai/ZaiUsageStats.swift +0 -718
  394. package/Sources/RunicCore/Sync/BackgroundSyncManager.swift +0 -385
  395. package/Sources/RunicCore/Sync/SyncConflictResolver.swift +0 -310
  396. package/Sources/RunicCore/Sync/SyncProtocol.swift +0 -288
  397. package/Sources/RunicCore/Sync/SyncRecord.swift +0 -405
  398. package/Sources/RunicCore/Sync/SyncUsageExample.swift +0 -294
  399. package/Sources/RunicCore/Sync/TeamSyncRecords.swift +0 -775
  400. package/Sources/RunicCore/Sync/iCloudSyncEngine.swift +0 -333
  401. package/Sources/RunicCore/Team/TeamModels.swift +0 -583
  402. package/Sources/RunicCore/Team/TeamShowbackExporter.swift +0 -495
  403. package/Sources/RunicCore/Team/TeamStore.swift +0 -792
  404. package/Sources/RunicCore/TextParsing.swift +0 -55
  405. package/Sources/RunicCore/UsageFetcher.swift +0 -694
  406. package/Sources/RunicCore/UsageFormatter.swift +0 -410
  407. package/Sources/RunicCore/UsageLedger/ClaudeUsageLogSource.swift +0 -427
  408. package/Sources/RunicCore/UsageLedger/CodexUsageLogSource.swift +0 -494
  409. package/Sources/RunicCore/UsageLedger/LedgerCache.swift +0 -158
  410. package/Sources/RunicCore/UsageLedger/OTelGenAILedgerAdapter.swift +0 -550
  411. package/Sources/RunicCore/UsageLedger/UsageLedger.swift +0 -29
  412. package/Sources/RunicCore/UsageLedger/UsageLedgerAggregator.swift +0 -660
  413. package/Sources/RunicCore/UsageLedger/UsageLedgerInsightsAdvisor.swift +0 -209
  414. package/Sources/RunicCore/UsageLedger/UsageLedgerModels.swift +0 -417
  415. package/Sources/RunicCore/UsageLedger/UsageLedgerProjectIdentity.swift +0 -245
  416. package/Sources/RunicCore/UsagePace.swift +0 -81
  417. package/Sources/RunicCore/Vendored/CostUsage/CostUsageCache.swift +0 -73
  418. package/Sources/RunicCore/Vendored/CostUsage/CostUsageJsonl.swift +0 -74
  419. package/Sources/RunicCore/Vendored/CostUsage/CostUsagePricing.swift +0 -208
  420. package/Sources/RunicCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +0 -455
  421. package/Sources/RunicCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift +0 -132
  422. package/Sources/RunicCore/Vendored/CostUsage/CostUsageScanner.swift +0 -589
  423. package/Sources/RunicCore/WidgetSnapshot.swift +0 -183
  424. package/Sources/RunicMacroSupport/ProviderRegistrationMacros.swift +0 -14
  425. package/Sources/RunicMacros/ProviderRegistrationMacros.swift +0 -216
  426. package/Sources/RunicWidget/RunicWidgetBundle.swift +0 -79
  427. package/Sources/RunicWidget/RunicWidgetProvider.swift +0 -283
  428. package/Sources/RunicWidget/RunicWidgetViews.swift +0 -680
  429. package/Tests/RunicTests/AntigravityStatusProbeTests.swift +0 -50
  430. package/Tests/RunicTests/AppDelegateTests.swift +0 -43
  431. package/Tests/RunicTests/BrowserCookieOrderLabelTests.swift +0 -22
  432. package/Tests/RunicTests/CLIArgumentParsingTests.swift +0 -70
  433. package/Tests/RunicTests/CLICostTests.swift +0 -65
  434. package/Tests/RunicTests/CLIProviderSelectionTests.swift +0 -22
  435. package/Tests/RunicTests/CLISnapshotTests.swift +0 -72
  436. package/Tests/RunicTests/CLIWebFallbackTests.swift +0 -58
  437. package/Tests/RunicTests/ClaudeOAuthTests.swift +0 -164
  438. package/Tests/RunicTests/ClaudeResilienceTests.swift +0 -30
  439. package/Tests/RunicTests/ClaudeUsageTests.swift +0 -370
  440. package/Tests/RunicTests/CodexOAuthTests.swift +0 -122
  441. package/Tests/RunicTests/CodexUsageLogSourceTests.swift +0 -82
  442. package/Tests/RunicTests/CopilotTokenResolverTests.swift +0 -75
  443. package/Tests/RunicTests/CopilotVSCodeTokenReaderTests.swift +0 -75
  444. package/Tests/RunicTests/CostUsageDecodingTests.swift +0 -394
  445. package/Tests/RunicTests/CostUsagePricingTests.swift +0 -50
  446. package/Tests/RunicTests/CostUsageScannerTests.swift +0 -700
  447. package/Tests/RunicTests/CursorStatusProbeTests.swift +0 -323
  448. package/Tests/RunicTests/FactoryStatusProbeTests.swift +0 -69
  449. package/Tests/RunicTests/GeminiAPITestHelpers.swift +0 -94
  450. package/Tests/RunicTests/GeminiLoginAlertTests.swift +0 -28
  451. package/Tests/RunicTests/GeminiStatusProbeAPITests.swift +0 -283
  452. package/Tests/RunicTests/GeminiStatusProbePlanTests.swift +0 -279
  453. package/Tests/RunicTests/GeminiStatusProbeTests.swift +0 -233
  454. package/Tests/RunicTests/GeminiTestEnvironment.swift +0 -87
  455. package/Tests/RunicTests/GoogleWorkspaceStatusTests.swift +0 -73
  456. package/Tests/RunicTests/InstallOriginTests.swift +0 -18
  457. package/Tests/RunicTests/KeyboardShortcutsBundleTests.swift +0 -9
  458. package/Tests/RunicTests/LiveAccountTests.swift +0 -24
  459. package/Tests/RunicTests/LoadingPatternTests.swift +0 -35
  460. package/Tests/RunicTests/MenuCardModelTests.swift +0 -545
  461. package/Tests/RunicTests/MiniMaxCookieHeaderStoreTestSupport.swift +0 -8
  462. package/Tests/RunicTests/MiniMaxGroupIDStoreTestSupport.swift +0 -8
  463. package/Tests/RunicTests/MiniMaxParsingTests.swift +0 -68
  464. package/Tests/RunicTests/MiniMaxTokenStoreTestSupport.swift +0 -8
  465. package/Tests/RunicTests/OTelGenAILedgerAdapterTests.swift +0 -112
  466. package/Tests/RunicTests/OpenAIDashboardBrowserCookieImporterTests.swift +0 -17
  467. package/Tests/RunicTests/OpenAIDashboardFetcherCreditsWaitTests.swift +0 -75
  468. package/Tests/RunicTests/OpenAIDashboardOffscreenHostTests.swift +0 -27
  469. package/Tests/RunicTests/OpenAIDashboardParserTests.swift +0 -134
  470. package/Tests/RunicTests/OpenAIWebAccountSwitchTests.swift +0 -53
  471. package/Tests/RunicTests/OpenRouterTokenStoreTestSupport.swift +0 -92
  472. package/Tests/RunicTests/PathBuilderTests.swift +0 -237
  473. package/Tests/RunicTests/ProviderFetchPipelineTests.swift +0 -160
  474. package/Tests/RunicTests/ProviderIconResourcesTests.swift +0 -38
  475. package/Tests/RunicTests/ProviderMetadataStatusLinkTests.swift +0 -16
  476. package/Tests/RunicTests/ProviderRegistryTests.swift +0 -29
  477. package/Tests/RunicTests/ProviderSettingsDescriptorTests.swift +0 -202
  478. package/Tests/RunicTests/ProviderToggleStoreTests.swift +0 -51
  479. package/Tests/RunicTests/RunicTests.swift +0 -120
  480. package/Tests/RunicTests/SessionQuotaNotificationLogicTests.swift +0 -36
  481. package/Tests/RunicTests/SettingsStoreTests.swift +0 -161
  482. package/Tests/RunicTests/StatusItemAnimationTests.swift +0 -49
  483. package/Tests/RunicTests/StatusMenuTests.swift +0 -434
  484. package/Tests/RunicTests/StatusProbeTests.swift +0 -377
  485. package/Tests/RunicTests/SubprocessRunnerTests.swift +0 -20
  486. package/Tests/RunicTests/SubscriptionDetectionTests.swift +0 -65
  487. package/Tests/RunicTests/TTYCommandRunnerTests.swift +0 -148
  488. package/Tests/RunicTests/TTYIntegrationTests.swift +0 -41
  489. package/Tests/RunicTests/TeamShowbackExporterTests.swift +0 -159
  490. package/Tests/RunicTests/TextParsingTests.swift +0 -12
  491. package/Tests/RunicTests/UsageFormatterTests.swift +0 -112
  492. package/Tests/RunicTests/UsageLedgerAnomalyDetectionTests.swift +0 -172
  493. package/Tests/RunicTests/UsageLedgerForecastTests.swift +0 -213
  494. package/Tests/RunicTests/UsageLedgerInsightsAdvisorTests.swift +0 -83
  495. package/Tests/RunicTests/UsageLedgerProjectNameTests.swift +0 -170
  496. package/Tests/RunicTests/UsagePaceTests.swift +0 -77
  497. package/Tests/RunicTests/UsagePaceTextTests.swift +0 -109
  498. package/Tests/RunicTests/UsagePolicyEngineTests.swift +0 -91
  499. package/Tests/RunicTests/WidgetSnapshotTests.swift +0 -45
  500. package/Tests/RunicTests/ZaiProviderTests.swift +0 -61
  501. package/Tests/RunicTests/ZaiTokenStoreTestSupport.swift +0 -6
  502. package/TestsLinux/PlatformGatingTests.swift +0 -46
  503. package/appcast.xml +0 -144
  504. package/assets/logo-concept-stack.svg +0 -24
  505. package/assets/logo-minimal-bars.svg +0 -15
  506. package/assets/logo.svg +0 -20
  507. package/assets/logo2.svg +0 -43
  508. package/bin/docs-list +0 -5
  509. package/build-and-run.sh +0 -369
  510. package/docs/.nojekyll +0 -1
  511. package/docs/CNAME +0 -1
  512. package/docs/antigravity.md +0 -75
  513. package/docs/architecture.md +0 -32
  514. package/docs/claude.md +0 -93
  515. package/docs/cli-commands/integration_guide.md +0 -371
  516. package/docs/cli-commands/quick_reference.md +0 -362
  517. package/docs/cli-commands/readme.md +0 -361
  518. package/docs/cli.md +0 -156
  519. package/docs/cloudkit_sync_guide.md +0 -495
  520. package/docs/codebase_review.md +0 -288
  521. package/docs/codex-oauth.md +0 -635
  522. package/docs/codex.md +0 -104
  523. package/docs/codexbar.png +0 -0
  524. package/docs/copilot.md +0 -44
  525. package/docs/cursor.md +0 -46
  526. package/docs/design/designcomparisonchart.md +0 -510
  527. package/docs/design/implementationguide.md +0 -792
  528. package/docs/design/menubaricondesignconcepts.md +0 -796
  529. package/docs/design/mockups/hybridiconconcept.svg +0 -62
  530. package/docs/design/mockups/infinityiconconcept.svg +0 -41
  531. package/docs/design/mockups/infinityiconstates.svg +0 -129
  532. package/docs/design/mockups/wavelogorefined.svg +0 -48
  533. package/docs/design/quick_summary.md +0 -426
  534. package/docs/design/readme.md +0 -478
  535. package/docs/design/sizespecifications.md +0 -575
  536. package/docs/design-system.md +0 -836
  537. package/docs/factory.md +0 -103
  538. package/docs/gemini.md +0 -73
  539. package/docs/icon.md +0 -37
  540. package/docs/icon.png +0 -0
  541. package/docs/index.html +0 -175
  542. package/docs/keychain_fix.md +0 -274
  543. package/docs/minimax.md +0 -71
  544. package/docs/packaging.md +0 -25
  545. package/docs/progress-bar-improvements.md +0 -240
  546. package/docs/progress-bar-quick-reference.md +0 -283
  547. package/docs/progress-bar-technical-details.md +0 -432
  548. package/docs/progress-bar-visual-test.swift +0 -375
  549. package/docs/provider.md +0 -202
  550. package/docs/providers.md +0 -86
  551. package/docs/refactor/macros.md +0 -35
  552. package/docs/refresh-loop.md +0 -22
  553. package/docs/releasing-homebrew.md +0 -35
  554. package/docs/releasing.md +0 -121
  555. package/docs/research/concrete_todo_2026-02-22.md +0 -47
  556. package/docs/research/core-feature-research.md +0 -248
  557. package/docs/research/feature_uiux_roadmap_2026-02-22.md +0 -202
  558. package/docs/research/parity_matrix.md +0 -49
  559. package/docs/research/provider_expansion_candidates_2026-02-22.md +0 -80
  560. package/docs/research/provider_model_token_project_matrix_2026-02-23.md +0 -46
  561. package/docs/research/release_gate_checklist.md +0 -57
  562. package/docs/site.css +0 -505
  563. package/docs/sparkle.md +0 -28
  564. package/docs/status.md +0 -28
  565. package/docs/sync/implementation.md +0 -393
  566. package/docs/sync/readme.md +0 -317
  567. package/docs/sync_files_summary.md +0 -391
  568. package/docs/sync_quick_reference.md +0 -225
  569. package/docs/todo.md +0 -11
  570. package/docs/ui.md +0 -29
  571. package/docs/web-integration.md +0 -9
  572. package/docs/widgets.md +0 -85
  573. package/docs/workflows/error-handling-improvements.md +0 -434
  574. package/docs/workflows/folder_structure.md +0 -299
  575. package/docs/workflows/loading_states.md +0 -446
  576. package/docs/workflows/loading_states_examples.md +0 -565
  577. package/docs/workflows/loading_states_flow.md +0 -430
  578. package/docs/workflows/performance_optimizations.md +0 -457
  579. package/docs/workflows/phase1-cli-testing-report.md +0 -313
  580. package/docs/workflows/quick_reference.md +0 -228
  581. package/docs/workflows/readme.md +0 -59
  582. package/docs/workflows/recent_improvements.md +0 -275
  583. package/docs/workflows/sync_enablement_summary.md +0 -400
  584. package/docs/zai.md +0 -43
  585. package/runic.png +0 -0
  586. package/version.env +0 -2
@@ -1,3435 +0,0 @@
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
- }