Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ jobs:
- name: Test chat parsing
run: bash scripts/test-chat-parsing.sh

- name: Test plan gauge
run: bash scripts/test-plan-gauge.sh

- name: Build
run: |
xcodebuild \
Expand Down
12 changes: 12 additions & 0 deletions NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
1558BB00C530B721172F6E65 /* StripePoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EF78999D68D56ADE4CEFBE2 /* StripePoller.swift */; };
160EDB9D06275E2607DA5151 /* N8nPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = B89DA1A0C21AE3535A1760BB /* N8nPoller.swift */; };
186AB8904029767DAA5277CF /* IslandScreenGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78BC1357785CA1B099A33969 /* IslandScreenGeometry.swift */; };
18D47AE663EB96BE5638D5F7 /* ClaudePlanCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4263B550838CC5614569D13 /* ClaudePlanCardView.swift */; };
1969590AFEE6CB21404346F1 /* CalcomPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3104222B3F17BA5E8DEA5277 /* CalcomPoller.swift */; };
1F11EC4E6F604C612A1ADAD1 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E4F18AA940EFF7E047B9F7 /* AppState.swift */; };
2132477000FD77F7EB234DAA /* FileDropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3232F258523DD8D09B670363 /* FileDropView.swift */; };
Expand Down Expand Up @@ -53,6 +54,7 @@
8C32A0C3EAF536D3836F61D5 /* GreetingCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C581F7B1A9F14B281B60063E /* GreetingCanvasView.swift */; };
8D1C01F283FA7806119A1C85 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */; };
8FC5907E9506DC79EB1DDC3C /* ChatMarkdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = D797CC384B5BC70D34F121BC /* ChatMarkdown.swift */; };
94E0849D13762C6EC8548FEF /* ClaudePlanCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4263B550838CC5614569D13 /* ClaudePlanCardView.swift */; };
967B525F7D3837F31D740D18 /* ResendPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B5FDDE03F5F711146937E2 /* ResendPoller.swift */; };
9C653B8291EFFA414565E8A8 /* MusicController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F142FB3952B5F0639383047 /* MusicController.swift */; };
9DD56C95ECABDDCE23652D50 /* HookServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79D8DD79C3DD9BBC584B3CB2 /* HookServer.swift */; };
Expand All @@ -69,12 +71,14 @@
CD8DECA66B9E09C97E469DB9 /* BotCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A29F4C0B02BE91654D02AA0C /* BotCanvasView.swift */; };
D58B1E93AF65DE404C40E21E /* PillCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B6A38B5686C4A1F15AB5D29 /* PillCatalog.swift */; };
D82E93014C8087AE81511E40 /* IslandTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D3CE3617E9D336DDBA10F7A /* IslandTypes.swift */; };
D861479F6E7EAD07A6AC2105 /* ClaudePlanGauge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF22817EC70748F2D1FB2E7 /* ClaudePlanGauge.swift */; };
DCAC390FF9EC65B945A15B24 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 506824380E9C80B9BC6DCE72 /* Assets.xcassets */; };
DE05D09A32117582AC93A24A /* NotchBuddyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1BD8F3CAA60CF37640759E1 /* NotchBuddyApp.swift */; };
E4B9986674F4D425826F15A8 /* StripePoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EF78999D68D56ADE4CEFBE2 /* StripePoller.swift */; };
E776C7D1DE5C62EA8D4BA3E3 /* FileDropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3232F258523DD8D09B670363 /* FileDropView.swift */; };
E7A5D23F455565FE1E210398 /* PillCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B6A38B5686C4A1F15AB5D29 /* PillCatalog.swift */; };
E81575CE4433C6F88E424A8F /* LocalChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8790ACE9F73BD74640A0F20C /* LocalChat.swift */; };
E9C21D11BCB72C392436D19E /* ClaudePlanGauge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF22817EC70748F2D1FB2E7 /* ClaudePlanGauge.swift */; };
EB23ED9F1C12EA8B54A76A91 /* ResendPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B5FDDE03F5F711146937E2 /* ResendPoller.swift */; };
ECECA2F225E82B7C951658D7 /* UploadSequenceEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FD65943C32E150F1CD65C31 /* UploadSequenceEngine.swift */; };
F06EE0A97CB77096A337AA8B /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = C066E2B991AA4F56B2724263 /* sounds */; };
Expand All @@ -101,6 +105,7 @@
79D8DD79C3DD9BBC584B3CB2 /* HookServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HookServer.swift; sourceTree = "<group>"; };
7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
8790ACE9F73BD74640A0F20C /* LocalChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalChat.swift; sourceTree = "<group>"; };
8BF22817EC70748F2D1FB2E7 /* ClaudePlanGauge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudePlanGauge.swift; sourceTree = "<group>"; };
8CB8B6E5E9DA6164F979E88F /* GithubPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubPoller.swift; sourceTree = "<group>"; };
8D3CE3617E9D336DDBA10F7A /* IslandTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandTypes.swift; sourceTree = "<group>"; };
9F6D7015D6A77AA59323C0E7 /* AppLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLog.swift; sourceTree = "<group>"; };
Expand All @@ -121,6 +126,7 @@
D8212018F09EA1E96FBD63CD /* IslandStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandStateMachine.swift; sourceTree = "<group>"; };
E91B733ADC638E14E9BB6EFA /* WindowContextCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowContextCapture.swift; sourceTree = "<group>"; };
F1BD8F3CAA60CF37640759E1 /* NotchBuddyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchBuddyApp.swift; sourceTree = "<group>"; };
F4263B550838CC5614569D13 /* ClaudePlanCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudePlanCardView.swift; sourceTree = "<group>"; };
F4A13AEE6E06F46CEC9D908C /* UserIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIdentity.swift; sourceTree = "<group>"; };
FA02B08886939FF6FE0475DD /* IslandViewContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandViewContent.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
Expand All @@ -137,6 +143,8 @@
3104222B3F17BA5E8DEA5277 /* CalcomPoller.swift */,
D797CC384B5BC70D34F121BC /* ChatMarkdown.swift */,
BFE88A53AEAA637F866D479D /* ChatMarkdownView.swift */,
F4263B550838CC5614569D13 /* ClaudePlanCardView.swift */,
8BF22817EC70748F2D1FB2E7 /* ClaudePlanGauge.swift */,
A83D5410BD030B243ADDCFF4 /* ClaudeService.swift */,
3232F258523DD8D09B670363 /* FileDropView.swift */,
8CB8B6E5E9DA6164F979E88F /* GithubPoller.swift */,
Expand Down Expand Up @@ -314,6 +322,8 @@
4F1E0B3C838DA3CD6F864BC0 /* CalcomPoller.swift in Sources */,
8FC5907E9506DC79EB1DDC3C /* ChatMarkdown.swift in Sources */,
4976F8BF814D6F817370F1D8 /* ChatMarkdownView.swift in Sources */,
94E0849D13762C6EC8548FEF /* ClaudePlanCardView.swift in Sources */,
D861479F6E7EAD07A6AC2105 /* ClaudePlanGauge.swift in Sources */,
2DEB459FBD0BA3CEAAA5FDE9 /* ClaudeService.swift in Sources */,
E776C7D1DE5C62EA8D4BA3E3 /* FileDropView.swift in Sources */,
C808B654C84D9F71DBF8493A /* GithubPoller.swift in Sources */,
Expand Down Expand Up @@ -356,6 +366,8 @@
1969590AFEE6CB21404346F1 /* CalcomPoller.swift in Sources */,
B2D86BE268D6F4C86BD0C446 /* ChatMarkdown.swift in Sources */,
733FCC6A45DE963EF2024B4E /* ChatMarkdownView.swift in Sources */,
18D47AE663EB96BE5638D5F7 /* ClaudePlanCardView.swift in Sources */,
E9C21D11BCB72C392436D19E /* ClaudePlanGauge.swift in Sources */,
0329E76BA4A8FEEA8A6D76E7 /* ClaudeService.swift in Sources */,
2132477000FD77F7EB234DAA /* FileDropView.swift in Sources */,
C18227D148D99FE6EE3CC51A /* GithubPoller.swift in Sources */,
Expand Down
31 changes: 31 additions & 0 deletions NotchBuddy/Sources/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,31 @@ final class AppState: ObservableObject {
@Published var musicAutomationDenied: Bool = false
#endif

// Claude plan gauge (from statusline hook)
@Published var claudePlanUsage: PlanUsage? = nil {
didSet {
if let u = claudePlanUsage,
let data = try? JSONEncoder().encode(u) {
UserDefaults.standard.set(data, forKey: "claudePlanUsage")
}
}
}

// Plan gauge: show pill in notch header — persisted
#if !APPSTORE
@Published var showPlanInNotch: Bool = false {
didSet { UserDefaults.standard.set(showPlanInNotch, forKey: "showPlanInNotch") }
}
// Cached relay-installed state — updated at launch, after install/uninstall, on Settings open
@Published var planRelayInstalled: Bool = false
// Transient — reset when island closes or view changes
@Published var showingPlanDetail: Bool = false

func refreshPlanRelayState() {
planRelayInstalled = HookServer.statusLineInstalled()
}
#endif

// MARK: - Init (loads persisted settings)

private init() {
Expand Down Expand Up @@ -328,6 +353,12 @@ final class AppState: ObservableObject {
PillCatalog.available.contains(where: { $0.id == v && $0.category == .workspace && !$0.comingSoon }) {
mainPillId = v
}
if let d = ud.data(forKey: "claudePlanUsage"),
let u = try? JSONDecoder().decode(PlanUsage.self, from: d) { claudePlanUsage = u }
#if !APPSTORE
if let v = ud.object(forKey: "showPlanInNotch") as? Bool { showPlanInNotch = v }
planRelayInstalled = HookServer.statusLineInstalled()
#endif

// Sync SoundEngine volume on launch
SoundEngine.shared.volume = Float(soundVolume)
Expand Down
11 changes: 11 additions & 0 deletions NotchBuddy/Sources/App/BotCanvasView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,20 @@ struct BotCanvasView: View {
}
// Integration pills have a fixed brand color → use it as bodyColor.
// Claude Code tasks use state-based gradient (working=blue, thinking=purple, etc.).
#if !APPSTORE
if state.showingPlanDetail {
let hex = ClaudePlanGauge.color(for: state.claudePlanUsage.flatMap { ClaudePlanGauge.dominantPct($0) })
engine.bodyColor = cgColorFromHex(hex)
} else {
engine.bodyColor = (state.focusTask?.isIntegration == true)
? cgColorFromHex(state.focusTask!.color)
: nil
}
#else
engine.bodyColor = (state.focusTask?.isIntegration == true)
? cgColorFromHex(state.focusTask!.color)
: nil
#endif

// Compute shouldDance per-frame (no observer lag)
let dancing: Bool = {
Expand Down
128 changes: 128 additions & 0 deletions NotchBuddy/Sources/App/ClaudePlanCardView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import SwiftUI

// MARK: - Claude Plan Card View

struct ClaudePlanCardView: View {
let usage: PlanUsage? // nil = installed, no data yet

@State private var now = Date()

var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack(spacing: 6) {
let dominant = usage.flatMap { ClaudePlanGauge.dominantPct($0) }
Circle()
.fill(Color(hex: ClaudePlanGauge.color(for: dominant)))
.frame(width: 7, height: 7)
Text("Claude plan")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8"))
Text(subtitleText)
.font(.system(size: 11))
.foregroundColor(Color(hex: "#8E939C"))
.lineLimit(1)
.fixedSize()
}
.padding(.top, 6)
.padding(.leading, 108)
.padding(.trailing, 36)

// Gauge rows
VStack(alignment: .leading, spacing: 5) {
GaugeRowView(label: "5 hours", window: usage?.fiveHour, now: now)
GaugeRowView(label: "Week", window: usage?.sevenDay, now: now, weekly: true)
}
.padding(.top, 8)
.padding(.leading, 108)
.padding(.trailing, 12)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.top, 4)
// Update countdown every 30s, only while visible
.background(
TimelineView(.periodic(from: .now, by: 30)) { ctx in
Color.clear.onChange(of: ctx.date) { _, d in now = d }
}
)
}

private var subtitleText: String {
guard let usage else { return "Waiting for a Claude Code reply" }
let diff = now.timeIntervalSince(usage.updatedAt)
if diff < 60 { return "just now" }
let mins = Int(diff / 60)
if mins < 60 { return "\(mins) min ago" }
return "\(mins / 60) h ago"
}
}

// MARK: - Gauge Row

// DateFormatter created once, in English, for the weekly reset label
private let weeklyResetFormatter: DateFormatter = {
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
fmt.dateFormat = "EEE H:mm"
return fmt
}()

private struct GaugeRowView: View {
let label: String
let window: PlanWindow?
let now: Date
var weekly: Bool = false

var body: some View {
HStack(spacing: 5) {
Text(label)
.font(.system(size: 11))
.foregroundColor(Color(hex: "#6B7079"))
.frame(width: 40, alignment: .leading)
if let w = window {
let pct = ClaudePlanGauge.effectivePct(w)
let accent = Color(hex: ClaudePlanGauge.color(for: pct))
// Fixed-width bar (~50pt)
ZStack(alignment: .leading) {
Capsule()
.fill(Color.white.opacity(0.08))
.frame(width: 50, height: 4)
Capsule()
.fill(accent)
.frame(width: max(0, 50 * CGFloat(pct / 100)), height: 4)
}
.frame(width: 50, height: 4)
Text("\(Int(pct.rounded()))%")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(Color(hex: "#C5C8CD"))
.monospacedDigit()
.frame(width: 30, alignment: .trailing)
Image(systemName: "arrow.clockwise")
.font(.system(size: 8))
.foregroundColor(Color(hex: "#6B7079"))
Text(resetLabel(w))
.font(.system(size: 10))
.foregroundColor(Color(hex: "#6B7079"))
.fixedSize()
} else {
Text("—")
.font(.system(size: 11))
.foregroundColor(Color(hex: "#6B7079"))
Spacer()
}
}
}

private func resetLabel(_ w: PlanWindow) -> String {
let secs = w.resetsAt.timeIntervalSince(now)
guard secs > 0 else { return "Resetting…" }
if weekly {
return weeklyResetFormatter.string(from: w.resetsAt)
} else {
let h = Int(secs / 3600)
let m = Int((secs.truncatingRemainder(dividingBy: 3600)) / 60)
if h > 0 { return "in \(h) h \(m)" }
return "in \(m) min"
}
}
}
80 changes: 80 additions & 0 deletions NotchBuddy/Sources/App/ClaudePlanGauge.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import Foundation

// MARK: - Plan window

struct PlanWindow: Codable {
let usedPct: Double // 0–100, clamped
let resetsAt: Date
}

// MARK: - Plan usage

struct PlanUsage: Codable {
var fiveHour: PlanWindow?
var sevenDay: PlanWindow?
var updatedAt: Date
}

// MARK: - Parsing + helpers (Foundation-only, no AppKit/SwiftUI)

enum ClaudePlanGauge {

/// Parses rate_limits from a statusline payload dict. Returns nil if absent/malformed.
static func parse(payload: [String: Any]) -> PlanUsage? {
guard let limits = payload["rate_limits"] as? [String: Any] else { return nil }
let fh = parseWindow(limits["five_hour"])
let sd = parseWindow(limits["seven_day"])
guard fh != nil || sd != nil else { return nil }
return PlanUsage(fiveHour: fh, sevenDay: sd, updatedAt: Date())
}

private static func parseWindow(_ raw: Any?) -> PlanWindow? {
guard let d = raw as? [String: Any] else { return nil }
let rawPct: Double
if let v = d["used_percentage"] as? Double { rawPct = v }
else if let v = d["used_percentage"] as? Int { rawPct = Double(v) }
else { return nil }
guard rawPct >= 0, rawPct <= 200 else { return nil }
let pct = min(100, rawPct) // clamp 100–200 down to 100
let rawEpoch: Double
if let v = d["resets_at"] as? Double { rawEpoch = v }
else if let v = d["resets_at"] as? Int { rawEpoch = Double(v) }
else { return nil }
guard rawEpoch > 0 else { return nil }
// Reject if more than 400 days in the future (likely milliseconds, not seconds)
let maxEpoch = Date().timeIntervalSince1970 + 400 * 86400
guard rawEpoch <= maxEpoch else { return nil }
return PlanWindow(usedPct: pct, resetsAt: Date(timeIntervalSince1970: rawEpoch))
}

/// Effective % for display: 0 if the reset time is in the past.
static func effectivePct(_ w: PlanWindow) -> Double {
w.resetsAt <= Date() ? 0 : w.usedPct
}

/// The higher of the two effective percentages. nil if both windows absent.
static func dominantPct(_ usage: PlanUsage) -> Double? {
let p1 = usage.fiveHour.map { effectivePct($0) }
let p2 = usage.sevenDay.map { effectivePct($0) }
switch (p1, p2) {
case (.none, .none): return nil
case (let x?, .none): return x
case (.none, let y?): return y
case (let x?, let y?): return max(x, y)
}
}

/// Hex color for a given percentage (nil → grey).
static func color(for pct: Double?) -> String {
guard let p = pct else { return "#6B7079" }
if p < 50 { return "#22C55E" }
if p < 80 { return "#F59E0B" }
return "#F4505E"
}

/// Label shown in the island pill chip.
static func pillLabel(_ usage: PlanUsage?) -> String {
guard let usage, let pct = dominantPct(usage) else { return "Claude plan" }
return "Claude \(Int(pct.rounded()))%"
}
}
Loading
Loading