mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-18 23:37:13 +00:00
Add appstore/ to .gitignore
Private Mac App Store build, not for the public repo.
This commit is contained in:
parent
d8629c6978
commit
b835b69cf4
19 changed files with 3 additions and 2867 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -41,5 +41,8 @@ assets/discord-*.png
|
|||
# Desktop app experiments
|
||||
desktop/
|
||||
|
||||
# Mac App Store app (private)
|
||||
appstore/
|
||||
|
||||
# WIP / not ready
|
||||
src/summit.ts
|
||||
|
|
|
|||
|
|
@ -1,492 +0,0 @@
|
|||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
2ECF1C27FA4DDA36495BA68C /* litellm-snapshot.json in Resources */ = {isa = PBXBuildFile; fileRef = 8AAE65B7A750492C322FA5E7 /* litellm-snapshot.json */; };
|
||||
3007410303C4D0434669B50E /* ProviderData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E37160EB1A5432B8293AA2D /* ProviderData.swift */; };
|
||||
3DB70769F5E327B7D81A4A2F /* ParserFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 166E0C0CE8503D3A2D2879E0 /* ParserFactory.swift */; };
|
||||
3F6F4EDBAB788441F22F21D4 /* PricingEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C4B76B18A1F16C3BFEA8852 /* PricingEngine.swift */; };
|
||||
493862507A93C42271509F33 /* MenuBarPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505DEE70935AC5B6EFD96342 /* MenuBarPopover.swift */; };
|
||||
4D9E1DC41999837FD6234105 /* SessionDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE26E7C025F9E9B76F1EF5E2 /* SessionDiscovery.swift */; };
|
||||
81BDD9D51CF36753E4DE4039 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A081AEB9C62823EA049C381 /* Theme.swift */; };
|
||||
8FF7627DBD6A1925F25B6998 /* DashboardSections.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D18497715889FD2E28988D6 /* DashboardSections.swift */; };
|
||||
A9920B49759503DA503CDAE6 /* SessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B016A43AF196CE6A493C7538 /* SessionStore.swift */; };
|
||||
CA53CB4F02B48520B5632AAF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8F460B9E6390F3FB1E15FD87 /* Assets.xcassets */; };
|
||||
DFEEF70015C2BDAECF8380BB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1764E5E823EB826E9A95BA /* SettingsView.swift */; };
|
||||
FFDB0DFF1ED97F07AB94BA08 /* CodeBurnProApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A1E8B2BAC761E675B36E779 /* CodeBurnProApp.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
BE21D11E0B437BB5CC92D560 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 98C12FE29026B86BB7691704 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 4644213E89A759CA3536CE98;
|
||||
remoteInfo = CodeBurnPro;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
0E37160EB1A5432B8293AA2D /* ProviderData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderData.swift; sourceTree = "<group>"; };
|
||||
166E0C0CE8503D3A2D2879E0 /* ParserFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParserFactory.swift; sourceTree = "<group>"; };
|
||||
1A081AEB9C62823EA049C381 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
|
||||
1D18497715889FD2E28988D6 /* DashboardSections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardSections.swift; sourceTree = "<group>"; };
|
||||
3C4B76B18A1F16C3BFEA8852 /* PricingEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PricingEngine.swift; sourceTree = "<group>"; };
|
||||
40A6F8098AC554850CC48A55 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
505DEE70935AC5B6EFD96342 /* MenuBarPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarPopover.swift; sourceTree = "<group>"; };
|
||||
5A1E8B2BAC761E675B36E779 /* CodeBurnProApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeBurnProApp.swift; sourceTree = "<group>"; };
|
||||
8AAE65B7A750492C322FA5E7 /* litellm-snapshot.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "litellm-snapshot.json"; sourceTree = "<group>"; };
|
||||
8F460B9E6390F3FB1E15FD87 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
B016A43AF196CE6A493C7538 /* SessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStore.swift; sourceTree = "<group>"; };
|
||||
B81E89E9FB0BDF09FBAD945C /* CodeBurnPro.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CodeBurnPro.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D8D13C20D2180A5466744F05 /* CodeBurnProTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CodeBurnProTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
DC63231DAA219A843397A91C /* CodeBurnPro.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CodeBurnPro.entitlements; sourceTree = "<group>"; };
|
||||
DE26E7C025F9E9B76F1EF5E2 /* SessionDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDiscovery.swift; sourceTree = "<group>"; };
|
||||
FC1764E5E823EB826E9A95BA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
010E84A815B086F865EE139D /* Tests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
path = Tests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2743809DB6BC37FB231BF136 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1D18497715889FD2E28988D6 /* DashboardSections.swift */,
|
||||
505DEE70935AC5B6EFD96342 /* MenuBarPopover.swift */,
|
||||
FC1764E5E823EB826E9A95BA /* SettingsView.swift */,
|
||||
1A081AEB9C62823EA049C381 /* Theme.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
354902BC5C1D6FA2706BB87B /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DC63231DAA219A843397A91C /* CodeBurnPro.entitlements */,
|
||||
5A1E8B2BAC761E675B36E779 /* CodeBurnProApp.swift */,
|
||||
40A6F8098AC554850CC48A55 /* Info.plist */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
370A693B64F8F4F443EA6F75 /* Sources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
354902BC5C1D6FA2706BB87B /* App */,
|
||||
49E1E2079DE6A12CA955D709 /* Core */,
|
||||
A8136629A97D7302328F9226 /* Models */,
|
||||
B53474647E57314BBB4A5BF9 /* Resources */,
|
||||
2743809DB6BC37FB231BF136 /* Views */,
|
||||
);
|
||||
path = Sources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
49E1E2079DE6A12CA955D709 /* Core */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
166E0C0CE8503D3A2D2879E0 /* ParserFactory.swift */,
|
||||
3C4B76B18A1F16C3BFEA8852 /* PricingEngine.swift */,
|
||||
DE26E7C025F9E9B76F1EF5E2 /* SessionDiscovery.swift */,
|
||||
);
|
||||
path = Core;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A8136629A97D7302328F9226 /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0E37160EB1A5432B8293AA2D /* ProviderData.swift */,
|
||||
B016A43AF196CE6A493C7538 /* SessionStore.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AE4CF1BC0160C3FD593C6DD6 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
370A693B64F8F4F443EA6F75 /* Sources */,
|
||||
010E84A815B086F865EE139D /* Tests */,
|
||||
D5B44508B31DEDE68BF93CE7 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B53474647E57314BBB4A5BF9 /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8F460B9E6390F3FB1E15FD87 /* Assets.xcassets */,
|
||||
8AAE65B7A750492C322FA5E7 /* litellm-snapshot.json */,
|
||||
);
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D5B44508B31DEDE68BF93CE7 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B81E89E9FB0BDF09FBAD945C /* CodeBurnPro.app */,
|
||||
D8D13C20D2180A5466744F05 /* CodeBurnProTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
4644213E89A759CA3536CE98 /* CodeBurnPro */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 39BA4438AB143ED6916F0111 /* Build configuration list for PBXNativeTarget "CodeBurnPro" */;
|
||||
buildPhases = (
|
||||
C9DC56CFCB5B5C9F97B3143E /* Sources */,
|
||||
7325A33B9C9197DA66E1F3BE /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = CodeBurnPro;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = CodeBurnPro;
|
||||
productReference = B81E89E9FB0BDF09FBAD945C /* CodeBurnPro.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
C1F291F2AC223737AE163858 /* CodeBurnProTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 27D48476E54F490BB2D51D5D /* Build configuration list for PBXNativeTarget "CodeBurnProTests" */;
|
||||
buildPhases = (
|
||||
4AAAA6AF0579CE4D2C32BF22 /* Sources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
90669F9181C61FA9ED854A6E /* PBXTargetDependency */,
|
||||
);
|
||||
name = CodeBurnProTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = CodeBurnProTests;
|
||||
productReference = D8D13C20D2180A5466744F05 /* CodeBurnProTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
98C12FE29026B86BB7691704 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1600;
|
||||
TargetAttributes = {
|
||||
4644213E89A759CA3536CE98 = {
|
||||
DevelopmentTeam = "";
|
||||
ProvisioningStyle = Manual;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 82F59EE0DDE26D399B09396D /* Build configuration list for PBXProject "CodeBurnPro" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
Base,
|
||||
en,
|
||||
);
|
||||
mainGroup = AE4CF1BC0160C3FD593C6DD6;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = D5B44508B31DEDE68BF93CE7 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
4644213E89A759CA3536CE98 /* CodeBurnPro */,
|
||||
C1F291F2AC223737AE163858 /* CodeBurnProTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
7325A33B9C9197DA66E1F3BE /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CA53CB4F02B48520B5632AAF /* Assets.xcassets in Resources */,
|
||||
2ECF1C27FA4DDA36495BA68C /* litellm-snapshot.json in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
4AAAA6AF0579CE4D2C32BF22 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
C9DC56CFCB5B5C9F97B3143E /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
FFDB0DFF1ED97F07AB94BA08 /* CodeBurnProApp.swift in Sources */,
|
||||
8FF7627DBD6A1925F25B6998 /* DashboardSections.swift in Sources */,
|
||||
493862507A93C42271509F33 /* MenuBarPopover.swift in Sources */,
|
||||
3DB70769F5E327B7D81A4A2F /* ParserFactory.swift in Sources */,
|
||||
3F6F4EDBAB788441F22F21D4 /* PricingEngine.swift in Sources */,
|
||||
3007410303C4D0434669B50E /* ProviderData.swift in Sources */,
|
||||
4D9E1DC41999837FD6234105 /* SessionDiscovery.swift in Sources */,
|
||||
A9920B49759503DA503CDAE6 /* SessionStore.swift in Sources */,
|
||||
DFEEF70015C2BDAECF8380BB /* SettingsView.swift in Sources */,
|
||||
81BDD9D51CF36753E4DE4039 /* Theme.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
90669F9181C61FA9ED854A6E /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 4644213E89A759CA3536CE98 /* CodeBurnPro */;
|
||||
targetProxy = BE21D11E0B437BB5CC92D560 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
16636C9409E6025D87CC6B93 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = Sources/App/CodeBurnPro.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development: toruk_makto1406@icloud.com (T3289GPL2P)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = Sources/App/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.agentseal.codeburn-pro";
|
||||
PRODUCT_NAME = "CodeBurn Pro";
|
||||
SDKROOT = macosx;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
28D3BCE119F61F0FC7E45532 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
"@loader_path/../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.agentseal.codeburn-pro-tests";
|
||||
SDKROOT = macosx;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CodeBurnPro.app/Contents/MacOS/CodeBurnPro";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
669A9FA55E2ED179ED72D24A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 6.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
6B26F1606268EC787A0BEF18 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = Sources/App/CodeBurnPro.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development: toruk_makto1406@icloud.com (T3289GPL2P)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = Sources/App/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.agentseal.codeburn-pro";
|
||||
PRODUCT_NAME = "CodeBurn Pro";
|
||||
SDKROOT = macosx;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
6C4287EB5F1EF379F9E640B0 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"DEBUG=1",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = macosx;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 6.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A63E55710D34F3B756F6E463 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
"@loader_path/../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.agentseal.codeburn-pro-tests";
|
||||
SDKROOT = macosx;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CodeBurnPro.app/Contents/MacOS/CodeBurnPro";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
27D48476E54F490BB2D51D5D /* Build configuration list for PBXNativeTarget "CodeBurnProTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
28D3BCE119F61F0FC7E45532 /* Debug */,
|
||||
A63E55710D34F3B756F6E463 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
39BA4438AB143ED6916F0111 /* Build configuration list for PBXNativeTarget "CodeBurnPro" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
16636C9409E6025D87CC6B93 /* Debug */,
|
||||
6B26F1606268EC787A0BEF18 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
82F59EE0DDE26D399B09396D /* Build configuration list for PBXProject "CodeBurnPro" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
6C4287EB5F1EF379F9E640B0 /* Debug */,
|
||||
669A9FA55E2ED179ED72D24A /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 98C12FE29026B86BB7691704 /* Project object */;
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct CodeBurnProApp: App {
|
||||
@StateObject private var store = SessionStore()
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra {
|
||||
MenuBarPopover(store: store)
|
||||
} label: {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "flame.fill")
|
||||
if !store.needsOnboarding {
|
||||
Text(store.todayCost.asMenuBarCurrency())
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
|
||||
Settings {
|
||||
SettingsView(store: store)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Double {
|
||||
func asMenuBarCurrency() -> String {
|
||||
if self <= 0 { return "$0" }
|
||||
if self >= 1000 { return String(format: "$%.0fK", self / 1000) }
|
||||
if self >= 100 { return String(format: "$%.0f", self) }
|
||||
if self >= 10 { return String(format: "$%.1f", self) }
|
||||
return String(format: "$%.2f", self)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>CodeBurn Pro</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>CodeBurn Pro</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.agentseal.codeburn-pro</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright 2026 AgentSeal. All rights reserved.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -1,307 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
enum ParserFactory {
|
||||
static func parser(for provider: String) -> SessionParser {
|
||||
switch provider {
|
||||
case "claude": ClaudeParser()
|
||||
case "codex": CodexParser()
|
||||
case "copilot": CopilotParser()
|
||||
default: GenericJSONLParser(provider: provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol SessionParser {
|
||||
func parseSessions(at paths: [URL], in dateRange: ClosedRange<Date>) -> [ParsedSession]
|
||||
}
|
||||
|
||||
// MARK: - Claude JSONL parser
|
||||
|
||||
struct ClaudeParser: SessionParser {
|
||||
func parseSessions(at paths: [URL], in dateRange: ClosedRange<Date>) -> [ParsedSession] {
|
||||
paths.compactMap { parseClaudeSession(at: $0, in: dateRange) }
|
||||
}
|
||||
|
||||
private func parseClaudeSession(at url: URL, in dateRange: ClosedRange<Date>) -> ParsedSession? {
|
||||
guard let handle = try? FileHandle(forReadingFrom: url) else { return nil }
|
||||
defer { try? handle.close() }
|
||||
guard let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize, size < 100_000_000 else { return nil }
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
else { return nil }
|
||||
|
||||
let lines = text.components(separatedBy: .newlines).filter { !$0.isEmpty }
|
||||
var turns: [ParsedTurn] = []
|
||||
var firstTimestamp: Date?
|
||||
|
||||
let isoFormatter = ISO8601DateFormatter()
|
||||
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
for line in lines {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any] else { continue }
|
||||
guard let message = json["message"] as? [String: Any],
|
||||
let role = message["role"] as? String, role == "assistant"
|
||||
else { continue }
|
||||
|
||||
let timestamp = (json["timestamp"] as? String).flatMap { isoFormatter.date(from: $0) }
|
||||
?? (message["timestamp"] as? String).flatMap { isoFormatter.date(from: $0) }
|
||||
?? Date()
|
||||
if firstTimestamp == nil { firstTimestamp = timestamp }
|
||||
|
||||
guard dateRange.contains(timestamp) else { continue }
|
||||
|
||||
let usage = message["usage"] as? [String: Any] ?? [:]
|
||||
let inputTokens = usage["input_tokens"] as? Int ?? 0
|
||||
let outputTokens = usage["output_tokens"] as? Int ?? 0
|
||||
let cacheRead = usage["cache_read_input_tokens"] as? Int ?? 0
|
||||
let cacheWrite = usage["cache_creation_input_tokens"] as? Int ?? 0
|
||||
let speed = usage["speed"] as? String ?? "standard"
|
||||
|
||||
let cacheCreation = usage["cache_creation"] as? [String: Any]
|
||||
let oneHourCacheWrite = cacheCreation?["ephemeral_1h_input_tokens"] as? Int ?? 0
|
||||
|
||||
let model = message["model"] as? String ?? "unknown"
|
||||
let cost = PricingEngine.cost(
|
||||
model: model, input: inputTokens, output: outputTokens,
|
||||
cacheRead: cacheRead, cacheWrite: cacheWrite,
|
||||
oneHourCacheWrite: oneHourCacheWrite, speed: speed
|
||||
)
|
||||
|
||||
let toolUse = (message["content"] as? [[String: Any]])?.compactMap { $0["name"] as? String } ?? []
|
||||
|
||||
turns.append(ParsedTurn(
|
||||
timestamp: timestamp, model: model,
|
||||
inputTokens: inputTokens, outputTokens: outputTokens,
|
||||
cacheReadTokens: cacheRead, cacheWriteTokens: cacheWrite,
|
||||
cost: cost, toolCalls: toolUse
|
||||
))
|
||||
}
|
||||
|
||||
guard !turns.isEmpty else { return nil }
|
||||
|
||||
let project = url.deletingLastPathComponent().lastPathComponent
|
||||
return ParsedSession(
|
||||
id: url.lastPathComponent,
|
||||
project: project,
|
||||
provider: "claude",
|
||||
startDate: firstTimestamp ?? Date(),
|
||||
calls: turns.count,
|
||||
cost: turns.reduce(0) { $0 + $1.cost },
|
||||
inputTokens: turns.reduce(0) { $0 + $1.inputTokens },
|
||||
outputTokens: turns.reduce(0) { $0 + $1.outputTokens },
|
||||
cacheReadTokens: turns.reduce(0) { $0 + $1.cacheReadTokens },
|
||||
cacheWriteTokens: turns.reduce(0) { $0 + $1.cacheWriteTokens },
|
||||
model: turns.first?.model ?? "unknown",
|
||||
turns: turns
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Codex JSONL parser
|
||||
|
||||
struct CodexParser: SessionParser {
|
||||
func parseSessions(at paths: [URL], in dateRange: ClosedRange<Date>) -> [ParsedSession] {
|
||||
paths.compactMap { parseCodexSession(at: $0, in: dateRange) }
|
||||
}
|
||||
|
||||
private func parseCodexSession(at url: URL, in dateRange: ClosedRange<Date>) -> ParsedSession? {
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
else { return nil }
|
||||
|
||||
let lines = text.components(separatedBy: .newlines).filter { !$0.isEmpty }
|
||||
var turns: [ParsedTurn] = []
|
||||
var firstTimestamp: Date?
|
||||
var sessionModel = "unknown"
|
||||
var currentModel = "unknown"
|
||||
var prevInput = 0, prevCached = 0, prevOutput = 0
|
||||
|
||||
let isoFormatter = ISO8601DateFormatter()
|
||||
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
for line in lines {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any],
|
||||
let type = json["type"] as? String
|
||||
else { continue }
|
||||
|
||||
let payload = json["payload"] as? [String: Any] ?? [:]
|
||||
|
||||
if type == "session_meta" {
|
||||
if let m = payload["model"] as? String, !m.isEmpty { sessionModel = m; currentModel = m }
|
||||
let originator = (payload["originator"] as? String ?? "").lowercased()
|
||||
guard originator.hasPrefix("codex") else { return nil }
|
||||
continue
|
||||
}
|
||||
|
||||
if type == "turn_context" {
|
||||
if let m = payload["model"] as? String, !m.isEmpty { currentModel = m }
|
||||
continue
|
||||
}
|
||||
|
||||
guard type == "event_msg",
|
||||
let payloadType = payload["type"] as? String, payloadType == "token_count"
|
||||
else { continue }
|
||||
|
||||
let ts = (json["timestamp"] as? String).flatMap { isoFormatter.date(from: $0) } ?? Date()
|
||||
if firstTimestamp == nil { firstTimestamp = ts }
|
||||
guard dateRange.contains(ts) else { continue }
|
||||
|
||||
let info = payload["info"] as? [String: Any] ?? [:]
|
||||
|
||||
let inputTokens: Int
|
||||
let cachedTokens: Int
|
||||
let outputTokens: Int
|
||||
|
||||
if let last = info["last_token_usage"] as? [String: Any] {
|
||||
inputTokens = last["input_tokens"] as? Int ?? 0
|
||||
cachedTokens = last["cached_input_tokens"] as? Int ?? 0
|
||||
outputTokens = last["output_tokens"] as? Int ?? 0
|
||||
} else if let total = info["total_token_usage"] as? [String: Any] {
|
||||
let totalIn = total["input_tokens"] as? Int ?? 0
|
||||
let totalCached = total["cached_input_tokens"] as? Int ?? 0
|
||||
let totalOut = total["output_tokens"] as? Int ?? 0
|
||||
inputTokens = max(0, totalIn - prevInput)
|
||||
cachedTokens = max(0, totalCached - prevCached)
|
||||
outputTokens = max(0, totalOut - prevOutput)
|
||||
prevInput = totalIn
|
||||
prevCached = totalCached
|
||||
prevOutput = totalOut
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
let uncachedInput = max(0, inputTokens - cachedTokens)
|
||||
let cost = PricingEngine.cost(
|
||||
model: currentModel, input: uncachedInput, output: outputTokens,
|
||||
cacheRead: cachedTokens
|
||||
)
|
||||
|
||||
turns.append(ParsedTurn(
|
||||
timestamp: ts, model: currentModel,
|
||||
inputTokens: uncachedInput, outputTokens: outputTokens,
|
||||
cacheReadTokens: cachedTokens, cacheWriteTokens: 0,
|
||||
cost: cost, toolCalls: []
|
||||
))
|
||||
}
|
||||
|
||||
guard !turns.isEmpty else { return nil }
|
||||
let project = url.deletingLastPathComponent().lastPathComponent
|
||||
return ParsedSession(
|
||||
id: url.lastPathComponent, project: project, provider: "codex",
|
||||
startDate: firstTimestamp ?? Date(), calls: turns.count,
|
||||
cost: turns.reduce(0) { $0 + $1.cost },
|
||||
inputTokens: turns.reduce(0) { $0 + $1.inputTokens },
|
||||
outputTokens: turns.reduce(0) { $0 + $1.outputTokens },
|
||||
cacheReadTokens: turns.reduce(0) { $0 + $1.cacheReadTokens },
|
||||
cacheWriteTokens: 0,
|
||||
model: sessionModel, turns: turns
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Copilot parser
|
||||
|
||||
struct CopilotParser: SessionParser {
|
||||
private static let charsPerToken = 4
|
||||
|
||||
func parseSessions(at paths: [URL], in dateRange: ClosedRange<Date>) -> [ParsedSession] {
|
||||
paths.compactMap { parseCopilotSession(at: $0, in: dateRange) }
|
||||
}
|
||||
|
||||
private func parseCopilotSession(at url: URL, in dateRange: ClosedRange<Date>) -> ParsedSession? {
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
else { return nil }
|
||||
|
||||
let lines = text.components(separatedBy: .newlines).filter { !$0.isEmpty }
|
||||
var turns: [ParsedTurn] = []
|
||||
var firstTimestamp: Date?
|
||||
|
||||
for line in lines {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any] else { continue }
|
||||
guard let type = json["type"] as? String else { continue }
|
||||
|
||||
let data = json["data"] as? [String: Any] ?? [:]
|
||||
let ts = (json["timestamp"] as? TimeInterval).map { Date(timeIntervalSince1970: $0 / 1000) }
|
||||
?? (json["timestamp"] as? String).flatMap { ISO8601DateFormatter().date(from: $0) }
|
||||
?? Date()
|
||||
|
||||
if firstTimestamp == nil { firstTimestamp = ts }
|
||||
guard dateRange.contains(ts) else { continue }
|
||||
|
||||
if type == "response" || type == "assistant" {
|
||||
let content = data["content"] as? String ?? ""
|
||||
let outputTokens = data["outputTokens"] as? Int ?? (content.count + Self.charsPerToken - 1) / Self.charsPerToken
|
||||
let model = inferModel(from: data)
|
||||
let cost = PricingEngine.cost(model: model, input: 0, output: outputTokens)
|
||||
|
||||
turns.append(ParsedTurn(
|
||||
timestamp: ts, model: model,
|
||||
inputTokens: 0, outputTokens: outputTokens,
|
||||
cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
cost: cost, toolCalls: []
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
guard !turns.isEmpty else { return nil }
|
||||
let project = url.deletingLastPathComponent().lastPathComponent
|
||||
return ParsedSession(
|
||||
id: url.lastPathComponent, project: project, provider: "copilot",
|
||||
startDate: firstTimestamp ?? Date(), calls: turns.count,
|
||||
cost: turns.reduce(0) { $0 + $1.cost },
|
||||
inputTokens: 0,
|
||||
outputTokens: turns.reduce(0) { $0 + $1.outputTokens },
|
||||
cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
model: turns.first?.model ?? "copilot-auto", turns: turns
|
||||
)
|
||||
}
|
||||
|
||||
private func inferModel(from data: [String: Any]) -> String {
|
||||
if let model = data["model"] as? String, !model.isEmpty { return model }
|
||||
let toolRequests = data["toolRequests"] as? [[String: Any]] ?? []
|
||||
for req in toolRequests {
|
||||
if let id = req["id"] as? String {
|
||||
if id.hasPrefix("toolu_") { return "copilot-anthropic-auto" }
|
||||
if id.hasPrefix("call_") { return "copilot-openai-auto" }
|
||||
}
|
||||
}
|
||||
return "copilot-auto"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Generic fallback
|
||||
|
||||
struct GenericJSONLParser: SessionParser {
|
||||
let provider: String
|
||||
|
||||
func parseSessions(at paths: [URL], in dateRange: ClosedRange<Date>) -> [ParsedSession] {
|
||||
paths.compactMap { path in
|
||||
guard let data = try? Data(contentsOf: path),
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
else { return nil }
|
||||
|
||||
let lines = text.components(separatedBy: .newlines).filter { !$0.isEmpty }
|
||||
var cost = 0.0
|
||||
var calls = 0
|
||||
|
||||
for line in lines {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any] else { continue }
|
||||
let usage = json["usage"] as? [String: Any] ?? [:]
|
||||
let input = usage["input_tokens"] as? Int ?? 0
|
||||
let output = usage["output_tokens"] as? Int ?? 0
|
||||
let model = json["model"] as? String ?? "unknown"
|
||||
cost += PricingEngine.cost(model: model, input: input, output: output)
|
||||
calls += 1
|
||||
}
|
||||
|
||||
guard calls > 0 else { return nil }
|
||||
return ParsedSession(
|
||||
id: path.lastPathComponent, project: path.deletingLastPathComponent().lastPathComponent,
|
||||
provider: provider, startDate: Date(), calls: calls, cost: cost,
|
||||
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
model: "unknown", turns: []
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
enum PricingEngine {
|
||||
struct ModelCosts {
|
||||
let inputPerToken: Double
|
||||
let outputPerToken: Double
|
||||
let cacheWritePerToken: Double
|
||||
let cacheReadPerToken: Double
|
||||
let fastMultiplier: Double
|
||||
}
|
||||
|
||||
private static let oneHourCacheWriteMultiplier = 1.6
|
||||
|
||||
private static let fastMultipliers: [String: Double] = [
|
||||
"claude-opus-4-7": 6,
|
||||
"claude-opus-4-6": 6,
|
||||
]
|
||||
|
||||
private static let builtinAliases: [String: String] = [
|
||||
"claude-4.6-opus": "claude-opus-4-6",
|
||||
"claude-4.6-opus-fast-mode": "claude-opus-4-6",
|
||||
"claude-4.6-opus-high": "claude-opus-4-6",
|
||||
"claude-4.6-opus-low": "claude-opus-4-6",
|
||||
"claude-4.6-opus-medium": "claude-opus-4-6",
|
||||
"claude-4.6-opus-high-thinking": "claude-opus-4-6",
|
||||
"claude-4.7-opus": "claude-opus-4-7",
|
||||
"claude-opus-4-7-thinking-high": "claude-opus-4-7",
|
||||
"claude-4.5-opus": "claude-opus-4-5",
|
||||
"claude-4.5-opus-high": "claude-opus-4-5",
|
||||
"claude-4.5-opus-low": "claude-opus-4-5",
|
||||
"claude-4.5-opus-medium": "claude-opus-4-5",
|
||||
"claude-4.5-opus-high-thinking": "claude-opus-4-5",
|
||||
"claude-4-opus": "claude-opus-4",
|
||||
"anthropic--claude-4.6-opus": "claude-opus-4-6",
|
||||
"anthropic--claude-4.5-opus": "claude-opus-4-5",
|
||||
"claude-opus-4.7": "claude-opus-4-7",
|
||||
"claude-opus-4.6": "claude-opus-4-6",
|
||||
"claude-opus-4.5": "claude-opus-4-5",
|
||||
]
|
||||
|
||||
private static let snapshot: [String: ModelCosts] = {
|
||||
guard let url = Bundle.main.url(forResource: "litellm-snapshot", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else { return [:] }
|
||||
|
||||
var map: [String: ModelCosts] = [:]
|
||||
for (name, raw) in dict {
|
||||
guard let arr = raw as? [Any], arr.count >= 2,
|
||||
let input = arr[0] as? Double, input >= 0,
|
||||
let output = arr[1] as? Double, output >= 0
|
||||
else { continue }
|
||||
let cacheWrite = (arr.count > 2 ? arr[2] as? Double : nil) ?? (input * 1.25)
|
||||
let cacheRead = (arr.count > 3 ? arr[3] as? Double : nil) ?? (input * 0.1)
|
||||
map[name] = ModelCosts(
|
||||
inputPerToken: input,
|
||||
outputPerToken: output,
|
||||
cacheWritePerToken: cacheWrite,
|
||||
cacheReadPerToken: cacheRead,
|
||||
fastMultiplier: fastMultipliers[name] ?? 1
|
||||
)
|
||||
}
|
||||
return map
|
||||
}()
|
||||
|
||||
private static let sortedKeys: [String] = {
|
||||
Array(snapshot.keys).sorted { $0.count > $1.count }
|
||||
}()
|
||||
|
||||
static func cost(
|
||||
model: String,
|
||||
input: Int,
|
||||
output: Int,
|
||||
cacheRead: Int = 0,
|
||||
cacheWrite: Int = 0,
|
||||
oneHourCacheWrite: Int = 0,
|
||||
speed: String = "standard"
|
||||
) -> Double {
|
||||
guard let costs = lookup(model) else { return 0 }
|
||||
|
||||
let multiplier = speed == "fast" ? costs.fastMultiplier : 1.0
|
||||
|
||||
let safeOneHour = max(0, oneHourCacheWrite)
|
||||
let totalCW = max(max(0, cacheWrite), safeOneHour)
|
||||
let fiveMinCW = max(0, totalCW - safeOneHour)
|
||||
|
||||
return multiplier * (
|
||||
Double(max(0, input)) * costs.inputPerToken
|
||||
+ Double(max(0, output)) * costs.outputPerToken
|
||||
+ Double(fiveMinCW) * costs.cacheWritePerToken
|
||||
+ Double(safeOneHour) * costs.cacheWritePerToken * oneHourCacheWriteMultiplier
|
||||
+ Double(max(0, cacheRead)) * costs.cacheReadPerToken
|
||||
)
|
||||
}
|
||||
|
||||
private static func lookup(_ model: String) -> ModelCosts? {
|
||||
let stripped = model
|
||||
.replacingOccurrences(of: #"@.*$"#, with: "", options: .regularExpression)
|
||||
.replacingOccurrences(of: #"-\d{8}$"#, with: "", options: .regularExpression)
|
||||
if let costs = snapshot[stripped] { return costs }
|
||||
|
||||
let canonical = resolveCanonical(stripped)
|
||||
if let costs = snapshot[canonical] { return costs }
|
||||
|
||||
for key in sortedKeys {
|
||||
if canonical == key || canonical.hasPrefix(key + "-") {
|
||||
return snapshot[key]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveCanonical(_ model: String) -> String {
|
||||
var name = model
|
||||
if let slashRange = name.range(of: "/") {
|
||||
name = String(name[slashRange.upperBound...])
|
||||
}
|
||||
if let resolved = builtinAliases[name] { return resolved }
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
enum SessionDiscovery {
|
||||
static func discoverAll(under root: URL, modifiedAfter cutoff: Date) -> [(String, [URL])] {
|
||||
var results: [(String, [URL])] = []
|
||||
|
||||
for spec in providerSpecs {
|
||||
let paths = spec.discover(root, cutoff)
|
||||
if !paths.isEmpty {
|
||||
results.append((spec.name, paths))
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private static let providerSpecs: [ProviderSpec] = [
|
||||
.claude, .codex, .copilotLegacy, .copilotVSCode,
|
||||
.cursor, .gemini, .cline, .rooCode, .kiloCode,
|
||||
]
|
||||
}
|
||||
|
||||
struct ProviderSpec: Sendable {
|
||||
let name: String
|
||||
let discover: @Sendable (URL, Date) -> [URL]
|
||||
}
|
||||
|
||||
extension ProviderSpec {
|
||||
static let claude = ProviderSpec(name: "claude") { root, cutoff in
|
||||
let dir = root.appendingPathComponent(".claude/projects")
|
||||
return findJSONLFiles(under: dir, maxDepth: 4, modifiedAfter: cutoff)
|
||||
}
|
||||
|
||||
static let codex = ProviderSpec(name: "codex") { root, cutoff in
|
||||
let dir = root.appendingPathComponent(".codex")
|
||||
return findJSONLFiles(under: dir, maxDepth: 6, modifiedAfter: cutoff)
|
||||
}
|
||||
|
||||
static let copilotLegacy = ProviderSpec(name: "copilot") { root, cutoff in
|
||||
let dir = root.appendingPathComponent(".copilot/session-state")
|
||||
return findJSONLFiles(under: dir, maxDepth: 3, modifiedAfter: cutoff)
|
||||
}
|
||||
|
||||
static let copilotVSCode = ProviderSpec(name: "copilot") { root, cutoff in
|
||||
let base = root.appendingPathComponent("Library/Application Support/Code/User/workspaceStorage")
|
||||
guard FileManager.default.fileExists(atPath: base.path) else { return [] }
|
||||
var results: [URL] = []
|
||||
let fm = FileManager.default
|
||||
guard let workspaces = try? fm.contentsOfDirectory(at: base, includingPropertiesForKeys: nil) else { return results }
|
||||
for ws in workspaces {
|
||||
let transcripts = ws.appendingPathComponent("GitHub.copilot-chat/transcripts")
|
||||
results.append(contentsOf: findJSONLFiles(under: transcripts, maxDepth: 1, modifiedAfter: cutoff))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
static let cursor = ProviderSpec(name: "cursor") { root, _ in
|
||||
let paths = [
|
||||
root.appendingPathComponent("Library/Application Support/Cursor/User/globalStorage/cursor.db"),
|
||||
root.appendingPathComponent(".cursor/globalStorage/cursor.db"),
|
||||
]
|
||||
return paths.filter { FileManager.default.fileExists(atPath: $0.path) }
|
||||
}
|
||||
|
||||
static let gemini = ProviderSpec(name: "gemini") { root, cutoff in
|
||||
let dir = root.appendingPathComponent(".gemini/sessions")
|
||||
return findJSONFiles(under: dir, maxDepth: 2, modifiedAfter: cutoff)
|
||||
}
|
||||
|
||||
static let cline = ProviderSpec(name: "cline") { root, cutoff in
|
||||
let base = root.appendingPathComponent("Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/tasks")
|
||||
return findJSONFiles(under: base, maxDepth: 2, modifiedAfter: cutoff, named: "ui_messages.json")
|
||||
}
|
||||
|
||||
static let rooCode = ProviderSpec(name: "roo-code") { root, cutoff in
|
||||
let base = root.appendingPathComponent("Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks")
|
||||
return findJSONFiles(under: base, maxDepth: 2, modifiedAfter: cutoff, named: "ui_messages.json")
|
||||
}
|
||||
|
||||
static let kiloCode = ProviderSpec(name: "kilo-code") { root, cutoff in
|
||||
let base = root.appendingPathComponent("Library/Application Support/Code/User/globalStorage/kilocode.kilo-code/tasks")
|
||||
return findJSONFiles(under: base, maxDepth: 2, modifiedAfter: cutoff, named: "ui_messages.json")
|
||||
}
|
||||
}
|
||||
|
||||
private func findJSONLFiles(under dir: URL, maxDepth: Int, modifiedAfter cutoff: Date) -> [URL] {
|
||||
findFiles(under: dir, maxDepth: maxDepth, extensions: ["jsonl"], modifiedAfter: cutoff)
|
||||
}
|
||||
|
||||
private func findJSONFiles(under dir: URL, maxDepth: Int, modifiedAfter cutoff: Date, named: String? = nil) -> [URL] {
|
||||
findFiles(under: dir, maxDepth: maxDepth, extensions: ["json"], modifiedAfter: cutoff, named: named)
|
||||
}
|
||||
|
||||
private func findFiles(under dir: URL, maxDepth: Int, extensions: [String], modifiedAfter cutoff: Date, named: String? = nil) -> [URL] {
|
||||
let fm = FileManager.default
|
||||
guard fm.fileExists(atPath: dir.path) else { return [] }
|
||||
|
||||
var results: [URL] = []
|
||||
guard let enumerator = fm.enumerator(
|
||||
at: dir,
|
||||
includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else { return [] }
|
||||
|
||||
for case let url as URL in enumerator {
|
||||
if enumerator.level > maxDepth { enumerator.skipDescendants(); continue }
|
||||
if let named, url.lastPathComponent != named { continue }
|
||||
guard extensions.contains(url.pathExtension) else { continue }
|
||||
if let modDate = try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate,
|
||||
modDate < cutoff { continue }
|
||||
results.append(url)
|
||||
}
|
||||
return results.sorted { a, b in
|
||||
let aDate = (try? a.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||
let bDate = (try? b.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||
return aDate > bDate
|
||||
}
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
struct ProviderData: Identifiable, Sendable {
|
||||
let id = UUID()
|
||||
let name: String
|
||||
var sessions: [ParsedSession]
|
||||
|
||||
var totalCost: Double { sessions.reduce(0) { $0 + $1.cost } }
|
||||
var totalCalls: Int { sessions.reduce(0) { $0 + $1.calls } }
|
||||
|
||||
init(name: String, sessions: [ParsedSession] = []) {
|
||||
self.name = name
|
||||
self.sessions = sessions
|
||||
}
|
||||
}
|
||||
|
||||
struct ParsedSession: Identifiable, Sendable {
|
||||
let id: String
|
||||
let project: String
|
||||
let provider: String
|
||||
let startDate: Date
|
||||
let calls: Int
|
||||
let cost: Double
|
||||
let inputTokens: Int
|
||||
let outputTokens: Int
|
||||
let cacheReadTokens: Int
|
||||
let cacheWriteTokens: Int
|
||||
let model: String
|
||||
let turns: [ParsedTurn]
|
||||
}
|
||||
|
||||
struct ParsedTurn: Sendable {
|
||||
let timestamp: Date
|
||||
let model: String
|
||||
let inputTokens: Int
|
||||
let outputTokens: Int
|
||||
let cacheReadTokens: Int
|
||||
let cacheWriteTokens: Int
|
||||
let cost: Double
|
||||
let toolCalls: [String]
|
||||
}
|
||||
|
||||
extension SessionStore.Period {
|
||||
var dateRange: ClosedRange<Date> {
|
||||
let now = Date()
|
||||
let calendar = Calendar.current
|
||||
let startOfToday = calendar.startOfDay(for: now)
|
||||
|
||||
let start: Date = switch self {
|
||||
case .today: startOfToday
|
||||
case .week: calendar.date(byAdding: .day, value: -7, to: startOfToday)!
|
||||
case .month: calendar.date(byAdding: .day, value: -30, to: startOfToday)!
|
||||
case .threeMonths: calendar.date(byAdding: .month, value: -3, to: startOfToday)!
|
||||
case .sixMonths: calendar.date(byAdding: .month, value: -6, to: startOfToday)!
|
||||
}
|
||||
return start...now
|
||||
}
|
||||
|
||||
var fileCutoff: Date {
|
||||
let calendar = Calendar.current
|
||||
let startOfToday = calendar.startOfDay(for: Date())
|
||||
return switch self {
|
||||
case .today: calendar.date(byAdding: .day, value: -1, to: startOfToday)!
|
||||
case .week: calendar.date(byAdding: .day, value: -8, to: startOfToday)!
|
||||
case .month: calendar.date(byAdding: .day, value: -31, to: startOfToday)!
|
||||
case .threeMonths: calendar.date(byAdding: .month, value: -3, to: calendar.date(byAdding: .day, value: -1, to: startOfToday)!)!
|
||||
case .sixMonths: calendar.date(byAdding: .month, value: -6, to: calendar.date(byAdding: .day, value: -1, to: startOfToday)!)!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class SessionStore: ObservableObject {
|
||||
@Published var providers: [ProviderData] = []
|
||||
@Published var selectedProvider: String = "all"
|
||||
@Published var selectedPeriod: Period = .today
|
||||
@Published var isLoading = false
|
||||
@Published var grantedFolders: [URL] = []
|
||||
@Published var needsOnboarding: Bool = true
|
||||
|
||||
var todayCost: Double {
|
||||
providers.reduce(0) { $0 + $1.totalCost }
|
||||
}
|
||||
|
||||
private var bookmarks: [Data] = []
|
||||
private var refreshTimer: Timer?
|
||||
|
||||
enum Period: String, CaseIterable, Identifiable {
|
||||
case today, week, month, threeMonths, sixMonths
|
||||
var id: String { rawValue }
|
||||
var label: String {
|
||||
switch self {
|
||||
case .today: "Today"
|
||||
case .week: "7 Days"
|
||||
case .month: "30 Days"
|
||||
case .threeMonths: "3 Months"
|
||||
case .sixMonths: "6 Months"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
loadBookmarks()
|
||||
needsOnboarding = grantedFolders.isEmpty
|
||||
if !needsOnboarding {
|
||||
Task { await refresh() }
|
||||
startAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func stopTimer() {
|
||||
MainActor.assumeIsolated {
|
||||
refreshTimer?.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
func grantFolderAccess() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.message = "Select your home folder so CodeBurn Pro can read session logs from coding tools."
|
||||
panel.prompt = "Grant Access"
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser
|
||||
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||
|
||||
guard url.startAccessingSecurityScopedResource() else { return }
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
|
||||
if let bookmark = try? url.bookmarkData(
|
||||
options: .withSecurityScope,
|
||||
includingResourceValuesForKeys: nil,
|
||||
relativeTo: nil
|
||||
) {
|
||||
bookmarks.append(bookmark)
|
||||
grantedFolders.append(url)
|
||||
saveBookmarks()
|
||||
needsOnboarding = false
|
||||
Task { await refresh() }
|
||||
startAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
isLoading = true
|
||||
|
||||
let resolvedURLs = resolveBookmarks()
|
||||
let period = selectedPeriod.dateRange
|
||||
let fileCutoff = selectedPeriod.fileCutoff
|
||||
NSLog("CodeBurnPro: refresh — %d bookmarks, period %@..%@", resolvedURLs.count, "\(period.lowerBound)", "\(period.upperBound)")
|
||||
|
||||
let result: [ProviderData] = await Task.detached(priority: .userInitiated) {
|
||||
var allData: [ProviderData] = []
|
||||
|
||||
for url in resolvedURLs {
|
||||
guard url.startAccessingSecurityScopedResource() else {
|
||||
NSLog("CodeBurnPro: failed to access: %@", url.path)
|
||||
continue
|
||||
}
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
|
||||
let discovered = SessionDiscovery.discoverAll(under: url, modifiedAfter: fileCutoff)
|
||||
|
||||
for (providerName, paths) in discovered {
|
||||
NSLog("CodeBurnPro: %@ — %d files", providerName, paths.count)
|
||||
let parser = ParserFactory.parser(for: providerName)
|
||||
let sessions = parser.parseSessions(at: paths, in: period)
|
||||
NSLog("CodeBurnPro: %@ — %d sessions, $%.2f", providerName, sessions.count, sessions.reduce(0) { $0 + $1.cost })
|
||||
|
||||
if let idx = allData.firstIndex(where: { $0.name == providerName }) {
|
||||
allData[idx].sessions.append(contentsOf: sessions)
|
||||
} else {
|
||||
allData.append(ProviderData(name: providerName, sessions: sessions))
|
||||
}
|
||||
}
|
||||
}
|
||||
return allData
|
||||
}.value
|
||||
|
||||
providers = result
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func startAutoRefresh() {
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
await self.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bookmark persistence
|
||||
|
||||
private static var bookmarkURL: URL {
|
||||
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("CodeBurnPro", isDirectory: true)
|
||||
.appendingPathComponent("bookmarks.plist")
|
||||
}
|
||||
|
||||
private func saveBookmarks() {
|
||||
let dir = Self.bookmarkURL.deletingLastPathComponent()
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
try? PropertyListEncoder().encode(bookmarks).write(to: Self.bookmarkURL)
|
||||
}
|
||||
|
||||
private func loadBookmarks() {
|
||||
guard let data = try? Data(contentsOf: Self.bookmarkURL),
|
||||
let decoded = try? PropertyListDecoder().decode([Data].self, from: data)
|
||||
else { return }
|
||||
bookmarks = decoded
|
||||
grantedFolders = resolveBookmarks()
|
||||
needsOnboarding = grantedFolders.isEmpty
|
||||
}
|
||||
|
||||
private func resolveBookmarks() -> [URL] {
|
||||
bookmarks.compactMap { data in
|
||||
var isStale = false
|
||||
guard let url = try? URL(
|
||||
resolvingBookmarkData: data,
|
||||
options: .withSecurityScope,
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &isStale
|
||||
) else { return nil }
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,928 +0,0 @@
|
|||
import SwiftUI
|
||||
|
||||
// MARK: - Shared Components
|
||||
|
||||
struct SectionCaption: View {
|
||||
let text: String
|
||||
var body: some View {
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(Theme.brandAccent.opacity(0.7))
|
||||
.frame(width: 3, height: 3)
|
||||
Text(text)
|
||||
.font(.system(size: 11.5, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.tracking(-0.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CollapsibleSection<Trailing: View, Content: View>: View {
|
||||
let caption: String
|
||||
@Binding var isExpanded: Bool
|
||||
let trailing: Trailing
|
||||
let content: Content
|
||||
|
||||
init(
|
||||
caption: String,
|
||||
isExpanded: Binding<Bool>,
|
||||
@ViewBuilder trailing: () -> Trailing,
|
||||
@ViewBuilder content: () -> Content
|
||||
) {
|
||||
self.caption = caption
|
||||
self._isExpanded = isExpanded
|
||||
self.trailing = trailing()
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.18)) { isExpanded.toggle() }
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(Theme.brandAccent.opacity(0.7))
|
||||
.frame(width: 3, height: 3)
|
||||
Text(caption)
|
||||
.font(.system(size: 11.5, weight: .medium))
|
||||
.tracking(-0.1)
|
||||
}
|
||||
Spacer()
|
||||
trailing
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||||
.opacity(0.55)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if isExpanded {
|
||||
content.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 11)
|
||||
}
|
||||
}
|
||||
|
||||
extension CollapsibleSection where Trailing == EmptyView {
|
||||
init(caption: String, isExpanded: Binding<Bool>, @ViewBuilder content: () -> Content) {
|
||||
self.init(caption: caption, isExpanded: isExpanded, trailing: { EmptyView() }, content: content)
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedBar: View {
|
||||
let fraction: Double
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 2).fill(.secondary.opacity(0.15))
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Theme.brandAccent)
|
||||
.frame(width: max(0, min(geo.size.width, geo.size.width * CGFloat(fraction))))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MiniStat: View {
|
||||
let label: String
|
||||
let value: String
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(label)
|
||||
.font(.system(size: 9.5, weight: .medium))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(value)
|
||||
.font(.system(size: 11.5, weight: .semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Insight Pill Switcher
|
||||
|
||||
enum InsightMode: String, CaseIterable, Identifiable {
|
||||
case trend = "Trend"
|
||||
case forecast = "Forecast"
|
||||
case pulse = "Pulse"
|
||||
case stats = "Stats"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
struct InsightSection: View {
|
||||
@ObservedObject var store: SessionStore
|
||||
@State private var selected: InsightMode = .trend
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(InsightMode.allCases) { mode in
|
||||
Button { selected = mode } label: {
|
||||
Text(mode.rawValue)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(selected == mode ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(selected == mode ? AnyShapeStyle(Theme.brandAccent) : AnyShapeStyle(Color.secondary.opacity(0.10)))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
switch selected {
|
||||
case .trend: TrendInsight(store: store)
|
||||
case .forecast: ForecastInsight(store: store)
|
||||
case .pulse: PulseInsight(store: store)
|
||||
case .stats: StatsInsight(store: store)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trend Insight
|
||||
|
||||
private let trendDays = 19
|
||||
private let trendBarWidth: CGFloat = 13
|
||||
private let trendBarGap: CGFloat = 4
|
||||
private let trendChartHeight: CGFloat = 90
|
||||
|
||||
private struct TrendBar: Identifiable {
|
||||
var id: String { date }
|
||||
let date: String
|
||||
let cost: Double
|
||||
let inputTokens: Double
|
||||
let outputTokens: Double
|
||||
let isToday: Bool
|
||||
let topModels: [(name: String, cost: Double)]
|
||||
var tokens: Double { inputTokens + outputTokens }
|
||||
}
|
||||
|
||||
private struct TrendInsight: View {
|
||||
@ObservedObject var store: SessionStore
|
||||
|
||||
var body: some View {
|
||||
let bars = buildBars()
|
||||
let totalCost = bars.reduce(0.0) { $0 + $1.cost }
|
||||
let totalTokens = bars.reduce(0.0) { $0 + $1.tokens }
|
||||
let useTokens = totalTokens > 0
|
||||
let metric: (TrendBar) -> Double = useTokens ? { $0.tokens } : { $0.cost }
|
||||
let maxValue = max(bars.map(metric).max() ?? 1, 0.01)
|
||||
let avgValue = bars.isEmpty ? 0 : bars.map(metric).reduce(0, +) / Double(bars.count)
|
||||
let peakBar = bars.filter({ metric($0) > 0 }).max(by: { metric($0) < metric($1) })
|
||||
let yesterdayBar = bars.dropLast().last
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Last \(trendDays) days")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(useTokens ? "\(formatTokens(totalTokens)) tokens" : totalCost.asCurrency())
|
||||
.font(.system(size: 18, weight: .semibold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
Spacer()
|
||||
if let delta = deltaPercent(bars: bars, metric: metric) {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: delta >= 0 ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
Text("\(delta >= 0 ? "+" : "")\(String(format: "%.0f", delta))% vs prior \(trendDays)d")
|
||||
.font(.system(size: 10.5))
|
||||
.monospacedDigit()
|
||||
}
|
||||
.foregroundStyle(Theme.brandAccent)
|
||||
}
|
||||
}
|
||||
|
||||
TrendChart(bars: bars, maxValue: maxValue, avgValue: avgValue, metric: metric) { v in
|
||||
useTokens ? "\(formatTokens(v)) tok" : v.asCompactCurrency()
|
||||
}
|
||||
|
||||
HStack(spacing: 14) {
|
||||
MiniStat(label: "Avg/day", value: formatValue(avgValue, useTokens: useTokens))
|
||||
MiniStat(label: "Peak", value: peakLabel(peakBar, metric: metric, useTokens: useTokens))
|
||||
MiniStat(label: "Yesterday", value: yesterdayBar.map { formatValue(metric($0), useTokens: useTokens) } ?? "—")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatValue(_ v: Double, useTokens: Bool) -> String {
|
||||
useTokens ? "\(formatTokens(v)) tok" : v.asCompactCurrency()
|
||||
}
|
||||
|
||||
private func peakLabel(_ peak: TrendBar?, metric: (TrendBar) -> Double, useTokens: Bool) -> String {
|
||||
guard let peak, metric(peak) > 0 else { return "—" }
|
||||
let parts = peak.date.split(separator: "-")
|
||||
let short = parts.count == 3 ? "\(parts[1])/\(parts[2])" : peak.date
|
||||
return "\(formatValue(metric(peak), useTokens: useTokens)) on \(short)"
|
||||
}
|
||||
|
||||
private func deltaPercent(bars: [TrendBar], metric: (TrendBar) -> Double) -> Double? {
|
||||
let sessions = filteredSessions
|
||||
let calendar = Calendar.current
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
|
||||
guard let priorStart = calendar.date(byAdding: .day, value: -(2 * trendDays - 1), to: today),
|
||||
let thisStart = calendar.date(byAdding: .day, value: -(trendDays - 1), to: today)
|
||||
else { return nil }
|
||||
|
||||
let thisTotal = bars.map(metric).reduce(0, +)
|
||||
var priorTotal = 0.0
|
||||
for session in sessions {
|
||||
let sd = calendar.startOfDay(for: session.startDate)
|
||||
if sd >= priorStart && sd < thisStart {
|
||||
priorTotal += session.cost
|
||||
}
|
||||
}
|
||||
guard priorTotal > 0 else { return nil }
|
||||
return ((thisTotal - priorTotal) / priorTotal) * 100
|
||||
}
|
||||
|
||||
private func buildBars() -> [TrendBar] {
|
||||
let calendar = Calendar.current
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
let todayKey = formatter.string(from: today)
|
||||
|
||||
let sessions = filteredSessions
|
||||
var costByDate: [String: Double] = [:]
|
||||
var inputByDate: [String: Double] = [:]
|
||||
var outputByDate: [String: Double] = [:]
|
||||
var modelsByDate: [String: [String: Double]] = [:]
|
||||
|
||||
for session in sessions {
|
||||
for turn in session.turns {
|
||||
let key = formatter.string(from: turn.timestamp)
|
||||
costByDate[key, default: 0] += turn.cost
|
||||
inputByDate[key, default: 0] += Double(turn.inputTokens)
|
||||
outputByDate[key, default: 0] += Double(turn.outputTokens)
|
||||
modelsByDate[key, default: [:]][turn.model, default: 0] += turn.cost
|
||||
}
|
||||
if session.turns.isEmpty {
|
||||
let key = formatter.string(from: session.startDate)
|
||||
costByDate[key, default: 0] += session.cost
|
||||
}
|
||||
}
|
||||
|
||||
return (0..<trendDays).reversed().map { offset in
|
||||
let date = calendar.date(byAdding: .day, value: -offset, to: today)!
|
||||
let key = formatter.string(from: date)
|
||||
let models = (modelsByDate[key] ?? [:]).sorted(by: { $0.value > $1.value }).prefix(3).map { ($0.key, $0.value) }
|
||||
return TrendBar(
|
||||
date: key,
|
||||
cost: costByDate[key] ?? 0,
|
||||
inputTokens: inputByDate[key] ?? 0,
|
||||
outputTokens: outputByDate[key] ?? 0,
|
||||
isToday: key == todayKey,
|
||||
topModels: models
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredSessions: [ParsedSession] {
|
||||
if store.selectedProvider == "all" {
|
||||
return store.providers.flatMap(\.sessions)
|
||||
}
|
||||
return store.providers.first(where: { $0.name == store.selectedProvider })?.sessions ?? []
|
||||
}
|
||||
}
|
||||
|
||||
private struct TrendChart: View {
|
||||
let bars: [TrendBar]
|
||||
let maxValue: Double
|
||||
let avgValue: Double
|
||||
let metric: (TrendBar) -> Double
|
||||
let formatValue: (Double) -> String
|
||||
@State private var hoveredBarID: TrendBar.ID?
|
||||
|
||||
var body: some View {
|
||||
let avgFraction = maxValue > 0 ? CGFloat(min(avgValue / maxValue, 1.0)) : 0
|
||||
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
HStack(alignment: .bottom, spacing: trendBarGap) {
|
||||
ForEach(bars) { bar in
|
||||
BarColumn(bar: bar, value: metric(bar), maxValue: maxValue, isHovered: hoveredBarID == bar.id)
|
||||
.onHover { hovering in
|
||||
hoveredBarID = hovering ? bar.id : (hoveredBarID == bar.id ? nil : hoveredBarID)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(height: trendChartHeight, alignment: .bottom)
|
||||
|
||||
GeometryReader { geo in
|
||||
Path { p in
|
||||
let y = geo.size.height - (geo.size.height * avgFraction)
|
||||
p.move(to: CGPoint(x: 0, y: y))
|
||||
p.addLine(to: CGPoint(x: geo.size.width, y: y))
|
||||
}
|
||||
.stroke(Color.secondary.opacity(0.5), style: StrokeStyle(lineWidth: 1, dash: [3, 3]))
|
||||
}
|
||||
.frame(height: trendChartHeight)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.frame(height: trendChartHeight)
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
if let bar = bars.first(where: { $0.id == hoveredBarID }) {
|
||||
BarTooltipCard(bar: bar, value: metric(bar), formatValue: formatValue)
|
||||
.padding(.top, 6)
|
||||
.offset(y: 92)
|
||||
.transition(.opacity)
|
||||
.allowsHitTesting(false)
|
||||
.zIndex(10)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.12), value: hoveredBarID)
|
||||
}
|
||||
}
|
||||
|
||||
private struct BarColumn: View {
|
||||
let bar: TrendBar
|
||||
let value: Double
|
||||
let maxValue: Double
|
||||
let isHovered: Bool
|
||||
|
||||
var body: some View {
|
||||
let fraction = maxValue > 0 ? CGFloat(value / maxValue) : 0
|
||||
let height = max(2, trendChartHeight * fraction)
|
||||
|
||||
VStack(spacing: 2) {
|
||||
Spacer(minLength: 0)
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(barColor)
|
||||
.frame(width: trendBarWidth, height: height)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.stroke(Theme.brandAccent.opacity(isHovered ? 0.9 : 0), lineWidth: 1)
|
||||
)
|
||||
.scaleEffect(x: isHovered ? 1.08 : 1.0, y: 1.0, anchor: .bottom)
|
||||
.animation(.easeOut(duration: 0.12), value: isHovered)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private var barColor: Color {
|
||||
if bar.isToday { return Theme.brandAccent }
|
||||
if value <= 0 { return Color.secondary.opacity(0.15) }
|
||||
return isHovered ? Theme.brandAccent.opacity(0.85) : Theme.brandAccent.opacity(0.55)
|
||||
}
|
||||
}
|
||||
|
||||
private struct BarTooltipCard: View {
|
||||
let bar: TrendBar
|
||||
let value: Double
|
||||
let formatValue: (Double) -> String
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(prettyDate(bar.date))
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(colorScheme == .dark ? .black : .white)
|
||||
Spacer()
|
||||
Text(formatValue(value))
|
||||
.font(.codeMono(size: 10.5, weight: .semibold))
|
||||
.foregroundStyle(Theme.brandAccent)
|
||||
}
|
||||
if !bar.topModels.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
ForEach(bar.topModels.prefix(3), id: \.name) { m in
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(Theme.brandAccent.opacity(0.7)).frame(width: 4, height: 4)
|
||||
Text(m.name)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(colorScheme == .dark ? .black : .white)
|
||||
Spacer()
|
||||
Text(m.cost.asCompactCurrency())
|
||||
.font(.codeMono(size: 9.5, weight: .medium))
|
||||
.foregroundStyle(colorScheme == .dark ? Color.black.opacity(0.7) : Color.white.opacity(0.72))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(11)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(colorScheme == .dark ? Color.white : Color.black)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(colorScheme == .dark ? Color.black.opacity(0.12) : Color.white.opacity(0.12), lineWidth: 0.5)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.35), radius: 10, y: 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Forecast Insight
|
||||
|
||||
private struct ForecastInsight: View {
|
||||
@ObservedObject var store: SessionStore
|
||||
|
||||
var body: some View {
|
||||
let stats = computeForecast()
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Month-to-date")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(stats.mtd.asCurrency())
|
||||
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Theme.brandAccent)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("On pace for")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(stats.projection.asCurrency())
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 14) {
|
||||
MiniStat(label: "Avg/day (this wk)", value: stats.weekAvg.asCompactCurrency())
|
||||
MiniStat(label: "Yesterday", value: stats.yesterday.asCompactCurrency())
|
||||
MiniStat(label: "Last 7d", value: stats.weekTotal.asCompactCurrency())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func computeForecast() -> (mtd: Double, projection: Double, weekAvg: Double, weekTotal: Double, yesterday: Double) {
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
let today = calendar.startOfDay(for: now)
|
||||
let comps = calendar.dateComponents([.year, .month, .day], from: now)
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
|
||||
let sessions = filteredSessions
|
||||
var costByDate: [String: Double] = [:]
|
||||
for session in sessions {
|
||||
let key = formatter.string(from: session.startDate)
|
||||
costByDate[key, default: 0] += session.cost
|
||||
}
|
||||
|
||||
let firstOfMonth = calendar.date(from: DateComponents(year: comps.year, month: comps.month, day: 1))!
|
||||
let firstStr = formatter.string(from: firstOfMonth)
|
||||
let daysInMonth = calendar.range(of: .day, in: .month, for: firstOfMonth)?.count ?? 30
|
||||
let dayOfMonth = comps.day ?? 1
|
||||
|
||||
let mtd = costByDate.filter { $0.key >= firstStr }.values.reduce(0, +)
|
||||
let avgPerDay = dayOfMonth > 0 ? mtd / Double(dayOfMonth) : 0
|
||||
let projection = avgPerDay * Double(daysInMonth)
|
||||
|
||||
let weekStart = calendar.date(byAdding: .day, value: -6, to: today)!
|
||||
let weekStartStr = formatter.string(from: weekStart)
|
||||
let weekTotal = costByDate.filter { $0.key >= weekStartStr }.values.reduce(0, +)
|
||||
let weekAvg = weekTotal / 7.0
|
||||
|
||||
let yesterdayStr = formatter.string(from: calendar.date(byAdding: .day, value: -1, to: today)!)
|
||||
let yesterday = costByDate[yesterdayStr] ?? 0
|
||||
|
||||
return (mtd, projection, weekAvg, weekTotal, yesterday)
|
||||
}
|
||||
|
||||
private var filteredSessions: [ParsedSession] {
|
||||
if store.selectedProvider == "all" {
|
||||
return store.providers.flatMap(\.sessions)
|
||||
}
|
||||
return store.providers.first(where: { $0.name == store.selectedProvider })?.sessions ?? []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pulse Insight
|
||||
|
||||
private struct PulseInsight: View {
|
||||
@ObservedObject var store: SessionStore
|
||||
|
||||
var body: some View {
|
||||
let sessions = filteredSessions
|
||||
let totalInput = sessions.reduce(0) { $0 + $1.inputTokens }
|
||||
let totalCacheRead = sessions.reduce(0) { $0 + $1.cacheReadTokens }
|
||||
let cacheHit = totalInput > 0 ? Double(totalCacheRead) / Double(totalInput + totalCacheRead) * 100 : 0
|
||||
let costPerSession = sessions.isEmpty ? 0 : sessions.reduce(0.0) { $0 + $1.cost } / Double(sessions.count)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
PulseTile(label: "Cache hit", value: totalCacheRead > 0 ? String(format: "%.0f%%", cacheHit) : "—", color: Theme.brandAccent)
|
||||
PulseTile(label: "Cost / session", value: sessions.isEmpty ? "—" : costPerSession.asCompactCurrency(), color: .secondary)
|
||||
PulseTile(label: "Sessions", value: "\(sessions.count)", color: Theme.brandAccent)
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredSessions: [ParsedSession] {
|
||||
if store.selectedProvider == "all" {
|
||||
return store.providers.flatMap(\.sessions)
|
||||
}
|
||||
return store.providers.first(where: { $0.name == store.selectedProvider })?.sessions ?? []
|
||||
}
|
||||
}
|
||||
|
||||
private struct PulseTile: View {
|
||||
let label: String
|
||||
let value: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(label)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(value)
|
||||
.font(.system(size: 18, weight: .semibold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.secondary.opacity(0.06))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stats Insight
|
||||
|
||||
private struct StatsInsight: View {
|
||||
@ObservedObject var store: SessionStore
|
||||
|
||||
var body: some View {
|
||||
let stats = computeStats()
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
StatRow(label: "Favorite model", value: stats.favoriteModel)
|
||||
StatRow(label: "Active days", value: stats.activeDaysFraction)
|
||||
StatRow(label: "Peak day spend", value: stats.peakDaySpend)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
StatRow(label: "Sessions today", value: "\(stats.sessionsToday)")
|
||||
StatRow(label: "Current streak", value: stats.currentStreak)
|
||||
StatRow(label: "Longest streak", value: stats.longestStreak)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
if stats.lifetimeTotal > 0 {
|
||||
Divider().opacity(0.5)
|
||||
HStack {
|
||||
Text("Total tracked spend")
|
||||
.font(.system(size: 10.5, weight: .medium))
|
||||
.foregroundStyle(.tertiary)
|
||||
Spacer()
|
||||
Text(stats.lifetimeTotal.asCurrency())
|
||||
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Theme.brandAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Stats {
|
||||
let favoriteModel: String
|
||||
let activeDaysFraction: String
|
||||
let peakDaySpend: String
|
||||
let sessionsToday: Int
|
||||
let currentStreak: String
|
||||
let longestStreak: String
|
||||
let lifetimeTotal: Double
|
||||
}
|
||||
|
||||
private func computeStats() -> Stats {
|
||||
let sessions = filteredSessions
|
||||
let allTurns = sessions.flatMap(\.turns)
|
||||
let calendar = Calendar.current
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
|
||||
let modelCosts = Dictionary(grouping: allTurns, by: \.model)
|
||||
.mapValues { $0.reduce(0.0) { $0 + $1.cost } }
|
||||
let favoriteModel = modelCosts.max(by: { $0.value < $1.value })?.key ?? "—"
|
||||
|
||||
var costByDate: [String: Double] = [:]
|
||||
for session in sessions {
|
||||
let key = formatter.string(from: session.startDate)
|
||||
costByDate[key, default: 0] += session.cost
|
||||
}
|
||||
|
||||
let comps = calendar.dateComponents([.year, .month, .day], from: Date())
|
||||
let firstOfMonth = calendar.date(from: DateComponents(year: comps.year, month: comps.month, day: 1))!
|
||||
let firstStr = formatter.string(from: firstOfMonth)
|
||||
let daysInMonth = calendar.range(of: .day, in: .month, for: firstOfMonth)?.count ?? 30
|
||||
let activeDays = costByDate.filter { $0.key >= firstStr && $0.value > 0 }.count
|
||||
|
||||
let peak = costByDate.max(by: { $0.value < $1.value })
|
||||
let peakDaySpend = peak.map { $0.value.asCompactCurrency() } ?? "—"
|
||||
|
||||
let todayStr = formatter.string(from: today)
|
||||
let sessionsToday = sessions.filter { formatter.string(from: $0.startDate) == todayStr }.count
|
||||
|
||||
var currentStreak = 0
|
||||
for offset in 0..<400 {
|
||||
guard let d = calendar.date(byAdding: .day, value: -offset, to: today) else { break }
|
||||
let key = formatter.string(from: d)
|
||||
if (costByDate[key] ?? 0) > 0 { currentStreak += 1 } else { break }
|
||||
}
|
||||
|
||||
var longestStreak = 0
|
||||
var running = 0
|
||||
let sortedDates = costByDate.keys.sorted()
|
||||
if let first = sortedDates.first, let last = sortedDates.last,
|
||||
let start = formatter.date(from: first), let end = formatter.date(from: last) {
|
||||
var cursor = start
|
||||
while cursor <= end {
|
||||
let key = formatter.string(from: cursor)
|
||||
if (costByDate[key] ?? 0) > 0 {
|
||||
running += 1
|
||||
longestStreak = max(longestStreak, running)
|
||||
} else {
|
||||
running = 0
|
||||
}
|
||||
guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
|
||||
cursor = next
|
||||
}
|
||||
}
|
||||
|
||||
let lifetime = sessions.reduce(0.0) { $0 + $1.cost }
|
||||
|
||||
return Stats(
|
||||
favoriteModel: favoriteModel,
|
||||
activeDaysFraction: "\(activeDays)/\(daysInMonth)",
|
||||
peakDaySpend: peakDaySpend,
|
||||
sessionsToday: sessionsToday,
|
||||
currentStreak: currentStreak == 0 ? "—" : "\(currentStreak) days",
|
||||
longestStreak: longestStreak == 0 ? "—" : "\(longestStreak) days",
|
||||
lifetimeTotal: lifetime
|
||||
)
|
||||
}
|
||||
|
||||
private var filteredSessions: [ParsedSession] {
|
||||
if store.selectedProvider == "all" {
|
||||
return store.providers.flatMap(\.sessions)
|
||||
}
|
||||
return store.providers.first(where: { $0.name == store.selectedProvider })?.sessions ?? []
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(label)
|
||||
.font(.system(size: 9.5, weight: .medium))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(value)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Activity Section (Projects)
|
||||
|
||||
struct ActivitySection: View {
|
||||
@ObservedObject var store: SessionStore
|
||||
@State private var isExpanded: Bool = true
|
||||
|
||||
var body: some View {
|
||||
let projects = topProjects
|
||||
if projects.isEmpty { return AnyView(EmptyView()) }
|
||||
let maxCost = projects.first?.cost ?? 1
|
||||
|
||||
return AnyView(
|
||||
CollapsibleSection(
|
||||
caption: "Activity",
|
||||
isExpanded: $isExpanded,
|
||||
trailing: {
|
||||
HStack(spacing: 8) {
|
||||
Text("Cost").frame(minWidth: 54, alignment: .trailing)
|
||||
Text("Turns").frame(minWidth: 52, alignment: .trailing)
|
||||
}
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.tertiary)
|
||||
.tracking(-0.05)
|
||||
}
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
ForEach(projects, id: \.name) { project in
|
||||
HStack(spacing: 8) {
|
||||
FixedBar(fraction: project.cost / maxCost)
|
||||
.frame(width: 56, height: 6)
|
||||
Text(project.name)
|
||||
.font(.system(size: 12.5, weight: .medium))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Text(project.cost.asCompactCurrency())
|
||||
.font(.codeMono(size: 12, weight: .medium))
|
||||
.tracking(-0.2)
|
||||
.frame(minWidth: 54, alignment: .trailing)
|
||||
Text("\(project.turns)")
|
||||
.font(.system(size: 11))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 52, alignment: .trailing)
|
||||
}
|
||||
.padding(.vertical, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private struct ProjectEntry {
|
||||
let name: String
|
||||
let cost: Double
|
||||
let turns: Int
|
||||
}
|
||||
|
||||
private var topProjects: [ProjectEntry] {
|
||||
let sessions = filteredSessions
|
||||
var byCost: [String: Double] = [:]
|
||||
var byTurns: [String: Int] = [:]
|
||||
for s in sessions {
|
||||
byCost[s.project, default: 0] += s.cost
|
||||
byTurns[s.project, default: 0] += s.turns.count
|
||||
}
|
||||
return byCost.map { ProjectEntry(name: $0.key, cost: $0.value, turns: byTurns[$0.key] ?? 0) }
|
||||
.sorted(by: { $0.cost > $1.cost })
|
||||
.prefix(8)
|
||||
.map { $0 }
|
||||
}
|
||||
|
||||
private var filteredSessions: [ParsedSession] {
|
||||
if store.selectedProvider == "all" {
|
||||
return store.providers.flatMap(\.sessions)
|
||||
}
|
||||
return store.providers.first(where: { $0.name == store.selectedProvider })?.sessions ?? []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Models Section
|
||||
|
||||
struct ModelsSection: View {
|
||||
@ObservedObject var store: SessionStore
|
||||
@State private var isExpanded: Bool = true
|
||||
|
||||
var body: some View {
|
||||
let models = topModels
|
||||
if models.isEmpty { return AnyView(EmptyView()) }
|
||||
let maxCost = models.first?.cost ?? 1
|
||||
|
||||
return AnyView(
|
||||
CollapsibleSection(
|
||||
caption: "Models",
|
||||
isExpanded: $isExpanded,
|
||||
trailing: {
|
||||
HStack(spacing: 8) {
|
||||
Text("Cost").frame(minWidth: 54, alignment: .trailing)
|
||||
Text("Calls").frame(minWidth: 52, alignment: .trailing)
|
||||
}
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.tertiary)
|
||||
.tracking(-0.05)
|
||||
}
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
ForEach(models, id: \.name) { model in
|
||||
HStack(spacing: 8) {
|
||||
FixedBar(fraction: model.cost / maxCost)
|
||||
.frame(width: 56, height: 6)
|
||||
Text(model.name)
|
||||
.font(.system(size: 12.5, weight: .medium))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.lineLimit(1)
|
||||
Text(model.cost.asCompactCurrency())
|
||||
.font(.codeMono(size: 12, weight: .medium))
|
||||
.tracking(-0.2)
|
||||
.frame(minWidth: 54, alignment: .trailing)
|
||||
Text("\(model.calls)")
|
||||
.font(.system(size: 11))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 52, alignment: .trailing)
|
||||
}
|
||||
.padding(.vertical, 1)
|
||||
}
|
||||
|
||||
TokensLine(store: store)
|
||||
.padding(.top, 5)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private struct ModelEntry {
|
||||
let name: String
|
||||
let cost: Double
|
||||
let calls: Int
|
||||
}
|
||||
|
||||
private var topModels: [ModelEntry] {
|
||||
let sessions = filteredSessions
|
||||
let allTurns = sessions.flatMap(\.turns)
|
||||
var byCost: [String: Double] = [:]
|
||||
var byCalls: [String: Int] = [:]
|
||||
for t in allTurns {
|
||||
byCost[t.model, default: 0] += t.cost
|
||||
byCalls[t.model, default: 0] += 1
|
||||
}
|
||||
return byCost.map { ModelEntry(name: $0.key, cost: $0.value, calls: byCalls[$0.key] ?? 0) }
|
||||
.sorted(by: { $0.cost > $1.cost })
|
||||
.prefix(6)
|
||||
.map { $0 }
|
||||
}
|
||||
|
||||
private var filteredSessions: [ParsedSession] {
|
||||
if store.selectedProvider == "all" {
|
||||
return store.providers.flatMap(\.sessions)
|
||||
}
|
||||
return store.providers.first(where: { $0.name == store.selectedProvider })?.sessions ?? []
|
||||
}
|
||||
}
|
||||
|
||||
private struct TokensLine: View {
|
||||
@ObservedObject var store: SessionStore
|
||||
|
||||
var body: some View {
|
||||
let sessions = store.selectedProvider == "all"
|
||||
? store.providers.flatMap(\.sessions)
|
||||
: store.providers.first(where: { $0.name == store.selectedProvider })?.sessions ?? []
|
||||
let totalIn = sessions.reduce(0) { $0 + $1.inputTokens }
|
||||
let totalOut = sessions.reduce(0) { $0 + $1.outputTokens }
|
||||
let totalCacheRead = sessions.reduce(0) { $0 + $1.cacheReadTokens }
|
||||
let cacheHit = (totalIn + totalCacheRead) > 0
|
||||
? String(format: "%.0f", Double(totalCacheRead) / Double(totalIn + totalCacheRead) * 100)
|
||||
: "0"
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text("Tokens")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(formatTokens(totalIn) + " in")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("·")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(formatTokens(totalOut) + " out")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("·")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(cacheHit + "% cache hit")
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.font(.system(size: 10.5))
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func prettyDate(_ ymd: String) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
guard let date = formatter.date(from: ymd) else { return ymd }
|
||||
let pretty = DateFormatter()
|
||||
pretty.dateFormat = "EEE MMM d"
|
||||
return pretty.string(from: date)
|
||||
}
|
||||
|
||||
func formatTokens(_ n: Double) -> String {
|
||||
if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) }
|
||||
if n >= 1_000 { return String(format: "%.0fK", n / 1_000) }
|
||||
return String(format: "%.0f", n)
|
||||
}
|
||||
|
||||
func formatTokens(_ n: Int) -> String {
|
||||
formatTokens(Double(n))
|
||||
}
|
||||
|
|
@ -1,404 +0,0 @@
|
|||
import SwiftUI
|
||||
|
||||
struct MenuBarPopover: View {
|
||||
@ObservedObject var store: SessionStore
|
||||
|
||||
var body: some View {
|
||||
if store.needsOnboarding {
|
||||
OnboardingView(store: store)
|
||||
.frame(width: 360, height: 300)
|
||||
} else {
|
||||
DashboardView(store: store)
|
||||
.frame(width: 380)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dashboard
|
||||
|
||||
struct DashboardView: View {
|
||||
@ObservedObject var store: SessionStore
|
||||
@State private var showSettings = false
|
||||
|
||||
private var totalCost: Double {
|
||||
filteredSessions.reduce(0) { $0 + $1.cost }
|
||||
}
|
||||
|
||||
private var totalCalls: Int {
|
||||
filteredSessions.reduce(0) { $0 + $1.calls }
|
||||
}
|
||||
|
||||
private var totalSessions: Int {
|
||||
filteredSessions.count
|
||||
}
|
||||
|
||||
private var filteredSessions: [ParsedSession] {
|
||||
if store.selectedProvider == "all" {
|
||||
return store.providers.flatMap(\.sessions)
|
||||
}
|
||||
return store.providers.first(where: { $0.name == store.selectedProvider })?.sessions ?? []
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Header(store: store)
|
||||
Divider()
|
||||
|
||||
if !store.providers.isEmpty {
|
||||
AgentTabStrip(store: store)
|
||||
Divider()
|
||||
}
|
||||
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
HeroSection(store: store, cost: totalCost, calls: totalCalls, sessions: totalSessions)
|
||||
Divider().opacity(0.5)
|
||||
PeriodSegmentedControl(store: store)
|
||||
Divider().opacity(0.5)
|
||||
|
||||
if isFilteredEmpty {
|
||||
EmptyProviderState(provider: store.selectedProvider, period: store.selectedPeriod)
|
||||
} else {
|
||||
InsightSection(store: store)
|
||||
.zIndex(10)
|
||||
Divider().opacity(0.5)
|
||||
ActivitySection(store: store)
|
||||
Divider().opacity(0.5)
|
||||
ModelsSection(store: store)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 520)
|
||||
.animation(.easeInOut(duration: 0.2), value: store.isLoading)
|
||||
|
||||
Divider()
|
||||
FooterBar(store: store, showSettings: $showSettings)
|
||||
}
|
||||
}
|
||||
|
||||
private var isFilteredEmpty: Bool {
|
||||
totalCost <= 0 && totalCalls <= 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private struct Header: View {
|
||||
@ObservedObject var store: SessionStore
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
(
|
||||
Text("Code").foregroundStyle(.primary)
|
||||
+ Text("Burn").foregroundStyle(Theme.brandEmber)
|
||||
)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.tracking(-0.15)
|
||||
Text("AI Coding Cost Tracker")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if store.isLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.6)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hero Section
|
||||
|
||||
private struct HeroSection: View {
|
||||
@ObservedObject var store: SessionStore
|
||||
let cost: Double
|
||||
let calls: Int
|
||||
let sessions: Int
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SectionCaption(text: caption)
|
||||
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(cost.asCurrency())
|
||||
.font(.system(size: 32, weight: .semibold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.tracking(-1)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Theme.brandAccent, Theme.brandAccentDeep],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("\(calls.asThousandsSeparated()) calls")
|
||||
.font(.system(size: 11))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(sessions) sessions")
|
||||
.font(.system(size: 10.5))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
|
||||
private var caption: String {
|
||||
let label = store.selectedPeriod.label
|
||||
if store.selectedPeriod == .today {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEE MMM d"
|
||||
return "\(label) · \(formatter.string(from: Date()))"
|
||||
}
|
||||
return label
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Period Picker
|
||||
|
||||
private struct PeriodSegmentedControl: View {
|
||||
@ObservedObject var store: SessionStore
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 1) {
|
||||
ForEach(SessionStore.Period.allCases) { period in
|
||||
Button {
|
||||
store.selectedPeriod = period
|
||||
Task { await store.refresh() }
|
||||
} label: {
|
||||
Text(period.label)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(store.selectedPeriod == period ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 4)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(store.selectedPeriod == period ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear)
|
||||
.shadow(color: .black.opacity(store.selectedPeriod == period ? 0.06 : 0), radius: 1, y: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(Color.secondary.opacity(0.08))
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Agent Tab Strip
|
||||
|
||||
private struct AgentTabStrip: View {
|
||||
@ObservedObject var store: SessionStore
|
||||
|
||||
private var totalCost: Double {
|
||||
store.providers.reduce(0) { $0 + $1.totalCost }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 5) {
|
||||
AgentTab(
|
||||
name: "All",
|
||||
cost: totalCost,
|
||||
color: Theme.brandAccent,
|
||||
isActive: store.selectedProvider == "all"
|
||||
) {
|
||||
store.selectedProvider = "all"
|
||||
}
|
||||
|
||||
ForEach(store.providers) { provider in
|
||||
AgentTab(
|
||||
name: provider.name.capitalized,
|
||||
cost: provider.totalCost,
|
||||
color: Theme.providerColor(provider.name),
|
||||
isActive: store.selectedProvider == provider.name
|
||||
) {
|
||||
store.selectedProvider = provider.name
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.frame(height: 38)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgentTab: View {
|
||||
let name: String
|
||||
let cost: Double
|
||||
let color: Color
|
||||
let isActive: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 3) {
|
||||
HStack(spacing: 5) {
|
||||
Text(name)
|
||||
.font(.system(size: 11.5, weight: .medium))
|
||||
.tracking(-0.05)
|
||||
if cost > 0 {
|
||||
Text(cost.asCompactCurrency())
|
||||
.font(.codeMono(size: 10.5, weight: .medium))
|
||||
.foregroundStyle(isActive ? AnyShapeStyle(.white.opacity(0.8)) : AnyShapeStyle(.secondary))
|
||||
.tracking(-0.2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(isActive ? AnyShapeStyle(color) : AnyShapeStyle(Color.secondary.opacity(0.08)))
|
||||
)
|
||||
.foregroundStyle(isActive ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onTap() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
|
||||
private struct EmptyProviderState: View {
|
||||
let provider: String
|
||||
let period: SessionStore.Period
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "tray")
|
||||
.font(.system(size: 26))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("No \(provider == "all" ? "" : provider + " ")data for \(periodPhrase)")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 60)
|
||||
}
|
||||
|
||||
private var periodPhrase: String {
|
||||
switch period {
|
||||
case .today: "today"
|
||||
case .week: "the last 7 days"
|
||||
case .month: "the last 30 days"
|
||||
case .threeMonths: "the last 3 months"
|
||||
case .sixMonths: "the last 6 months"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Footer
|
||||
|
||||
private struct FooterBar: View {
|
||||
@ObservedObject var store: SessionStore
|
||||
@Binding var showSettings: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Button {
|
||||
Task { await store.refresh() }
|
||||
} label: {
|
||||
Image(systemName: store.isLoading ? "arrow.triangle.2.circlepath" : "arrow.clockwise")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(store.isLoading)
|
||||
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Image(systemName: "gearshape")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.popover(isPresented: $showSettings) {
|
||||
SettingsView(store: store)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "")
|
||||
.font(.system(size: 10, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
|
||||
Button {
|
||||
NSApplication.shared.terminate(nil)
|
||||
} label: {
|
||||
Text("Quit")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Onboarding
|
||||
|
||||
struct OnboardingView: View {
|
||||
@ObservedObject var store: SessionStore
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Theme.brandAccent, Theme.brandAccentDeep],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
|
||||
(
|
||||
Text("Code").foregroundStyle(.primary)
|
||||
+ Text("Burn").foregroundStyle(Theme.brandAccent)
|
||||
+ Text(" Pro").foregroundStyle(.primary)
|
||||
)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
|
||||
Text("Grant access to your home folder so CodeBurn can discover session logs from your coding tools.")
|
||||
.font(.system(size: 12))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
|
||||
Button("Grant Folder Access") {
|
||||
store.grantFolderAccess()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Theme.brandAccent)
|
||||
.controlSize(.large)
|
||||
|
||||
Text("We only read session logs. Nothing is uploaded.")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@ObservedObject var store: SessionStore
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Folder Access") {
|
||||
ForEach(store.grantedFolders, id: \.absoluteString) { url in
|
||||
HStack {
|
||||
Image(systemName: "folder")
|
||||
Text(url.path.replacingOccurrences(of: FileManager.default.homeDirectoryForCurrentUser.path, with: "~"))
|
||||
}
|
||||
}
|
||||
|
||||
Button("Add Folder...") {
|
||||
store.grantFolderAccess()
|
||||
}
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
LabeledContent("Version", value: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "—")
|
||||
LabeledContent("Providers Found", value: "\(store.providers.count)")
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.frame(width: 400, height: 300)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import SwiftUI
|
||||
|
||||
enum Theme {
|
||||
static let brandEmber = Color(red: 0xC9/255.0, green: 0x52/255.0, blue: 0x1D/255.0)
|
||||
static let brandAccent = Color(red: 0xC9/255.0, green: 0x52/255.0, blue: 0x1D/255.0)
|
||||
static let brandAccentLight = Color(red: 0xE8/255.0, green: 0x77/255.0, blue: 0x4A/255.0)
|
||||
static let brandAccentDeep = Color(red: 0x8B/255.0, green: 0x3E/255.0, blue: 0x13/255.0)
|
||||
static let brandAccentGlow = Color(red: 0xF0/255.0, green: 0xA0/255.0, blue: 0x70/255.0)
|
||||
|
||||
static func providerColor(_ name: String) -> Color {
|
||||
switch name.lowercased() {
|
||||
case "claude": Color(red: 0xC9/255.0, green: 0x52/255.0, blue: 0x1D/255.0)
|
||||
case "codex": Color(red: 0x4A/255.0, green: 0x7D/255.0, blue: 0x5C/255.0)
|
||||
case "copilot": Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0)
|
||||
case "cursor": Color(red: 0x3F/255.0, green: 0x6B/255.0, blue: 0x8C/255.0)
|
||||
case "gemini": Color(red: 0x44/255.0, green: 0x85/255.0, blue: 0xF4/255.0)
|
||||
case "cline": Color(red: 0x23/255.0, green: 0x8A/255.0, blue: 0x7E/255.0)
|
||||
case "roo-code", "roo_code": Color(red: 0x4C/255.0, green: 0xAF/255.0, blue: 0x50/255.0)
|
||||
case "kilo-code": Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0)
|
||||
default: .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Font {
|
||||
static func codeMono(size: CGFloat, weight: Font.Weight = .regular) -> Font {
|
||||
.system(size: size, weight: weight, design: .monospaced)
|
||||
}
|
||||
}
|
||||
|
||||
extension Double {
|
||||
func asCurrency() -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = "USD"
|
||||
return formatter.string(from: NSNumber(value: self)) ?? "$0.00"
|
||||
}
|
||||
|
||||
func asCompactCurrency() -> String {
|
||||
if self >= 1000 { return String(format: "$%.0fK", self / 1000) }
|
||||
if self >= 100 { return String(format: "$%.0f", self) }
|
||||
if self >= 10 { return String(format: "$%.1f", self) }
|
||||
return String(format: "$%.2f", self)
|
||||
}
|
||||
}
|
||||
|
||||
extension Int {
|
||||
func asThousandsSeparated() -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
return formatter.string(from: NSNumber(value: self)) ?? "\(self)"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
name: CodeBurnPro
|
||||
options:
|
||||
bundleIdPrefix: com.agentseal
|
||||
deploymentTarget:
|
||||
macOS: "14.0"
|
||||
xcodeVersion: "16.0"
|
||||
generateEmptyDirectories: true
|
||||
|
||||
settings:
|
||||
base:
|
||||
MARKETING_VERSION: "1.0.0"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
SWIFT_VERSION: "6.0"
|
||||
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||
|
||||
targets:
|
||||
CodeBurnPro:
|
||||
type: application
|
||||
platform: macOS
|
||||
sources:
|
||||
- path: Sources
|
||||
resources:
|
||||
- path: Sources/Resources
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.agentseal.codeburn-pro
|
||||
PRODUCT_NAME: CodeBurn Pro
|
||||
CODE_SIGN_ENTITLEMENTS: Sources/App/CodeBurnPro.entitlements
|
||||
INFOPLIST_FILE: Sources/App/Info.plist
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
ENABLE_HARDENED_RUNTIME: true
|
||||
CODE_SIGN_IDENTITY: "Apple Development: toruk_makto1406@icloud.com (T3289GPL2P)"
|
||||
CODE_SIGN_STYLE: Manual
|
||||
DEVELOPMENT_TEAM: ""
|
||||
entitlements:
|
||||
path: Sources/App/CodeBurnPro.entitlements
|
||||
|
||||
CodeBurnProTests:
|
||||
type: bundle.unit-test
|
||||
platform: macOS
|
||||
sources:
|
||||
- path: Tests
|
||||
dependencies:
|
||||
- target: CodeBurnPro
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.agentseal.codeburn-pro-tests
|
||||
Loading…
Add table
Add a link
Reference in a new issue