feat(macos): surface session activity in menu bar

This commit is contained in:
Peter Steinberger
2025-12-09 01:28:16 +01:00
parent 73cc34467a
commit 6b10f4241d
10 changed files with 505 additions and 38 deletions

View File

@@ -104,6 +104,10 @@ final class AppState: ObservableObject {
didSet { UserDefaults.standard.set(self.voicePushToTalkEnabled, forKey: voicePushToTalkEnabledKey) }
}
@Published var iconOverride: IconOverrideSelection {
didSet { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) }
}
@Published var isWorking: Bool = false
@Published var earBoostActive: Bool = false
@Published var blinkTick: Int = 0
@@ -190,6 +194,13 @@ final class AppState: ObservableObject {
self.heartbeatsEnabled = true
UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey)
}
if let storedOverride = UserDefaults.standard.string(forKey: iconOverrideKey),
let selection = IconOverrideSelection(rawValue: storedOverride) {
self.iconOverride = selection
} else {
self.iconOverride = .system
UserDefaults.standard.set(IconOverrideSelection.system.rawValue, forKey: iconOverrideKey)
}
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
self.connectionMode = ConnectionMode(rawValue: storedMode ?? "local") ?? .local

View File

@@ -23,6 +23,7 @@ let voiceWakeForwardPortKey = "clawdis.voiceWakeForwardPort"
let voiceWakeForwardIdentityKey = "clawdis.voiceWakeForwardIdentity"
let voiceWakeForwardCommandKey = "clawdis.voiceWakeForwardCommand"
let voicePushToTalkEnabledKey = "clawdis.voicePushToTalkEnabled"
let iconOverrideKey = "clawdis.iconOverride"
let connectionModeKey = "clawdis.connectionMode"
let remoteTargetKey = "clawdis.remoteTarget"
let remoteIdentityKey = "clawdis.remoteIdentity"

View File

@@ -469,8 +469,24 @@ final class ControlChannel: ObservableObject {
let working = self.jobStates.values.contains { workingStates.contains($0) }
Task { @MainActor in
AppStateStore.shared.setWorking(working)
WorkActivityStore.shared.handleJob(
sessionKey: event.runId,
state: state)
}
}
} else if event.stream == "tool" {
guard let phase = event.data["phase"]?.value as? String else { return }
let name = event.data["name"]?.value as? String
let meta = event.data["meta"]?.value as? String
let args = event.data["args"]?.value as? [String: AnyCodable]
Task { @MainActor in
WorkActivityStore.shared.handleTool(
sessionKey: event.runId,
phase: phase,
name: name,
meta: meta,
args: args)
}
}
}

View File

@@ -9,6 +9,7 @@ struct CritterStatusLabel: View {
var sendCelebrationTick: Int
var relayStatus: RelayProcessManager.Status
var animationsEnabled: Bool
var iconState: IconState
@State private var blinkAmount: CGFloat = 0
@State private var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5))
@@ -21,6 +22,10 @@ struct CritterStatusLabel: View {
@State private var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0...14.0))
private let ticker = Timer.publish(every: 0.35, on: .main, in: .common).autoconnect()
private var isWorkingNow: Bool {
self.iconState.isWorking || self.isWorking
}
var body: some View {
ZStack(alignment: .bottomTrailing) {
Group {
@@ -30,7 +35,7 @@ struct CritterStatusLabel: View {
} else {
Image(nsImage: CritterIconRenderer.makeIcon(
blink: self.blinkAmount,
legWiggle: max(self.legWiggle, self.isWorking ? 0.6 : 0),
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
earWiggle: self.earWiggle,
earScale: self.earBoostActive ? 1.9 : 1.0,
earHoles: self.earBoostActive))
@@ -63,7 +68,7 @@ struct CritterStatusLabel: View {
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
}
if self.isWorking {
if self.isWorkingNow {
self.scurry()
}
}
@@ -99,6 +104,21 @@ struct CritterStatusLabel: View {
.frame(width: 8, height: 8)
.offset(x: 4, y: 4)
}
if case .idle = self.iconState {
EmptyView()
} else {
Text(self.iconState.glyph)
.font(.system(size: 9))
.padding(3)
.background(
Circle()
.fill(self.iconState.tint.opacity(0.9))
)
.foregroundStyle(Color.white)
.offset(x: -4, y: -2)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
}
}
}

View File

@@ -5,6 +5,7 @@ import UniformTypeIdentifiers
struct DebugSettings: View {
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
@AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue
@State private var modelsCount: Int?
@State private var modelsLoading = false
@State private var modelsError: String?
@@ -26,6 +27,15 @@ struct DebugSettings: View {
Text(self.healthStore.summaryLine)
}
}
LabeledContent("Icon override") {
Picker("Icon override", selection: self.bindingOverride) {
ForEach(IconOverrideSelection.allCases) { option in
Text(option.label).tag(option.rawValue)
}
}
.labelsHidden()
.frame(maxWidth: 280)
}
LabeledContent("CLI helper") {
let loc = CLIInstaller.installedLocation()
Text(loc ?? "missing")
@@ -403,6 +413,20 @@ struct DebugSettings: View {
}
}
private var bindingOverride: Binding<String> {
Binding {
self.iconOverrideRaw
} set: { newValue in
self.iconOverrideRaw = newValue
if let selection = IconOverrideSelection(rawValue: newValue) {
Task { @MainActor in
AppStateStore.shared.iconOverride = selection
WorkActivityStore.shared.resolveIconState(override: selection)
}
}
}
}
private func configURL() -> URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdis")

View File

@@ -0,0 +1,105 @@
import Foundation
import SwiftUI
enum SessionRole {
case main
case other
}
enum ToolKind: String, Codable {
case bash, read, write, edit, attach, other
}
enum ActivityKind: Codable, Equatable {
case job
case tool(ToolKind)
}
enum IconState: Equatable {
case idle
case workingMain(ActivityKind)
case workingOther(ActivityKind)
case overridden(ActivityKind)
var glyph: String {
switch self.activity {
case .tool(.bash): return "💻"
case .tool(.read): return "📄"
case .tool(.write): return "✍️"
case .tool(.edit): return "📝"
case .tool(.attach): return "📎"
case .tool(.other), .job: return "🛠️"
}
}
var tint: Color {
switch self {
case .workingMain: return .accentColor
case .workingOther: return .gray
case .overridden: return .orange
case .idle: return .clear
}
}
var isWorking: Bool {
switch self {
case .idle: return false
default: return true
}
}
private var activity: ActivityKind {
switch self {
case let .workingMain(kind),
let .workingOther(kind),
let .overridden(kind):
return kind
case .idle:
return .job
}
}
}
enum IconOverrideSelection: String, CaseIterable, Identifiable {
case system
case idle
case mainBash, mainRead, mainWrite, mainEdit, mainOther
case otherBash, otherRead, otherWrite, otherEdit, otherOther
var id: String { self.rawValue }
var label: String {
switch self {
case .system: return "System (auto)"
case .idle: return "Idle"
case .mainBash: return "Working main bash"
case .mainRead: return "Working main read"
case .mainWrite: return "Working main write"
case .mainEdit: return "Working main edit"
case .mainOther: return "Working main other"
case .otherBash: return "Working other bash"
case .otherRead: return "Working other read"
case .otherWrite: return "Working other write"
case .otherEdit: return "Working other edit"
case .otherOther: return "Working other other"
}
}
func toIconState() -> IconState {
let map: (ToolKind) -> ActivityKind = { .tool($0) }
switch self {
case .system: return .idle
case .idle: return .idle
case .mainBash: return .workingMain(map(.bash))
case .mainRead: return .workingMain(map(.read))
case .mainWrite: return .workingMain(map(.write))
case .mainEdit: return .workingMain(map(.edit))
case .mainOther: return .workingMain(map(.other))
case .otherBash: return .workingOther(map(.bash))
case .otherRead: return .workingOther(map(.read))
case .otherWrite: return .workingOther(map(.write))
case .otherEdit: return .workingOther(map(.edit))
case .otherOther: return .workingOther(map(.other))
}
}
}

View File

@@ -11,6 +11,7 @@ struct ClawdisApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@StateObject private var state: AppState
@StateObject private var relayManager = RelayProcessManager.shared
@StateObject private var activityStore = WorkActivityStore.shared
@State private var statusItem: NSStatusItem?
@State private var isMenuPresented = false
@@ -27,7 +28,8 @@ struct ClawdisApp: App {
blinkTick: self.state.blinkTick,
sendCelebrationTick: self.state.sendCelebrationTick,
relayStatus: self.relayManager.status,
animationsEnabled: self.state.iconAnimationsEnabled)
animationsEnabled: self.state.iconAnimationsEnabled,
iconState: self.effectiveIconState)
}
.menuBarExtraStyle(.menu)
.menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in
@@ -50,6 +52,20 @@ struct ClawdisApp: App {
private func applyStatusItemAppearance(paused: Bool) {
self.statusItem?.button?.appearsDisabled = paused
}
private var effectiveIconState: IconState {
let selection = self.state.iconOverride
if selection == .system {
return self.activityStore.iconState
}
let overrideState = selection.toIconState()
switch overrideState {
case let .workingMain(kind): return .overridden(kind)
case let .workingOther(kind): return .overridden(kind)
case .idle: return .idle
case let .overridden(kind): return .overridden(kind)
}
}
}
final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate {

View File

@@ -11,6 +11,7 @@ struct MenuContent: View {
@ObservedObject private var healthStore = HealthStore.shared
@ObservedObject private var heartbeatStore = HeartbeatStore.shared
@ObservedObject private var controlChannel = ControlChannel.shared
@ObservedObject private var activityStore = WorkActivityStore.shared
@Environment(\.openSettings) private var openSettings
@State private var availableMics: [AudioInputDevice] = []
@State private var loadingMics = false
@@ -64,44 +65,59 @@ struct MenuContent: View {
}
private var statusRow: some View {
let health = self.healthStore.state
let isRefreshing = self.healthStore.isRefreshing
let lastAge = self.healthStore.lastSuccess.map { age(from: $0) }
let label: String
let color: Color
if isRefreshing {
label = "Health check running…"
color = health.tint
} else {
switch health {
case .ok:
let ageText = lastAge.map { " · checked \($0)" } ?? ""
label = "Health ok\(ageText)"
color = .green
case .linkingNeeded:
label = "Health: login required"
color = .red
case let .degraded(reason):
let ageText = lastAge.map { " · checked \($0)" } ?? ""
label = "Health degraded: \(reason)\(ageText)"
color = .orange
case .unknown:
label = "Health pending"
color = .secondary
if let activity = self.activityStore.current {
let color: Color = activity.role == .main ? .accentColor : .gray
let roleLabel = activity.role == .main ? "Main" : "Other"
let text = "\(roleLabel) · \(activity.label)"
return HStack(spacing: 8) {
Circle()
.fill(color)
.frame(width: 8, height: 8)
Text(text)
.font(.caption.weight(.semibold))
.foregroundStyle(.primary)
}
}
.padding(.vertical, 4)
} else {
let health = self.healthStore.state
let isRefreshing = self.healthStore.isRefreshing
let lastAge = self.healthStore.lastSuccess.map { age(from: $0) }
return HStack(spacing: 8) {
Circle()
.fill(color)
.frame(width: 8, height: 8)
Text(label)
.font(.caption.weight(.semibold))
.foregroundStyle(.primary)
let label: String
let color: Color
if isRefreshing {
label = "Health check running…"
color = health.tint
} else {
switch health {
case .ok:
let ageText = lastAge.map { " · checked \($0)" } ?? ""
label = "Health ok\(ageText)"
color = .green
case .linkingNeeded:
label = "Health: login required"
color = .red
case let .degraded(reason):
let ageText = lastAge.map { " · checked \($0)" } ?? ""
label = "Health degraded: \(reason)\(ageText)"
color = .orange
case .unknown:
label = "Health pending"
color = .secondary
}
}
return HStack(spacing: 8) {
Circle()
.fill(color)
.frame(width: 8, height: 8)
Text(label)
.font(.caption.weight(.semibold))
.foregroundStyle(.primary)
}
.padding(.vertical, 4)
}
.padding(.vertical, 4)
}
private var heartbeatStatusRow: some View {

View File

@@ -0,0 +1,195 @@
import Foundation
import SwiftUI
@MainActor
final class WorkActivityStore: ObservableObject {
static let shared = WorkActivityStore()
struct Activity: Equatable {
let sessionKey: String
let role: SessionRole
let kind: ActivityKind
let label: String
let startedAt: Date
var lastUpdate: Date
}
@Published private(set) var current: Activity?
@Published private(set) var iconState: IconState = .idle
private var active: [String: Activity] = [:]
private var currentSessionKey: String?
private let mainSessionKey = "main"
private let toolResultGrace: TimeInterval = 2.0
func handleJob(sessionKey: String, state: String) {
let isStart = state.lowercased() == "started" || state.lowercased() == "streaming"
if isStart {
let activity = Activity(
sessionKey: sessionKey,
role: self.role(for: sessionKey),
kind: .job,
label: "job",
startedAt: Date(),
lastUpdate: Date())
self.setActive(activity)
} else {
self.markIdle(sessionKey: sessionKey)
}
}
func handleTool(
sessionKey: String,
phase: String,
name: String?,
meta: String?,
args: [String: AnyCodable]?
) {
let toolKind = Self.mapToolKind(name)
let label = Self.buildLabel(kind: toolKind, meta: meta, args: args)
if phase.lowercased() == "start" {
let activity = Activity(
sessionKey: sessionKey,
role: self.role(for: sessionKey),
kind: .tool(toolKind),
label: label,
startedAt: Date(),
lastUpdate: Date())
self.setActive(activity)
} else {
// Delay removal slightly to avoid flicker on rapid result/start bursts.
let key = sessionKey
Task { [weak self] in
let nsDelay = UInt64((self?.toolResultGrace ?? 0) * 1_000_000_000)
try? await Task.sleep(nanoseconds: nsDelay)
await MainActor.run {
self?.markIdle(sessionKey: key)
}
}
}
}
func resolveIconState(override selection: IconOverrideSelection) {
switch selection {
case .system:
self.iconState = self.deriveIconState()
case .idle:
self.iconState = .idle
default:
let base = selection.toIconState()
switch base {
case let .workingMain(kind),
let .workingOther(kind):
self.iconState = .overridden(kind)
case let .overridden(kind):
self.iconState = .overridden(kind)
case .idle:
self.iconState = .idle
}
}
}
private func setActive(_ activity: Activity) {
self.active[activity.sessionKey] = activity
// Main session preempts immediately.
if activity.role == .main {
self.currentSessionKey = activity.sessionKey
} else if self.currentSessionKey == nil || self.active[self.currentSessionKey!] == nil {
self.currentSessionKey = activity.sessionKey
}
self.current = self.active[self.currentSessionKey ?? ""]
self.iconState = self.deriveIconState()
}
private func markIdle(sessionKey: String) {
guard let existing = self.active[sessionKey] else { return }
// Update timestamp so replacement prefers newer others.
var updated = existing
updated.lastUpdate = Date()
self.active[sessionKey] = updated
self.active.removeValue(forKey: sessionKey)
if self.currentSessionKey == sessionKey {
self.pickNextSession()
}
self.current = self.active[self.currentSessionKey ?? ""]
self.iconState = self.deriveIconState()
}
private func pickNextSession() {
// Prefer main if present.
if let main = self.active[self.mainSessionKey] {
self.currentSessionKey = main.sessionKey
return
}
// Otherwise, pick most recent by lastUpdate.
if let next = self.active.values.sorted(by: { $0.lastUpdate > $1.lastUpdate }).first {
self.currentSessionKey = next.sessionKey
} else {
self.currentSessionKey = nil
}
}
private func role(for sessionKey: String) -> SessionRole {
sessionKey == self.mainSessionKey ? .main : .other
}
private func deriveIconState() -> IconState {
guard let activity = self.current else { return .idle }
switch activity.role {
case .main: return .workingMain(activity.kind)
case .other: return .workingOther(activity.kind)
}
}
private static func mapToolKind(_ name: String?) -> ToolKind {
switch name?.lowercased() {
case "bash", "shell": return .bash
case "read": return .read
case "write": return .write
case "edit": return .edit
case "attach": return .attach
default: return .other
}
}
private static func buildLabel(
kind: ToolKind,
meta: String?,
args: [String: AnyCodable]?
) -> String {
switch kind {
case .bash:
if let cmd = args?["command"]?.value as? String {
return "bash: \(cmd.split(separator: "\n").first ?? "")"
}
return "bash"
case .read, .write, .edit, .attach:
if let path = Self.extractPath(args: args, meta: meta) {
return "\(kind.rawValue): \(path)"
}
return kind.rawValue
case .other:
if let name = args?["name"]?.value as? String {
return name
}
return "tool"
}
}
private static func extractPath(args: [String: AnyCodable]?, meta: String?) -> String? {
if let p = args?["path"]?.value as? String { return shortenHome(path: p) }
if let p = args?["file_path"]?.value as? String { return shortenHome(path: p) }
if let meta { return shortenHome(path: meta) }
return nil
}
private static func shortenHome(path: String) -> String {
let home = NSHomeDirectory()
if path.hasPrefix(home) {
return "~" + path.dropFirst(home.count)
}
return path
}
}