@join-pantheon/cli 0.25.2 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -50,6 +50,8 @@ struct TrayState {
50
50
  var decisionsPrompt = ""
51
51
  var decisions: [(id: String, body: String, detail: String, prompt: String)] = []
52
52
  var items: [Item] = []
53
+ /// every team this machine is in, joined order; more than one and the board is drawn per team
54
+ var spaces: [String] = []
53
55
  var version = ""
54
56
  }
55
57
 
@@ -75,14 +77,15 @@ struct Item {
75
77
  default: return "Waiting on \(who)"
76
78
  }
77
79
  }
78
- /// "praneet · 2d ago · pantheon"
79
- var subtitle: String {
80
+ /// "praneet · 2d ago · pantheon" — or without the team, when the section heading already says it
81
+ var subtitle: String { subtitle(showSpace: true) }
82
+ func subtitle(showSpace: Bool) -> String {
80
83
  var parts: [String] = []
81
84
  // Who is asking, on every kind. A decision row used to say only "11h ago",
82
85
  // which is the one fact a reader could already guess.
83
86
  if kind == "owed" { parts.append("you owe") } else if !who.isEmpty { parts.append(who) }
84
87
  if when > 0 { parts.append(Item.age(when)) }
85
- if !space.isEmpty { parts.append(space) }
88
+ if showSpace && !space.isEmpty { parts.append(space) }
86
89
  return parts.joined(separator: " · ")
87
90
  }
88
91
  static func age(_ ms: Double) -> String {
@@ -136,6 +139,7 @@ func fetchState() -> TrayState {
136
139
  s.surfaceName = j["surface_name"] as? String ?? "agent"
137
140
  s.surfaceBundleID = j["surface_bundle_id"] as? String ?? ""
138
141
  s.decisionsPrompt = j["decisions_prompt"] as? String ?? ""
142
+ s.spaces = j["spaces"] as? [String] ?? []
139
143
  for i in (j["items"] as? [[String: Any]] ?? []) {
140
144
  s.items.append(Item(
141
145
  id: i["id"] as? String ?? "",
@@ -182,12 +186,24 @@ final class RowView: NSView {
182
186
  private var copied = false
183
187
 
184
188
  private let titleLines: Int
189
+ /// Inside a team section rows are indented and a decision carries a dot — "what needs
190
+ /// you" is then read off the dots, since the kind headings are gone. Single-team
191
+ /// boards keep their headings and neither.
192
+ private let leading: CGFloat
193
+ private let dot: Bool
194
+ private let sub: String
185
195
  /// The row's MINIMUM width — the menu is at least this wide. The row then
186
196
  /// stretches to the menu's real width (macOS adds its own trailing column
187
197
  /// for submenu chevrons), so the copy control sits at the edge instead of
188
198
  /// floating fifty points left of it over a blank gutter, which is what a
189
199
  /// fixed-width row did at every width we tried.
190
200
  static let rowWidth: CGFloat = 430
201
+ /// The width the menu actually gave the last row drawn. Rows are 430 wide until the
202
+ /// menu stretches them (a trailing column for submenu chevrons, ~35pt here); a title
203
+ /// measured at 430 that then fits one line at 465 left a blank line above its
204
+ /// subtitle. So heights are measured against the width last seen, and the first open
205
+ /// of a session — before any row has been drawn — splits any leftover evenly instead.
206
+ static var knownWidth: CGFloat?
191
207
  private static let titleFont = NSFont.systemFont(ofSize: 13, weight: .medium)
192
208
  private static let lineHeight: CGFloat = 17
193
209
 
@@ -207,16 +223,19 @@ final class RowView: NSView {
207
223
  return max(1, min(3, Int(ceil(h / lineHeight))))
208
224
  }
209
225
 
210
- init(item: Item, width: CGFloat, onOpen: @escaping (Item) -> Void, onCopy: @escaping (Item) -> Void) {
226
+ init(item: Item, width: CGFloat, inTeamSection: Bool = false, onOpen: @escaping (Item) -> Void, onCopy: @escaping (Item) -> Void) {
211
227
  self.item = item
212
228
  self.onOpen = onOpen
213
229
  self.onCopy = onCopy
214
- self.titleLines = RowView.lines(for: item.title, width: width - RowView.textInsets)
230
+ self.leading = inTeamSection ? 26 : 12
231
+ self.dot = inTeamSection && item.isDecision
232
+ self.sub = item.subtitle(showSpace: !inTeamSection)
233
+ self.titleLines = RowView.lines(for: item.title, width: (RowView.knownWidth ?? width) - RowView.textInsets - (leading - 12))
215
234
  let textHeight = CGFloat(titleLines) * RowView.lineHeight
216
- super.init(frame: NSRect(x: 0, y: 0, width: width, height: textHeight + (item.subtitle.isEmpty ? 13 : 29)))
235
+ super.init(frame: NSRect(x: 0, y: 0, width: width, height: textHeight + (sub.isEmpty ? 13 : 29)))
217
236
  autoresizingMask = [.width] // fill the menu: the text runs to the control, the control to the edge
218
237
  setAccessibilityRole(.button)
219
- setAccessibilityLabel("\(item.title). \(item.subtitle). Return opens it; the button on the right copies the question.")
238
+ setAccessibilityLabel("\(item.title). \(sub). Return opens it; the button on the right copies the question.")
220
239
  }
221
240
  required init?(coder: NSCoder) { return nil }
222
241
 
@@ -265,21 +284,33 @@ final class RowView: NSView {
265
284
  para.lineSpacing = 0
266
285
  let flat = NSMutableParagraphStyle()
267
286
  flat.lineBreakMode = .byTruncatingTail
268
- let textWidth = bounds.width - RowView.textInsets
269
- let hasSub = !item.subtitle.isEmpty
270
- let titleHeight = CGFloat(titleLines) * RowView.lineHeight
271
- let titleY = bounds.maxY - 7 - titleHeight
287
+ let textWidth = bounds.width - RowView.textInsets - (leading - 12)
288
+ if RowView.knownWidth != bounds.width { RowView.knownWidth = bounds.width }
289
+ let hasSub = !sub.isEmpty
290
+ // Lay out for the lines the title really needs at this width. Whatever the row
291
+ // was allocated beyond that is split above and below, so title and subtitle
292
+ // stay together and the row reads as slightly taller, never as a gap.
293
+ let actual = min(titleLines, RowView.lines(for: item.title, width: textWidth))
294
+ let titleHeight = CGFloat(actual) * RowView.lineHeight
295
+ let spare = (CGFloat(titleLines) - CGFloat(actual)) * RowView.lineHeight / 2
296
+ let titleY = bounds.maxY - 7 - spare - titleHeight
272
297
  let cell = NSTextFieldCell() // gives the last visible line its ellipsis
273
298
  cell.attributedStringValue = NSAttributedString(string: item.title, attributes: [
274
299
  .font: RowView.titleFont, .foregroundColor: fg, .paragraphStyle: para,
275
300
  ])
276
301
  cell.truncatesLastVisibleLine = true
277
302
  cell.wraps = true
278
- cell.drawInterior(withFrame: NSRect(x: 12, y: titleY, width: textWidth, height: titleHeight), in: self)
303
+ cell.drawInterior(withFrame: NSRect(x: leading, y: titleY, width: textWidth, height: titleHeight), in: self)
279
304
  if hasSub {
280
- NSAttributedString(string: item.subtitle, attributes: [
305
+ NSAttributedString(string: sub, attributes: [
281
306
  .font: NSFont.systemFont(ofSize: 11), .foregroundColor: dim, .paragraphStyle: flat,
282
- ]).draw(in: NSRect(x: 14, y: bounds.minY + 6, width: textWidth, height: 14))
307
+ ]).draw(in: NSRect(x: leading + 2, y: bounds.minY + 6 + spare, width: textWidth, height: 14))
308
+ }
309
+ if dot {
310
+ // the one mark that says "this needs you", where the kind heading used to;
311
+ // centred on the title's first line
312
+ (selected ? NSColor.selectedMenuItemTextColor : NSColor.systemBlue).setFill()
313
+ NSBezierPath(ovalIn: NSRect(x: 12, y: titleY + titleHeight - RowView.lineHeight / 2 - 3, width: 6, height: 6)).fill()
283
314
  }
284
315
 
285
316
  // the copy control
@@ -307,6 +338,115 @@ final class RowView: NSView {
307
338
  }
308
339
  }
309
340
 
341
+ /// A section heading or summary line drawn by us, not as a disabled menu item.
342
+ /// A disabled item is rendered dim by the system whatever colour it is given, so
343
+ /// the team names and "2 need you" — the words a person scans for — were the
344
+ /// faintest text in the menu. This draws the lead in the reading colour and the
345
+ /// rest one step down, at the same left edge as the rows' dots.
346
+ final class HeaderView: NSView {
347
+ private let lead: String
348
+ private let rest: String
349
+ private let bright: Bool
350
+ init(lead: String, rest: String, width: CGFloat, bright: Bool = true) {
351
+ self.lead = lead; self.rest = rest; self.bright = bright
352
+ super.init(frame: NSRect(x: 0, y: 0, width: width, height: 22))
353
+ autoresizingMask = [.width]
354
+ }
355
+ required init?(coder: NSCoder) { return nil }
356
+ override func draw(_ dirtyRect: NSRect) {
357
+ let text = NSMutableAttributedString(string: lead, attributes: [
358
+ .font: NSFont.systemFont(ofSize: 12, weight: .semibold),
359
+ .foregroundColor: bright ? NSColor.labelColor : NSColor.secondaryLabelColor,
360
+ ])
361
+ if !rest.isEmpty {
362
+ text.append(NSAttributedString(string: " " + rest, attributes: [
363
+ .font: NSFont.systemFont(ofSize: 12, weight: .regular),
364
+ .foregroundColor: NSColor.labelColor.withAlphaComponent(0.72),
365
+ ]))
366
+ }
367
+ let para = NSMutableParagraphStyle(); para.lineBreakMode = .byTruncatingTail
368
+ text.addAttribute(.paragraphStyle, value: para, range: NSRange(location: 0, length: text.length))
369
+ text.draw(in: NSRect(x: 12, y: bounds.minY + 3, width: bounds.width - 24, height: 16))
370
+ }
371
+ }
372
+
373
+ /*
374
+ * The board: an opaque, dark panel under the icon.
375
+ *
376
+ * It was an NSMenu. A menu is translucent by the system's decision — the
377
+ * wallpaper tints it, the text sits on whatever is behind it — and there is no
378
+ * switch to make one opaque. The person reading this every day asked for the
379
+ * clarity of a dark card, the way Cursor's notification list is drawn; that is a
380
+ * panel, not a menu. The rows and headings are the same views the menu used; the
381
+ * panel gives them a solid background, a fixed width, and one click to dismiss.
382
+ * A right-click on the icon still opens the classic menu — keyboard users and
383
+ * anything that ever goes wrong with the panel have a way in.
384
+ */
385
+ final class BoardPanel: NSPanel {
386
+ var onEscape: (() -> Void)?
387
+ override var canBecomeKey: Bool { true }
388
+ override func cancelOperation(_ sender: Any?) { onEscape?() }
389
+ }
390
+
391
+ final class BoardBackground: NSView {
392
+ override func draw(_ dirtyRect: NSRect) {
393
+ let path = NSBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), xRadius: 12, yRadius: 12)
394
+ NSColor(calibratedWhite: 0.115, alpha: 1).setFill()
395
+ path.fill()
396
+ NSColor(calibratedWhite: 1, alpha: 0.13).setStroke()
397
+ path.lineWidth = 1
398
+ path.stroke()
399
+ }
400
+ }
401
+
402
+ /// A one-line action or note in the board: a title, an optional trailing hint,
403
+ /// a hover highlight when it does something, and nothing at all when it does not.
404
+ final class ActionRow: NSView {
405
+ private let title: String
406
+ private let hint: String
407
+ private let onClick: (() -> Void)?
408
+ private var hovering = false
409
+ init(_ title: String, hint: String = "", width: CGFloat, onClick: (() -> Void)? = nil) {
410
+ self.title = title; self.hint = hint; self.onClick = onClick
411
+ super.init(frame: NSRect(x: 0, y: 0, width: width, height: 26))
412
+ autoresizingMask = [.width]
413
+ if onClick != nil { setAccessibilityRole(.button); setAccessibilityLabel(title) }
414
+ }
415
+ required init?(coder: NSCoder) { return nil }
416
+ override func updateTrackingAreas() {
417
+ super.updateTrackingAreas()
418
+ for a in trackingAreas { removeTrackingArea(a) }
419
+ if onClick != nil { addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .activeAlways], owner: self, userInfo: nil)) }
420
+ }
421
+ override func mouseEntered(with e: NSEvent) { hovering = true; needsDisplay = true }
422
+ override func mouseExited(with e: NSEvent) { hovering = false; needsDisplay = true }
423
+ override func mouseUp(with e: NSEvent) { onClick?() }
424
+ override func draw(_ dirtyRect: NSRect) {
425
+ if hovering {
426
+ NSColor.selectedContentBackgroundColor.setFill()
427
+ NSBezierPath(roundedRect: bounds.insetBy(dx: 5, dy: 1), xRadius: 5, yRadius: 5).fill()
428
+ }
429
+ let fg: NSColor = onClick == nil ? .secondaryLabelColor : hovering ? .selectedMenuItemTextColor : .labelColor
430
+ let para = NSMutableParagraphStyle(); para.lineBreakMode = .byTruncatingTail
431
+ NSAttributedString(string: title, attributes: [.font: NSFont.systemFont(ofSize: 13), .foregroundColor: fg, .paragraphStyle: para])
432
+ .draw(in: NSRect(x: 12, y: bounds.midY - 8, width: bounds.width - 24 - (hint.isEmpty ? 0 : 80), height: 17))
433
+ if !hint.isEmpty {
434
+ let h = NSMutableParagraphStyle(); h.alignment = .right
435
+ NSAttributedString(string: hint, attributes: [.font: NSFont.systemFont(ofSize: 12), .foregroundColor: hovering ? NSColor.selectedMenuItemTextColor.withAlphaComponent(0.8) : NSColor.tertiaryLabelColor, .paragraphStyle: h])
436
+ .draw(in: NSRect(x: bounds.maxX - 12 - 160, y: bounds.midY - 8, width: 160, height: 17))
437
+ }
438
+ }
439
+ }
440
+
441
+ final class BoardSeparator: NSView {
442
+ init(width: CGFloat) { super.init(frame: NSRect(x: 0, y: 0, width: width, height: 9)); autoresizingMask = [.width] }
443
+ required init?(coder: NSCoder) { return nil }
444
+ override func draw(_ dirtyRect: NSRect) {
445
+ NSColor(calibratedWhite: 1, alpha: 0.12).setFill()
446
+ NSRect(x: 12, y: bounds.midY, width: bounds.width - 24, height: 1).fill()
447
+ }
448
+ }
449
+
310
450
  final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
311
451
  private var item: NSStatusItem!
312
452
  private var state = TrayState()
@@ -322,7 +462,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
322
462
  item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
323
463
  let menu = NSMenu()
324
464
  menu.delegate = self
325
- item.menu = menu
465
+ classicMenu = menu
466
+ // Left click: the board. Right click: the classic menu, which stays exactly as it was.
467
+ item.button?.target = self
468
+ item.button?.action = #selector(iconClicked(_:))
469
+ item.button?.sendAction(on: [.leftMouseUp, .rightMouseUp])
326
470
  paint()
327
471
  refresh()
328
472
  timer = Timer.scheduledTimer(withTimeInterval: pollSeconds, repeats: true) { [weak self] _ in
@@ -341,8 +485,172 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
341
485
  self.refreshing = false
342
486
  self.paint()
343
487
  if !self.menuOpen, let menu = self.item.menu { self.menuNeedsUpdate(menu) }
488
+ // The board redraws only when what it shows has changed, so a poll never
489
+ // pulls a row out from under the pointer for nothing.
490
+ if self.boardPanel?.isVisible == true && self.boardSignature != self.signature() { self.rebuildBoard() }
491
+ }
492
+ }
493
+ }
494
+
495
+ // MARK: the board
496
+
497
+ private var classicMenu: NSMenu?
498
+ private var boardPanel: BoardPanel?
499
+ private var boardSignature = ""
500
+ private var boardMonitor: Any?
501
+ static let boardWidth: CGFloat = 470
502
+
503
+ private func signature() -> String {
504
+ "\(state.items.map { $0.id }.joined(separator: ","))|\(state.decisions.count)|\(state.online)|\(state.members)|\(state.workerRunning)|\(state.relayOk)|\(actionError ?? "")|\(workerAction ?? "")"
505
+ }
506
+
507
+ @objc private func iconClicked(_ sender: Any?) {
508
+ if NSApp.currentEvent?.type == .rightMouseUp, let menu = classicMenu {
509
+ closeBoard()
510
+ item.menu = menu
511
+ item.button?.performClick(nil)
512
+ item.menu = nil
513
+ return
514
+ }
515
+ if boardPanel?.isVisible == true { closeBoard() } else { openBoard() }
516
+ }
517
+
518
+ private func openBoard() {
519
+ refresh()
520
+ let panel = boardPanel ?? makeBoardPanel()
521
+ boardPanel = panel
522
+ rebuildBoard()
523
+ positionBoard(panel)
524
+ panel.makeKeyAndOrderFront(nil)
525
+ item.button?.highlight(true)
526
+ // A click anywhere else closes it, the way a menu closes — but without a menu's
527
+ // grip on the event loop, so the rest of the Mac keeps working while it is up.
528
+ boardMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] _ in self?.closeBoard() }
529
+ }
530
+
531
+ func closeBoard() {
532
+ if let m = boardMonitor { NSEvent.removeMonitor(m); boardMonitor = nil }
533
+ boardPanel?.orderOut(nil)
534
+ item.button?.highlight(false)
535
+ }
536
+
537
+ private func makeBoardPanel() -> BoardPanel {
538
+ let panel = BoardPanel(contentRect: NSRect(x: 0, y: 0, width: AppDelegate.boardWidth, height: 200),
539
+ styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
540
+ panel.isOpaque = false
541
+ panel.backgroundColor = .clear
542
+ panel.hasShadow = true
543
+ panel.level = .popUpMenu
544
+ panel.isReleasedWhenClosed = false
545
+ panel.hidesOnDeactivate = false
546
+ panel.appearance = NSAppearance(named: .darkAqua) // the dark card, whatever the system is set to
547
+ panel.onEscape = { [weak self] in self?.closeBoard() }
548
+ panel.contentView = BoardBackground(frame: panel.contentRect(forFrameRect: panel.frame))
549
+ return panel
550
+ }
551
+
552
+ private func positionBoard(_ panel: BoardPanel) {
553
+ guard let button = item.button, let win = button.window, let screen = win.screen ?? NSScreen.main else { return }
554
+ let anchor = win.convertToScreen(button.convert(button.bounds, to: nil))
555
+ let size = panel.frame.size
556
+ var x = anchor.midX - size.width / 2
557
+ x = max(screen.visibleFrame.minX + 8, min(x, screen.visibleFrame.maxX - size.width - 8))
558
+ let y = anchor.minY - 6 - size.height
559
+ panel.setFrameOrigin(NSPoint(x: x, y: y))
560
+ }
561
+
562
+ /// Everything the classic menu shows, laid out as a card: the summary, one
563
+ /// section per team (or the kind headings for a single team), then the actions.
564
+ private func rebuildBoard() {
565
+ guard let panel = boardPanel, let bg = panel.contentView else { return }
566
+ boardSignature = signature()
567
+ bg.subviews.forEach { $0.removeFromSuperview() }
568
+ let w = AppDelegate.boardWidth
569
+ var rows: [NSView] = []
570
+ let note = { (text: String) in rows.append(ActionRow(text, width: w)) }
571
+ let act = { (text: String, hint: String, f: @escaping () -> Void) in rows.append(ActionRow(text, hint: hint, width: w, onClick: { [weak self] in self?.closeBoard(); f() })) }
572
+ let sep = { rows.append(BoardSeparator(width: w)) }
573
+ let openRow: (Item) -> Void = { [weak self] it in self?.closeBoard(); self?.showDetail(it) }
574
+ let copyRow: (Item) -> Void = { [weak self] it in self?.copyPrompt(it) }
575
+ let section = { (title: String, detail: String, items: [Item], team: Bool) in
576
+ guard !items.isEmpty else { return }
577
+ rows.append(HeaderView(lead: title, rest: detail, width: w, bright: team))
578
+ for it in items.prefix(team ? 8 : 6) { rows.append(RowView(item: it, width: w, inTeamSection: team, onOpen: openRow, onCopy: copyRow)) }
579
+ if items.count > (team ? 8 : 6) { rows.append(ActionRow("…and \(items.count - (team ? 8 : 6)) more", width: w)) }
580
+ }
581
+
582
+ if !hasLoaded {
583
+ note("Checking Pantheon…")
584
+ } else if !state.reachable {
585
+ note("Unable to check Pantheon status"); note("Try Refresh now, or Check connection below")
586
+ } else if !state.configured {
587
+ note("Connect with your team"); note("Run pantheon start, or join with an invite link")
588
+ } else {
589
+ let teams = state.spaces.count > 1 ? state.spaces : []
590
+ let decisions = state.items.filter { $0.kind == "decision" }
591
+ let owed = state.items.filter { $0.kind == "owed" }
592
+ let waiting = state.items.filter { $0.kind == "waiting" }
593
+ let need = decisions.isEmpty ? "Nothing needs you" : "\(decisions.count) need\(decisions.count == 1 ? "s" : "") you"
594
+ let where_ = teams.isEmpty ? state.space : "\(teams.count) teams"
595
+ rows.append(HeaderView(lead: need, rest: "\(state.member) · \(where_) · " + (state.relayOk ? "\(state.online) of \(state.members) online" : "connection unavailable"), width: w))
596
+ if !state.relayOk { note("Could not reach: \(state.unavailableSpaces.joined(separator: ", "))") }
597
+ if !state.decisionsOk { note("Some of this could not be checked — the list may be incomplete") }
598
+ sep()
599
+ if !teams.isEmpty {
600
+ for (i, team) in teams.enumerated() {
601
+ if i > 0 { sep() }
602
+ let mine = state.items.filter { $0.space == team }
603
+ let ordered = mine.filter { $0.kind == "decision" } + mine.filter { $0.kind == "owed" } + mine.filter { $0.kind == "waiting" }
604
+ if state.unavailableSpaces.contains(team) { rows.append(HeaderView(lead: team, rest: "could not be checked", width: w, bright: false)) }
605
+ else if ordered.isEmpty { rows.append(HeaderView(lead: team, rest: "nothing open", width: w, bright: false)) }
606
+ else { section(team, counts(ordered), ordered, true) }
607
+ }
608
+ } else if state.items.isEmpty {
609
+ if state.decisionsOk { note("Nothing open — nothing needs you, nothing owed either way") }
610
+ } else {
611
+ section(decisions.count == 1 ? "1 decision needs you" : "\(decisions.count) decisions need you", "", decisions, false)
612
+ section(owed.count == 1 ? "You owe 1 thing" : "You owe \(owed.count) things", "", owed, false)
613
+ for who in orderedPeers(waiting) {
614
+ let theirs = waiting.filter { $0.who == who }
615
+ section(theirs.count == 1 ? "Waiting on \(who)" : "Waiting on \(who) · \(theirs.count)", "", theirs, false)
616
+ }
617
+ }
618
+ if !decisions.isEmpty {
619
+ sep()
620
+ act(decisions.count > 1 ? "Review all \(decisions.count) in \(state.surfaceName)" : "Review it in \(state.surfaceName)", "") { [weak self] in self?.openAllDecisions() }
344
621
  }
622
+ sep()
623
+ if let action = workerAction { note(action) }
624
+ else if state.workerRunning { act("Pause unattended work", state.workerState) { [weak self] in self?.pauseWorker() } }
625
+ else if state.workerInstalled { act("Resume unattended work", "paused") { [weak self] in self?.resumeWorker() } }
626
+ else { note("Unattended work is not set up — pantheon service install worker") }
345
627
  }
628
+ if let error = actionError { sep(); note(error) }
629
+ sep()
630
+ act("Open status in Terminal", "") { [weak self] in self?.openStatus() }
631
+ act("Check connection in Terminal", "version \(state.version)") { [weak self] in self?.openDoctor() }
632
+ act("View decision details in Terminal", "") { [weak self] in self?.openApprovals() }
633
+ act("Refresh now", "") { [weak self] in self?.refresh() }
634
+ sep()
635
+ act("Quit pantheon menu bar", "") { [weak self] in self?.quit() }
636
+
637
+ // Lay the rows out top to bottom at their own heights, in a card no taller than the screen.
638
+ let total = rows.reduce(CGFloat(16)) { $0 + $1.frame.height }
639
+ let maxH = (panel.screen ?? NSScreen.main)?.visibleFrame.height ?? 900
640
+ let height = min(total, maxH - 24)
641
+ panel.setContentSize(NSSize(width: w, height: height))
642
+ bg.frame = NSRect(x: 0, y: 0, width: w, height: height)
643
+ let doc = NSView(frame: NSRect(x: 0, y: 0, width: w, height: total))
644
+ var y = total - 8
645
+ for v in rows { y -= v.frame.height; v.frame = NSRect(x: 0, y: y, width: w, height: v.frame.height); doc.addSubview(v) }
646
+ let scroll = NSScrollView(frame: NSRect(x: 0, y: 0, width: w, height: height))
647
+ scroll.drawsBackground = false
648
+ scroll.hasVerticalScroller = total > height
649
+ scroll.documentView = doc
650
+ scroll.autoresizingMask = [.width, .height]
651
+ bg.addSubview(scroll)
652
+ if total > height { doc.scroll(NSPoint(x: 0, y: total)) } // start at the top
653
+ if panel.isVisible { positionBoard(panel) }
346
654
  }
347
655
 
348
656
  // Keep targets stable while a person is navigating the menu. The badge can
@@ -424,6 +732,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
424
732
  info("Connect with your team")
425
733
  info("Run pantheon start, or join with an invite link")
426
734
  } else {
735
+ let teams = state.spaces.count > 1 ? state.spaces : []
736
+ let decisions = state.items.filter { $0.kind == "decision" }
737
+ let owed = state.items.filter { $0.kind == "owed" }
738
+ let waiting = state.items.filter { $0.kind == "waiting" }
739
+ if !teams.isEmpty {
740
+ // Several teams: the board is drawn per team, and "what needs you" is read
741
+ // off the dots. One mixed list with the team as a grey suffix on every row
742
+ // was the version a person with two teams called impossible to follow.
743
+ let summary = NSMenuItem(title: state.member, action: nil, keyEquivalent: "")
744
+ summary.isEnabled = false
745
+ summary.view = HeaderView(lead: decisions.isEmpty ? "Nothing needs you" : "\(decisions.count) need\(decisions.count == 1 ? "s" : "") you",
746
+ rest: "\(state.member) · \(teams.count) teams · " + (state.relayOk ? "\(state.online) of \(state.members) online" : "connection unavailable"),
747
+ width: RowView.rowWidth)
748
+ menu.addItem(summary)
749
+ if !state.relayOk { info("Could not reach: \(state.unavailableSpaces.joined(separator: ", "))") }
750
+ menu.addItem(.separator())
751
+ for (index, team) in teams.enumerated() {
752
+ if index > 0 { menu.addItem(.separator()) }
753
+ let rows = state.items.filter { $0.space == team }
754
+ let ordered = rows.filter { $0.kind == "decision" } + rows.filter { $0.kind == "owed" } + rows.filter { $0.kind == "waiting" }
755
+ if state.unavailableSpaces.contains(team) {
756
+ let h = NSMenuItem(title: team, action: nil, keyEquivalent: ""); h.isEnabled = false
757
+ h.view = HeaderView(lead: team, rest: "could not be checked", width: RowView.rowWidth, bright: false); menu.addItem(h)
758
+ } else if ordered.isEmpty {
759
+ let h = NSMenuItem(title: team, action: nil, keyEquivalent: ""); h.isEnabled = false
760
+ h.view = HeaderView(lead: team, rest: "nothing open", width: RowView.rowWidth, bright: false); menu.addItem(h)
761
+ } else {
762
+ addRows(menu, team, ordered, team: true, detail: counts(ordered))
763
+ }
764
+ }
765
+ if !decisions.isEmpty {
766
+ menu.addItem(.separator())
767
+ menu.addItem(makeItem(decisions.count > 1 ? "Review all \(decisions.count) in \(state.surfaceName)" : "Review it in \(state.surfaceName)", #selector(openAllDecisions)))
768
+ }
769
+ } else {
427
770
  info("\(state.member) · \(state.space)")
428
771
  info(state.relayOk
429
772
  ? "Connected · \(state.online) of \(state.members) members online"
@@ -433,9 +776,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
433
776
  info("Some of this could not be checked")
434
777
  info("Refresh to try again — the list may be incomplete")
435
778
  }
436
- let decisions = state.items.filter { $0.kind == "decision" }
437
- let owed = state.items.filter { $0.kind == "owed" }
438
- let waiting = state.items.filter { $0.kind == "waiting" }
439
779
  if state.items.isEmpty {
440
780
  if state.decisionsOk { info("Nothing open — nothing needs you, nothing owed either way") }
441
781
  } else {
@@ -451,6 +791,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
451
791
  menu.addItem(makeItem(decisions.count > 1 ? "Review all \(decisions.count) in \(state.surfaceName)" : "Review it in \(state.surfaceName)", #selector(openAllDecisions)))
452
792
  }
453
793
  }
794
+ }
454
795
  menu.addItem(.separator())
455
796
  info(state.workerRunning ? "Unattended work is on" : state.workerInstalled ? "Unattended work is paused" : "Unattended work is not set up")
456
797
  if !state.workerState.isEmpty && state.workerRunning { info(state.workerState) }
@@ -856,30 +1197,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
856
1197
 
857
1198
  /// A titled group of rows. Six at most: a menu is for triage, and anything
858
1199
  /// deeper belongs in `pantheon brief`, which the last row opens.
859
- private func addRows(_ menu: NSMenu, _ title: String, _ items: [Item]) {
1200
+ private func addRows(_ menu: NSMenu, _ title: String, _ items: [Item], team: Bool = false, detail: String = "") {
860
1201
  guard !items.isEmpty else { return }
861
1202
  let header = NSMenuItem(title: title, action: nil, keyEquivalent: "")
862
1203
  header.isEnabled = false
863
- header.attributedTitle = NSAttributedString(string: title, attributes: [
864
- .font: NSFont.systemFont(ofSize: 11, weight: .semibold),
865
- .foregroundColor: NSColor.secondaryLabelColor,
866
- ])
1204
+ if team {
1205
+ // A team heading is drawn by us: the name in the reading colour, the counts
1206
+ // a step down — a disabled item would render both as the dimmest text here.
1207
+ header.view = HeaderView(lead: title, rest: detail, width: RowView.rowWidth)
1208
+ } else {
1209
+ header.attributedTitle = NSAttributedString(string: title, attributes: [
1210
+ .font: NSFont.systemFont(ofSize: 11, weight: .semibold),
1211
+ .foregroundColor: NSColor.secondaryLabelColor,
1212
+ ])
1213
+ }
867
1214
  menu.addItem(header)
868
- for item in items.prefix(6) {
1215
+ for item in items.prefix(team ? 8 : 6) {
869
1216
  let mi = NSMenuItem(title: item.title, action: nil, keyEquivalent: "")
870
1217
  mi.toolTip = item.detail
871
- mi.view = RowView(item: item, width: RowView.rowWidth,
1218
+ mi.view = RowView(item: item, width: RowView.rowWidth, inTeamSection: team,
872
1219
  onOpen: { [weak self] it in self?.showDetail(it) },
873
1220
  onCopy: { [weak self] it in self?.copyPrompt(it) })
874
1221
  menu.addItem(mi)
875
1222
  }
876
- if items.count > 6 {
877
- let more = NSMenuItem(title: "…and \(items.count - 6) more", action: nil, keyEquivalent: "")
1223
+ let shown = team ? 8 : 6
1224
+ if items.count > shown {
1225
+ let more = NSMenuItem(title: "…and \(items.count - shown) more", action: nil, keyEquivalent: "")
878
1226
  more.isEnabled = false
879
1227
  menu.addItem(more)
880
1228
  }
881
1229
  }
882
1230
 
1231
+ /// "5 need you · 1 owed · 2 waiting" — only the parts that are not zero.
1232
+ private func counts(_ rows: [Item]) -> String {
1233
+ let need = rows.filter { $0.kind == "decision" }.count
1234
+ let owed = rows.filter { $0.kind == "owed" }.count
1235
+ let waiting = rows.filter { $0.kind == "waiting" }.count
1236
+ var parts: [String] = []
1237
+ if need > 0 { parts.append("\(need) need\(need == 1 ? "s" : "") you") }
1238
+ if owed > 0 { parts.append("\(owed) owed") }
1239
+ if waiting > 0 { parts.append("\(waiting) waiting") }
1240
+ return parts.joined(separator: " · ")
1241
+ }
1242
+
883
1243
  private func makeItem(_ title: String, _ sel: Selector) -> NSMenuItem {
884
1244
  let mi = NSMenuItem(title: title, action: sel, keyEquivalent: "")
885
1245
  mi.target = self
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@join-pantheon/cli",
3
- "version": "0.25.2",
3
+ "version": "0.26.0",
4
4
  "description": "Let your coding agent collaborate directly with your teammates' agents — ask, agree an interface, and deliver, without a human relaying messages.",
5
5
  "type": "module",
6
6
  "bin": {