diff --git a/build.sh b/build.sh index a2afeb3..fd1ea92 100755 --- a/build.sh +++ b/build.sh @@ -237,6 +237,7 @@ build_app "$APP" "stack-nudge" \ panel/SessionStore.swift \ panel/SessionUsage.swift \ panel/Sessions.swift \ + panel/CompactView.swift \ panel/TranscriptStats.swift \ panel/ModelLimits.swift \ panel/Phrases.swift \ diff --git a/panel/Bootstrap.swift b/panel/Bootstrap.swift index f99798b..5dc97f4 100644 --- a/panel/Bootstrap.swift +++ b/panel/Bootstrap.swift @@ -1217,7 +1217,7 @@ struct UninstallView: View { if nav.uninstallPhase == .confirm { FooterHint(label: "Uninstall", keys: ["⏎"], primary: true) FooterDivider() - FooterHint(label: "Cancel", keys: ["esc"]) + FooterHint(label: "Cancel", keys: ["Esc"]) } else { FooterHint(label: "Don't quit StackNudge during uninstall", keys: []) } diff --git a/panel/CompactView.swift b/panel/CompactView.swift new file mode 100644 index 0000000..5e39c2f --- /dev/null +++ b/panel/CompactView.swift @@ -0,0 +1,1040 @@ +import SwiftUI + +// Pill-shaped glance widget. Layout left→right: +// [gauge] [7d%, reset-in] | [headline: project · tokens · status] +// [active count badge] [expand] +// +// Gauge = full-circle 5h-quota meter with angular gradient stroke +// (green→red), pulsing inner glow when any session is busy, center +// digital % readout, and a sweeping spinner dot when actively polling. +// Glass background with soft cyan border glow. +struct CompactView: View { + + @ObservedObject var store: EventStore + @ObservedObject var sessions: SessionStore + @ObservedObject var nav: PanelNav + + let onExpand: () -> Void + let onExitCompact: () -> Void + + @State private var rippleScale: CGFloat = 0.3 + @State private var rippleOpacity: Double = 0 + + private static let glowColor = Color(red: 0.4, green: 0.85, blue: 1.0) + private static let recentEventWindow: TimeInterval = 5 * 60 + + var body: some View { + HStack(spacing: 10) { + gaugeCluster + separator + headline + Spacer(minLength: 4) + sessionBadge + expandButton + } + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(pillBackground) + .overlay(ripple) + .contentShape(Capsule()) + .onTapGesture(count: 2) { onExitCompact() } + .onChange(of: store.events.first?.id) { _ in triggerRipple() } + } + + // Soft outward wave on event arrival. Capsule scales from 0.3 → 1.15 + // and fades from 0.45 → 0 over ~600ms, drawn under the pill content + // so it reads as "the pill pulsed" rather than a separate overlay. + private var ripple: some View { + Capsule() + .stroke(urgencyColor, lineWidth: 1.5) + .scaleEffect(rippleScale) + .opacity(rippleOpacity) + .allowsHitTesting(false) + } + + private func triggerRipple() { + rippleScale = 0.6 + rippleOpacity = 0.55 + withAnimation(.easeOut(duration: 0.7)) { + rippleScale = 1.18 + rippleOpacity = 0 + } + } + + // MARK: - Gauge cluster (5h gauge + 7d + reset countdown) + + private var gaugeCluster: some View { + HStack(spacing: 6) { + ZStack { + if !nav.compactDragging { + Circle() + .fill(urgencyColor.opacity(0.18)) + .frame(width: 44, height: 44) + .blur(radius: 8) + } + QuotaGauge( + fivePct: nav.quota?.fiveHour?.utilization ?? 0, + sevenPct: nav.quota?.sevenDay?.utilization ?? 0, + hasFive: nav.quota?.fiveHour != nil, + hasSeven: nav.quota?.sevenDay != nil, + polling: nav.quotaSyncing, + anyBusy: anyBusy, + paused: nav.compactDragging + ) + .frame(width: 42, height: 42) + } + + if let reset = nav.quota?.fiveHour?.resetsAt { + Text(Self.shortDuration(until: reset)) + .font(.system(size: 9).monospacedDigit()) + .foregroundStyle(.tertiary) + } + } + } + + // MARK: - Headline + + @ViewBuilder + private var headline: some View { + HStack(spacing: 6) { + BotMascot(state: botState, kind: nav.mascot, paused: nav.compactDragging) + .frame(width: 26, height: 24) + headlineText + } + } + + @ViewBuilder + private var headlineText: some View { + if let busy = busiestSession { + HStack(spacing: 4) { + Text(displayName(busy)) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + if let stats = transcriptStats(for: busy) { + Text("·") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + Text(Self.formatTokens(stats.tokens)) + .font(.system(size: 10, weight: .medium).monospacedDigit()) + .foregroundStyle(.secondary) + } + } + } else if let recent = recentEvent { + HStack(spacing: 4) { + Text(recent.title) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + Text("·") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + // Adaptive: pending count when the queue has built up, + // otherwise show age of the latest event so the user can + // gauge whether it's fresh. + if store.events.count > 1 { + Text("×\(store.events.count)") + .font(.system(size: 10, weight: .semibold).monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize() + } else { + Text(Self.relative.localizedString(for: recent.timestamp, relativeTo: Date())) + .font(.system(size: 10).monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } else if let active = mostRecentActive { + Text(displayName(active)) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + } else { + Text("watching") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + } + + private var botState: BotState { + if let _ = recentEvent { + // Most recent event drives expression briefly + switch store.events.first?.kind { + case .permission: return .alert + case .stop: return .happy + default: return .alert + } + } + if busiestSession != nil { return .busy } + if mostRecentActive != nil { return .watching } + return .idle + } + + // MARK: - Right side + + @ViewBuilder + private var sessionBadge: some View { + let activeCount = sessions.sessions.filter { $0.status == .active }.count + if activeCount > 0 { + HStack(spacing: 3) { + Circle() + .fill(anyBusy ? Color.yellow : Color.green) + .frame(width: 6, height: 6) + Text("\(activeCount)") + .font(.system(size: 11, weight: .semibold).monospacedDigit()) + .foregroundStyle(.primary) + } + } + } + + private var expandButton: some View { + Button(action: onExpand) { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 18, height: 18) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var separator: some View { + Rectangle() + .fill(Color.secondary.opacity(0.22)) + .frame(width: 1, height: 22) + } + + // MARK: - Background + outer glow + + @ViewBuilder + private var pillBackground: some View { + if nav.compactDragging { + // Static render while dragging — frees the main thread for + // AppKit's drag handler so the pill keeps up with the cursor. + staticPillBackground + } else { + animatedPillBackground + } + } + + private var staticPillBackground: some View { + let color = urgencyColor + return ZStack { + Capsule().fill(.regularMaterial) + Capsule().strokeBorder(color.opacity(0.55), lineWidth: 0.8) + } + .shadow(color: .black.opacity(0.30), radius: 6, y: 2) + } + + private var animatedPillBackground: some View { + TimelineView(.animation(minimumInterval: 0.05)) { tl in + let pulse = pulseAmount(at: tl.date) + let color = urgencyColor + ZStack { + Capsule().fill(.regularMaterial) + Capsule() + .strokeBorder(color.opacity(0.45 + 0.3 * pulse), lineWidth: 0.8) + Capsule() + .stroke(color.opacity(0.20 + 0.25 * pulse), lineWidth: 3) + .blur(radius: 4) + } + .shadow(color: .black.opacity(0.30), radius: 10, y: 3) + .animation(.easeInOut(duration: 0.6), value: color) + } + } + + // Border color tracks 5h quota urgency: cyan under 75%, amber 75–90%, + // red 90%+. Pulse rate climbs with severity so red-state pill is + // visibly more urgent than amber. + private var urgencyColor: Color { + let pct = nav.quota?.fiveHour?.utilization ?? 0 + if pct >= 90 { return .red } + if pct >= 75 { return .orange } + return Self.glowColor + } + + private func pulseAmount(at date: Date) -> Double { + let pct = nav.quota?.fiveHour?.utilization ?? 0 + let busyPulse = anyBusy + guard busyPulse || pct >= 75 else { return 0 } + let speed: Double = pct >= 90 ? 4.5 : pct >= 75 ? 3.2 : 2.4 + let t = date.timeIntervalSinceReferenceDate + return (sin(t * speed) + 1) / 2 + } + + // MARK: - Data helpers + + private var anyBusy: Bool { + sessions.sessions.contains { $0.claudeStatus == "busy" } + } + + private var recentEvent: NudgeEvent? { + guard let e = store.events.first, + Date().timeIntervalSince(e.timestamp) < Self.recentEventWindow + else { return nil } + return e + } + + private var busiestSession: Session? { + sessions.sessions.first { $0.status == .active && $0.claudeStatus == "busy" } + } + + private var mostRecentActive: Session? { + sessions.sessions.first { $0.status == .active } + } + + private func transcriptStats(for s: Session) -> TranscriptStats? { + guard let id = s.claudeSessionID else { return nil } + return nav.claudeSessionStats[id] + } + + private func displayName(_ s: Session) -> String { + if let custom = s.customName, !custom.isEmpty { return custom } + if let name = s.claudeName, !name.isEmpty, name != "main-agent" { return name } + return s.projectName ?? "session" + } + + private func glyph(for e: NudgeEvent) -> String { + switch e.kind { + case .permission: return "questionmark.circle.fill" + case .stop: return "checkmark.circle.fill" + case .other: return "bell.fill" + } + } + + fileprivate static func gaugeColor(pct: Double) -> Color { + if pct >= 90 { return .red } + if pct >= 75 { return .orange } + if pct >= 50 { return .yellow } + return Color(red: 0.4, green: 0.85, blue: 1.0) + } + + private static let relative: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .short + return f + }() + + private static func formatTokens(_ n: Int) -> String { + if n >= 1_000_000 { + return String(format: "%.1fM", Double(n) / 1_000_000) + } + if n >= 1_000 { + return "\(Int((Double(n) / 1_000).rounded()))K" + } + return "\(n)" + } + + private static func shortDuration(until date: Date) -> String { + let s = max(0, Int(date.timeIntervalSinceNow)) + if s >= 3600 { + let h = s / 3600 + let m = (s % 3600) / 60 + return m > 0 ? "\(h)h\(m)m" : "\(h)h" + } + return "\(max(1, s / 60))m" + } +} + +// Concentric quota gauge: outer ring = 7d utilization, inner ring = 5h. +// Each fills clockwise from 12 o'clock with its own angular gradient +// (cyan → yellow → orange → red). Inner glow pulses on busy sessions. +// A spinner dot orbits the outer ring when actively polling. Center +// shows the 5h % since that's the more immediate concern. +private struct QuotaGauge: View { + + let fivePct: Double + let sevenPct: Double + let hasFive: Bool + let hasSeven: Bool + let polling: Bool + let anyBusy: Bool + let paused: Bool + + private static let cyan = Color(red: 0.30, green: 0.92, blue: 1.0) + private static let outerLineWidth: CGFloat = 4.0 + private static let innerLineWidth: CGFloat = 4.0 + private static let ringGap: CGFloat = 5.0 + + var body: some View { + if paused { + // Static render — no TimelineView re-ticks during drag. + ZStack { + outerTrack + innerTrack + if hasSeven { outerFill } + if hasFive { innerFill } + centerReadout + } + } else { + TimelineView(.animation(minimumInterval: 0.05)) { tl in + ZStack { + outerTrack + innerTrack + if hasSeven { outerFill } + if hasFive { innerFill } + innerGlow(at: tl.date) + centerReadout + if polling { spinnerDot(at: tl.date) } + } + .animation(.easeOut(duration: 0.45), value: fivePct) + .animation(.easeOut(duration: 0.45), value: sevenPct) + } + } + } + + private var outerTrack: some View { + Circle() + .stroke(Color.secondary.opacity(0.16), lineWidth: Self.outerLineWidth) + .padding(Self.outerLineWidth / 2) + } + + private var innerTrack: some View { + Circle() + .stroke(Color.secondary.opacity(0.14), lineWidth: Self.innerLineWidth) + .padding(Self.outerLineWidth + Self.ringGap) + } + + private var outerFill: some View { + Circle() + .trim(from: 0, to: max(0, min(1, sevenPct / 100))) + .stroke(gradient, style: StrokeStyle(lineWidth: Self.outerLineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .padding(Self.outerLineWidth / 2) + } + + private var innerFill: some View { + Circle() + .trim(from: 0, to: max(0, min(1, fivePct / 100))) + .stroke(gradient, style: StrokeStyle(lineWidth: Self.innerLineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .padding(Self.outerLineWidth + Self.ringGap) + } + + private var gradient: AngularGradient { + AngularGradient( + gradient: Gradient(stops: [ + .init(color: Self.cyan, location: 0.0), + .init(color: .yellow, location: 0.55), + .init(color: .orange, location: 0.80), + .init(color: .red, location: 1.0), + ]), + center: .center, + startAngle: .degrees(-90), + endAngle: .degrees(270) + ) + } + + private func innerGlow(at date: Date) -> some View { + let t = date.timeIntervalSinceReferenceDate + let pulse = anyBusy ? (sin(t * 2.4) + 1) / 2 : 0 + let intensity = 0.22 + 0.45 * pulse + return Circle() + .fill( + RadialGradient( + gradient: Gradient(colors: [Self.cyan.opacity(intensity), .clear]), + center: .center, + startRadius: 0, + endRadius: 22 + ) + ) + .padding(Self.outerLineWidth + Self.ringGap + 2) + } + + private var centerReadout: some View { + Text(hasFive ? "\(Int(fivePct.rounded()))" : "—") + .font(.system(size: 14, weight: .semibold).monospacedDigit()) + .foregroundStyle(.primary) + } + + private func spinnerDot(at date: Date) -> some View { + let t = date.timeIntervalSinceReferenceDate + let angle = (t * 180).truncatingRemainder(dividingBy: 360) + return Circle() + .fill(Self.cyan) + .frame(width: 3, height: 3) + .offset(y: -15) + .rotationEffect(.degrees(angle)) + } +} + +enum BotState { + case idle // no sessions, no recent events + case watching // sessions exist but nothing happening + case busy // a session is busy + case alert // recent permission event + case happy // recent stop event +} + +// Dispatcher: picks the user-chosen mascot kind. Each mascot owns its +// own SwiftUI rendering and expression-state mapping. +private struct BotMascot: View { + + let state: BotState + let kind: MascotKind + let paused: Bool + + var body: some View { + switch kind { + case .robot: RobotMascot(state: state, paused: paused) + case .cat: CatMascot(state: state, paused: paused) + case .eye: EyeMascot(state: state, paused: paused) + case .ghost: GhostMascot(state: state, paused: paused) + } + } +} + +private struct RobotMascot: View { + + let state: BotState + let paused: Bool + + private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0) + private static let outline = Color.secondary.opacity(0.7) + + var body: some View { + if paused { + // Static: no blink, no antenna pulse, no TimelineView ticks. + ZStack { + head + staticAntenna + staticEyes + mouth + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + TimelineView(.animation(minimumInterval: 0.05)) { tl in + let t = tl.date.timeIntervalSinceReferenceDate + ZStack { + head + antenna(at: t) + eyes(at: t) + mouth + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + + private var staticAntenna: some View { + VStack(spacing: 0) { + Circle().fill(antennaColor.opacity(0.9)) + .frame(width: 3.5, height: 3.5) + Rectangle().fill(Self.outline).frame(width: 1, height: 3) + } + .offset(y: -8.5) + } + + private var staticEyes: some View { + HStack(spacing: 4.5) { + eyeShape + eyeShape + } + .offset(y: 0.5) + } + + private var head: some View { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(Self.outline, lineWidth: 1.2) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(headFill) + ) + .frame(width: 20, height: 15) + .offset(y: 2) + } + + private var headFill: Color { + switch state { + case .alert: return .orange.opacity(0.15) + case .happy: return .green.opacity(0.15) + case .busy: return Self.cyan.opacity(0.18) + default: return Color.secondary.opacity(0.10) + } + } + + private func antenna(at t: TimeInterval) -> some View { + let blink = (sin(t * 2.2) + 1) / 2 + return VStack(spacing: 0) { + Circle() + .fill(antennaColor.opacity(0.6 + 0.4 * blink)) + .frame(width: 3.5, height: 3.5) + Rectangle() + .fill(Self.outline) + .frame(width: 1, height: 3) + } + .offset(y: -8.5) + } + + private var antennaColor: Color { + switch state { + case .alert: return .orange + case .happy: return .green + case .busy: return Self.cyan + default: return Self.outline + } + } + + private func eyes(at t: TimeInterval) -> some View { + // Blink: scale the eyes vertically toward 0 every ~3.2s for ~150ms. + let cycle = t.truncatingRemainder(dividingBy: 3.2) + let blinking = state != .alert && cycle < 0.15 + let scaleY: CGFloat = blinking ? 0.15 : 1.0 + + return HStack(spacing: 4.5) { + eyeShape + eyeShape + } + .scaleEffect(x: 1, y: scaleY) + .animation(.easeInOut(duration: 0.08), value: blinking) + .offset(y: 0.5) + } + + @ViewBuilder + private var eyeShape: some View { + switch state { + case .busy: + // Focused: narrow horizontal slits + Capsule() + .fill(Self.cyan) + .frame(width: 4.5, height: 1.8) + case .alert: + // Surprised: bigger round eyes + Circle() + .fill(Color.orange) + .frame(width: 4.5, height: 4.5) + case .happy: + // Smiling closed-arc eyes (carets) + Path { p in + p.move(to: CGPoint(x: 0, y: 2.2)) + p.addQuadCurve(to: CGPoint(x: 4.5, y: 2.2), + control: CGPoint(x: 2.25, y: -0.6)) + } + .stroke(Color.green, style: StrokeStyle(lineWidth: 1.2, lineCap: .round)) + .frame(width: 4.5, height: 3) + case .watching: + Circle() + .fill(Self.cyan) + .frame(width: 3.3, height: 3.3) + case .idle: + Circle() + .fill(Self.outline) + .frame(width: 2.7, height: 2.7) + } + } + + @ViewBuilder + private var mouth: some View { + switch state { + case .happy: + Path { p in + p.move(to: CGPoint(x: 0, y: 0)) + p.addQuadCurve(to: CGPoint(x: 6, y: 0), + control: CGPoint(x: 3, y: 2.4)) + } + .stroke(Color.green, style: StrokeStyle(lineWidth: 1.2, lineCap: .round)) + .frame(width: 6, height: 2.4) + .offset(y: 6) + case .alert: + Circle() + .fill(Color.orange.opacity(0.7)) + .frame(width: 2.3, height: 2.3) + .offset(y: 6) + case .busy: + Rectangle() + .fill(Self.cyan.opacity(0.5)) + .frame(width: 4.5, height: 1.2) + .offset(y: 6) + default: + EmptyView() + } + } +} + +// Triangle-eared cat with vertical-slit eyes when busy, big round when +// alert, smiling closed-arcs when happy. Whiskers as static decoration. +private struct CatMascot: View { + + let state: BotState + let paused: Bool + + private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0) + private static let outline = Color.secondary.opacity(0.7) + + var body: some View { + if paused { + ZStack { head; staticEyes; mouth; whiskers } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + TimelineView(.animation(minimumInterval: 0.05)) { tl in + let t = tl.date.timeIntervalSinceReferenceDate + ZStack { head; eyes(at: t); mouth; whiskers } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + + private var head: some View { + ZStack { + // Face + ears combined as a single path so the ears feel + // attached to the head, not floating above. + CatHeadShape() + .fill(headFill) + .overlay(CatHeadShape().stroke(Self.outline, lineWidth: 1.2)) + .frame(width: 20, height: 18) + .offset(y: 1) + // Inner ear hints (small triangles inside each ear) + Triangle() + .fill(Self.outline.opacity(0.4)) + .frame(width: 2, height: 2.2) + .offset(x: -5.5, y: -5.5) + Triangle() + .fill(Self.outline.opacity(0.4)) + .frame(width: 2, height: 2.2) + .offset(x: 5.5, y: -5.5) + // Nose (tiny triangle) + Triangle() + .rotation(.degrees(180)) + .fill(Self.outline) + .frame(width: 2, height: 1.5) + .offset(y: 3) + } + } + + private var headFill: Color { + switch state { + case .alert: return .orange.opacity(0.18) + case .happy: return .green.opacity(0.18) + case .busy: return Self.cyan.opacity(0.20) + default: return Color.secondary.opacity(0.12) + } + } + + private func eyes(at t: TimeInterval) -> some View { + let cycle = t.truncatingRemainder(dividingBy: 3.5) + let blinking = state != .alert && cycle < 0.15 + let scaleY: CGFloat = blinking ? 0.15 : 1.0 + return HStack(spacing: 5) { eyeShape; eyeShape } + .scaleEffect(x: 1, y: scaleY) + .animation(.easeInOut(duration: 0.08), value: blinking) + .offset(y: -0.5) + } + + private var staticEyes: some View { + HStack(spacing: 5) { eyeShape; eyeShape }.offset(y: -0.5) + } + + @ViewBuilder + private var eyeShape: some View { + switch state { + case .busy: + Capsule().fill(Self.cyan).frame(width: 1.4, height: 4.5) // slit + case .alert: + Circle().fill(Color.orange).frame(width: 4.5, height: 4.5) + case .happy: + Path { p in + p.move(to: CGPoint(x: 0, y: 2.2)) + p.addQuadCurve(to: CGPoint(x: 4.5, y: 2.2), + control: CGPoint(x: 2.25, y: -0.6)) + } + .stroke(Color.green, style: StrokeStyle(lineWidth: 1.2, lineCap: .round)) + .frame(width: 4.5, height: 3) + case .watching: + Circle().fill(Self.cyan).frame(width: 3.0, height: 3.0) + case .idle: + Circle().fill(Self.outline).frame(width: 2.4, height: 2.4) + } + } + + @ViewBuilder + private var mouth: some View { + switch state { + case .happy: + Path { p in + p.move(to: CGPoint(x: 0, y: 0)) + p.addQuadCurve(to: CGPoint(x: 5, y: 0), + control: CGPoint(x: 2.5, y: 2)) + } + .stroke(Color.green, style: StrokeStyle(lineWidth: 1.2, lineCap: .round)) + .frame(width: 5, height: 2) + .offset(y: 6) + default: + // Tiny "w" mouth: two arcs + Path { p in + p.move(to: CGPoint(x: 0, y: 0)) + p.addQuadCurve(to: CGPoint(x: 2, y: 0), control: CGPoint(x: 1, y: 1.5)) + p.addQuadCurve(to: CGPoint(x: 4, y: 0), control: CGPoint(x: 3, y: 1.5)) + } + .stroke(Self.outline, style: StrokeStyle(lineWidth: 0.9, lineCap: .round)) + .frame(width: 4, height: 1.5) + .offset(y: 5) + } + } + + private var whiskers: some View { + HStack(spacing: 14) { + VStack(spacing: 1.5) { + Rectangle().fill(Self.outline).frame(width: 3.5, height: 0.5) + Rectangle().fill(Self.outline).frame(width: 3.5, height: 0.5) + } + VStack(spacing: 1.5) { + Rectangle().fill(Self.outline).frame(width: 3.5, height: 0.5) + Rectangle().fill(Self.outline).frame(width: 3.5, height: 0.5) + } + } + .opacity(0.45) + .offset(y: 4.5) + } +} + +private struct Triangle: Shape { + func path(in rect: CGRect) -> Path { + Path { p in + p.move(to: CGPoint(x: rect.midX, y: rect.minY)) + p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + p.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + p.closeSubpath() + } + } +} + +// Rounded face with two pointy ears that meet the face curve cleanly — +// drawn as one path so fill + stroke read as a single silhouette. +private struct CatHeadShape: Shape { + func path(in rect: CGRect) -> Path { + Path { p in + let w = rect.width + let h = rect.height + let faceTop = rect.minY + h * 0.30 + let earBaseInnerLeft = CGPoint(x: rect.minX + w * 0.30, y: faceTop - 1) + let earBaseOuterLeft = CGPoint(x: rect.minX + w * 0.10, y: faceTop + 2) + let earTipLeft = CGPoint(x: rect.minX + w * 0.05, y: rect.minY) + let earBaseInnerRight = CGPoint(x: rect.minX + w * 0.70, y: faceTop - 1) + let earBaseOuterRight = CGPoint(x: rect.minX + w * 0.90, y: faceTop + 2) + let earTipRight = CGPoint(x: rect.minX + w * 0.95, y: rect.minY) + // Start at left ear inner-base, go up to tip, down to outer-base. + p.move(to: earBaseInnerLeft) + p.addLine(to: earTipLeft) + p.addLine(to: earBaseOuterLeft) + // Curve along the left/bottom of the face to the right ear. + p.addQuadCurve(to: CGPoint(x: rect.minX, y: rect.midY + h * 0.15), + control: CGPoint(x: rect.minX, y: faceTop + 4)) + p.addQuadCurve(to: CGPoint(x: rect.midX, y: rect.maxY), + control: CGPoint(x: rect.minX, y: rect.maxY)) + p.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.midY + h * 0.15), + control: CGPoint(x: rect.maxX, y: rect.maxY)) + p.addQuadCurve(to: earBaseOuterRight, + control: CGPoint(x: rect.maxX, y: faceTop + 4)) + // Right ear + p.addLine(to: earTipRight) + p.addLine(to: earBaseInnerRight) + // Curve between the two ear inner-bases (top of head dip). + p.addQuadCurve(to: earBaseInnerLeft, + control: CGPoint(x: rect.midX, y: faceTop + 2)) + p.closeSubpath() + } + } +} + +// Sentinel-style single eye: outer lens ring, inner pupil that moves +// horizontally when watching, contracts when busy, dilates red on alert. +private struct EyeMascot: View { + + let state: BotState + let paused: Bool + + private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0) + private static let outline = Color.secondary.opacity(0.7) + + var body: some View { + if paused { + ZStack { lens; staticPupil } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + TimelineView(.animation(minimumInterval: 0.05)) { tl in + let t = tl.date.timeIntervalSinceReferenceDate + ZStack { lens; pupil(at: t) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + + private var lens: some View { + ZStack { + Circle() + .stroke(Self.outline, lineWidth: 1.2) + .background(Circle().fill(lensFill)) + .frame(width: 22, height: 22) + // Inner ring + Circle() + .stroke(Self.outline.opacity(0.5), lineWidth: 0.8) + .frame(width: 16, height: 16) + } + .offset(y: 1) + } + + private var lensFill: Color { + switch state { + case .alert: return .red.opacity(0.18) + case .happy: return .green.opacity(0.14) + case .busy: return Self.cyan.opacity(0.16) + default: return Color.secondary.opacity(0.10) + } + } + + private func pupil(at t: TimeInterval) -> some View { + let scan = state == .watching ? sin(t * 1.5) * 3 : 0 + return Circle() + .fill(pupilColor) + .frame(width: pupilSize, height: pupilSize) + .offset(x: scan, y: 1) + } + + private var staticPupil: some View { + Circle().fill(pupilColor).frame(width: pupilSize, height: pupilSize).offset(y: 1) + } + + private var pupilSize: CGFloat { + switch state { + case .busy: return 4.5 + case .alert: return 7.5 + case .happy: return 5.5 + default: return 5.5 + } + } + + private var pupilColor: Color { + switch state { + case .alert: return .red + case .happy: return .green + case .busy: return Self.cyan + default: return Self.outline + } + } +} + +// Floaty ghost: rounded top with wavy bottom, two eye dots. Gentle bob. +private struct GhostMascot: View { + + let state: BotState + let paused: Bool + + private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0) + private static let outline = Color.secondary.opacity(0.7) + + var body: some View { + if paused { + ZStack { body_; staticEyes; mouth } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + TimelineView(.animation(minimumInterval: 0.05)) { tl in + let t = tl.date.timeIntervalSinceReferenceDate + let bob = sin(t * 1.3) * 1.0 + ZStack { body_; eyes(at: t); mouth } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .offset(y: bob) + } + } + } + + private var body_: some View { + GhostShape() + .stroke(Self.outline, lineWidth: 1.2) + .background(GhostShape().fill(headFill)) + .frame(width: 18, height: 22) + } + + private var headFill: Color { + switch state { + case .alert: return .orange.opacity(0.18) + case .happy: return .green.opacity(0.18) + case .busy: return Self.cyan.opacity(0.20) + default: return Color.secondary.opacity(0.12) + } + } + + private func eyes(at t: TimeInterval) -> some View { + let cycle = t.truncatingRemainder(dividingBy: 3.0) + let blinking = state != .alert && cycle < 0.15 + let scaleY: CGFloat = blinking ? 0.15 : 1.0 + return HStack(spacing: 4) { eyeShape; eyeShape } + .scaleEffect(x: 1, y: scaleY) + .animation(.easeInOut(duration: 0.08), value: blinking) + .offset(y: -2) + } + + private var staticEyes: some View { + HStack(spacing: 4) { eyeShape; eyeShape }.offset(y: -2) + } + + @ViewBuilder + private var eyeShape: some View { + switch state { + case .busy: + Capsule().fill(Self.cyan).frame(width: 4, height: 1.6) + case .alert: + Circle().fill(Color.orange).frame(width: 4, height: 4) + case .happy: + Path { p in + p.move(to: CGPoint(x: 0, y: 2)) + p.addQuadCurve(to: CGPoint(x: 4, y: 2), + control: CGPoint(x: 2, y: -0.4)) + } + .stroke(Color.green, style: StrokeStyle(lineWidth: 1.1, lineCap: .round)) + .frame(width: 4, height: 2.5) + case .watching: + Circle().fill(Self.cyan).frame(width: 3, height: 3) + case .idle: + Circle().fill(Self.outline).frame(width: 2.4, height: 2.4) + } + } + + @ViewBuilder + private var mouth: some View { + switch state { + case .happy: + Path { p in + p.move(to: CGPoint(x: 0, y: 0)) + p.addQuadCurve(to: CGPoint(x: 4, y: 0), control: CGPoint(x: 2, y: 2)) + } + .stroke(Color.green, style: StrokeStyle(lineWidth: 1.1, lineCap: .round)) + .frame(width: 4, height: 2) + .offset(y: 4) + case .alert: + Capsule().fill(Color.orange.opacity(0.8)) + .frame(width: 2.4, height: 3.5) + .offset(y: 4) + default: + EmptyView() + } + } +} + +// Rounded top + three wavy humps on the bottom edge. +private struct GhostShape: Shape { + func path(in rect: CGRect) -> Path { + Path { p in + let r = rect.width / 2 + // Rounded top: semicircle on top + p.addArc(center: CGPoint(x: rect.midX, y: rect.minY + r), + radius: r, + startAngle: .degrees(180), + endAngle: .degrees(360), + clockwise: false) + // Down the right side + p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - 3)) + // Three humps along the bottom (right -> left) + let humpW = rect.width / 3 + for i in (0..<3).reversed() { + let x0 = rect.minX + CGFloat(i) * humpW + let xMid = x0 + humpW / 2 + p.addQuadCurve( + to: CGPoint(x: x0, y: rect.maxY - 3), + control: CGPoint(x: xMid, y: rect.maxY + 3)) + } + p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + r)) + p.closeSubpath() + } + } +} diff --git a/panel/Panel.swift b/panel/Panel.swift index 7c0e7f9..efeca8f 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -29,6 +29,7 @@ private enum KeyCode { static let four: UInt16 = 21 static let nKey: UInt16 = 45 static let pKey: UInt16 = 35 + static let mKey: UInt16 = 46 } // Floating, non-activating panel. Shown via global hotkey; receives key @@ -80,7 +81,24 @@ struct PanelContentView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - if nav.mode == .bootstrap { + if nav.compactMode, !nav.compactExpanded, + nav.mode != .bootstrap, nav.mode != .postUpdate { + // Glance-only widget. Expand button → transient full panel; + // double-click on the pill → exit compact mode entirely. + // Callbacks are wired from PanelController so the window + // resize happens synchronously before SwiftUI re-renders + // the full panel content into the still-compact frame. + CompactView( + store: store, + sessions: sessions, + nav: nav, + // Both the expand button and double-click exit compact + // mode persistently — that's what the user wants: a + // one-way "ok I want the full panel from now on." + onExpand: { nav.actions?.exitCompactMode() }, + onExitCompact: { nav.actions?.exitCompactMode() } + ) + } else if nav.mode == .bootstrap { // Full-screen first-launch experience: install + onboarding // + Grant Permissions, all in one cohesive flow. BootstrapView( @@ -249,7 +267,8 @@ struct PanelContentView: View { private var footer: some View { PageFooter { if store.events.isEmpty { - FooterHint(label: "Hide", keys: ["esc"]) + FooterHint(label: "Compact", keys: ["M"]) + FooterHint(label: "Hide", keys: ["Esc"]) } else { if let primary = primaryActionLabel { FooterHint(label: primary, keys: ["⏎"], primary: true) @@ -263,7 +282,8 @@ struct PanelContentView: View { FooterHint(label: "Snooze", keys: ["S"]) .opacity(snoozeEnabled ? 1.0 : 0.35) FooterHint(label: "Dismiss", keys: ["⌫"]) - FooterHint(label: "Hide", keys: ["esc"]) + FooterHint(label: "Compact", keys: ["M"]) + FooterHint(label: "Hide", keys: ["Esc"]) } } } @@ -605,7 +625,9 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, beginUninstall: { [weak self] in self?.beginUninstallFlow() }, runUninstall: { [weak self] in self?.runUninstall() }, runBootstrap: { [weak self] in self?.runBootstrap() }, - quit: { NSApp.terminate(nil) } + quit: { NSApp.terminate(nil) }, + expandFromCompact: { [weak self] in self?.expandFromCompact() }, + exitCompactMode: { [weak self] in self?.exitCompactMode() } ) nav.setHotkey = { [weak self] spec in self?.registerHotkey(spec: spec) ?? false @@ -640,6 +662,25 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, .sink { [weak self] _ in self?.refreshAllClaudeStats() } .store(in: &cancellables) + // Re-apply window layout whenever compact-mode state flips. Covers + // both the persistent toggle (Settings → "Compact widget") and the + // transient expanded flag (widget click → expand). + nav.$compactMode + .removeDuplicates() + .dropFirst() // initial value applied via applyCompactLayout on launch + .sink { [weak self] _ in self?.applyCompactLayout() } + .store(in: &cancellables) + nav.$compactExpanded + .removeDuplicates() + .sink { [weak self] _ in self?.applyCompactLayout() } + .store(in: &cancellables) + nav.$compactCorner + .removeDuplicates() + .dropFirst() + .sink { [weak self] _ in self?.applyCompactLayout() } + .store(in: &cancellables) + applyCompactLayout() + // If a previous panel instance was pkilled mid-update by install.sh, // it left a status file behind. Read it now and surface a brief toast // so the user knows the update completed (or failed). @@ -677,6 +718,33 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, name: NSWindow.didResignKeyNotification, object: panel ) + + // Compact-mode drag handling: explicit mouse-up signal beats the + // ambiguous time-based debounce. mouseDown resets the "moved" + // flag; didMoveNotification sets it; mouseUp consults it to + // decide whether to snap. A plain click without movement doesn't + // trigger a snap. + compactMouseEventMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.leftMouseDown, .leftMouseUp] + ) { [weak self] event in + guard let self, + event.window === self.panel, + self.nav.compactMode, !self.nav.compactExpanded + else { return event } + switch event.type { + case .leftMouseDown: + self.compactMovedSinceMouseDown = false + self.nav.compactDragging = true + case .leftMouseUp: + self.nav.compactDragging = false + if self.compactMovedSinceMouseDown { + self.compactMovedSinceMouseDown = false + self.snapCompactToNearestCorner() + } + default: break + } + return event + } } // Triggered from the welcome screen's "Grant permissions" button. Opens @@ -735,10 +803,179 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, } @objc private func panelDidResignKey(_ notification: Notification) { + // Compact mode: don't hide on focus loss — the widget is meant to + // stay visible. Collapse back from "expanded" if the user clicked + // away while in the full-panel render. Apply layout synchronously + // so the panel actually shrinks before SwiftUI re-evaluates body + // (otherwise the new CompactView renders at the still-large size). + if nav.compactMode { + if nav.compactExpanded { + nav.compactExpanded = false + applyCompactLayout() + } + return + } guard !nav.panelPinned, panel.isVisible else { return } hidePanel() } + // MARK: - Compact widget layout + + private static let compactWidgetSize = NSSize(width: 320, height: 56) + private static let compactWidgetInset: CGFloat = 14 + + // Apply window size + origin appropriate to the current compact-mode + // state. Called whenever nav.compactMode or nav.compactExpanded changes + // and once at launch to handle config-restored state. + private func applyCompactLayout() { + if nav.compactMode, !nav.compactExpanded { + // Widget: shrink + pin to the chosen corner, float above + // everything, follow the user across spaces. Transparent + // window background so the SwiftUI Capsule shows through. + let size = Self.compactWidgetSize + // Lower the minimum content size below the widget dimensions + // so AppKit doesn't enforce the original 560x260 floor after + // a system event like screen reconfiguration or lock/unlock. + panel.contentMinSize = size + var frame = panel.frame + frame.size = size + frame.origin = compactCornerOrigin(for: size) + ignoringProgrammaticMove = true + panel.setFrame(frame, display: true, animate: false) + ignoringProgrammaticMove = false + panel.level = .statusBar + panel.collectionBehavior = [.canJoinAllSpaces, .stationary, + .fullScreenAuxiliary, .ignoresCycle] + panel.hasShadow = false // SwiftUI Capsule provides its own + panel.isMovableByWindowBackground = true // drag to reposition + panel.orderFront(nil) + } else { + // Full panel: restore saved size + saved origin. Keep + // backgroundColor/isOpaque at the original transparent + // settings — the NSVisualEffectView contentView provides + // the visual; overriding the window's opacity here would + // break the blur. + let size = Self.loadSavedPanelSize() + var frame = panel.frame + frame.size = size + panel.setFrame(frame, display: true, animate: false) + // Restore the original layout-protecting minimum so SwiftUI's + // full panel content (Settings, Sessions, etc.) has room. + panel.contentMinSize = NSSize(width: 560, height: 260) + panel.level = .floating + panel.collectionBehavior = [] + panel.hasShadow = true + panel.isMovableByWindowBackground = false + positionPanel() + if nav.compactExpanded { + NSApp.activate(ignoringOtherApps: true) + panel.makeKeyAndOrderFront(nil) + } + } + } + + // Called from the widget's expand button. Sets the expanded flag, + // applies layout synchronously (so the panel is resized before SwiftUI + // re-renders the full content into the still-compact frame), then + // brings the window forward. + private func expandFromCompact() { + nav.compactExpanded = true + applyCompactLayout() + } + + // Compact mode is always on, so what used to be "exit compact" + // (double-click, expand button) now means "expand to full panel + // temporarily." Calling expandFromCompact lets the existing wiring + // and Settings actions keep working without renaming. + private func exitCompactMode() { + expandFromCompact() + } + + // Called from the "M" keystroke in Events/Sessions/Usage tabs to + // collapse back to the pill. + private func enterCompactMode() { + if nav.compactExpanded { + nav.compactExpanded = false + applyCompactLayout() + } + } + + // Debounced snap: each move during a drag bumps the deadline forward. + // After the user releases (no further moves for ~280ms), pick the + // nearest corner of the active screen, persist it, and animate the + // panel home. + // Set TRUE around each programmatic setFrame so the resulting + // didMoveNotification (which fires synchronously when the frame + // changes) doesn't get treated as a user drag. + private var ignoringProgrammaticMove = false + // Tracks whether the panel actually moved since the user pressed + // the mouse button. The .leftMouseUp monitor consumes this to + // decide whether a snap is warranted (a plain click shouldn't snap). + private var compactMovedSinceMouseDown = false + private var compactMouseEventMonitor: Any? + + private func snapCompactToNearestCorner() { + guard nav.compactMode, !nav.compactExpanded else { return } + let visible = activeScreen().visibleFrame + let size = panel.frame.size + let center = NSPoint(x: panel.frame.midX, y: panel.frame.midY) + let corner: CompactCorner + switch (center.x < visible.midX, center.y < visible.midY) { + case (true, false): corner = .topLeft + case (false, false): corner = .topRight + case (true, true): corner = .bottomLeft + case (false, true): corner = .bottomRight + } + // Animate from the released position to the target corner FIRST. + // Persist the corner only after the animation finishes — otherwise + // updating nav.compactCorner mid-animation fires the Combine sink, + // applyCompactLayout instant-snaps the panel to the new corner, + // and the subsequent animator setFrame has nothing to tween. + let target = cornerOrigin(for: size, corner: corner) + // animator().setFrame updates panel.frame synchronously (the + // animation is purely visual via Core Animation). Wrap that one + // synchronous call so its didMove doesn't schedule another snap. + ignoringProgrammaticMove = true + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.28 + ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) + panel.animator().setFrame(NSRect(origin: target, size: size), + display: true) + } + ignoringProgrammaticMove = false + // Commit the corner. Combine sink fires applyCompactLayout, which + // also goes through ignoringProgrammaticMove via its own + // wrapped setFrame, so no extra snap is scheduled. + if nav.compactCorner != corner { + nav.compactCorner = corner + ConfigFile.write(key: "STACKNUDGE_COMPACT_CORNER", + value: corner.rawValue) + } + } + + private func compactCornerOrigin(for size: NSSize) -> NSPoint { + cornerOrigin(for: size, corner: nav.compactCorner) + } + + private func cornerOrigin(for size: NSSize, corner: CompactCorner) -> NSPoint { + let visible = activeScreen().visibleFrame + let inset = Self.compactWidgetInset + switch corner { + case .topLeft: + return NSPoint(x: visible.minX + inset, + y: visible.maxY - size.height - inset) + case .topRight: + return NSPoint(x: visible.maxX - size.width - inset, + y: visible.maxY - size.height - inset) + case .bottomLeft: + return NSPoint(x: visible.minX + inset, + y: visible.minY + inset) + case .bottomRight: + return NSPoint(x: visible.maxX - size.width - inset, + y: visible.minY + inset) + } + } + // MARK: - Bootstrap (first-launch install) // Run Bootstrap.install on a background queue, stream progress lines @@ -1506,6 +1743,17 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, let blockingMods: NSEvent.ModifierFlags = [.control, .option] let cmdOnly = mods.intersection([.command, .control, .option, .shift]) == .command + // From the pill (compact-not-expanded), M expands to full panel. + // Mirrors the M-to-collapse shortcut shown in the full panel's + // footer. The pill must be key for this to land, so a single + // click anywhere on the pill arms it. + if nav.compactMode, !nav.compactExpanded, + event.keyCode == KeyCode.mKey, + mods.intersection([.command, .control, .option, .shift]).isEmpty { + expandFromCompact() + return true + } + // While recording a hotkey, capture the next combo. Arrow keys / Tab // bail out gracefully — otherwise users who entered record mode by // mistake would be stuck on row 0 with all their keypresses swallowed. @@ -1696,6 +1944,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, guard mods.intersection([.command, .control, .option]).isEmpty else { return false } let pid = sessions.selectedPID ?? sessions.sessions.first?.pid if let pid { sessions.startRenaming(pid) } + case KeyCode.mKey where plain: + enterCompactMode() default: return false } @@ -1783,6 +2033,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // the next scheduled tick (up to the configured poll // interval, which could be 30 min). if nav.quotaTrackingEnabled { syncQuotaNow() } + case KeyCode.mKey: + enterCompactMode() default: break } @@ -1818,6 +2070,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, actOnSelected(approve: false) case KeyCode.rKey, KeyCode.delete, KeyCode.forwardDelete: dismissSelected() + case KeyCode.mKey: + enterCompactMode() default: return false } @@ -1936,7 +2190,18 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // Toggle behaves on focus, not visibility: hotkey while panel is key hides it; // hotkey while the panel is hidden OR visible-but-defocused brings it forward. + // In compact mode, hotkey toggles the expand state instead of show/hide so + // the pill is always visible — that's the whole point of compact mode. private func toggle() { + if nav.compactMode { + if nav.compactExpanded { + nav.compactExpanded = false + applyCompactLayout() + } else { + expandFromCompact() + } + return + } if panel.isKeyWindow { hidePanel() } else { @@ -1945,6 +2210,16 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, } func showPanel() { + // In compact mode, "show" means "expand" — bringing back the full + // panel rather than just raising the pill (which is already visible). + if nav.compactMode { + if !nav.compactExpanded { expandFromCompact() } + else { + NSApp.activate(ignoringOtherApps: true) + panel.makeKeyAndOrderFront(nil) + } + return + } positionPanel() // re-resolve in case the user moved to a different display NSApp.activate(ignoringOtherApps: true) panel.makeKeyAndOrderFront(nil) @@ -1953,6 +2228,16 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // NSApp.hide hides all our windows AND deactivates the app, so the system // frontmost reverts to whatever was active before the panel was summoned. private func hidePanel() { + // Compact mode: "hide" means "collapse back to the pill," never + // make the user lose their widget entirely. Esc / hotkey / focus + // loss all funnel through here. + if nav.compactMode { + if nav.compactExpanded { + nav.compactExpanded = false + applyCompactLayout() + } + return + } panel.orderOut(nil) NSApp.hide(nil) } @@ -2010,7 +2295,11 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, object: panel, queue: .main ) { [weak self] _ in - guard let panel = self?.panel else { return } + guard let self, let panel = self.panel as NSWindow? else { return } + // Don't persist the compact-widget frame as the user's panel + // size — they'd lose their full-panel size every time they + // switched modes. + if self.nav.compactMode, !self.nav.compactExpanded { return } UserDefaults.standard.set( ["width": panel.frame.width, "height": panel.frame.height], forKey: Self.panelSizeKey @@ -2021,7 +2310,17 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, object: panel, queue: .main ) { [weak self] _ in - guard let panel = self?.panel else { return } + guard let self, let panel = self.panel as NSWindow? else { return } + // Compact widget: don't persist the moved origin (the corner + // is the source of truth). The actual snap fires from the + // mouse-up event monitor. Here we just record that movement + // happened during the current mouse-down so the monitor + // knows whether to snap. + if self.ignoringProgrammaticMove { return } + if self.nav.compactMode, !self.nav.compactExpanded { + self.compactMovedSinceMouseDown = true + return + } UserDefaults.standard.set( ["x": panel.frame.origin.x, "y": panel.frame.origin.y], forKey: Self.panelOriginKey diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index 410381d..2a4ca1d 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -38,6 +38,38 @@ enum UpdateCheckStatus: Equatable { case failed } +enum CompactCorner: String, CaseIterable { + case topLeft = "tl" + case topRight = "tr" + case bottomLeft = "bl" + case bottomRight = "br" + + var label: String { + switch self { + case .topLeft: return "Top left" + case .topRight: return "Top right" + case .bottomLeft: return "Bottom left" + case .bottomRight: return "Bottom right" + } + } +} + +enum MascotKind: String, CaseIterable { + case robot + case cat + case eye + case ghost + + var label: String { + switch self { + case .robot: return "Robot" + case .cat: return "Cat" + case .eye: return "Sentinel" + case .ghost: return "Ghost" + } + } +} + struct SettingsActions { let checkPermissions: () -> Void let openConfig: () -> Void @@ -50,6 +82,10 @@ struct SettingsActions { let runUninstall: () -> Void let runBootstrap: () -> Void let quit: () -> Void + // Compact widget callbacks — wired by PanelController so the window + // resize happens synchronously before SwiftUI re-renders. + let expandFromCompact: () -> Void + let exitCompactMode: () -> Void } // Owns the panel's navigation state plus the live values the Settings page @@ -142,6 +178,23 @@ final class PanelNav: ObservableObject { // tokens (not %) because Claude 4.x context windows vary by model. @Published var contextAlertThresholdK: Int = 0 static let contextAlertThresholdOptions: [Int] = [0, 100, 150, 175, 200, 300, 500, 750] + // Compact widget mode. Shrinks the panel to a glance-only widget pinned + // to a screen corner; clicking it expands back to the full panel. + // Compact mode is always-on now. The compactMode field is kept (and + // forced to true in loadFromConfig) to avoid threading the rest of + // the controller's compact-aware code; just don't expose a toggle. + @Published var compactMode: Bool = true + @Published var compactCorner: CompactCorner = .topRight + @Published var mascot: MascotKind = .robot + // Transient: true when the widget was clicked → render full panel + // at full size; resignKey resets this. Not persisted — purely a + // session-local "the user wants details right now" flag. + @Published var compactExpanded: Bool = false + // Transient: true between mouseDown and mouseUp while dragging the + // pill. CompactView reads this to skip its decorative TimelineView + // animations so the main thread can keep up with AppKit's drag + // handler. Reset by the controller's mouse-event monitor. + @Published var compactDragging: Bool = false // First-launch bootstrap wizard state. Populated by PanelController // on launch when Bootstrap.isInstalled() returns false; drives // BootstrapView (mode = .bootstrap). @@ -214,7 +267,7 @@ final class PanelNav: ObservableObject { // when the offset is 1. var updateRowOffset: Int { updateAvailable != nil ? 1 : 0 } - var rowCount: Int { 23 + updateRowOffset } + var rowCount: Int { 25 + updateRowOffset } // Row layout (kept in one place so the controller, view, and indexing // logic all agree on what each row index means). When updateAvailable @@ -225,24 +278,26 @@ final class PanelNav: ObservableObject { // 2 Mute when focused toggle // 3 Pin panel toggle // 4 Launch at login toggle - // 5 Sound enabled toggle (gates rows 6 + 7) - // 6 Agent done sound cycle - // 7 Permission sound cycle - // 8 Voice notifications toggle (gates rows 9 + 10) - // 9 Voice cycle (or "Download model" action) - // 10 Speed cycle - // 11 Quota tracking toggle (master; gates rows 12-14) - // 12 Quota alerts toggle - // 13 Alert threshold cycle - // 14 Poll frequency cycle - // 15 Context alert at cycle (per-session token thresholds) - // 16 Edit phrases… action - // 17 Check permissions… action - // 18 Open config file… action - // 19 View release notes… action - // 20 Check for updates… action - // 21 Uninstall stack-nudge action - // 22 Quit panel action + // 5 Widget corner cycle + // 6 Mascot cycle + // 7 Sound enabled toggle (gates rows 8 + 9) + // 8 Agent done sound cycle + // 9 Permission sound cycle + // 10 Voice notifications toggle (gates rows 11 + 12) + // 11 Voice cycle (or "Download model" action) + // 12 Speed cycle + // 13 Quota tracking toggle (master; gates rows 14-16) + // 14 Quota alerts toggle + // 15 Alert threshold cycle + // 16 Poll frequency cycle + // 17 Context alert at cycle (per-session token thresholds) + // 18 Edit phrases… action + // 19 Check permissions… action + // 20 Open config file… action + // 21 View release notes… action + // 22 Check for updates… action + // 23 Uninstall stack-nudge action + // 24 Quit panel action // MARK: - Disk I/O @@ -275,6 +330,10 @@ final class PanelNav: ObservableObject { quotaPollMinutes = Self.quotaPollMinuteOptions.min(by: { abs($0 - rawPoll) < abs($1 - rawPoll) }) ?? 5 let rawCtx = Int(config["STACKNUDGE_CONTEXT_ALERT_THRESHOLD"] ?? "") ?? 0 contextAlertThresholdK = Self.contextAlertThresholdOptions.min(by: { abs($0 - rawCtx) < abs($1 - rawCtx) }) ?? 0 + compactMode = true // always-on; toggle removed from Settings + compactCorner = CompactCorner(rawValue: config["STACKNUDGE_COMPACT_CORNER"] ?? "") + ?? .topRight + mascot = MascotKind(rawValue: config["STACKNUDGE_MASCOT"] ?? "") ?? .robot } // MARK: - Agent reconciliation @@ -458,8 +517,8 @@ final class PanelNav: ObservableObject { } switch selectedSettingIndex - updateRowOffset { case 0: startRecordingHotkey() - case 9 where !voiceModelCached: - // Pre-download state: index 9 is the "Download voice model" + case 11 where !voiceModelCached: + // Pre-download state: index 11 is the "Download voice model" // action, not a cycle. Enter triggers (or cancels) the // download. if voiceModelDownloading { @@ -467,13 +526,13 @@ final class PanelNav: ObservableObject { } else { startVoiceModelDownload() } - case 16: actions?.editPhrases() - case 17: actions?.checkPermissions() - case 18: actions?.openConfig() - case 19: actions?.openReleaseNotes() - case 20: actions?.checkForUpdates() - case 21: actions?.beginUninstall() - case 22: actions?.quit() + case 18: actions?.editPhrases() + case 19: actions?.checkPermissions() + case 20: actions?.openConfig() + case 21: actions?.openReleaseNotes() + case 22: actions?.checkForUpdates() + case 23: actions?.beginUninstall() + case 24: actions?.quit() default: applyCycle(forward: true) } } @@ -529,16 +588,29 @@ final class PanelNav: ObservableObject { "stack-nudge: setLaunchAtLogin(\(target)) failed: \(error)\n".utf8)) } case 5: + let list = CompactCorner.allCases + let idx = list.firstIndex(of: compactCorner) ?? 0 + let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count + compactCorner = list[next] + ConfigFile.write(key: "STACKNUDGE_COMPACT_CORNER", + value: compactCorner.rawValue) + case 6: + let list = MascotKind.allCases + let idx = list.firstIndex(of: mascot) ?? 0 + let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count + mascot = list[next] + ConfigFile.write(key: "STACKNUDGE_MASCOT", value: mascot.rawValue) + case 7: soundEnabled.toggle() ConfigFile.write(key: "STACKNUDGE_SOUND", value: soundEnabled ? "true" : "false") - case 6: + case 8: soundStop = step(soundStop, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_STOP", preview: true) - case 7: + case 9: soundPermission = step(soundPermission, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_PERMISSION", preview: true) - case 8: + case 10: voiceEnabled.toggle() ConfigFile.write(key: "STACKNUDGE_VOICE", value: voiceEnabled ? "true" : "false") - case 9: + case 11: // Pre-download: the row is an action, not a cycle. Treat // left/right arrow as a trigger so a user discovering the // row keyboard-only can still start the download. @@ -550,17 +622,17 @@ final class PanelNav: ObservableObject { voice = step(voice, in: voicesAvailable, forward: forward, key: "STACKNUDGE_VOICE_NAME", preview: false) let phrase = Self.voicePreviewPhrases.randomElement() ?? "Hello." Speaker.speak(phrase, voice: voice, speed: String(format: "%.2f", voiceSpeed)) - case 10: + case 12: let next = forward ? voiceSpeed + Self.speedStep : voiceSpeed - Self.speedStep voiceSpeed = max(Self.speedMin, min(Self.speedMax, (next * 100).rounded() / 100)) ConfigFile.write(key: "STACKNUDGE_VOICE_SPEED", value: String(format: "%.2f", voiceSpeed)) - case 11: + case 13: toggleQuotaTracking() - case 12: + case 14: quotaAlertsEnabled.toggle() ConfigFile.write(key: "STACKNUDGE_QUOTA_ALERTS", value: quotaAlertsEnabled ? "true" : "false") - case 13: + case 15: // Cycle through the static thresholds list. Index wraps in both // directions so the user can dial in either way. let list = Self.quotaThresholds @@ -569,14 +641,14 @@ final class PanelNav: ObservableObject { quotaAlertThreshold = list[next] ConfigFile.write(key: "STACKNUDGE_QUOTA_THRESHOLD", value: String(quotaAlertThreshold)) - case 14: + case 16: let list = Self.quotaPollMinuteOptions let idx = list.firstIndex(of: quotaPollMinutes) ?? 2 let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count quotaPollMinutes = list[next] ConfigFile.write(key: "STACKNUDGE_USAGE_POLL_MIN", value: String(quotaPollMinutes)) - case 15: + case 17: let list = Self.contextAlertThresholdOptions let idx = list.firstIndex(of: contextAlertThresholdK) ?? 0 let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count diff --git a/panel/Phrases.swift b/panel/Phrases.swift index 129084f..9d0c2cf 100644 --- a/panel/Phrases.swift +++ b/panel/Phrases.swift @@ -492,7 +492,7 @@ struct PhrasesView: View { FooterHint(label: "Add", keys: ["⏎"], primary: true) FooterHint(label: "Toggle", keys: ["␣"]) FooterHint(label: "Remove", keys: ["⌫"]) - FooterHint(label: "Back", keys: ["esc"]) + FooterHint(label: "Back", keys: ["Esc"]) } } diff --git a/panel/SessionUsage.swift b/panel/SessionUsage.swift index af3763f..1ae4f0b 100644 --- a/panel/SessionUsage.swift +++ b/panel/SessionUsage.swift @@ -247,10 +247,11 @@ struct UsageView: View { PageFooter { FooterHint(label: footerStatusLabel, keys: []) if nav.quotaTrackingEnabled { - FooterHint(label: "Sync now", keys: ["r"]) + FooterHint(label: "Sync now", keys: ["R"]) } - FooterHint(label: nav.quotaTrackingEnabled ? "Pause" : "Resume", keys: ["p"]) - FooterHint(label: "Hide", keys: ["esc"]) + FooterHint(label: nav.quotaTrackingEnabled ? "Pause" : "Resume", keys: ["P"]) + FooterHint(label: "Compact", keys: ["M"]) + FooterHint(label: "Hide", keys: ["Esc"]) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) diff --git a/panel/Sessions.swift b/panel/Sessions.swift index 98a4b6b..f860056 100644 --- a/panel/Sessions.swift +++ b/panel/Sessions.swift @@ -75,13 +75,14 @@ struct SessionsView: View { PageFooter { if store.renamingPID != nil { FooterHint(label: "Save", keys: ["⏎"]) - FooterHint(label: "Cancel", keys: ["esc"]) + FooterHint(label: "Cancel", keys: ["Esc"]) } else { - FooterHint(label: "Select", keys: ["↑", "↓"]) - FooterHint(label: "Focus", keys: ["⏎"]) - FooterHint(label: "Rename", keys: ["n"]) - FooterHint(label: "Kill", keys: ["⌫"]) - FooterHint(label: "Back", keys: ["esc"]) + FooterHint(label: "Select", keys: ["↑", "↓"]) + FooterHint(label: "Focus", keys: ["⏎"]) + FooterHint(label: "Rename", keys: ["N"]) + FooterHint(label: "Kill", keys: ["⌫"]) + FooterHint(label: "Compact", keys: ["M"]) + FooterHint(label: "Back", keys: ["Esc"]) } } } diff --git a/panel/Settings.swift b/panel/Settings.swift index 5fa6349..21e7380 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -52,38 +52,43 @@ struct SettingsView: View { row(4 + off, label: "Launch at login", kind: .toggle, value: nav.launchAtLogin ? "On" : "Off") } + section("Widget") { + row(5 + off, label: "Widget corner", kind: .cycle, value: nav.compactCorner.label) + row(6 + off, label: "Mascot", kind: .cycle, value: nav.mascot.label) + } + section("Sounds") { - row(5 + off, label: "Sound enabled", kind: .toggle, value: nav.soundEnabled ? "On" : "Off") - row(6 + off, label: "Agent done", kind: .cycle, value: nav.soundStop, enabled: nav.soundEnabled) - row(7 + off, label: "Permission", kind: .cycle, value: nav.soundPermission, enabled: nav.soundEnabled) + row(7 + off, label: "Sound enabled", kind: .toggle, value: nav.soundEnabled ? "On" : "Off") + row(8 + off, label: "Agent done", kind: .cycle, value: nav.soundStop, enabled: nav.soundEnabled) + row(9 + off, label: "Permission", kind: .cycle, value: nav.soundPermission, enabled: nav.soundEnabled) } section("Voice") { - row(8 + off, label: "Voice notifications", kind: .toggle, value: nav.voiceEnabled ? "On" : "Off") + row(10 + off, label: "Voice notifications", kind: .toggle, value: nav.voiceEnabled ? "On" : "Off") if nav.voiceModelCached { - row(9 + off, label: "Voice", kind: .cycle, value: voiceLabel, enabled: nav.voiceEnabled) - row(10 + off, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed), enabled: nav.voiceEnabled) + row(11 + off, label: "Voice", kind: .cycle, value: voiceLabel, enabled: nav.voiceEnabled) + row(12 + off, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed), enabled: nav.voiceEnabled) } else { - voiceModelDownloadRow(index: 9 + off) + voiceModelDownloadRow(index: 11 + off) } } section("Usage") { - row(11 + off, label: "Quota tracking", kind: .toggle, value: nav.quotaTrackingEnabled ? "On" : "Off") - row(12 + off, label: "Quota alerts", kind: .toggle, value: nav.quotaAlertsEnabled ? "On" : "Off", enabled: nav.quotaTrackingEnabled) - row(13 + off, label: "Alert threshold", kind: .cycle, value: "\(nav.quotaAlertThreshold)%", enabled: nav.quotaTrackingEnabled && nav.quotaAlertsEnabled) - row(14 + off, label: "Poll frequency", kind: .cycle, value: "\(nav.quotaPollMinutes) min", enabled: nav.quotaTrackingEnabled) - row(15 + off, label: "Context alert at", kind: .cycle, value: contextAlertLabel) + row(13 + off, label: "Quota tracking", kind: .toggle, value: nav.quotaTrackingEnabled ? "On" : "Off") + row(14 + off, label: "Quota alerts", kind: .toggle, value: nav.quotaAlertsEnabled ? "On" : "Off", enabled: nav.quotaTrackingEnabled) + row(15 + off, label: "Alert threshold", kind: .cycle, value: "\(nav.quotaAlertThreshold)%", enabled: nav.quotaTrackingEnabled && nav.quotaAlertsEnabled) + row(16 + off, label: "Poll frequency", kind: .cycle, value: "\(nav.quotaPollMinutes) min", enabled: nav.quotaTrackingEnabled) + row(17 + off, label: "Context alert at", kind: .cycle, value: contextAlertLabel) } section("Actions") { - row(16 + off, label: "Edit phrases…", kind: .action, value: "") - row(17 + off, label: "Check permissions…", kind: .action, value: "") - row(18 + off, label: "Open config file…", kind: .action, value: "") - row(19 + off, label: "View release notes…", kind: .action, value: "") - row(20 + off, label: "Check for updates…", kind: .action, value: checkForUpdatesStatus) - row(21 + off, label: "Uninstall StackNudge…", kind: .action, value: "") - row(22 + off, label: "Quit panel", kind: .action, value: "") + row(18 + off, label: "Edit phrases…", kind: .action, value: "") + row(19 + off, label: "Check permissions…", kind: .action, value: "") + row(20 + off, label: "Open config file…", kind: .action, value: "") + row(21 + off, label: "View release notes…", kind: .action, value: "") + row(22 + off, label: "Check for updates…", kind: .action, value: checkForUpdatesStatus) + row(23 + off, label: "Uninstall StackNudge…", kind: .action, value: "") + row(24 + off, label: "Quit panel", kind: .action, value: "") } aboutFooter @@ -102,12 +107,12 @@ struct SettingsView: View { PageFooter { if nav.recordingHotkey { FooterHint(label: "Press a combo with ⌘ / ⇧ / ⌥ / ⌃", keys: [], primary: true) - FooterHint(label: "Cancel", keys: ["esc"]) + FooterHint(label: "Cancel", keys: ["Esc"]) } else { FooterHint(label: "Move", keys: ["↑", "↓"]) FooterHint(label: "Cycle", keys: ["←", "→"]) FooterHint(label: "Act", keys: ["⏎"]) - FooterHint(label: "Back", keys: ["esc"]) + FooterHint(label: "Back", keys: ["Esc"]) } } } diff --git a/panel/Updater.swift b/panel/Updater.swift index d382223..8014310 100644 --- a/panel/Updater.swift +++ b/panel/Updater.swift @@ -589,7 +589,7 @@ struct UpdateConfirmView: View { PageFooter { FooterHint(label: "Update now", keys: ["⏎"], primary: true) FooterDivider() - FooterHint(label: "Cancel", keys: ["esc"]) + FooterHint(label: "Cancel", keys: ["Esc"]) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -838,7 +838,7 @@ struct UpdatingView: View { PageFooter { if nav.updaterPhase == .failed { - FooterHint(label: "Close", keys: ["esc"]) + FooterHint(label: "Close", keys: ["Esc"]) } else { FooterHint(label: "Don't quit stack-nudge during update", keys: []) }