mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-04-28 15:09:43 +00:00
Introduces mac/ with a native SwiftUI menubar app that replaces the previous SwiftBar plugin entirely. Install via `npx codeburn menubar`, which downloads the .app from GitHub Releases, strips Gatekeeper quarantine, and drops it into ~/Applications. Highlights - mac/ SwiftUI app: agent tabs, Today/7/30/Month/All period switcher, Trend/Forecast/Pulse/Stats/Plan insights, activity + model breakdowns, optimize findings, CSV/JSON export, Star-on-GitHub banner, live 60s refresh, instant currency switching with offline FX cache. - Security: CodeburnCLI argv-based spawn (no shell interpretation), SafeFile symlink guards + O_NOFOLLOW writes, FX rate clamping to [0.0001, 1_000_000], keychain filtered to account == "default", removed byte-window credential log, in-flight refresh guard, POSIX flock on config.json writes, TerminalLauncher validates argv before AppleScript interpolation. - Performance: shared static NumberFormatter (thousands of allocations per popover redraw eliminated), concurrent pipe drain with 20 MB cap + 60s timeout in DataClient, Observation-tracked reactive UI, 5-min payload cache keyed on (period, provider). - CLI: new `codeburn menubar` subcommand that downloads + installs + launches the .app (no clone, no build). New `status --format menubar-json` payload builder. `export` rewritten to produce a folder of one-table-per-file CSVs with a `.codeburn-export` marker so arbitrary -o paths cannot be silently deleted. - Removed: src/menubar.ts (SwiftBar plugin generator), install-menubar / uninstall-menubar subcommands, `status --format menubar` directive output, tests/menubar.test.ts, tests/security/menubar-injection.test.ts. - Release: .github/workflows/release-menubar.yml builds universal binary, assembles .app, ad-hoc signs, zips, uploads on mac-v* tag push. Runs on the free macos-latest runner. Tests - 230 TypeScript tests pass - 10 Swift CapacityEstimator tests pass - TypeScript typecheck clean - Swift release build clean
290 lines
11 KiB
Swift
290 lines
11 KiB
Swift
import SwiftUI
|
|
|
|
private let winColor = Theme.brandAccent
|
|
private let riskColor = Theme.brandAccent
|
|
private let improveColor = Theme.brandAccent
|
|
|
|
/// Three-category insights panel: wins, improvements, risks.
|
|
/// Wins/risks are derived from current + history; improvements come from the optimize findings.
|
|
struct FindingsSection: View {
|
|
@Environment(AppStore.self) private var store
|
|
@State private var isExpanded: Bool = true
|
|
|
|
var body: some View {
|
|
let groups = computeTipGroups(payload: store.payload)
|
|
if groups.allSatisfy({ $0.items.isEmpty }) { return AnyView(EmptyView()) }
|
|
|
|
return AnyView(
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.18)) { isExpanded.toggle() }
|
|
} label: {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "lightbulb.fill")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(Theme.brandAccent)
|
|
Text("Tips for you")
|
|
.font(.system(size: 12.5, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
}
|
|
Spacer()
|
|
Text("\(groups.flatMap { $0.items }.count) signals")
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
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 {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
ForEach(groups) { group in
|
|
if !group.items.isEmpty {
|
|
TipsGroup(group: group)
|
|
}
|
|
}
|
|
|
|
if store.payload.optimize.findingCount > 0 {
|
|
Button {
|
|
openOptimize()
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Text("Open Full Optimize")
|
|
.font(.system(size: 11.5, weight: .semibold))
|
|
Image(systemName: "arrow.forward")
|
|
.font(.system(size: 9, weight: .semibold))
|
|
}
|
|
.foregroundStyle(Theme.brandAccent)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(Color.secondary.opacity(0.06))
|
|
)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 8)
|
|
)
|
|
}
|
|
|
|
private func openOptimize() {
|
|
TerminalLauncher.open(subcommand: ["optimize"])
|
|
}
|
|
}
|
|
|
|
private struct TipsGroup: View {
|
|
let group: TipGroup
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
HStack(spacing: 5) {
|
|
Image(systemName: group.icon)
|
|
.font(.system(size: 10, weight: .bold))
|
|
.foregroundStyle(group.color)
|
|
Text(group.label)
|
|
.font(.system(size: 10.5, weight: .semibold))
|
|
.foregroundStyle(group.color)
|
|
.textCase(.uppercase)
|
|
.tracking(0.4)
|
|
}
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ForEach(group.items) { item in
|
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
|
Circle().fill(group.color).frame(width: 3, height: 3).padding(.top, 4)
|
|
Text(item.text)
|
|
.font(.system(size: 11.5))
|
|
.foregroundStyle(.primary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
if let trailing = item.trailing {
|
|
Text(trailing)
|
|
.font(.codeMono(size: 11, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
.tracking(-0.2)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct TipGroup: Identifiable {
|
|
let id = UUID()
|
|
let label: String
|
|
let icon: String
|
|
let color: Color
|
|
let items: [TipItem]
|
|
}
|
|
|
|
private struct TipItem: Identifiable {
|
|
let id = UUID()
|
|
let text: String
|
|
let trailing: String?
|
|
}
|
|
|
|
private func computeTipGroups(payload: MenubarPayload) -> [TipGroup] {
|
|
let stats = computeHistoryStats(history: payload.history.daily)
|
|
|
|
// What's working
|
|
var wins: [TipItem] = []
|
|
let cacheHit = payload.current.cacheHitPercent
|
|
if cacheHit >= 80 {
|
|
wins.append(TipItem(
|
|
text: "Cache hit at \(Int(cacheHit))% — most prompts reuse cache",
|
|
trailing: nil
|
|
))
|
|
}
|
|
if let oneShot = payload.current.oneShotRate, oneShot >= 0.75 {
|
|
wins.append(TipItem(
|
|
text: "\(Int(oneShot * 100))% one-shot — edits landing first try",
|
|
trailing: nil
|
|
))
|
|
}
|
|
if let delta = stats.weekDeltaPercent, delta < -10 {
|
|
wins.append(TipItem(
|
|
text: "Spend down \(Int(abs(delta)))% vs last 7 days",
|
|
trailing: nil
|
|
))
|
|
}
|
|
if stats.activeStreakDays >= 5 {
|
|
wins.append(TipItem(
|
|
text: "\(stats.activeStreakDays)-day usage streak",
|
|
trailing: nil
|
|
))
|
|
}
|
|
|
|
// What to improve (existing optimize findings)
|
|
var improvements: [TipItem] = []
|
|
for finding in payload.optimize.topFindings.prefix(3) {
|
|
improvements.append(TipItem(
|
|
text: finding.title,
|
|
trailing: finding.savingsUSD.asCompactCurrency()
|
|
))
|
|
}
|
|
|
|
// Risks
|
|
var risks: [TipItem] = []
|
|
if let delta = stats.weekDeltaPercent, delta > 25 {
|
|
risks.append(TipItem(
|
|
text: "Spend up \(Int(delta))% vs prior 7 days",
|
|
trailing: nil
|
|
))
|
|
}
|
|
if cacheHit > 0 && cacheHit < 50 {
|
|
risks.append(TipItem(
|
|
text: "Cache hit only \(Int(cacheHit))% — paying for cold prompts",
|
|
trailing: nil
|
|
))
|
|
}
|
|
if let oneShot = payload.current.oneShotRate, oneShot < 0.5 {
|
|
risks.append(TipItem(
|
|
text: "\(Int(oneShot * 100))% one-shot — lots of iteration",
|
|
trailing: nil
|
|
))
|
|
}
|
|
if let projected = stats.projectedMonth, let prevMonth = stats.previousMonthTotal, projected > prevMonth * 1.3 {
|
|
risks.append(TipItem(
|
|
text: "On pace for \(projected.asCompactCurrency()) this month (+\(Int(((projected - prevMonth) / prevMonth) * 100))% vs last)",
|
|
trailing: nil
|
|
))
|
|
}
|
|
|
|
return [
|
|
TipGroup(label: "What's working", icon: "checkmark.circle.fill", color: winColor, items: wins),
|
|
TipGroup(label: "What to improve", icon: "arrow.up.right.circle.fill", color: improveColor, items: improvements),
|
|
TipGroup(label: "Risks", icon: "exclamationmark.triangle.fill", color: riskColor, items: risks),
|
|
]
|
|
}
|
|
|
|
private struct HistoryStats {
|
|
let weekDeltaPercent: Double?
|
|
let activeStreakDays: Int
|
|
let projectedMonth: Double?
|
|
let previousMonthTotal: Double?
|
|
}
|
|
|
|
private func computeHistoryStats(history: [DailyHistoryEntry]) -> HistoryStats {
|
|
var calendar = Calendar(identifier: .gregorian)
|
|
calendar.timeZone = TimeZone(identifier: "UTC")!
|
|
let formatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "yyyy-MM-dd"
|
|
f.timeZone = TimeZone(identifier: "UTC")
|
|
return f
|
|
}()
|
|
let now = Date()
|
|
let today = calendar.startOfDay(for: now)
|
|
let costByDate = Dictionary(uniqueKeysWithValues: history.map { ($0.date, $0.cost) })
|
|
|
|
let lastWeekStart = calendar.date(byAdding: .day, value: -6, to: today)
|
|
let priorWeekStart = calendar.date(byAdding: .day, value: -13, to: today)
|
|
let priorWeekEnd = calendar.date(byAdding: .day, value: -7, to: today)
|
|
var weekDeltaPercent: Double? = nil
|
|
if let lws = lastWeekStart, let pws = priorWeekStart, let pwe = priorWeekEnd {
|
|
let lwsStr = formatter.string(from: lws)
|
|
let pwsStr = formatter.string(from: pws)
|
|
let pweStr = formatter.string(from: pwe)
|
|
let thisWeek = history.filter { $0.date >= lwsStr }.reduce(0.0) { $0 + $1.cost }
|
|
let prior = history.filter { $0.date >= pwsStr && $0.date <= pweStr }.reduce(0.0) { $0 + $1.cost }
|
|
if prior > 0 {
|
|
weekDeltaPercent = ((thisWeek - prior) / prior) * 100
|
|
}
|
|
}
|
|
|
|
var streak = 0
|
|
for offset in 0..<60 {
|
|
guard let d = calendar.date(byAdding: .day, value: -offset, to: today) else { break }
|
|
let key = formatter.string(from: d)
|
|
if (costByDate[key] ?? 0) > 0 { streak += 1 } else { break }
|
|
}
|
|
|
|
var projectedMonth: Double? = nil
|
|
var previousMonthTotal: Double? = nil
|
|
let comps = calendar.dateComponents([.year, .month, .day], from: now)
|
|
if
|
|
let firstOfMonth = calendar.date(from: DateComponents(year: comps.year, month: comps.month, day: 1)),
|
|
let rangeOfMonth = calendar.range(of: .day, in: .month, for: firstOfMonth)
|
|
{
|
|
let firstStr = formatter.string(from: firstOfMonth)
|
|
let mtd = history.filter { $0.date >= firstStr }.reduce(0.0) { $0 + $1.cost }
|
|
let dayOfMonth = comps.day ?? 1
|
|
if dayOfMonth > 0 {
|
|
projectedMonth = (mtd / Double(dayOfMonth)) * Double(rangeOfMonth.count)
|
|
}
|
|
|
|
if
|
|
let prevMonth = calendar.date(byAdding: .month, value: -1, to: firstOfMonth),
|
|
let prevRange = calendar.range(of: .day, in: .month, for: prevMonth),
|
|
let prevFirst = calendar.date(from: DateComponents(
|
|
year: calendar.component(.year, from: prevMonth),
|
|
month: calendar.component(.month, from: prevMonth),
|
|
day: 1
|
|
)),
|
|
let prevLast = calendar.date(byAdding: .day, value: prevRange.count - 1, to: prevFirst)
|
|
{
|
|
let prevFirstStr = formatter.string(from: prevFirst)
|
|
let prevLastStr = formatter.string(from: prevLast)
|
|
let prevTotal = history.filter { $0.date >= prevFirstStr && $0.date <= prevLastStr }
|
|
.reduce(0.0) { $0 + $1.cost }
|
|
if prevTotal > 0 { previousMonthTotal = prevTotal }
|
|
}
|
|
}
|
|
|
|
return HistoryStats(
|
|
weekDeltaPercent: weekDeltaPercent,
|
|
activeStreakDays: streak,
|
|
projectedMonth: projectedMonth,
|
|
previousMonthTotal: previousMonthTotal
|
|
)
|
|
}
|