diff --git a/Releases/v5.0.0/.claude/PAI/PULSE/MenuBar/PulseMenuBar.swift b/Releases/v5.0.0/.claude/PAI/PULSE/MenuBar/PulseMenuBar.swift index 91a5396aff..cbb552f220 100644 --- a/Releases/v5.0.0/.claude/PAI/PULSE/MenuBar/PulseMenuBar.swift +++ b/Releases/v5.0.0/.claude/PAI/PULSE/MenuBar/PulseMenuBar.swift @@ -15,6 +15,24 @@ struct PulseState: Codable { } } +// MARK: - PAI Work Session Model + +struct WorkSession: Codable { + let task: String? + let sessionName: String? + let sessionUUID: String? + let phase: String? + let progress: String? + let effort: String? + let mode: String? + let started: String? + let updatedAt: String? +} + +struct WorkRegistry: Codable { + let sessions: [String: WorkSession] +} + // MARK: - PULSE.toml Job Definition struct HeartbeatJob { @@ -85,6 +103,182 @@ func formatAgo(_ epochMs: Double) -> String { return "\(hours)h \(minutes % 60)m ago" } +// MARK: - Work-session helpers + +func formatAgeShort(_ date: Date) -> String { + let seconds = Date().timeIntervalSince(date) + if seconds < 0 { return "now" } + if seconds < 60 { return "\(Int(seconds))s" } + let minutes = Int(seconds) / 60 + if minutes < 60 { return "\(minutes)m" } + let hours = minutes / 60 + if hours < 24 { return "\(hours)h" } + let days = hours / 24 + return "\(days)d" +} + +let isoFracFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f +}() + +let isoPlainFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f +}() + +func parseISO(_ s: String?) -> Date? { + guard let s = s else { return nil } + if let d = isoFracFormatter.date(from: s) { return d } + return isoPlainFormatter.date(from: s) +} + +func parseWorkRegistry(from path: String) -> [(slug: String, session: WorkSession)] { + let fm = FileManager.default + guard fm.fileExists(atPath: path), + let data = fm.contents(atPath: path), + let registry = try? JSONDecoder().decode(WorkRegistry.self, from: data) else { + return [] + } + return registry.sessions.map { (slug: $0.key, session: $0.value) } +} + +let algorithmPhases: Set = ["observe", "think", "plan", "build", "execute", "verify", "learn"] + +func sessionIndicator(phase: String?, inRecent: Bool) -> String { + let p = phase ?? "" + if inRecent { return "โœ“" } + if algorithmPhases.contains(p) { return "โšก" } + if p == "complete" { return "โœ“" } + return "๐Ÿ“ก" +} + +func sessionLabel(_ session: WorkSession, slug: String) -> String { + let raw = session.task? + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "\n", with: " ") + let source = (raw?.isEmpty == false ? raw! : slug) + let limit = 40 + if source.count <= limit { return source } + let idx = source.index(source.startIndex, offsetBy: limit) + return String(source[.. String { + let ind = sessionIndicator(phase: session.phase, inRecent: inRecent) + let effort = session.effort ?? "โ€”" + let label = sessionLabel(session, slug: slug) + let phaseUp = (session.phase ?? "").uppercased() + let phaseStr = (phaseUp.isEmpty || phaseUp == "NATIVE") ? "" : phaseUp + let prog = (session.progress != nil && session.progress != "0/0") ? session.progress! : "" + let age = parseISO(session.updatedAt).map { formatAgeShort($0) } ?? "" + + var parts: [String] = [ind, effort, label] + if !phaseStr.isEmpty { parts.append(phaseStr) } + if !prog.isEmpty { parts.append(prog) } + if !age.isEmpty { parts.append(age) } + return parts.joined(separator: " ") +} + +// MARK: - Session Transcript Reader + +enum TranscriptKind { + case userText + case assistantText + case toolUse + case thinking + case other +} + +struct TranscriptEvent { + let kind: TranscriptKind + let text: String // For toolUse: tool name. For text: the text content. + let toolDetail: String // For toolUse: short detail (cmd, file, etc.). Empty otherwise. +} + +func transcriptPath(forUUID uuid: String) -> String { + let home = ProcessInfo.processInfo.environment["HOME"] ?? NSString(string: "~").expandingTildeInPath + return "\(home)/.claude/projects/-Users-janrenz/\(uuid).jsonl" +} + +/// Reads the JSONL transcript and returns the last `limit` user/assistant/tool events. +func loadTranscript(uuid: String, limit: Int = 80) -> [TranscriptEvent] { + let path = transcriptPath(forUUID: uuid) + let fm = FileManager.default + guard fm.fileExists(atPath: path), + let data = fm.contents(atPath: path), + let content = String(data: data, encoding: .utf8) else { + return [] + } + + var events: [TranscriptEvent] = [] + let lines = content.split(separator: "\n", omittingEmptySubsequences: true) + for line in lines { + guard let lineData = line.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] else { + continue + } + let type = obj["type"] as? String ?? "" + guard type == "user" || type == "assistant" else { continue } + guard let message = obj["message"] as? [String: Any] else { continue } + let role = (message["role"] as? String) ?? type + + // Content can be String or [Block] + if let text = message["content"] as? String, !text.isEmpty { + events.append(TranscriptEvent( + kind: role == "user" ? .userText : .assistantText, + text: text, + toolDetail: "" + )) + continue + } + guard let blocks = message["content"] as? [[String: Any]] else { continue } + for block in blocks { + let btype = block["type"] as? String ?? "" + switch btype { + case "text": + let t = (block["text"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if !t.isEmpty { + events.append(TranscriptEvent( + kind: role == "user" ? .userText : .assistantText, + text: t, + toolDetail: "" + )) + } + case "tool_use": + let name = block["name"] as? String ?? "tool" + let input = block["input"] as? [String: Any] ?? [:] + var detail = "" + if let cmd = input["command"] as? String { + detail = cmd.split(separator: "\n").first.map(String.init) ?? cmd + } else if let path = input["file_path"] as? String { + detail = (path as NSString).lastPathComponent + } else if let q = input["query"] as? String { + detail = q + } else if let p = input["pattern"] as? String { + detail = p + } else if let prompt = input["prompt"] as? String { + detail = String(prompt.prefix(60)) + } + if detail.count > 80 { + let idx = detail.index(detail.startIndex, offsetBy: 80) + detail = String(detail[.. String { let parts = expr.trimmingCharacters(in: .whitespaces).split(separator: " ").map(String.init) guard parts.count == 5 else { return expr } @@ -254,6 +448,15 @@ class PulseMenuBarApp: NSObject, NSApplicationDelegate { private let pulseDir: String private let pollInterval: TimeInterval = 5.0 + private var paiDir: String { (pulseDir as NSString).deletingLastPathComponent } + private var workJsonPath: String { "\(paiDir)/MEMORY/STATE/work.json" } + private var activeSessions: [(slug: String, session: WorkSession)] = [] + private var recentSessions: [(slug: String, session: WorkSession)] = [] + private var activeSessionsCount: Int { activeSessions.count } + + private let sessionPopover = NSPopover() + private let popoverController = SessionPopoverViewController() + override init() { self.pulseDir = ProcessInfo.processInfo.environment["PAI_PULSE_DIR"] ?? NSString(string: "~/.claude/PAI/PULSE").expandingTildeInPath @@ -263,6 +466,9 @@ class PulseMenuBarApp: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + sessionPopover.behavior = .transient + sessionPopover.contentViewController = popoverController + updateIcon() rebuildMenu() @@ -279,10 +485,36 @@ class PulseMenuBarApp: NSObject, NSApplicationDelegate { let (status, state) = determinePulseStatus(pulseDir: pulseDir) self.currentStatus = status self.currentState = state + refreshWorkSessions() updateIcon() rebuildMenu() } + private func refreshWorkSessions() { + let all = parseWorkRegistry(from: workJsonPath) + let now = Date() + let fiveMin: TimeInterval = 300 + let oneDay: TimeInterval = 86_400 + + var active: [(slug: String, session: WorkSession, date: Date)] = [] + var recent: [(slug: String, session: WorkSession, date: Date)] = [] + for (slug, session) in all { + guard let updated = parseISO(session.updatedAt) else { continue } + let age = now.timeIntervalSince(updated) + if age < 0 { continue } + let isComplete = (session.phase ?? "") == "complete" + if !isComplete && age < fiveMin { + active.append((slug, session, updated)) + } else if age < oneDay { + recent.append((slug, session, updated)) + } + } + active.sort { $0.date > $1.date } + recent.sort { $0.date > $1.date } + self.activeSessions = Array(active.prefix(5)).map { (slug: $0.slug, session: $0.session) } + self.recentSessions = Array(recent.prefix(3)).map { (slug: $0.slug, session: $0.session) } + } + // MARK: - Icon private func updateIcon() { @@ -303,6 +535,11 @@ class PulseMenuBarApp: NSObject, NSApplicationDelegate { button.image = fallback?.withSymbolConfiguration(config) button.contentTintColor = currentStatus.iconColor } + + // Active-sessions badge โ€” number to the right of the icon + let count = activeSessionsCount + button.imagePosition = .imageLeft + button.title = count > 0 ? " \(count)" : "" } // MARK: - Menu Construction @@ -323,6 +560,54 @@ class PulseMenuBarApp: NSObject, NSApplicationDelegate { statusLine.indentationLevel = 1 menu.addItem(statusLine) + // Active Sessions + Recent โ€” surfaced from MEMORY/STATE/work.json + let hasAnySessions = !activeSessions.isEmpty || !recentSessions.isEmpty + if hasAnySessions { + menu.addItem(NSMenuItem.separator()) + } + + if !activeSessions.isEmpty { + let header = NSMenuItem(title: "Active Sessions", action: nil, keyEquivalent: "") + header.attributedTitle = NSAttributedString( + string: "Active Sessions", + attributes: [ + .font: NSFont.boldSystemFont(ofSize: 11), + .foregroundColor: NSColor.secondaryLabelColor, + ] + ) + menu.addItem(header) + for (slug, session) in activeSessions { + let title = formatSessionMenuTitle(session, slug: slug, inRecent: false) + let item = NSMenuItem(title: title, action: #selector(openSessionPopover(_:)), keyEquivalent: "") + item.target = self + item.representedObject = slug + item.indentationLevel = 1 + menu.addItem(item) + } + } + + if !recentSessions.isEmpty { + // Collapsible category โ€” Recent appears as a single item with a flyout submenu. + let recentItem = NSMenuItem(title: "Recent (\(recentSessions.count))", action: nil, keyEquivalent: "") + recentItem.attributedTitle = NSAttributedString( + string: "Recent (\(recentSessions.count))", + attributes: [ + .font: NSFont.systemFont(ofSize: 13), + .foregroundColor: NSColor.secondaryLabelColor, + ] + ) + let recentSub = NSMenu() + for (slug, session) in recentSessions { + let title = formatSessionMenuTitle(session, slug: slug, inRecent: true) + let item = NSMenuItem(title: title, action: #selector(openSessionPopover(_:)), keyEquivalent: "") + item.target = self + item.representedObject = slug + recentSub.addItem(item) + } + recentItem.submenu = recentSub + menu.addItem(recentItem) + } + menu.addItem(NSMenuItem.separator()) // Jobs section @@ -458,6 +743,21 @@ class PulseMenuBarApp: NSObject, NSApplicationDelegate { NSWorkspace.shared.open(URL(fileURLWithPath: configPath)) } + @objc private func openSessionPopover(_ sender: NSMenuItem) { + guard let slug = sender.representedObject as? String else { return } + // Look up the latest session data from our cached lists. + let combined = activeSessions + recentSessions + guard let entry = combined.first(where: { $0.slug == slug }) else { return } + + popoverController.configure(slug: slug, session: entry.session) + + guard let button = statusItem.button else { return } + if sessionPopover.isShown { + sessionPopover.performClose(nil) + } + sessionPopover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + } + @objc private func quitApp() { NSApplication.shared.terminate(nil) } @@ -488,6 +788,198 @@ class PulseMenuBarApp: NSObject, NSApplicationDelegate { } } +// MARK: - Session Popover (read-only transcript viewer) + +final class SessionPopoverViewController: NSViewController { + private var slug: String = "" + private var session: WorkSession? + private var refreshTimer: Timer? + + private let headerLabel = NSTextField(labelWithString: "") + private let subheaderLabel = NSTextField(labelWithString: "") + private let scrollView = NSScrollView() + private let textView = NSTextView() + private let footerLabel = NSTextField(labelWithString: "Read-only ยท auto-refreshes every 2s") + + override func loadView() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 480, height: 560)) + + // Header + headerLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold) + headerLabel.textColor = .labelColor + headerLabel.translatesAutoresizingMaskIntoConstraints = false + headerLabel.lineBreakMode = .byTruncatingTail + container.addSubview(headerLabel) + + subheaderLabel.font = NSFont.systemFont(ofSize: 11) + subheaderLabel.textColor = .secondaryLabelColor + subheaderLabel.translatesAutoresizingMaskIntoConstraints = false + subheaderLabel.lineBreakMode = .byTruncatingTail + container.addSubview(subheaderLabel) + + // Scroll + text view + textView.isEditable = false + textView.isSelectable = true + textView.drawsBackground = false + textView.textContainerInset = NSSize(width: 6, height: 6) + textView.font = NSFont.systemFont(ofSize: 12) + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.containerSize = NSSize(width: 460, height: CGFloat.greatestFiniteMagnitude) + + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.drawsBackground = false + scrollView.borderType = .noBorder + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.documentView = textView + container.addSubview(scrollView) + + // Footer + footerLabel.font = NSFont.systemFont(ofSize: 10) + footerLabel.textColor = .tertiaryLabelColor + footerLabel.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(footerLabel) + + NSLayoutConstraint.activate([ + headerLabel.topAnchor.constraint(equalTo: container.topAnchor, constant: 12), + headerLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 14), + headerLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -14), + + subheaderLabel.topAnchor.constraint(equalTo: headerLabel.bottomAnchor, constant: 2), + subheaderLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 14), + subheaderLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -14), + + scrollView.topAnchor.constraint(equalTo: subheaderLabel.bottomAnchor, constant: 10), + scrollView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 8), + scrollView.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -8), + scrollView.bottomAnchor.constraint(equalTo: footerLabel.topAnchor, constant: -6), + + footerLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 14), + footerLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -14), + footerLabel.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -8), + ]) + + self.view = container + } + + func configure(slug: String, session: WorkSession) { + self.slug = slug + self.session = session + renderHeader() + renderTranscript() + } + + override func viewWillAppear() { + super.viewWillAppear() + renderHeader() + renderTranscript() + refreshTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in + self?.renderHeader() + self?.renderTranscript() + } + } + + override func viewWillDisappear() { + super.viewWillDisappear() + refreshTimer?.invalidate() + refreshTimer = nil + } + + private func renderHeader() { + guard let s = session else { return } + let ind = sessionIndicator(phase: s.phase, inRecent: false) + let effort = s.effort ?? "โ€”" + let label = sessionLabel(s, slug: slug) + headerLabel.stringValue = "\(ind) \(effort) \(label)" + + let phaseUp = (s.phase ?? "").uppercased() + let phaseStr = (phaseUp.isEmpty || phaseUp == "NATIVE") ? "โ€”" : phaseUp + let prog = (s.progress != nil && s.progress != "0/0") ? " ยท \(s.progress!) ISCs" : "" + let age = parseISO(s.updatedAt).map { " ยท updated \(formatAgeShort($0)) ago" } ?? "" + subheaderLabel.stringValue = "\(phaseStr)\(prog)\(age)" + } + + private func renderTranscript() { + guard let s = session, let uuid = s.sessionUUID else { + setBodyPlain("No session UUID โ€” can't load transcript.") + return + } + let events = loadTranscript(uuid: uuid, limit: 80) + if events.isEmpty { + setBodyPlain("No transcript yet โ€” session may be brand new, or the file isn't readable.") + return + } + + let attr = NSMutableAttributedString() + let bodyFont = NSFont.systemFont(ofSize: 12) + let boldFont = NSFont.boldSystemFont(ofSize: 12) + let monoFont = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + + for event in events { + let prefix: String + let prefixColor: NSColor + let bodyColor: NSColor + let useMono: Bool + switch event.kind { + case .userText: + prefix = "You: " + prefixColor = .systemBlue + bodyColor = .labelColor + useMono = false + case .assistantText: + prefix = "Timmy: " + prefixColor = .systemPurple + bodyColor = .labelColor + useMono = false + case .toolUse: + prefix = "๐Ÿ”ง \(event.text): " + prefixColor = .systemOrange + bodyColor = .secondaryLabelColor + useMono = true + case .thinking: + prefix = "(thinking) " + prefixColor = .tertiaryLabelColor + bodyColor = .tertiaryLabelColor + useMono = false + case .other: + continue + } + + let prefixAttr = NSAttributedString(string: prefix, attributes: [ + .font: boldFont, + .foregroundColor: prefixColor, + ]) + attr.append(prefixAttr) + + let bodyText: String + switch event.kind { + case .toolUse: + bodyText = event.toolDetail.isEmpty ? "" : event.toolDetail + default: + bodyText = event.text + } + let bodyAttr = NSAttributedString(string: bodyText + "\n\n", attributes: [ + .font: useMono ? monoFont : bodyFont, + .foregroundColor: bodyColor, + ]) + attr.append(bodyAttr) + } + + textView.textStorage?.setAttributedString(attr) + textView.scrollToEndOfDocument(nil) + } + + private func setBodyPlain(_ msg: String) { + let attr = NSAttributedString(string: msg, attributes: [ + .font: NSFont.systemFont(ofSize: 12), + .foregroundColor: NSColor.secondaryLabelColor, + ]) + textView.textStorage?.setAttributedString(attr) + } +} + // MARK: - Entry Point let app = NSApplication.shared