From 0d1fa3f38f29b53dea2774f87c86fdacd2c88fe3 Mon Sep 17 00:00:00 2001 From: Louis Raille Date: Sat, 3 Oct 2026 01:04:48 +0200 Subject: [PATCH 1/5] Add Claude plan gauge pill with statusline relay and settings UI Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/build.yml | 3 + .../NotchBuddy.xcodeproj/project.pbxproj | 21 +- NotchBuddy/Sources/App/AppState.swift | 19 ++ .../Sources/App/ClaudePlanCardView.swift | 123 ++++++++++++ NotchBuddy/Sources/App/ClaudePlanGauge.swift | 77 ++++++++ NotchBuddy/Sources/App/HookServer.swift | 186 +++++++++++++++++- .../Sources/App/IslandViewContent.swift | 38 +++- NotchBuddy/Sources/App/PillCatalog.swift | 2 + NotchBuddy/Sources/App/SettingsView.swift | 71 +++++++ README.md | 2 + docs/INTEGRATIONS.md | 39 ++++ docs/SPEC.md | 3 +- docs/privacy.html | 4 +- docs/support.html | 4 +- scripts/test-plan-gauge.sh | 8 + tests/ClaudePlanGaugeTests.swift | 113 +++++++++++ 16 files changed, 689 insertions(+), 24 deletions(-) create mode 100644 NotchBuddy/Sources/App/ClaudePlanCardView.swift create mode 100644 NotchBuddy/Sources/App/ClaudePlanGauge.swift create mode 100755 scripts/test-plan-gauge.sh create mode 100644 tests/ClaudePlanGaugeTests.swift diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 48185b782..01f5d21a6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 \ diff --git a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj index 866c65c06..a09873500 100644 --- a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj +++ b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj @@ -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 */; }; @@ -52,7 +53,7 @@ 8A6B3688D6B3BF728D106D8B /* UserIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4A13AEE6E06F46CEC9D908C /* UserIdentity.swift */; }; 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 */; }; @@ -69,12 +70,13 @@ 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 */; }; @@ -100,7 +102,7 @@ 78BC1357785CA1B099A33969 /* IslandScreenGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandScreenGeometry.swift; sourceTree = ""; }; 79D8DD79C3DD9BBC584B3CB2 /* HookServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HookServer.swift; sourceTree = ""; }; 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; - 8790ACE9F73BD74640A0F20C /* LocalChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalChat.swift; sourceTree = ""; }; + 8BF22817EC70748F2D1FB2E7 /* ClaudePlanGauge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudePlanGauge.swift; sourceTree = ""; }; 8CB8B6E5E9DA6164F979E88F /* GithubPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubPoller.swift; sourceTree = ""; }; 8D3CE3617E9D336DDBA10F7A /* IslandTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandTypes.swift; sourceTree = ""; }; 9F6D7015D6A77AA59323C0E7 /* AppLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLog.swift; sourceTree = ""; }; @@ -121,6 +123,7 @@ D8212018F09EA1E96FBD63CD /* IslandStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandStateMachine.swift; sourceTree = ""; }; E91B733ADC638E14E9BB6EFA /* WindowContextCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowContextCapture.swift; sourceTree = ""; }; F1BD8F3CAA60CF37640759E1 /* NotchBuddyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchBuddyApp.swift; sourceTree = ""; }; + F4263B550838CC5614569D13 /* ClaudePlanCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudePlanCardView.swift; sourceTree = ""; }; F4A13AEE6E06F46CEC9D908C /* UserIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIdentity.swift; sourceTree = ""; }; FA02B08886939FF6FE0475DD /* IslandViewContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandViewContent.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -135,8 +138,8 @@ A29F4C0B02BE91654D02AA0C /* BotCanvasView.swift */, D4540F630F8BA04AFFFA3979 /* BotEngine.swift */, 3104222B3F17BA5E8DEA5277 /* CalcomPoller.swift */, - D797CC384B5BC70D34F121BC /* ChatMarkdown.swift */, - BFE88A53AEAA637F866D479D /* ChatMarkdownView.swift */, + F4263B550838CC5614569D13 /* ClaudePlanCardView.swift */, + 8BF22817EC70748F2D1FB2E7 /* ClaudePlanGauge.swift */, A83D5410BD030B243ADDCFF4 /* ClaudeService.swift */, 3232F258523DD8D09B670363 /* FileDropView.swift */, 8CB8B6E5E9DA6164F979E88F /* GithubPoller.swift */, @@ -312,8 +315,8 @@ CD8DECA66B9E09C97E469DB9 /* BotCanvasView.swift in Sources */, C24D863967D8D32D828ED4B1 /* BotEngine.swift in Sources */, 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 */, @@ -354,8 +357,8 @@ 651FA3F09608DE5D02D61CCE /* BotCanvasView.swift in Sources */, 0E9CE475EC53C67DAB00F8E5 /* BotEngine.swift in Sources */, 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 */, diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index 82bc69460..e2c854f89 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -293,6 +293,23 @@ 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") + } + // Update pill label and color + let label = ClaudePlanGauge.pillLabel(claudePlanUsage) + let colorHex = ClaudePlanGauge.color(for: claudePlanUsage.flatMap { ClaudePlanGauge.dominantPct($0) }) + if let idx = tasks.firstIndex(where: { $0.id == "integration_claude_plan" }) { + tasks[idx].name = label + tasks[idx].color = colorHex + } + } + } + // MARK: - Init (loads persisted settings) private init() { @@ -328,6 +345,8 @@ 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 } // Sync SoundEngine volume on launch SoundEngine.shared.volume = Float(soundVolume) diff --git a/NotchBuddy/Sources/App/ClaudePlanCardView.swift b/NotchBuddy/Sources/App/ClaudePlanCardView.swift new file mode 100644 index 000000000..74eb55061 --- /dev/null +++ b/NotchBuddy/Sources/App/ClaudePlanCardView.swift @@ -0,0 +1,123 @@ +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")) + } + .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 second, only while visible + .background( + TimelineView(.periodic(from: .now, by: 1)) { 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 "Updated just now" } + let mins = Int(diff / 60) + if mins < 60 { return "Updated \(mins) min ago" } + let hrs = mins / 60 + return "Updated \(hrs) h ago" + } +} + +// MARK: - Gauge Row + +private struct GaugeRowView: View { + let label: String + let window: PlanWindow? + let now: Date + var weekly: Bool = false + + var body: some View { + HStack(spacing: 7) { + Text(label) + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#6B7079")) + .frame(width: 42, alignment: .leading) + if let w = window { + let pct = ClaudePlanGauge.effectivePct(w) + let accent = Color(hex: ClaudePlanGauge.color(for: pct)) + // Thin bar + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.white.opacity(0.08)) + .frame(height: 4) + RoundedRectangle(cornerRadius: 2) + .fill(accent) + .frame(width: geo.size.width * CGFloat(pct / 100), height: 4) + } + } + .frame(height: 4) + Text("\(Int(pct.rounded()))%") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(Color(hex: "#C5C8CD")) + .monospacedDigit() + .frame(width: 30, alignment: .trailing) + Text(resetLabel(w)) + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#6B7079")) + .lineLimit(1) + } else { + Text("—") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#6B7079")) + Spacer() + } + } + .frame(maxWidth: .infinity) + } + + private func resetLabel(_ w: PlanWindow) -> String { + let secs = w.resetsAt.timeIntervalSince(now) + guard secs > 0 else { return "Resetting…" } + if weekly { + // "Resets Mon 9:00" + let fmt = DateFormatter() + fmt.dateFormat = "EEE H:mm" + return "Resets \(fmt.string(from: w.resetsAt))" + } else { + // "Resets in 1 h 20" or "Resets in 45 min" + let h = Int(secs / 3600) + let m = Int((secs.truncatingRemainder(dividingBy: 3600)) / 60) + if h > 0 { return "Resets in \(h) h \(m) min" } + return "Resets in \(m) min" + } + } +} diff --git a/NotchBuddy/Sources/App/ClaudePlanGauge.swift b/NotchBuddy/Sources/App/ClaudePlanGauge.swift new file mode 100644 index 000000000..c2bd6f82d --- /dev/null +++ b/NotchBuddy/Sources/App/ClaudePlanGauge.swift @@ -0,0 +1,77 @@ +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 <= 100 else { return nil } + let pct = max(0, min(100, rawPct)) + 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 } + 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()))%" + } +} diff --git a/NotchBuddy/Sources/App/HookServer.swift b/NotchBuddy/Sources/App/HookServer.swift index 066876623..329af755f 100644 --- a/NotchBuddy/Sources/App/HookServer.swift +++ b/NotchBuddy/Sources/App/HookServer.swift @@ -187,6 +187,14 @@ final class HookServer: @unchecked Sendable { return } + // statusline payloads are handled separately — no session, no reveal, no sound + if let kind = payload["coucou_kind"] as? String, kind == "statusline" { + Task { @MainActor in self.processStatusLine(payload: payload) } + sendLine(fd: fd, text: #"{"ok":true}"#) + close(fd) + return + } + let eventName = payload["hook_event_name"] as? String ?? "" if eventName == "PermissionRequest" { @@ -452,6 +460,15 @@ final class HookServer: @unchecked Sendable { // Already compact and non-alert: Mochi state update is enough, no expand } + // MARK: - Status line (plan gauge) + + @MainActor + private func processStatusLine(payload: [String: Any]) { + if let usage = ClaudePlanGauge.parse(payload: payload) { + AppState.shared.claudePlanUsage = usage + } + } + // MARK: - Permission request (blocking — Claude Code waits for decision) @MainActor @@ -915,6 +932,93 @@ final class HookServer: @unchecked Sendable { try newData.write(to: settingsURL, options: .atomic) } + // MARK: - Claude plan status line installer + + private var statusLinePreviousURL: URL { + Self.supportDir.appendingPathComponent("statusline-previous.json") + } + + /// Returns true if our statusLine command is installed in ~/.claude/settings.json. + static func statusLineInstalled() -> Bool { + let url = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude/settings.json") + guard let data = try? Data(contentsOf: url), + let settings = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let sl = settings["statusLine"] as? [String: Any], + let cmd = sl["command"] as? String else { return false } + return cmd.contains("nb-hook") + } + + private var _pendingStatusLineData: Data? + + /// Returns preview JSON without writing — call writeStatusLine() to confirm. + func previewStatusLine(install: Bool) throws -> String { + let data = try buildStatusLineData(install: install) + _pendingStatusLineData = data + return String(data: data, encoding: .utf8) ?? "" + } + + /// Writes settings.json (call after user confirms preview). + func writeStatusLine() throws { + guard let data = _pendingStatusLineData else { return } + let settingsURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude/settings.json") + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd-HHmm" + let stamp = formatter.string(from: Date()) + let backupURL = settingsURL.deletingLastPathComponent() + .appendingPathComponent("settings.json.bak-\(stamp)") + try? FileManager.default.copyItem(at: settingsURL, to: backupURL) + try? FileManager.default.createDirectory(at: settingsURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: settingsURL, options: .atomic) + _pendingStatusLineData = nil + } + + private func buildStatusLineData(install: Bool) throws -> Data { + let settingsURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude/settings.json") + var settings: [String: Any] = [:] + if let d = try? Data(contentsOf: settingsURL), + let parsed = (try? JSONSerialization.jsonObject(with: d)) as? [String: Any] { + settings = parsed + } + let hookPath = Self.hookScriptPath + let quotedPath = hookPath.replacingOccurrences(of: "\"", with: "\\\"") + let quotedCmd = "\"\(quotedPath)\" --statusline" + + if install { + // If there's already a statusLine that isn't ours, save it for later restoration + if let existing = settings["statusLine"] as? [String: Any], + let cmd = existing["command"] as? String, !cmd.contains("nb-hook") { + let prevData = try JSONSerialization.data(withJSONObject: existing, + options: [.prettyPrinted, .sortedKeys]) + try prevData.write(to: statusLinePreviousURL, options: .atomic) + var updated = existing + updated["command"] = quotedCmd + settings["statusLine"] = updated + } else { + settings["statusLine"] = ["type": "command", "command": quotedCmd] + } + } else { + // Uninstall: only touch it if it's still ours + guard let current = settings["statusLine"] as? [String: Any], + let cmd = current["command"] as? String, cmd.contains("nb-hook") else { + return try JSONSerialization.data(withJSONObject: settings, + options: [.prettyPrinted, .sortedKeys]) + } + if let prevData = try? Data(contentsOf: statusLinePreviousURL), + let prevObj = (try? JSONSerialization.jsonObject(with: prevData)) as? [String: Any] { + settings["statusLine"] = prevObj + try? FileManager.default.removeItem(at: statusLinePreviousURL) + } else { + settings.removeValue(forKey: "statusLine") + } + } + return try JSONSerialization.data(withJSONObject: settings, + options: [.prettyPrinted, .sortedKeys]) + } + // MARK: - App Store: hooks via security-scoped bookmark #if APPSTORE @@ -1478,6 +1582,43 @@ def main(): return payload = json.loads(raw) except Exception: + raw = b'' + payload = {} + + socket_path = os.path.expanduser( + '~/Library/Application Support/NotchBuddy/nb.sock' + ) + + # --statusline mode: relay rate_limits to Coucou, then delegate to saved previous + if '--statusline' in sys.argv[1:]: + relay = { + 'coucou_kind': 'statusline', + 'session_id': payload.get('session_id', ''), + 'rate_limits': payload.get('rate_limits', {}), + } + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(0.3) + s.connect(socket_path) + s.sendall((json.dumps(relay) + '\\n').encode()) + s.close() + except Exception: + pass + prev_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'statusline-previous.json') + if os.path.exists(prev_file): + try: + import subprocess + with open(prev_file) as f: + prev = json.load(f) + cmd = prev.get('command', '') + if cmd: + result = subprocess.run(['/bin/sh', '-c', cmd], input=raw, + capture_output=True, timeout=10) + if result.stdout: + sys.stdout.buffer.write(result.stdout) + sys.stdout.buffer.flush() + except Exception: + pass return # Parse --agent and optional positional event from argv. @@ -1521,9 +1662,7 @@ def main(): pass event = payload.get('hook_event_name', '') - socket_path = os.path.expanduser( - '~/Library/Application Support/NotchBuddy/nb.sock' - ) + # socket_path is already defined above if event == 'PermissionRequest': # Block and wait for Coucou's decision (Claude Code allows up to 120s) @@ -1648,6 +1787,43 @@ def main(): return payload = json.loads(raw) except Exception: + raw = b'' + payload = {} + + socket_path = os.path.expanduser( + '~/Library/Containers/fr.louisraille.Coucou/Data/nb.sock' + ) + + # --statusline mode: relay rate_limits to Coucou, then delegate to saved previous + if '--statusline' in sys.argv[1:]: + relay = { + 'coucou_kind': 'statusline', + 'session_id': payload.get('session_id', ''), + 'rate_limits': payload.get('rate_limits', {}), + } + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(0.3) + s.connect(socket_path) + s.sendall((json.dumps(relay) + '\\n').encode()) + s.close() + except Exception: + pass + prev_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'statusline-previous.json') + if os.path.exists(prev_file): + try: + import subprocess + with open(prev_file) as f: + prev = json.load(f) + cmd = prev.get('command', '') + if cmd: + result = subprocess.run(['/bin/sh', '-c', cmd], input=raw, + capture_output=True, timeout=10) + if result.stdout: + sys.stdout.buffer.write(result.stdout) + sys.stdout.buffer.flush() + except Exception: + pass return # Parse --agent and optional positional event from argv. @@ -1690,9 +1866,7 @@ def main(): pass event = payload.get('hook_event_name', '') - socket_path = os.path.expanduser( - '~/Library/Containers/fr.louisraille.Coucou/Data/nb.sock' - ) + # socket_path is already defined above if event == 'PermissionRequest': # Block and wait for Coucou's decision (Claude Code allows up to 120s) diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift index 3b2ab5063..82ea12508 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -1222,6 +1222,12 @@ struct IntegrationCardView: View { return cmd?.contains("NotchBuddy") == true || cmd?.contains("coucou") == true } ?? false } #endif + case "integration_claude_plan": + #if APPSTORE + return false // App Store installer not yet implemented + #else + return HookServer.statusLineInstalled() + #endif case "agent_gemini": #if !APPSTORE return HookServer.geminiHooksInstalled() @@ -1317,6 +1323,11 @@ struct IntegrationCardView: View { task.id == "integration_notion" && appState.notionLoaded } + // Claude plan: show card when installed (even with no data yet) + private var claudePlanIsActive: Bool { + task.id == "integration_claude_plan" && isConfigured + } + // Apple Music: show card when a track is loaded (playing or paused) or automation is denied private var musicIsActive: Bool { #if !APPSTORE @@ -1335,6 +1346,11 @@ struct IntegrationCardView: View { return appState.musicPlaying ? Color(hex: "#FA2D48") : Color(hex: "#22C55E") } #endif + if task.id == "integration_claude_plan" { + guard isConfigured else { return Color(hex: "#F4505E") } + let pct = appState.claudePlanUsage.flatMap { ClaudePlanGauge.dominantPct($0) } + return Color(hex: ClaudePlanGauge.color(for: pct)) + } if PillCatalog.definition(for: task.id)?.comingSoon == true { return Color(hex: "#6B7079") } let svcErr = task.id == "integration_stripe" ? appState.stripeError : task.id == "integration_calcom" ? appState.calcomError @@ -1351,6 +1367,13 @@ struct IntegrationCardView: View { return "Not playing" } #endif + if task.id == "integration_claude_plan" { + guard isConfigured else { return "Status line not installed" } + if let usage = appState.claudePlanUsage { + return ClaudePlanGauge.pillLabel(usage) + } + return "Waiting for a Claude Code reply" + } if PillCatalog.definition(for: task.id)?.comingSoon == true { return "Coming soon" } let svcErr = task.id == "integration_stripe" ? appState.stripeError : task.id == "integration_calcom" ? appState.calcomError @@ -1417,6 +1440,9 @@ struct IntegrationCardView: View { } else if notionHasData { NotionCardView() .transition(.opacity) + } else if claudePlanIsActive { + ClaudePlanCardView(usage: appState.claudePlanUsage) + .transition(.opacity) } else if musicIsActive { #if !APPSTORE MusicCardView() @@ -1588,10 +1614,14 @@ struct IntegrationCardView: View { && task.id != "integration_music" { Button("Settings…") { let section: String - switch PillCatalog.definition(for: task.id)?.category { - case .workspace, .agent: section = "agents" - case .ai: section = "chat" - default: section = "integrations" + if task.id == "integration_claude_plan" { + section = "agents" + } else { + switch PillCatalog.definition(for: task.id)?.category { + case .workspace, .agent: section = "agents" + case .ai: section = "chat" + default: section = "integrations" + } } NotificationCenter.default.post(name: .openFullSettings, object: section) } diff --git a/NotchBuddy/Sources/App/PillCatalog.swift b/NotchBuddy/Sources/App/PillCatalog.swift index 530b5ecfa..3e21d7144 100644 --- a/NotchBuddy/Sources/App/PillCatalog.swift +++ b/NotchBuddy/Sources/App/PillCatalog.swift @@ -71,6 +71,8 @@ enum PillCatalog { .init(id: "ai_lmstudio", name: "LM Studio", color: ChatProvider.lmstudio.accentHex, category: .ai, subtitle: "Chat", source: .n8n), // ── Services ───────────────────────────────────────────────────────── + .init(id: "integration_claude_plan", name: "Claude plan", color: "#6B7079", + category: .service, subtitle: "Integration", source: .n8n), .init(id: "integration_resend", name: "Resend", color: "#22C55E", category: .service, subtitle: "Integration", source: .n8n), .init(id: "integration_n8n", name: "n8n", color: "#F29B38", diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index 26c4775e9..e40ace686 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -33,6 +33,10 @@ struct SettingsView: View { @State private var hookNeedsUpdate: Bool = HookServer.hooksNeedUpdate() #if !APPSTORE + @State private var showStatusLineDiff: Bool = false + @State private var pendingStatusLineJSON: String = "" + @State private var statusLinePendingInstall: Bool = true + @State private var geminiHooksInstalled: Bool = HookServer.geminiHooksInstalled() @State private var showGeminiDiff: Bool = false @State private var pendingGeminiJSON: String = "" @@ -476,6 +480,38 @@ struct SettingsView: View { } .padding(6) } + + GroupBox("Plan usage") { + VStack(alignment: .leading, spacing: 10) { + Text("Shows your Claude plan usage (5-hour and weekly limits) in the notch. Coucou adds a small status line relay to ~/.claude/settings.json. If you already have a status line, it keeps working as before. Pro and Max plans only.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 10) { + Button("Install") { installStatusLine() } + .buttonStyle(.borderedProminent) + Button("Uninstall") { uninstallStatusLine() } + .buttonStyle(.bordered) + } + if showStatusLineDiff { + ScrollView { + Text(pendingStatusLineJSON) + .font(.system(size: 10, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 100) + .background(Color(NSColor.textBackgroundColor)) + .cornerRadius(6) + HStack { + Button("Confirm & write") { confirmStatusLine() } + .buttonStyle(.borderedProminent) + Button("Cancel") { showStatusLineDiff = false; pendingStatusLineJSON = "" } + .buttonStyle(.bordered) + } + } + } + .padding(6) + } #endif } @@ -951,6 +987,41 @@ struct SettingsView: View { statusMessage = "❌ \(error.localizedDescription)" } } + + private func installStatusLine() { + do { + pendingStatusLineJSON = try HookServer.shared.previewStatusLine(install: true) + showStatusLineDiff = true + statusLinePendingInstall = true + statusMessage = "Review the JSON below before confirming." + } catch { + statusMessage = "❌ \(error.localizedDescription)" + } + } + + private func uninstallStatusLine() { + do { + pendingStatusLineJSON = try HookServer.shared.previewStatusLine(install: false) + showStatusLineDiff = true + statusLinePendingInstall = false + statusMessage = "Review the JSON below before confirming." + } catch { + statusMessage = "❌ \(error.localizedDescription)" + } + } + + private func confirmStatusLine() { + do { + try HookServer.shared.writeStatusLine() + showStatusLineDiff = false + pendingStatusLineJSON = "" + statusMessage = statusLinePendingInstall + ? "✓ Status line installed." + : "✓ Status line removed." + } catch { + statusMessage = "❌ \(error.localizedDescription)" + } + } #endif private func saveIntegrations() { diff --git a/README.md b/README.md index 9951e17bd..fe7737ce5 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Meet **Mochi**: a soft little squircle with big eyes that pops out of your notch - ✅ **Approve from the notch** — Claude Code and Codex permission requests show up with **Allow / Deny** (and **Always** for Claude Code), in VS Code, Cursor's terminal, or Codex. One click, back to work. - 🧑‍💻 **Jump to the right terminal** — open the exact terminal window of a session *(macOS)*. - 💬 **Chat with Claude, Gemini, OpenAI, or a local model (Ollama / LM Studio)** — click the model name above the chat box to switch provider and pick a model. Cloud providers use your own API key; local providers connect to a server running on your Mac. *(Gemini, OpenAI and local models: macOS)* +- 📊 **Claude plan usage** *(macOS, GitHub build)* — see your 5-hour and weekly Claude plan limits in the notch. Add a one-line status line relay to `~/.claude/settings.json` from Settings → Agents → Plan usage. Pro and Max plans only. - 📋 **Declare the tools you use** — open Settings → Active pills and pick your main workspace tool (VS Code, Cursor, Codex or Antigravity), then toggle up to 4 more: Gemini CLI, Anthropic, Google AI, OpenAI, Ollama, LM Studio and service integrations *(macOS)*. - 📎 **Drop a file on the notch** — Mochi turns into a box and swallows it, then ask a question about it or send it by email *(email: macOS, Mail.app)*. - 🪟 **Drag Mochi onto any window** — attach that window as context for Claude *(macOS)*. @@ -132,6 +133,7 @@ Click the Coucou icon in the menu bar (macOS) or in the system tray (Windows, Li | What | Why | Where the key goes | |---|---|---| | **Claude Code hooks** | live sessions and approvals | **Install hooks** — Coucou backs up `~/.claude/settings.json`, merges its hooks and shows you the diff before writing anything | +| **Claude plan status line** *(macOS)* | Claude plan usage gauge | **Install** in Settings → Agents → Plan usage — backs up `~/.claude/settings.json` | | **Gemini CLI hooks** *(macOS)* | Gemini CLI sessions in the island | **Install hooks** in Settings → Gemini CLI — backs up `~/.gemini/settings.json` | | **Antigravity (agy) hooks** *(macOS)* | agy sessions in the island | **Install hooks** in Settings → Antigravity — backs up `~/.gemini/config/hooks.json` | | **Anthropic API key** | chat and questions about files | Settings → Anthropic API · Keychain / Windows Credential Manager / Secret Service | diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index b5448e9e2..da33b7a3b 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -68,6 +68,45 @@ Demande l'autorisation Automatisation la première fois (normal). --- +## 1bis. Jauge de forfait Claude (statusLine) + +**ID de pastille** : `integration_claude_plan` (couleur dynamique : vert `#22C55E` < 50 %, orange `#F59E0B` 50–80 %, rouge `#F4505E` ≥ 80 %, gris `#6B7079` sans données) +**Catégorie** : Services +**Plateforme** : macOS uniquement +**Plans** : Pro et Max uniquement (le champ `rate_limits` n'est présent que pour ces plans) + +Affiche la consommation du forfait Claude dans le notch : fenêtre de 5 heures et fenêtre hebdomadaire. + +### Données + +Claude Code envoie, à chaque réponse et avec un debounce de 300 ms, un JSON à la commande `statusLine` configurée dans `~/.claude/settings.json`. Ce JSON contient : + +```json +{ + "session_id": "…", + "rate_limits": { + "five_hour": { "used_percentage": 23.5, "resets_at": 1738425600 }, + "seven_day": { "used_percentage": 67.0, "resets_at": 1738598400 } + } +} +``` + +`used_percentage` va de 0 à 100. `resets_at` est un epoch UNIX en secondes. Le champ `rate_limits` peut être absent (plan Free, ou première réponse de la session). Chaque fenêtre peut être absente indépendamment. Les valeurs absurdes (< 0 ou > 100) sont ignorées. Une fenêtre dont `resets_at` est passé s'affiche à 0 % jusqu'à la prochaine mise à jour. + +### Relais + +nb-hook.py, en mode `--statusline`, lit le JSON de stdin, en extrait `rate_limits` et `session_id`, et envoie `{"coucou_kind": "statusline", …}` au socket en fire-and-forget (timeout 0,3 s). Si une `statusLine` précédente existait (sauvegardée dans `statusline-previous.json` à côté de nb-hook), elle est appelée via `/bin/sh -c` avec le même stdin et sa sortie est réécrite telle quelle (timeout 10 s, couleurs ANSI comprises). + +### Installation + +Réglages → Agents → Plan usage → **Install**. Coucou montre le diff de `~/.claude/settings.json` avant d'écrire quoi que ce soit. Si une `statusLine` existait, seul le champ `command` est remplacé ; les autres champs (`padding`, `refreshInterval`, etc.) sont conservés. + +### Désinstallation + +Réglages → Agents → Plan usage → **Uninstall**. Remet l'objet `statusLine` d'origine à l'identique, ou retire la clé si elle n'existait pas. Si la `statusLine` actuelle n'est plus celle de Coucou (l'utilisateur l'a changée), elle n'est pas touchée. + +--- + ## 2. n8n (workflows de Louis) - Réglages : URL de l'instance (probablement `https://n8nlouis.dcsys.tech`, **à confirmer avec Louis**) et clé API n8n (Trousseau). La clé se crée dans n8n : Settings → n8n API. diff --git a/docs/SPEC.md b/docs/SPEC.md index ab619113f..bdbc8f4ff 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -99,7 +99,7 @@ Toutes les pastilles déclarées sont définies dans `PillCatalog.all` (source d | `workspace` | Where you code | VS Code, Cursor, Antigravity *(GitHub only)*, Codex *(GitHub only)* | Integration | Claude Code / Cursor / Codex / Agent | | `agent` | Agents | Gemini CLI *(GitHub only)* | Agent | Agent | | `ai` | AI for the chat | Anthropic, Google AI, OpenAI, Ollama, LM Studio | Chat | — | -| `service` | Services | Resend, n8n, Vercel, GitHub, Notion, Cal.com, Stripe, Apple Music *(GitHub only)* | Integration | — | +| `service` | Services | Claude plan, Resend, n8n, Vercel, GitHub, Notion, Cal.com, Stripe, Apple Music *(GitHub only)* | Integration | — | Couleurs : Cursor `#C0C4CC`, Codex `#2DD4BF`, Gemini CLI `#8AB4F8`, Antigravity `#E879F9`, pastilles IA = `ChatProvider.accentHex` (Ollama `#FACC15`, LM Studio `#A3E635`). @@ -212,6 +212,7 @@ Fenêtre Réglages (SwiftUI, simple), sections dans l'ordre d'affichage : - **Chat — other providers** : clé Google AI (Trousseau) ; clé OpenAI (Trousseau). Les modèles se choisissent dans le chat (voir INTEGRATIONS §5bis). - **Local models** : URL du serveur Ollama (défaut `http://127.0.0.1:11434`) et/ou LM Studio (défaut `http://127.0.0.1:1234`). Bouton **Connect** : vérifie la joignabilité et sauvegarde l'URL. Bouton **Disconnect** : efface l'URL et le cache. Aucune clé requise (voir INTEGRATIONS §5ter). - **Claude Code Hooks** : état des hooks, bouton Installer / Désinstaller. +- **Plan usage** : status line dans `~/.claude/settings.json`. Bouton **Install** : aperçu du diff, sauvegarde datée, écriture après confirmation (même flux que les hooks). Bouton **Uninstall** : remet l'objet précédent ou retire la clé. Voir INTEGRATIONS §1bis. - **Gemini CLI Hooks** *(build GitHub)* : état des hooks, bouton Installer / Désinstaller. - **Antigravity Hooks** *(build GitHub)* : état des hooks, bouton Installer / Désinstaller. - **Integrations** : clé ou token (Trousseau) pour chaque service (n8n, Stripe, GitHub, Vercel, Resend, Notion, Cal.com). diff --git a/docs/privacy.html b/docs/privacy.html index 6b182c7ba..980b37fde 100644 --- a/docs/privacy.html +++ b/docs/privacy.html @@ -37,7 +37,7 @@

4. Services you connect yourself

We have no access to these requests or their responses.

5. Claude Code, Gemini CLI, Antigravity and Codex hooks

-

If you install the Claude Code hooks, Claude Code sends session events to Coucou through a local connection on your computer. If you install the Gemini CLI, Antigravity or Codex hooks, those agents send their session events the same way. These events never leave your computer through Coucou. Before changing any settings file (~/.claude/settings.json, ~/.gemini/settings.json, ~/.gemini/config/hooks.json, ~/.codex/hooks.json), Coucou makes a dated backup and shows you the changes.

+

If you install the Claude Code hooks, Claude Code sends session events to Coucou through a local connection on your computer. If you install the Gemini CLI, Antigravity or Codex hooks, those agents send their session events the same way. These events never leave your computer through Coucou. If you install the Plan usage status line, Claude Code also passes the percentage used and the reset times of your plan windows to Coucou through the same local connection; nothing leaves your computer. Before changing any settings file (~/.claude/settings.json, ~/.gemini/settings.json, ~/.gemini/config/hooks.json, ~/.codex/hooks.json), Coucou makes a dated backup and shows you the changes.

6. Apple Music

If you activate the Apple Music pill (macOS, GitHub build), Coucou reads the current track (title, artist, album) from Music on your Mac to display it in the notch, and only sends play, pause or skip commands to Music when you click. Nothing leaves your computer.

@@ -93,7 +93,7 @@

4. Services que tu connectes toi-même

Le développeur n’a accès ni à ces requêtes ni à leurs réponses.

5. Claude Code, Gemini CLI, Antigravity et Codex

-

Si tu installes les hooks Claude Code, Claude Code envoie les événements de session à Coucou par une connexion locale sur ton ordinateur. Si tu installes les hooks Gemini CLI, Antigravity ou Codex, ces agents envoient leurs événements de la même façon. Coucou ne les fait jamais sortir de ton ordinateur. Avant de modifier un fichier de configuration (~/.claude/settings.json, ~/.gemini/settings.json, ~/.gemini/config/hooks.json, ~/.codex/hooks.json), Coucou en fait une sauvegarde datée et te montre les changements.

+

Si tu installes les hooks Claude Code, Claude Code envoie les événements de session à Coucou par une connexion locale sur ton ordinateur. Si tu installes les hooks Gemini CLI, Antigravity ou Codex, ces agents envoient leurs événements de la même façon. Coucou ne les fait jamais sortir de ton ordinateur. Si tu installes le relais de jauge de forfait (Plan usage), Claude Code transmet aussi à Coucou, par la même connexion locale, les pourcentages utilisés et les heures de remise à zéro de tes fenêtres de forfait ; rien ne quitte ton ordinateur. Avant de modifier un fichier de configuration (~/.claude/settings.json, ~/.gemini/settings.json, ~/.gemini/config/hooks.json, ~/.codex/hooks.json), Coucou en fait une sauvegarde datée et te montre les changements.

6. Apple Music

Si tu actives la pastille Apple Music (macOS, version GitHub), Coucou lit le morceau en cours (titre, artiste, album) depuis l’app Musique de ton Mac pour l’afficher dans le notch, et n’envoie lecture, pause ou morceau suivant à Musique que quand tu cliques. Rien ne quitte ton ordinateur.

diff --git a/docs/support.html b/docs/support.html index d9fa00c9e..0be53c5ee 100644 --- a/docs/support.html +++ b/docs/support.html @@ -46,7 +46,7 @@

Can I use Ollama or LM Studio instead of a cloud API?

Does it work without a notch?

Yes. On an iMac, a Mac mini, or a MacBook with its lid closed on an external display, Mochi sits in a small bar at the top of the main screen. With the lid open, it stays in the notch.

How do I uninstall?

-

Remove hooks first: Settings → Claude Code → Uninstall, and if you installed them, also Settings → Gemini CLI → Uninstall and Settings → Antigravity → Uninstall. Then quit the app and move it to the Trash. Optionally delete ~/Library/Application Support/NotchBuddy.

+

Remove hooks first: Settings → Claude Code → Uninstall, and if you installed them, also Settings → Gemini CLI → Uninstall and Settings → Antigravity → Uninstall. If you installed the Plan usage status line (Settings → Agents → Plan usage), click Uninstall there first to restore your previous status line. Then quit the app and move it to the Trash. Optionally delete ~/Library/Application Support/NotchBuddy.

On Windows: same Uninstall button for the hooks, then Windows Settings → Apps → Coucou → Uninstall. Optionally delete %LOCALAPPDATA%\Coucou.

On Linux: same Uninstall button for the hooks, then sudo apt remove coucou or sudo dnf remove coucou, or delete the AppImage. Optionally delete ~/.config/coucou, ~/.local/share/coucou, ~/.local/share/fr.louisraille.coucou and ~/.cache/coucou.

@@ -93,7 +93,7 @@

Puis-je utiliser Ollama ou LM Studio à la place d'une API cloud ?

Ça marche sans notch ?

Oui. Sur un iMac, un Mac mini ou un MacBook capot fermé branché à un écran externe, Mochi s’affiche dans une petite barre en haut de l’écran principal. Capot ouvert, il reste dans le notch.

Comment désinstaller ?

-

Retire d’abord les hooks : Réglages → Claude Code → Uninstall, et si tu les as installés, aussi Réglages → Gemini CLI → Uninstall et Réglages → Antigravity → Uninstall. Puis quitte l’app et mets-la à la corbeille. Tu peux aussi supprimer ~/Library/Application Support/NotchBuddy.

+

Retire d’abord les hooks : Réglages → Claude Code → Uninstall, et si tu les as installés, aussi Réglages → Gemini CLI → Uninstall et Réglages → Antigravity → Uninstall. Si tu as installé le relais Plan usage (Réglages → Agents → Plan usage), clique sur Uninstall d’abord pour retrouver ta status line d’avant. Puis quitte l’app et mets-la à la corbeille. Tu peux aussi supprimer ~/Library/Application Support/NotchBuddy.

Sous Windows : le même bouton Uninstall pour les hooks, puis Paramètres Windows → Applications → Coucou → Désinstaller. Tu peux aussi supprimer %LOCALAPPDATA%\Coucou.

Sous Linux : le même bouton Uninstall pour les hooks, puis sudo apt remove coucou ou sudo dnf remove coucou, ou supprime l’AppImage. Tu peux aussi supprimer ~/.config/coucou, ~/.local/share/coucou, ~/.local/share/fr.louisraille.coucou et ~/.cache/coucou.

diff --git a/scripts/test-plan-gauge.sh b/scripts/test-plan-gauge.sh new file mode 100755 index 000000000..9133fad53 --- /dev/null +++ b/scripts/test-plan-gauge.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-plan-gauge.XXXXXX")" +trap 'rm -rf "$TEST_DIR"' EXIT +swiftc NotchBuddy/Sources/App/ClaudePlanGauge.swift \ + tests/ClaudePlanGaugeTests.swift -o "$TEST_DIR/plan-gauge-tests" +"$TEST_DIR/plan-gauge-tests" diff --git a/tests/ClaudePlanGaugeTests.swift b/tests/ClaudePlanGaugeTests.swift new file mode 100644 index 000000000..41eb0ecb9 --- /dev/null +++ b/tests/ClaudePlanGaugeTests.swift @@ -0,0 +1,113 @@ +import Foundation + +@main +enum ClaudePlanGaugeTests { + + static var failures = 0 + + static func check(_ label: String, _ got: String, _ expected: String) { + if got == expected { + print(" ✓ \(label)") + } else { + print(" ✗ \(label)") + print(" got: \(got.debugDescription)") + print(" expected: \(expected.debugDescription)") + failures += 1 + } + } + + static func checkTrue(_ label: String, _ value: Bool) { + if value { print(" ✓ \(label)") } + else { print(" ✗ \(label)"); failures += 1 } + } + + static func main() { + let futureEpoch: Double = Date().timeIntervalSince1970 + 7200 // 2 hours from now + let pastEpoch: Double = Date().timeIntervalSince1970 - 100 // already passed + + // ── parse ────────────────────────────────────────────────────────────── + print("ClaudePlanGauge.parse") + + let full: [String: Any] = ["rate_limits": [ + "five_hour": ["used_percentage": 23.5, "resets_at": futureEpoch], + "seven_day": ["used_percentage": 67.0, "resets_at": futureEpoch], + ]] + let usage = ClaudePlanGauge.parse(payload: full) + checkTrue("parse full payload → non-nil", usage != nil) + checkTrue("five_hour usedPct 23.5", usage?.fiveHour?.usedPct == 23.5) + checkTrue("seven_day usedPct 67.0", usage?.sevenDay?.usedPct == 67.0) + + // Missing rate_limits → nil + checkTrue("missing rate_limits → nil", ClaudePlanGauge.parse(payload: [:]) == nil) + + // Absurd pct (>100) → nil window + let badPct: [String: Any] = ["rate_limits": [ + "five_hour": ["used_percentage": 150.0, "resets_at": futureEpoch], + ]] + let badUsage = ClaudePlanGauge.parse(payload: badPct) + checkTrue("absurd pct (150) → nil window", badUsage?.fiveHour == nil) + + // Negative pct → nil window + let negPct: [String: Any] = ["rate_limits": [ + "five_hour": ["used_percentage": -5.0, "resets_at": futureEpoch], + ]] + checkTrue("negative pct → nil window", ClaudePlanGauge.parse(payload: negPct)?.fiveHour == nil) + + // Int pct accepted + let intPct: [String: Any] = ["rate_limits": [ + "five_hour": ["used_percentage": 42, "resets_at": Int(futureEpoch)], + ]] + let intUsage = ClaudePlanGauge.parse(payload: intPct) + checkTrue("Int pct parsed", intUsage?.fiveHour?.usedPct == 42.0) + + // ── effectivePct (expired window) ───────────────────────────────────── + print("ClaudePlanGauge.effectivePct") + + let expiredWindow = PlanWindow(usedPct: 80, resetsAt: Date(timeIntervalSince1970: pastEpoch)) + let futureWindow = PlanWindow(usedPct: 80, resetsAt: Date(timeIntervalSince1970: futureEpoch)) + checkTrue("expired window → 0", ClaudePlanGauge.effectivePct(expiredWindow) == 0) + checkTrue("future window → original pct", ClaudePlanGauge.effectivePct(futureWindow) == 80) + + // ── dominantPct ──────────────────────────────────────────────────────── + print("ClaudePlanGauge.dominantPct") + + let both = PlanUsage(fiveHour: PlanWindow(usedPct: 30, resetsAt: Date(timeIntervalSince1970: futureEpoch)), + sevenDay: PlanWindow(usedPct: 70, resetsAt: Date(timeIntervalSince1970: futureEpoch)), + updatedAt: Date()) + let fhOnly = PlanUsage(fiveHour: PlanWindow(usedPct: 55, resetsAt: Date(timeIntervalSince1970: futureEpoch)), + sevenDay: nil, updatedAt: Date()) + let empty = PlanUsage(fiveHour: nil, sevenDay: nil, updatedAt: Date()) + + checkTrue("dominant picks higher (70)", ClaudePlanGauge.dominantPct(both) == 70) + checkTrue("dominant with one window (55)", ClaudePlanGauge.dominantPct(fhOnly) == 55) + checkTrue("dominant with no windows → nil", ClaudePlanGauge.dominantPct(empty) == nil) + + // ── color ────────────────────────────────────────────────────────────── + print("ClaudePlanGauge.color") + + check("nil → grey", ClaudePlanGauge.color(for: nil), "#6B7079") + check("0 → green", ClaudePlanGauge.color(for: 0), "#22C55E") + check("49 → green", ClaudePlanGauge.color(for: 49), "#22C55E") + check("50 → orange", ClaudePlanGauge.color(for: 50), "#F59E0B") + check("79 → orange", ClaudePlanGauge.color(for: 79), "#F59E0B") + check("80 → red", ClaudePlanGauge.color(for: 80), "#F4505E") + check("100 → red", ClaudePlanGauge.color(for: 100), "#F4505E") + + // ── pillLabel ────────────────────────────────────────────────────────── + print("ClaudePlanGauge.pillLabel") + + check("nil usage → 'Claude plan'", ClaudePlanGauge.pillLabel(nil), "Claude plan") + check("no windows → 'Claude plan'", ClaudePlanGauge.pillLabel(empty), "Claude plan") + check("70 pct → 'Claude 70%'", ClaudePlanGauge.pillLabel(both), "Claude 70%") + check("55 pct → 'Claude 55%'", ClaudePlanGauge.pillLabel(fhOnly), "Claude 55%") + + // ── finish ───────────────────────────────────────────────────────────── + if failures == 0 { + print("\nAll tests passed.") + exit(0) + } else { + print("\n\(failures) test(s) failed.") + exit(1) + } + } +} From a311e08f0a1efa359994af28cd8fc436fbc20c6f Mon Sep 17 00:00:00 2001 From: Louis Raille Date: Sat, 3 Oct 2026 01:42:02 +0200 Subject: [PATCH 2/5] Fix claude-plan-gauge review: safe preview, live pill color, githubOnly, validation - Preview writes nothing; all file I/O deferred to confirm (fixes cancel-uninstall data loss) - Reinstall preserves existing statusLine padding/refreshInterval (only command swapped) - Pill label and color computed live from claudePlanUsage at render; removed tasks mutation in didSet - integration_claude_plan is githubOnly: true (App Store has no installer) - Install checks xcode-select -p and shows clear error if CLTools missing - nb-hook.py: invalid stdin returns early in normal mode; --statusline continues regardless - Validation: clamp pct 100-200 to 100 (not rejected); reject pct > 200; reject resets_at > 400 days - Card: TimelineView every 30s; DateFormatter created once with en_US_POSIX locale - Settings Plan usage: shows Installed/Not installed state and only the relevant button - Diff shows only the statusLine key (before/after), not full settings.json - settings.json written with .withoutEscapingSlashes - 27/27 tests pass Co-Authored-By: Claude Sonnet 4.6 --- .../NotchBuddy.xcodeproj/project.pbxproj | 9 + NotchBuddy/Sources/App/AppState.swift | 7 - .../Sources/App/ClaudePlanCardView.swift | 16 +- NotchBuddy/Sources/App/ClaudePlanGauge.swift | 7 +- NotchBuddy/Sources/App/HookServer.swift | 161 ++++++++++++------ .../Sources/App/IslandViewContent.swift | 28 ++- NotchBuddy/Sources/App/PillCatalog.swift | 2 +- NotchBuddy/Sources/App/SettingsView.swift | 18 +- tests/ClaudePlanGaugeTests.swift | 26 ++- 9 files changed, 192 insertions(+), 82 deletions(-) diff --git a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj index a09873500..94fd3635f 100644 --- a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj +++ b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj @@ -53,6 +53,7 @@ 8A6B3688D6B3BF728D106D8B /* UserIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4A13AEE6E06F46CEC9D908C /* UserIdentity.swift */; }; 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 */; }; @@ -76,6 +77,7 @@ 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 */; }; @@ -102,6 +104,7 @@ 78BC1357785CA1B099A33969 /* IslandScreenGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandScreenGeometry.swift; sourceTree = ""; }; 79D8DD79C3DD9BBC584B3CB2 /* HookServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HookServer.swift; sourceTree = ""; }; 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 8790ACE9F73BD74640A0F20C /* LocalChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalChat.swift; sourceTree = ""; }; 8BF22817EC70748F2D1FB2E7 /* ClaudePlanGauge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudePlanGauge.swift; sourceTree = ""; }; 8CB8B6E5E9DA6164F979E88F /* GithubPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubPoller.swift; sourceTree = ""; }; 8D3CE3617E9D336DDBA10F7A /* IslandTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandTypes.swift; sourceTree = ""; }; @@ -138,6 +141,8 @@ A29F4C0B02BE91654D02AA0C /* BotCanvasView.swift */, D4540F630F8BA04AFFFA3979 /* BotEngine.swift */, 3104222B3F17BA5E8DEA5277 /* CalcomPoller.swift */, + D797CC384B5BC70D34F121BC /* ChatMarkdown.swift */, + BFE88A53AEAA637F866D479D /* ChatMarkdownView.swift */, F4263B550838CC5614569D13 /* ClaudePlanCardView.swift */, 8BF22817EC70748F2D1FB2E7 /* ClaudePlanGauge.swift */, A83D5410BD030B243ADDCFF4 /* ClaudeService.swift */, @@ -315,6 +320,8 @@ CD8DECA66B9E09C97E469DB9 /* BotCanvasView.swift in Sources */, C24D863967D8D32D828ED4B1 /* BotEngine.swift in Sources */, 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 */, @@ -357,6 +364,8 @@ 651FA3F09608DE5D02D61CCE /* BotCanvasView.swift in Sources */, 0E9CE475EC53C67DAB00F8E5 /* BotEngine.swift in Sources */, 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 */, diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index e2c854f89..bb117e8ed 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -300,13 +300,6 @@ final class AppState: ObservableObject { let data = try? JSONEncoder().encode(u) { UserDefaults.standard.set(data, forKey: "claudePlanUsage") } - // Update pill label and color - let label = ClaudePlanGauge.pillLabel(claudePlanUsage) - let colorHex = ClaudePlanGauge.color(for: claudePlanUsage.flatMap { ClaudePlanGauge.dominantPct($0) }) - if let idx = tasks.firstIndex(where: { $0.id == "integration_claude_plan" }) { - tasks[idx].name = label - tasks[idx].color = colorHex - } } } diff --git a/NotchBuddy/Sources/App/ClaudePlanCardView.swift b/NotchBuddy/Sources/App/ClaudePlanCardView.swift index 74eb55061..b2d94fa52 100644 --- a/NotchBuddy/Sources/App/ClaudePlanCardView.swift +++ b/NotchBuddy/Sources/App/ClaudePlanCardView.swift @@ -37,9 +37,9 @@ struct ClaudePlanCardView: View { } .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.top, 4) - // Update countdown every second, only while visible + // Update countdown every 30 seconds, only while visible .background( - TimelineView(.periodic(from: .now, by: 1)) { ctx in + TimelineView(.periodic(from: .now, by: 30)) { ctx in Color.clear.onChange(of: ctx.date) { _, d in now = d } } ) @@ -58,6 +58,13 @@ struct ClaudePlanCardView: View { // MARK: - Gauge Row +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? @@ -108,10 +115,7 @@ private struct GaugeRowView: View { let secs = w.resetsAt.timeIntervalSince(now) guard secs > 0 else { return "Resetting…" } if weekly { - // "Resets Mon 9:00" - let fmt = DateFormatter() - fmt.dateFormat = "EEE H:mm" - return "Resets \(fmt.string(from: w.resetsAt))" + return "Resets \(weeklyResetFormatter.string(from: w.resetsAt))" } else { // "Resets in 1 h 20" or "Resets in 45 min" let h = Int(secs / 3600) diff --git a/NotchBuddy/Sources/App/ClaudePlanGauge.swift b/NotchBuddy/Sources/App/ClaudePlanGauge.swift index c2bd6f82d..e9258adcf 100644 --- a/NotchBuddy/Sources/App/ClaudePlanGauge.swift +++ b/NotchBuddy/Sources/App/ClaudePlanGauge.swift @@ -34,13 +34,16 @@ enum ClaudePlanGauge { 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 <= 100 else { return nil } - let pct = max(0, min(100, rawPct)) + 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)) } diff --git a/NotchBuddy/Sources/App/HookServer.swift b/NotchBuddy/Sources/App/HookServer.swift index 329af755f..7ba0d652b 100644 --- a/NotchBuddy/Sources/App/HookServer.swift +++ b/NotchBuddy/Sources/App/HookServer.swift @@ -950,32 +950,11 @@ final class HookServer: @unchecked Sendable { } private var _pendingStatusLineData: Data? + private var _pendingPreviousData: Data? + private var _pendingDeletePrevious: Bool = false - /// Returns preview JSON without writing — call writeStatusLine() to confirm. + /// Returns a diff string (only the statusLine key: before → after) without writing anything. func previewStatusLine(install: Bool) throws -> String { - let data = try buildStatusLineData(install: install) - _pendingStatusLineData = data - return String(data: data, encoding: .utf8) ?? "" - } - - /// Writes settings.json (call after user confirms preview). - func writeStatusLine() throws { - guard let data = _pendingStatusLineData else { return } - let settingsURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".claude/settings.json") - let formatter = DateFormatter() - formatter.dateFormat = "yyyyMMdd-HHmm" - let stamp = formatter.string(from: Date()) - let backupURL = settingsURL.deletingLastPathComponent() - .appendingPathComponent("settings.json.bak-\(stamp)") - try? FileManager.default.copyItem(at: settingsURL, to: backupURL) - try? FileManager.default.createDirectory(at: settingsURL.deletingLastPathComponent(), - withIntermediateDirectories: true) - try data.write(to: settingsURL, options: .atomic) - _pendingStatusLineData = nil - } - - private func buildStatusLineData(install: Bool) throws -> Data { let settingsURL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".claude/settings.json") var settings: [String: Any] = [:] @@ -987,36 +966,106 @@ final class HookServer: @unchecked Sendable { let quotedPath = hookPath.replacingOccurrences(of: "\"", with: "\\\"") let quotedCmd = "\"\(quotedPath)\" --statusline" + // Reset pending side-effects + _pendingPreviousData = nil + _pendingDeletePrevious = false + + let oldSL = settings["statusLine"] as? [String: Any] + let newSL: [String: Any]? + if install { - // If there's already a statusLine that isn't ours, save it for later restoration - if let existing = settings["statusLine"] as? [String: Any], + // Check that Python 3 is available (requires Command Line Tools) + let clCheck = Process() + clCheck.executableURL = URL(fileURLWithPath: "/usr/bin/xcode-select") + clCheck.arguments = ["-p"] + clCheck.standardOutput = FileHandle.nullDevice + clCheck.standardError = FileHandle.nullDevice + try? clCheck.run() + clCheck.waitUntilExit() + if clCheck.terminationStatus != 0 { + throw NSError(domain: "Coucou", code: 1, + userInfo: [NSLocalizedDescriptionKey: + "Command Line Tools are required but not installed. Run: xcode-select --install"]) + } + + if let existing = oldSL, let cmd = existing["command"] as? String, !cmd.contains("nb-hook") { - let prevData = try JSONSerialization.data(withJSONObject: existing, - options: [.prettyPrinted, .sortedKeys]) - try prevData.write(to: statusLinePreviousURL, options: .atomic) + // Keep existing object but swap command; save old for later restoration + var updated = existing + updated["command"] = quotedCmd + newSL = updated + _pendingPreviousData = try? JSONSerialization.data(withJSONObject: existing, + options: [.prettyPrinted, .sortedKeys]) + } else if let existing = oldSL, + let cmd = existing["command"] as? String, cmd.contains("nb-hook") { + // Already installed — rebuild to update path if needed, keep other fields var updated = existing updated["command"] = quotedCmd - settings["statusLine"] = updated + newSL = updated } else { - settings["statusLine"] = ["type": "command", "command": quotedCmd] + newSL = ["type": "command", "command": quotedCmd] } } else { - // Uninstall: only touch it if it's still ours - guard let current = settings["statusLine"] as? [String: Any], - let cmd = current["command"] as? String, cmd.contains("nb-hook") else { - return try JSONSerialization.data(withJSONObject: settings, - options: [.prettyPrinted, .sortedKeys]) - } - if let prevData = try? Data(contentsOf: statusLinePreviousURL), - let prevObj = (try? JSONSerialization.jsonObject(with: prevData)) as? [String: Any] { - settings["statusLine"] = prevObj - try? FileManager.default.removeItem(at: statusLinePreviousURL) + // Uninstall: only if it's ours + if let cur = oldSL, let cmd = cur["command"] as? String, cmd.contains("nb-hook") { + if let prevData = try? Data(contentsOf: statusLinePreviousURL), + let prevObj = (try? JSONSerialization.jsonObject(with: prevData)) as? [String: Any] { + newSL = prevObj + _pendingDeletePrevious = true + } else { + newSL = nil + } } else { - settings.removeValue(forKey: "statusLine") + newSL = oldSL // not ours — leave unchanged } } - return try JSONSerialization.data(withJSONObject: settings, - options: [.prettyPrinted, .sortedKeys]) + + // Build the full settings.json with the new statusLine + var newSettings = settings + if let sl = newSL { + newSettings["statusLine"] = sl + } else { + newSettings.removeValue(forKey: "statusLine") + } + let data = try JSONSerialization.data(withJSONObject: newSettings, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) + _pendingStatusLineData = data + + // Build a compact diff: show only the statusLine key before → after + func slJSON(_ val: [String: Any]?) throws -> String { + guard let v = val else { return "(none)" } + let d = try JSONSerialization.data(withJSONObject: v, options: [.prettyPrinted, .sortedKeys]) + return String(data: d, encoding: .utf8) ?? "(none)" + } + let before = try slJSON(oldSL) + let after = try slJSON(newSL) + return "statusLine\nBefore:\n\(before)\n\nAfter:\n\(after)" + } + + /// Writes settings.json and commits side effects (call after user confirms). + func writeStatusLine() throws { + guard let data = _pendingStatusLineData else { return } + let settingsURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude/settings.json") + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd-HHmm" + let stamp = formatter.string(from: Date()) + let backupURL = settingsURL.deletingLastPathComponent() + .appendingPathComponent("settings.json.bak-\(stamp)") + try? FileManager.default.copyItem(at: settingsURL, to: backupURL) + try? FileManager.default.createDirectory(at: settingsURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: settingsURL, options: .atomic) + // Commit side effects only after successful write + if let prevData = _pendingPreviousData { + try? prevData.write(to: statusLinePreviousURL, options: .atomic) + } + if _pendingDeletePrevious { + try? FileManager.default.removeItem(at: statusLinePreviousURL) + } + _pendingStatusLineData = nil + _pendingPreviousData = nil + _pendingDeletePrevious = false } // MARK: - App Store: hooks via security-scoped bookmark @@ -1576,14 +1625,18 @@ def normalize_tool_fields(payload): payload['session_id'] = sid def main(): + raw = b'' + payload = {} try: raw = sys.stdin.buffer.read() if not raw: - return - payload = json.loads(raw) + if '--statusline' not in sys.argv[1:]: + return + else: + payload = json.loads(raw) except Exception: - raw = b'' - payload = {} + if '--statusline' not in sys.argv[1:]: + return socket_path = os.path.expanduser( '~/Library/Application Support/NotchBuddy/nb.sock' @@ -1781,14 +1834,18 @@ def normalize_tool_fields(payload): payload['session_id'] = sid def main(): + raw = b'' + payload = {} try: raw = sys.stdin.buffer.read() if not raw: - return - payload = json.loads(raw) + if '--statusline' not in sys.argv[1:]: + return + else: + payload = json.loads(raw) except Exception: - raw = b'' - payload = {} + if '--statusline' not in sys.argv[1:]: + return socket_path = os.path.expanduser( '~/Library/Containers/fr.louisraille.Coucou/Data/nb.sock' diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift index 82ea12508..d7619a951 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -2726,9 +2726,20 @@ struct AgentPill: View { let onTap: () -> Void @State private var isHovered = false + private var effectiveColor: String { + if task.id == "integration_claude_plan" { + return ClaudePlanGauge.color(for: state.claudePlanUsage.flatMap { ClaudePlanGauge.dominantPct($0) }) + } + return task.color + } + // VS Code pill always shows "VS Code" label regardless of active project name private var displayName: String { - task.id == "integration_claude" ? "VS Code" : task.name + if task.id == "integration_claude" { return "VS Code" } + if task.id == "integration_claude_plan" { + return ClaudePlanGauge.pillLabel(state.claudePlanUsage) + } + return task.name } var body: some View { @@ -2737,12 +2748,15 @@ struct AgentPill: View { ZStack { Capsule() .fill(isHovered - ? Color(hex: task.color).opacity(0.18) + ? Color(hex: effectiveColor).opacity(0.18) : Color(hex: "#0E0F11")) Capsule() - .stroke(Color(hex: task.color).opacity(isHovered ? 0.55 : 0.14), lineWidth: 1) + .stroke(Color(hex: effectiveColor).opacity(isHovered ? 0.55 : 0.14), lineWidth: 1) HStack(spacing: 0) { - MiniBotCanvasView(task: task) + MiniBotCanvasView(task: task.id == "integration_claude_plan" + ? AgentTask(id: task.id, name: task.name, color: effectiveColor, + state: task.state, steps: task.steps, source: task.source) + : task) .frame(width: 22 / 0.6, height: 22 / 0.6) .frame(width: 22, height: 22, alignment: .center) .padding(.leading, 8) @@ -2751,7 +2765,7 @@ struct AgentPill: View { Text(displayName) .font(.system(size: 10, weight: .semibold)) .foregroundColor(isHovered - ? Color(hex: task.color).lighter(by: 0.3) + ? Color(hex: effectiveColor).lighter(by: 0.3) : Color(hex: "#6B7079")) .lineLimit(1) .truncationMode(.tail) @@ -2759,11 +2773,11 @@ struct AgentPill: View { } .frame(maxWidth: .infinity) .frame(height: 28) - .shadow(color: Color(hex: task.color).opacity(isHovered ? 0.35 : 0), radius: 10, x: 0, y: 2) + .shadow(color: Color(hex: effectiveColor).opacity(isHovered ? 0.35 : 0), radius: 10, x: 0, y: 2) // Alert badge (approval / finished / error) if let badge = task.pillBadge { - PillBadgeView(badge: badge, taskColor: task.color) + PillBadgeView(badge: badge, taskColor: effectiveColor) .offset(x: 3, y: -3) } } diff --git a/NotchBuddy/Sources/App/PillCatalog.swift b/NotchBuddy/Sources/App/PillCatalog.swift index 3e21d7144..c34e6fee2 100644 --- a/NotchBuddy/Sources/App/PillCatalog.swift +++ b/NotchBuddy/Sources/App/PillCatalog.swift @@ -72,7 +72,7 @@ enum PillCatalog { category: .ai, subtitle: "Chat", source: .n8n), // ── Services ───────────────────────────────────────────────────────── .init(id: "integration_claude_plan", name: "Claude plan", color: "#6B7079", - category: .service, subtitle: "Integration", source: .n8n), + category: .service, subtitle: "Integration", source: .n8n, githubOnly: true), .init(id: "integration_resend", name: "Resend", color: "#22C55E", category: .service, subtitle: "Integration", source: .n8n), .init(id: "integration_n8n", name: "n8n", color: "#F29B38", diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index e40ace686..6445565fe 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -487,11 +487,21 @@ struct SettingsView: View { .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) + let installed = HookServer.statusLineInstalled() HStack(spacing: 10) { - Button("Install") { installStatusLine() } - .buttonStyle(.borderedProminent) - Button("Uninstall") { uninstallStatusLine() } - .buttonStyle(.bordered) + if installed { + Text("Installed") + .font(.system(size: 11)) + .foregroundColor(.secondary) + Button("Uninstall") { uninstallStatusLine() } + .buttonStyle(.bordered) + } else { + Text("Not installed") + .font(.system(size: 11)) + .foregroundColor(.secondary) + Button("Install") { installStatusLine() } + .buttonStyle(.borderedProminent) + } } if showStatusLineDiff { ScrollView { diff --git a/tests/ClaudePlanGaugeTests.swift b/tests/ClaudePlanGaugeTests.swift index 41eb0ecb9..91fa1ce03 100644 --- a/tests/ClaudePlanGaugeTests.swift +++ b/tests/ClaudePlanGaugeTests.swift @@ -40,12 +40,32 @@ enum ClaudePlanGaugeTests { // Missing rate_limits → nil checkTrue("missing rate_limits → nil", ClaudePlanGauge.parse(payload: [:]) == nil) - // Absurd pct (>100) → nil window + // Absurd pct (>200) → nil window (150 is now clamped to 100) let badPct: [String: Any] = ["rate_limits": [ - "five_hour": ["used_percentage": 150.0, "resets_at": futureEpoch], + "five_hour": ["used_percentage": 250.0, "resets_at": futureEpoch], ]] let badUsage = ClaudePlanGauge.parse(payload: badPct) - checkTrue("absurd pct (150) → nil window", badUsage?.fiveHour == nil) + checkTrue("absurd pct (250) → nil window", badUsage?.fiveHour == nil) + + // pct 150 → clamped to 100 (not rejected) + let clampPct: [String: Any] = ["rate_limits": [ + "five_hour": ["used_percentage": 150.0, "resets_at": futureEpoch], + ]] + let clampUsage = ClaudePlanGauge.parse(payload: clampPct) + checkTrue("pct 150 → clamped to 100", clampUsage?.fiveHour?.usedPct == 100.0) + + // pct 201 → rejected + let tooBig: [String: Any] = ["rate_limits": [ + "five_hour": ["used_percentage": 201.0, "resets_at": futureEpoch], + ]] + checkTrue("pct 201 → nil window", ClaudePlanGauge.parse(payload: tooBig)?.fiveHour == nil) + + // resets_at > 400 days → rejected + let farFuture = Date().timeIntervalSince1970 + 401 * 86400 + let msEpoch: [String: Any] = ["rate_limits": [ + "five_hour": ["used_percentage": 50.0, "resets_at": farFuture], + ]] + checkTrue("resets_at > 400d → nil window", ClaudePlanGauge.parse(payload: msEpoch)?.fiveHour == nil) // Negative pct → nil window let negPct: [String: Any] = ["rate_limits": [ From 23327226fc30bbc15f045edbdeba7ef97630aa97 Mon Sep 17 00:00:00 2001 From: Louis Raille Date: Sat, 3 Oct 2026 01:44:38 +0200 Subject: [PATCH 3/5] docs: mark Claude plan gauge as GitHub build only Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 +- docs/SPEC.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fe7737ce5..595aa9d68 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ Click the Coucou icon in the menu bar (macOS) or in the system tray (Windows, Li | What | Why | Where the key goes | |---|---|---| | **Claude Code hooks** | live sessions and approvals | **Install hooks** — Coucou backs up `~/.claude/settings.json`, merges its hooks and shows you the diff before writing anything | -| **Claude plan status line** *(macOS)* | Claude plan usage gauge | **Install** in Settings → Agents → Plan usage — backs up `~/.claude/settings.json` | +| **Claude plan status line** *(macOS, GitHub build)* | Claude plan usage gauge | **Install** in Settings → Agents → Plan usage — backs up `~/.claude/settings.json` | | **Gemini CLI hooks** *(macOS)* | Gemini CLI sessions in the island | **Install hooks** in Settings → Gemini CLI — backs up `~/.gemini/settings.json` | | **Antigravity (agy) hooks** *(macOS)* | agy sessions in the island | **Install hooks** in Settings → Antigravity — backs up `~/.gemini/config/hooks.json` | | **Anthropic API key** | chat and questions about files | Settings → Anthropic API · Keychain / Windows Credential Manager / Secret Service | diff --git a/docs/SPEC.md b/docs/SPEC.md index bdbc8f4ff..5e810ec65 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -99,7 +99,7 @@ Toutes les pastilles déclarées sont définies dans `PillCatalog.all` (source d | `workspace` | Where you code | VS Code, Cursor, Antigravity *(GitHub only)*, Codex *(GitHub only)* | Integration | Claude Code / Cursor / Codex / Agent | | `agent` | Agents | Gemini CLI *(GitHub only)* | Agent | Agent | | `ai` | AI for the chat | Anthropic, Google AI, OpenAI, Ollama, LM Studio | Chat | — | -| `service` | Services | Claude plan, Resend, n8n, Vercel, GitHub, Notion, Cal.com, Stripe, Apple Music *(GitHub only)* | Integration | — | +| `service` | Services | Claude plan *(GitHub only)*, Resend, n8n, Vercel, GitHub, Notion, Cal.com, Stripe, Apple Music *(GitHub only)* | Integration | — | Couleurs : Cursor `#C0C4CC`, Codex `#2DD4BF`, Gemini CLI `#8AB4F8`, Antigravity `#E879F9`, pastilles IA = `ChatProvider.accentHex` (Ollama `#FACC15`, LM Studio `#A3E635`). From e64afee7f6bafa430dd53cb828f5f8bb1d486ff1 Mon Sep 17 00:00:00 2001 From: Louis Raille Date: Sat, 3 Oct 2026 02:06:51 +0200 Subject: [PATCH 4/5] Redesign Claude plan gauge: header pill and Settings toggle, not a catalog pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove integration_claude_plan from PillCatalog; it was never shipped - Add "Show in the notch" toggle in Settings → Agents → Plan usage; enabling it auto-triggers relay install if not yet installed; cancelling reverts toggle - Small capsule pill in IslandHeader (home view, GitHub build): colored dot + "Claude 73%" label, active look while plan detail is open - Tap pill: ClaudePlanCardView replaces left card; showingPlanDetail resets on focusId/view/mode change; big Mochi takes plan usage color while open - Card tweaks: fixed 50pt bar, arrow.clockwise icon, compact reset labels ("in 1 h 20" / "Mon 9:00"), subtitle "just now / N min ago / N h ago", no truncation; TimelineView every 30s - App Store build unaffected (all new UI is #if !APPSTORE) Co-Authored-By: Claude Sonnet 4.6 --- NotchBuddy/Sources/App/AppState.swift | 12 +++ NotchBuddy/Sources/App/BotCanvasView.swift | 11 +++ .../Sources/App/ClaudePlanCardView.swift | 47 ++++----- NotchBuddy/Sources/App/IslandRootView.swift | 97 ++++++++++++++++--- .../Sources/App/IslandViewContent.swift | 87 +++++++---------- NotchBuddy/Sources/App/PillCatalog.swift | 2 - NotchBuddy/Sources/App/SettingsView.swift | 44 +++++++-- README.md | 4 +- docs/INTEGRATIONS.md | 13 ++- docs/SPEC.md | 4 +- 10 files changed, 209 insertions(+), 112 deletions(-) diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index bb117e8ed..d6c7bda4c 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -303,6 +303,15 @@ final class AppState: ObservableObject { } } + // Plan gauge: show pill in notch header — persisted + #if !APPSTORE + @Published var showPlanInNotch: Bool = false { + didSet { UserDefaults.standard.set(showPlanInNotch, forKey: "showPlanInNotch") } + } + // Transient — reset when island closes or view changes + @Published var showingPlanDetail: Bool = false + #endif + // MARK: - Init (loads persisted settings) private init() { @@ -340,6 +349,9 @@ final class AppState: ObservableObject { } 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 } + #endif // Sync SoundEngine volume on launch SoundEngine.shared.volume = Float(soundVolume) diff --git a/NotchBuddy/Sources/App/BotCanvasView.swift b/NotchBuddy/Sources/App/BotCanvasView.swift index 050d47af1..23b1eddee 100644 --- a/NotchBuddy/Sources/App/BotCanvasView.swift +++ b/NotchBuddy/Sources/App/BotCanvasView.swift @@ -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 = { diff --git a/NotchBuddy/Sources/App/ClaudePlanCardView.swift b/NotchBuddy/Sources/App/ClaudePlanCardView.swift index b2d94fa52..08d5466c3 100644 --- a/NotchBuddy/Sources/App/ClaudePlanCardView.swift +++ b/NotchBuddy/Sources/App/ClaudePlanCardView.swift @@ -21,6 +21,8 @@ struct ClaudePlanCardView: View { Text(subtitleText) .font(.system(size: 11)) .foregroundColor(Color(hex: "#8E939C")) + .lineLimit(1) + .fixedSize() } .padding(.top, 6) .padding(.leading, 108) @@ -37,7 +39,7 @@ struct ClaudePlanCardView: View { } .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.top, 4) - // Update countdown every 30 seconds, only while visible + // 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 } @@ -48,16 +50,16 @@ struct ClaudePlanCardView: View { 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 "Updated just now" } + if diff < 60 { return "just now" } let mins = Int(diff / 60) - if mins < 60 { return "Updated \(mins) min ago" } - let hrs = mins / 60 - return "Updated \(hrs) h ago" + 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") @@ -72,35 +74,36 @@ private struct GaugeRowView: View { var weekly: Bool = false var body: some View { - HStack(spacing: 7) { + HStack(spacing: 5) { Text(label) .font(.system(size: 11)) .foregroundColor(Color(hex: "#6B7079")) - .frame(width: 42, alignment: .leading) + .frame(width: 36, alignment: .leading) if let w = window { let pct = ClaudePlanGauge.effectivePct(w) let accent = Color(hex: ClaudePlanGauge.color(for: pct)) - // Thin bar - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 2) - .fill(Color.white.opacity(0.08)) - .frame(height: 4) - RoundedRectangle(cornerRadius: 2) - .fill(accent) - .frame(width: geo.size.width * CGFloat(pct / 100), height: 4) - } + // 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(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")) - .lineLimit(1) + .fixedSize() } else { Text("—") .font(.system(size: 11)) @@ -108,7 +111,6 @@ private struct GaugeRowView: View { Spacer() } } - .frame(maxWidth: .infinity) } private func resetLabel(_ w: PlanWindow) -> String { @@ -117,11 +119,10 @@ private struct GaugeRowView: View { if weekly { return "Resets \(weeklyResetFormatter.string(from: w.resetsAt))" } else { - // "Resets in 1 h 20" or "Resets in 45 min" let h = Int(secs / 3600) let m = Int((secs.truncatingRemainder(dividingBy: 3600)) / 60) - if h > 0 { return "Resets in \(h) h \(m) min" } - return "Resets in \(m) min" + if h > 0 { return "in \(h) h \(m)" } + return "in \(m) min" } } } diff --git a/NotchBuddy/Sources/App/IslandRootView.swift b/NotchBuddy/Sources/App/IslandRootView.swift index 9eb8b69b5..5c819093e 100644 --- a/NotchBuddy/Sources/App/IslandRootView.swift +++ b/NotchBuddy/Sources/App/IslandRootView.swift @@ -483,25 +483,32 @@ struct IslandHeader: View { Spacer() - // Right: action icons - HStack(spacing: 14) { - Button(action: { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - state.view = .settings - } - }) { - Image(systemName: state.view == .settings ? "gearshape.fill" : "gearshape") - .font(.system(size: 14)) - .foregroundColor(state.view == .settings ? Color(hex: "#F5F6F8") : Color(hex: "#8E939C")) + // Right: plan pill (GitHub build, home view only) + action icons + HStack(spacing: 8) { + #if !APPSTORE + if state.view == .overview && state.showPlanInNotch && HookServer.statusLineInstalled() { + ClaudePlanHeaderPill(state: state) } - .buttonStyle(.plain) + #endif + HStack(spacing: 14) { + Button(action: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + state.view = .settings + } + }) { + Image(systemName: state.view == .settings ? "gearshape.fill" : "gearshape") + .font(.system(size: 14)) + .foregroundColor(state.view == .settings ? Color(hex: "#F5F6F8") : Color(hex: "#8E939C")) + } + .buttonStyle(.plain) - Button(action: { state.soundEnabled.toggle() }) { - Image(systemName: state.soundEnabled ? "speaker.wave.2" : "speaker.slash") - .font(.system(size: 14)) - .foregroundColor(Color(hex: "#8E939C")) + Button(action: { state.soundEnabled.toggle() }) { + Image(systemName: state.soundEnabled ? "speaker.wave.2" : "speaker.slash") + .font(.system(size: 14)) + .foregroundColor(Color(hex: "#8E939C")) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } .padding(.trailing, 16) } @@ -543,6 +550,64 @@ struct TabButton: View { } } +// MARK: - Claude Plan header pill (GitHub build only) + +#if !APPSTORE +struct ClaudePlanHeaderPill: View { + @ObservedObject var state: AppState + @State private var isHovered = false + + private var effectiveColor: String { + ClaudePlanGauge.color(for: state.claudePlanUsage.flatMap { ClaudePlanGauge.dominantPct($0) }) + } + + private var label: String { + guard let usage = state.claudePlanUsage, + let pct = ClaudePlanGauge.dominantPct(usage) else { return "Claude —" } + return "Claude \(Int(pct.rounded()))%" + } + + private var isActive: Bool { state.showingPlanDetail || isHovered } + + var body: some View { + Button(action: { + withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { + state.showingPlanDetail.toggle() + } + }) { + HStack(spacing: 4) { + Circle() + .fill(Color(hex: effectiveColor)) + .frame(width: 6, height: 6) + Text(label) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(isActive + ? Color(hex: effectiveColor).lighter(by: 0.3) + : Color(hex: "#6B7079")) + .lineLimit(1) + .fixedSize() + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule() + .fill(isActive + ? Color(hex: effectiveColor).opacity(0.18) + : Color(hex: "#0E0F11")) + ) + .overlay( + Capsule() + .stroke(Color(hex: effectiveColor).opacity(isActive ? 0.55 : 0.14), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .onHover { h in + withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = h } + } + } +} +#endif + // MARK: - Compact mini mochi grid (2×2 to the right of the notch) struct CompactMiniGrid: View { diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift index d7619a951..7eb124562 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -93,8 +93,22 @@ struct OverviewView: View { } } + // Plan detail overlays on top of normal content (GitHub build, home view only) + #if !APPSTORE + if state.showingPlanDetail { + CardBackground(wash: nil) + ClaudePlanCardView(usage: state.claudePlanUsage) + .transition(.opacity) + } + #endif + // ↗ jump button — last in ZStack so it renders on top; hidden while any detail is open - if !showingN8nDetail { + #if !APPSTORE + let hideJumpButton = showingN8nDetail || state.showingPlanDetail + #else + let hideJumpButton = showingN8nDetail + #endif + if !hideJumpButton { Button(action: { openAgentTarget(agent) }) { Image(systemName: "arrow.up.right") .font(.system(size: 8, weight: .medium)) @@ -116,7 +130,20 @@ struct OverviewView: View { AgentPillsView(state: state) } } - .onChange(of: state.focusId) { _, _ in showingN8nDetail = false } + .onChange(of: state.focusId) { _, _ in + showingN8nDetail = false + #if !APPSTORE + withAnimation(.easeIn(duration: 0.16)) { state.showingPlanDetail = false } + #endif + } + #if !APPSTORE + .onChange(of: state.view) { _, v in + if v != .overview { state.showingPlanDetail = false } + } + .onChange(of: state.mode) { _, m in + if m != .expanded { state.showingPlanDetail = false } + } + #endif } private func openAgentTarget(_ task: AgentTask?) { @@ -1222,12 +1249,6 @@ struct IntegrationCardView: View { return cmd?.contains("NotchBuddy") == true || cmd?.contains("coucou") == true } ?? false } #endif - case "integration_claude_plan": - #if APPSTORE - return false // App Store installer not yet implemented - #else - return HookServer.statusLineInstalled() - #endif case "agent_gemini": #if !APPSTORE return HookServer.geminiHooksInstalled() @@ -1323,11 +1344,6 @@ struct IntegrationCardView: View { task.id == "integration_notion" && appState.notionLoaded } - // Claude plan: show card when installed (even with no data yet) - private var claudePlanIsActive: Bool { - task.id == "integration_claude_plan" && isConfigured - } - // Apple Music: show card when a track is loaded (playing or paused) or automation is denied private var musicIsActive: Bool { #if !APPSTORE @@ -1346,11 +1362,6 @@ struct IntegrationCardView: View { return appState.musicPlaying ? Color(hex: "#FA2D48") : Color(hex: "#22C55E") } #endif - if task.id == "integration_claude_plan" { - guard isConfigured else { return Color(hex: "#F4505E") } - let pct = appState.claudePlanUsage.flatMap { ClaudePlanGauge.dominantPct($0) } - return Color(hex: ClaudePlanGauge.color(for: pct)) - } if PillCatalog.definition(for: task.id)?.comingSoon == true { return Color(hex: "#6B7079") } let svcErr = task.id == "integration_stripe" ? appState.stripeError : task.id == "integration_calcom" ? appState.calcomError @@ -1367,13 +1378,6 @@ struct IntegrationCardView: View { return "Not playing" } #endif - if task.id == "integration_claude_plan" { - guard isConfigured else { return "Status line not installed" } - if let usage = appState.claudePlanUsage { - return ClaudePlanGauge.pillLabel(usage) - } - return "Waiting for a Claude Code reply" - } if PillCatalog.definition(for: task.id)?.comingSoon == true { return "Coming soon" } let svcErr = task.id == "integration_stripe" ? appState.stripeError : task.id == "integration_calcom" ? appState.calcomError @@ -1440,9 +1444,6 @@ struct IntegrationCardView: View { } else if notionHasData { NotionCardView() .transition(.opacity) - } else if claudePlanIsActive { - ClaudePlanCardView(usage: appState.claudePlanUsage) - .transition(.opacity) } else if musicIsActive { #if !APPSTORE MusicCardView() @@ -1614,14 +1615,10 @@ struct IntegrationCardView: View { && task.id != "integration_music" { Button("Settings…") { let section: String - if task.id == "integration_claude_plan" { - section = "agents" - } else { - switch PillCatalog.definition(for: task.id)?.category { - case .workspace, .agent: section = "agents" - case .ai: section = "chat" - default: section = "integrations" - } + switch PillCatalog.definition(for: task.id)?.category { + case .workspace, .agent: section = "agents" + case .ai: section = "chat" + default: section = "integrations" } NotificationCenter.default.post(name: .openFullSettings, object: section) } @@ -2726,20 +2723,11 @@ struct AgentPill: View { let onTap: () -> Void @State private var isHovered = false - private var effectiveColor: String { - if task.id == "integration_claude_plan" { - return ClaudePlanGauge.color(for: state.claudePlanUsage.flatMap { ClaudePlanGauge.dominantPct($0) }) - } - return task.color - } + private var effectiveColor: String { task.color } // VS Code pill always shows "VS Code" label regardless of active project name private var displayName: String { - if task.id == "integration_claude" { return "VS Code" } - if task.id == "integration_claude_plan" { - return ClaudePlanGauge.pillLabel(state.claudePlanUsage) - } - return task.name + task.id == "integration_claude" ? "VS Code" : task.name } var body: some View { @@ -2753,10 +2741,7 @@ struct AgentPill: View { Capsule() .stroke(Color(hex: effectiveColor).opacity(isHovered ? 0.55 : 0.14), lineWidth: 1) HStack(spacing: 0) { - MiniBotCanvasView(task: task.id == "integration_claude_plan" - ? AgentTask(id: task.id, name: task.name, color: effectiveColor, - state: task.state, steps: task.steps, source: task.source) - : task) + MiniBotCanvasView(task: task) .frame(width: 22 / 0.6, height: 22 / 0.6) .frame(width: 22, height: 22, alignment: .center) .padding(.leading, 8) diff --git a/NotchBuddy/Sources/App/PillCatalog.swift b/NotchBuddy/Sources/App/PillCatalog.swift index c34e6fee2..530b5ecfa 100644 --- a/NotchBuddy/Sources/App/PillCatalog.swift +++ b/NotchBuddy/Sources/App/PillCatalog.swift @@ -71,8 +71,6 @@ enum PillCatalog { .init(id: "ai_lmstudio", name: "LM Studio", color: ChatProvider.lmstudio.accentHex, category: .ai, subtitle: "Chat", source: .n8n), // ── Services ───────────────────────────────────────────────────────── - .init(id: "integration_claude_plan", name: "Claude plan", color: "#6B7079", - category: .service, subtitle: "Integration", source: .n8n, githubOnly: true), .init(id: "integration_resend", name: "Resend", color: "#22C55E", category: .service, subtitle: "Integration", source: .n8n), .init(id: "integration_n8n", name: "n8n", color: "#F29B38", diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index 6445565fe..dd99a0158 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -36,6 +36,7 @@ struct SettingsView: View { @State private var showStatusLineDiff: Bool = false @State private var pendingStatusLineJSON: String = "" @State private var statusLinePendingInstall: Bool = true + @State private var planTogglePending: Bool = false @State private var geminiHooksInstalled: Bool = HookServer.geminiHooksInstalled() @State private var showGeminiDiff: Bool = false @@ -483,23 +484,39 @@ struct SettingsView: View { GroupBox("Plan usage") { VStack(alignment: .leading, spacing: 10) { - Text("Shows your Claude plan usage (5-hour and weekly limits) in the notch. Coucou adds a small status line relay to ~/.claude/settings.json. If you already have a status line, it keeps working as before. Pro and Max plans only.") + Text("Shows your Claude plan usage (5-hour and weekly limits) in the notch header. Coucou adds a status line relay to ~/.claude/settings.json. If you already have a status line, it keeps working as before. Pro and Max plans only.") .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) - let installed = HookServer.statusLineInstalled() + let relayInstalled = HookServer.statusLineInstalled() + Toggle("Show in the notch", isOn: Binding( + get: { state.showPlanInNotch || planTogglePending }, + set: { on in + if on { + if relayInstalled { + state.showPlanInNotch = true + } else { + planTogglePending = true + installStatusLine() + } + } else { + state.showPlanInNotch = false + planTogglePending = false + } + } + )) HStack(spacing: 10) { - if installed { - Text("Installed") + if relayInstalled { + Text("Relay: installed") .font(.system(size: 11)) .foregroundColor(.secondary) - Button("Uninstall") { uninstallStatusLine() } + Button("Uninstall relay") { uninstallStatusLine() } .buttonStyle(.bordered) } else { - Text("Not installed") + Text("Relay: not installed") .font(.system(size: 11)) .foregroundColor(.secondary) - Button("Install") { installStatusLine() } + Button("Install relay") { installStatusLine() } .buttonStyle(.borderedProminent) } } @@ -515,8 +532,12 @@ struct SettingsView: View { HStack { Button("Confirm & write") { confirmStatusLine() } .buttonStyle(.borderedProminent) - Button("Cancel") { showStatusLineDiff = false; pendingStatusLineJSON = "" } - .buttonStyle(.bordered) + Button("Cancel") { + showStatusLineDiff = false + pendingStatusLineJSON = "" + planTogglePending = false + } + .buttonStyle(.bordered) } } } @@ -1025,10 +1046,15 @@ struct SettingsView: View { try HookServer.shared.writeStatusLine() showStatusLineDiff = false pendingStatusLineJSON = "" + if planTogglePending { + state.showPlanInNotch = true + planTogglePending = false + } statusMessage = statusLinePendingInstall ? "✓ Status line installed." : "✓ Status line removed." } catch { + planTogglePending = false statusMessage = "❌ \(error.localizedDescription)" } } diff --git a/README.md b/README.md index 595aa9d68..d2e146373 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Meet **Mochi**: a soft little squircle with big eyes that pops out of your notch - ✅ **Approve from the notch** — Claude Code and Codex permission requests show up with **Allow / Deny** (and **Always** for Claude Code), in VS Code, Cursor's terminal, or Codex. One click, back to work. - 🧑‍💻 **Jump to the right terminal** — open the exact terminal window of a session *(macOS)*. - 💬 **Chat with Claude, Gemini, OpenAI, or a local model (Ollama / LM Studio)** — click the model name above the chat box to switch provider and pick a model. Cloud providers use your own API key; local providers connect to a server running on your Mac. *(Gemini, OpenAI and local models: macOS)* -- 📊 **Claude plan usage** *(macOS, GitHub build)* — see your 5-hour and weekly Claude plan limits in the notch. Add a one-line status line relay to `~/.claude/settings.json` from Settings → Agents → Plan usage. Pro and Max plans only. +- 📊 **Claude plan usage** *(macOS, GitHub build)* — a small pill in the notch header shows your 5-hour and weekly Claude plan limits. Enable it from Settings → Agents → Plan usage. Pro and Max plans only. - 📋 **Declare the tools you use** — open Settings → Active pills and pick your main workspace tool (VS Code, Cursor, Codex or Antigravity), then toggle up to 4 more: Gemini CLI, Anthropic, Google AI, OpenAI, Ollama, LM Studio and service integrations *(macOS)*. - 📎 **Drop a file on the notch** — Mochi turns into a box and swallows it, then ask a question about it or send it by email *(email: macOS, Mail.app)*. - 🪟 **Drag Mochi onto any window** — attach that window as context for Claude *(macOS)*. @@ -133,7 +133,7 @@ Click the Coucou icon in the menu bar (macOS) or in the system tray (Windows, Li | What | Why | Where the key goes | |---|---|---| | **Claude Code hooks** | live sessions and approvals | **Install hooks** — Coucou backs up `~/.claude/settings.json`, merges its hooks and shows you the diff before writing anything | -| **Claude plan status line** *(macOS, GitHub build)* | Claude plan usage gauge | **Install** in Settings → Agents → Plan usage — backs up `~/.claude/settings.json` | +| **Claude plan** *(macOS, GitHub build)* | Plan usage gauge in the notch header | **Install relay** in Settings → Agents → Plan usage, then enable "Show in the notch" | | **Gemini CLI hooks** *(macOS)* | Gemini CLI sessions in the island | **Install hooks** in Settings → Gemini CLI — backs up `~/.gemini/settings.json` | | **Antigravity (agy) hooks** *(macOS)* | agy sessions in the island | **Install hooks** in Settings → Antigravity — backs up `~/.gemini/config/hooks.json` | | **Anthropic API key** | chat and questions about files | Settings → Anthropic API · Keychain / Windows Credential Manager / Secret Service | diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index da33b7a3b..edbec867d 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -70,12 +70,11 @@ Demande l'autorisation Automatisation la première fois (normal). ## 1bis. Jauge de forfait Claude (statusLine) -**ID de pastille** : `integration_claude_plan` (couleur dynamique : vert `#22C55E` < 50 %, orange `#F59E0B` 50–80 %, rouge `#F4505E` ≥ 80 %, gris `#6B7079` sans données) -**Catégorie** : Services -**Plateforme** : macOS uniquement +**Affichage** : petit pill dans l'en-tête de l'île (vue home uniquement) — plus de pastille dans le catalogue Active pills. +**Plateforme** : macOS uniquement (build GitHub) **Plans** : Pro et Max uniquement (le champ `rate_limits` n'est présent que pour ces plans) -Affiche la consommation du forfait Claude dans le notch : fenêtre de 5 heures et fenêtre hebdomadaire. +Affiche la consommation du forfait Claude via un pill coloré dans l'en-tête de l'île. Couleur dynamique : vert `#22C55E` < 50 %, orange `#F59E0B` 50–80 %, rouge `#F4505E` ≥ 80 %, gris `#6B7079` sans données. Cliquer sur le pill bascule `showingPlanDetail`, ce qui remplace la carte en cours par `ClaudePlanCardView`. `showingPlanDetail` se remet à false au changement de focusId, de vue ou de mode. ### Données @@ -97,13 +96,13 @@ Claude Code envoie, à chaque réponse et avec un debounce de 300 ms, un JSON à nb-hook.py, en mode `--statusline`, lit le JSON de stdin, en extrait `rate_limits` et `session_id`, et envoie `{"coucou_kind": "statusline", …}` au socket en fire-and-forget (timeout 0,3 s). Si une `statusLine` précédente existait (sauvegardée dans `statusline-previous.json` à côté de nb-hook), elle est appelée via `/bin/sh -c` avec le même stdin et sa sortie est réécrite telle quelle (timeout 10 s, couleurs ANSI comprises). -### Installation +### Installation et activation -Réglages → Agents → Plan usage → **Install**. Coucou montre le diff de `~/.claude/settings.json` avant d'écrire quoi que ce soit. Si une `statusLine` existait, seul le champ `command` est remplacé ; les autres champs (`padding`, `refreshInterval`, etc.) sont conservés. +Réglages → Agents → Plan usage → **Install relay**. Coucou montre le diff de `~/.claude/settings.json` avant d'écrire quoi que ce soit. Si une `statusLine` existait, seul le champ `command` est remplacé ; les autres champs (`padding`, `refreshInterval`, etc.) sont conservés. Une fois le relais installé, activer le toggle **Show in the notch** pour faire apparaître le pill dans l'en-tête. Si le toggle est activé avant l'installation du relais, l'installation est lancée automatiquement ; le toggle s'active après confirmation. ### Désinstallation -Réglages → Agents → Plan usage → **Uninstall**. Remet l'objet `statusLine` d'origine à l'identique, ou retire la clé si elle n'existait pas. Si la `statusLine` actuelle n'est plus celle de Coucou (l'utilisateur l'a changée), elle n'est pas touchée. +Réglages → Agents → Plan usage → **Uninstall relay**. Remet l'objet `statusLine` d'origine à l'identique, ou retire la clé si elle n'existait pas. Si la `statusLine` actuelle n'est plus celle de Coucou (l'utilisateur l'a changée), elle n'est pas touchée. --- diff --git a/docs/SPEC.md b/docs/SPEC.md index 5e810ec65..c9bd6aed6 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -99,7 +99,7 @@ Toutes les pastilles déclarées sont définies dans `PillCatalog.all` (source d | `workspace` | Where you code | VS Code, Cursor, Antigravity *(GitHub only)*, Codex *(GitHub only)* | Integration | Claude Code / Cursor / Codex / Agent | | `agent` | Agents | Gemini CLI *(GitHub only)* | Agent | Agent | | `ai` | AI for the chat | Anthropic, Google AI, OpenAI, Ollama, LM Studio | Chat | — | -| `service` | Services | Claude plan *(GitHub only)*, Resend, n8n, Vercel, GitHub, Notion, Cal.com, Stripe, Apple Music *(GitHub only)* | Integration | — | +| `service` | Services | Resend, n8n, Vercel, GitHub, Notion, Cal.com, Stripe, Apple Music *(GitHub only)* | Integration | — | Couleurs : Cursor `#C0C4CC`, Codex `#2DD4BF`, Gemini CLI `#8AB4F8`, Antigravity `#E879F9`, pastilles IA = `ChatProvider.accentHex` (Ollama `#FACC15`, LM Studio `#A3E635`). @@ -212,7 +212,7 @@ Fenêtre Réglages (SwiftUI, simple), sections dans l'ordre d'affichage : - **Chat — other providers** : clé Google AI (Trousseau) ; clé OpenAI (Trousseau). Les modèles se choisissent dans le chat (voir INTEGRATIONS §5bis). - **Local models** : URL du serveur Ollama (défaut `http://127.0.0.1:11434`) et/ou LM Studio (défaut `http://127.0.0.1:1234`). Bouton **Connect** : vérifie la joignabilité et sauvegarde l'URL. Bouton **Disconnect** : efface l'URL et le cache. Aucune clé requise (voir INTEGRATIONS §5ter). - **Claude Code Hooks** : état des hooks, bouton Installer / Désinstaller. -- **Plan usage** : status line dans `~/.claude/settings.json`. Bouton **Install** : aperçu du diff, sauvegarde datée, écriture après confirmation (même flux que les hooks). Bouton **Uninstall** : remet l'objet précédent ou retire la clé. Voir INTEGRATIONS §1bis. +- **Plan usage** : toggle **Show in the notch** + bouton **Install relay** / **Uninstall relay**. Voir INTEGRATIONS §1bis. **Jauge de forfait Claude** *(GitHub only)* : petit pill dans l'en-tête de l'île (vue home uniquement). Activé via `showPlanInNotch` (UserDefaults) + `HookServer.statusLineInstalled()`. Couleur = `ClaudePlanGauge.color(for: dominantPct)`. Clic → `showingPlanDetail` bascule et `ClaudePlanCardView` s'affiche à la place de la carte en cours. `showingPlanDetail` se remet à false au changement de focusId, de vue ou de mode. Grand Mochi prend la couleur de l'usage quand `showingPlanDetail == true`. - **Gemini CLI Hooks** *(build GitHub)* : état des hooks, bouton Installer / Désinstaller. - **Antigravity Hooks** *(build GitHub)* : état des hooks, bouton Installer / Désinstaller. - **Integrations** : clé ou token (Trousseau) pour chaque service (n8n, Stripe, GitHub, Vercel, Resend, Notion, Cal.com). From b5aa95b02cf773d00c37f4a67c0efce1e01e23f7 Mon Sep 17 00:00:00 2001 From: Louis Raille Date: Sat, 3 Oct 2026 02:10:22 +0200 Subject: [PATCH 5/5] Polish plan gauge: reset label, label width, cached relay state - Weekly reset label: "Mon 9:00" without "Resets" prefix (icon suffices) - Label column widened to 40pt so "5 hours" never truncates - Cache relay-installed state in AppState.planRelayInstalled; set at launch, refreshed on Settings open and after every Install/Uninstall confirm; IslandHeader reads state.planRelayInstalled instead of calling HookServer.statusLineInstalled() on each render - Uninstall confirm sets showPlanInNotch = false Co-Authored-By: Claude Sonnet 4.6 --- NotchBuddy/Sources/App/AppState.swift | 7 +++++++ NotchBuddy/Sources/App/ClaudePlanCardView.swift | 4 ++-- NotchBuddy/Sources/App/IslandRootView.swift | 2 +- NotchBuddy/Sources/App/SettingsView.swift | 12 +++++++++--- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index d6c7bda4c..751ee9300 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -308,8 +308,14 @@ final class AppState: ObservableObject { @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) @@ -351,6 +357,7 @@ final class AppState: ObservableObject { 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 diff --git a/NotchBuddy/Sources/App/ClaudePlanCardView.swift b/NotchBuddy/Sources/App/ClaudePlanCardView.swift index 08d5466c3..e17bbce41 100644 --- a/NotchBuddy/Sources/App/ClaudePlanCardView.swift +++ b/NotchBuddy/Sources/App/ClaudePlanCardView.swift @@ -78,7 +78,7 @@ private struct GaugeRowView: View { Text(label) .font(.system(size: 11)) .foregroundColor(Color(hex: "#6B7079")) - .frame(width: 36, alignment: .leading) + .frame(width: 40, alignment: .leading) if let w = window { let pct = ClaudePlanGauge.effectivePct(w) let accent = Color(hex: ClaudePlanGauge.color(for: pct)) @@ -117,7 +117,7 @@ private struct GaugeRowView: View { let secs = w.resetsAt.timeIntervalSince(now) guard secs > 0 else { return "Resetting…" } if weekly { - return "Resets \(weeklyResetFormatter.string(from: w.resetsAt))" + return weeklyResetFormatter.string(from: w.resetsAt) } else { let h = Int(secs / 3600) let m = Int((secs.truncatingRemainder(dividingBy: 3600)) / 60) diff --git a/NotchBuddy/Sources/App/IslandRootView.swift b/NotchBuddy/Sources/App/IslandRootView.swift index 5c819093e..aa809e84b 100644 --- a/NotchBuddy/Sources/App/IslandRootView.swift +++ b/NotchBuddy/Sources/App/IslandRootView.swift @@ -486,7 +486,7 @@ struct IslandHeader: View { // Right: plan pill (GitHub build, home view only) + action icons HStack(spacing: 8) { #if !APPSTORE - if state.view == .overview && state.showPlanInNotch && HookServer.statusLineInstalled() { + if state.view == .overview && state.showPlanInNotch && state.planRelayInstalled { ClaudePlanHeaderPill(state: state) } #endif diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index dd99a0158..21f0ff453 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -169,6 +169,9 @@ struct SettingsView: View { } } .onAppear { + #if !APPSTORE + state.refreshPlanRelayState() + #endif guard fetchedModels.isEmpty, let key = KeychainStore.shared.get("anthropic-api-key"), !key.isEmpty else { return } Task { @@ -488,12 +491,11 @@ struct SettingsView: View { .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) - let relayInstalled = HookServer.statusLineInstalled() Toggle("Show in the notch", isOn: Binding( get: { state.showPlanInNotch || planTogglePending }, set: { on in if on { - if relayInstalled { + if state.planRelayInstalled { state.showPlanInNotch = true } else { planTogglePending = true @@ -506,7 +508,7 @@ struct SettingsView: View { } )) HStack(spacing: 10) { - if relayInstalled { + if state.planRelayInstalled { Text("Relay: installed") .font(.system(size: 11)) .foregroundColor(.secondary) @@ -1046,10 +1048,14 @@ struct SettingsView: View { try HookServer.shared.writeStatusLine() showStatusLineDiff = false pendingStatusLineJSON = "" + state.refreshPlanRelayState() if planTogglePending { state.showPlanInNotch = true planTogglePending = false } + if !statusLinePendingInstall { + state.showPlanInNotch = false + } statusMessage = statusLinePendingInstall ? "✓ Status line installed." : "✓ Status line removed."