@sriinnu/runic 2.0.0 → 2.11.2

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