diff --git a/apps/macos/Sources/Clawdis/AgentEventStore.swift b/apps/macos/Sources/Clawdis/AgentEventStore.swift new file mode 100644 index 000000000..afee39be2 --- /dev/null +++ b/apps/macos/Sources/Clawdis/AgentEventStore.swift @@ -0,0 +1,20 @@ +import Foundation + +@MainActor +final class AgentEventStore: ObservableObject { + static let shared = AgentEventStore() + + @Published private(set) var events: [ControlAgentEvent] = [] + private let maxEvents = 400 + + func append(_ event: ControlAgentEvent) { + self.events.append(event) + if self.events.count > maxEvents { + self.events.removeFirst(self.events.count - maxEvents) + } + } + + func clear() { + self.events.removeAll() + } +} diff --git a/apps/macos/Sources/Clawdis/AgentEventsWindow.swift b/apps/macos/Sources/Clawdis/AgentEventsWindow.swift new file mode 100644 index 000000000..b6fd4f99b --- /dev/null +++ b/apps/macos/Sources/Clawdis/AgentEventsWindow.swift @@ -0,0 +1,105 @@ +import SwiftUI + +@MainActor +struct AgentEventsWindow: View { + @ObservedObject private var store = AgentEventStore.shared + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Agent Events") + .font(.title3.weight(.semibold)) + Spacer() + Button("Clear") { store.clear() } + .buttonStyle(.bordered) + } + .padding(.bottom, 4) + + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(store.events.reversed(), id: \.seq) { evt in + EventRow(event: evt) + } + } + } + } + .padding(12) + .frame(minWidth: 520, minHeight: 360) + } +} + +private struct EventRow: View { + let event: ControlAgentEvent + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(event.stream.uppercased()) + .font(.caption2.weight(.bold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(self.tint) + .foregroundStyle(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + Text("run " + event.runId) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + Spacer() + Text(self.formattedTs) + .font(.caption2) + .foregroundStyle(.secondary) + } + if let json = self.prettyJSON(event.data) { + Text(json) + .font(.caption.monospaced()) + .foregroundStyle(.primary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 2) + } + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.primary.opacity(0.04)) + ) + } + + private var tint: Color { + switch event.stream { + case "job": return .blue + case "tool": return .orange + case "assistant": return .green + default: return .gray + } + } + + private var formattedTs: String { + let date = Date(timeIntervalSince1970: event.ts / 1000) + let f = DateFormatter() + f.dateFormat = "HH:mm:ss.SSS" + return f.string(from: date) + } + + private func prettyJSON(_ dict: [String: AnyCodable]) -> String? { + let normalized = dict.mapValues { $0.value } + guard JSONSerialization.isValidJSONObject(normalized), + let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.prettyPrinted]), + let str = String(data: data, encoding: .utf8) + else { return nil } + return str + } +} + +struct AgentEventsWindow_Previews: PreviewProvider { + static var previews: some View { + let sample = ControlAgentEvent( + runId: "abc", + seq: 1, + stream: "tool", + ts: Date().timeIntervalSince1970 * 1000, + data: ["phase": AnyCodable("start"), "name": AnyCodable("bash")]) + AgentEventStore.shared.append(sample) + return AgentEventsWindow() + } +} diff --git a/apps/macos/Sources/Clawdis/ControlChannel.swift b/apps/macos/Sources/Clawdis/ControlChannel.swift index c7242edff..c2643d4a0 100644 --- a/apps/macos/Sources/Clawdis/ControlChannel.swift +++ b/apps/macos/Sources/Clawdis/ControlChannel.swift @@ -13,7 +13,7 @@ struct ControlHeartbeatEvent: Codable { let reason: String? } -struct ControlAgentEvent: Codable { +struct ControlAgentEvent: Codable, Sendable { let runId: String let seq: Int let stream: String @@ -21,7 +21,11 @@ struct ControlAgentEvent: Codable { let data: [String: AnyCodable] } -struct AnyCodable: Codable { +extension Notification.Name { + static let controlAgentEvent = Notification.Name("clawdis.control.agent") +} + +struct AnyCodable: Codable, @unchecked Sendable { let value: Any init(_ value: Any) { self.value = value } @@ -196,6 +200,24 @@ final class ControlChannel: ObservableObject { await self.disconnect() self.mode = mode try await self.connect() + + NotificationCenter.default.addObserver( + forName: .controlAgentEvent, + object: nil, + queue: .main) + { note in + if let evt = note.object as? ControlAgentEvent { + DispatchQueue.main.async { @MainActor in + let payload = ControlAgentEvent( + runId: evt.runId, + seq: evt.seq, + stream: evt.stream, + ts: evt.ts, + data: evt.data.mapValues { AnyCodable($0.value) }) + AgentEventStore.shared.append(payload) + } + } + } } func disconnect() async { @@ -410,6 +432,7 @@ final class ControlChannel: ObservableObject { if let payloadData = try? JSONSerialization.data(withJSONObject: payload), let agent = try? JSONDecoder().decode(ControlAgentEvent.self, from: payloadData) { self.handleAgentEvent(agent) + NotificationCenter.default.post(name: .controlAgentEvent, object: agent) } } return diff --git a/apps/macos/Sources/Clawdis/DebugSettings.swift b/apps/macos/Sources/Clawdis/DebugSettings.swift index 50c44278e..826445dab 100644 --- a/apps/macos/Sources/Clawdis/DebugSettings.swift +++ b/apps/macos/Sources/Clawdis/DebugSettings.swift @@ -149,6 +149,19 @@ struct DebugSettings: View { } } .buttonStyle(.bordered) + Button("Open Agent Events") { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 620, height: 420), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false) + window.title = "Agent Events" + window.isReleasedWhenClosed = false + window.contentView = NSHostingView(rootView: AgentEventsWindow()) + window.center() + window.makeKeyAndOrderFront(nil) + } + .buttonStyle(.borderedProminent) VStack(alignment: .leading, spacing: 6) { Button { Task { await self.sendVoiceDebug() }