@join-pantheon/cli 0.25.1 → 0.25.3

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,9 +186,24 @@ final class RowView: NSView {
182
186
  private var copied = false
183
187
 
184
188
  private let titleLines: Int
185
- /// The menu is the board people read every day; 430 wrapped a plain sentence
186
- /// onto two lines and truncated a decision. 520 carries ~40% more per line.
187
- static let rowWidth: CGFloat = 520
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
195
+ /// The row's MINIMUM width — the menu is at least this wide. The row then
196
+ /// stretches to the menu's real width (macOS adds its own trailing column
197
+ /// for submenu chevrons), so the copy control sits at the edge instead of
198
+ /// floating fifty points left of it over a blank gutter, which is what a
199
+ /// fixed-width row did at every width we tried.
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?
188
207
  private static let titleFont = NSFont.systemFont(ofSize: 13, weight: .medium)
189
208
  private static let lineHeight: CGFloat = 17
190
209
 
@@ -204,15 +223,19 @@ final class RowView: NSView {
204
223
  return max(1, min(3, Int(ceil(h / lineHeight))))
205
224
  }
206
225
 
207
- 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) {
208
227
  self.item = item
209
228
  self.onOpen = onOpen
210
229
  self.onCopy = onCopy
211
- 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))
212
234
  let textHeight = CGFloat(titleLines) * RowView.lineHeight
213
- 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)))
236
+ autoresizingMask = [.width] // fill the menu: the text runs to the control, the control to the edge
214
237
  setAccessibilityRole(.button)
215
- 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.")
216
239
  }
217
240
  required init?(coder: NSCoder) { return nil }
218
241
 
@@ -261,21 +284,33 @@ final class RowView: NSView {
261
284
  para.lineSpacing = 0
262
285
  let flat = NSMutableParagraphStyle()
263
286
  flat.lineBreakMode = .byTruncatingTail
264
- let textWidth = bounds.width - RowView.textInsets
265
- let hasSub = !item.subtitle.isEmpty
266
- let titleHeight = CGFloat(titleLines) * RowView.lineHeight
267
- 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
268
297
  let cell = NSTextFieldCell() // gives the last visible line its ellipsis
269
298
  cell.attributedStringValue = NSAttributedString(string: item.title, attributes: [
270
299
  .font: RowView.titleFont, .foregroundColor: fg, .paragraphStyle: para,
271
300
  ])
272
301
  cell.truncatesLastVisibleLine = true
273
302
  cell.wraps = true
274
- 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)
275
304
  if hasSub {
276
- NSAttributedString(string: item.subtitle, attributes: [
305
+ NSAttributedString(string: sub, attributes: [
277
306
  .font: NSFont.systemFont(ofSize: 11), .foregroundColor: dim, .paragraphStyle: flat,
278
- ]).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()
279
314
  }
280
315
 
281
316
  // the copy control
@@ -303,6 +338,115 @@ final class RowView: NSView {
303
338
  }
304
339
  }
305
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
+
306
450
  final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
307
451
  private var item: NSStatusItem!
308
452
  private var state = TrayState()
@@ -318,7 +462,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
318
462
  item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
319
463
  let menu = NSMenu()
320
464
  menu.delegate = self
321
- 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])
322
470
  paint()
323
471
  refresh()
324
472
  timer = Timer.scheduledTimer(withTimeInterval: pollSeconds, repeats: true) { [weak self] _ in
@@ -337,8 +485,172 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
337
485
  self.refreshing = false
338
486
  self.paint()
339
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() }
340
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") }
341
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) }
342
654
  }
343
655
 
344
656
  // Keep targets stable while a person is navigating the menu. The badge can
@@ -420,6 +732,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
420
732
  info("Connect with your team")
421
733
  info("Run pantheon start, or join with an invite link")
422
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 {
423
770
  info("\(state.member) · \(state.space)")
424
771
  info(state.relayOk
425
772
  ? "Connected · \(state.online) of \(state.members) members online"
@@ -429,9 +776,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
429
776
  info("Some of this could not be checked")
430
777
  info("Refresh to try again — the list may be incomplete")
431
778
  }
432
- let decisions = state.items.filter { $0.kind == "decision" }
433
- let owed = state.items.filter { $0.kind == "owed" }
434
- let waiting = state.items.filter { $0.kind == "waiting" }
435
779
  if state.items.isEmpty {
436
780
  if state.decisionsOk { info("Nothing open — nothing needs you, nothing owed either way") }
437
781
  } else {
@@ -447,6 +791,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
447
791
  menu.addItem(makeItem(decisions.count > 1 ? "Review all \(decisions.count) in \(state.surfaceName)" : "Review it in \(state.surfaceName)", #selector(openAllDecisions)))
448
792
  }
449
793
  }
794
+ }
450
795
  menu.addItem(.separator())
451
796
  info(state.workerRunning ? "Unattended work is on" : state.workerInstalled ? "Unattended work is paused" : "Unattended work is not set up")
452
797
  if !state.workerState.isEmpty && state.workerRunning { info(state.workerState) }
@@ -852,30 +1197,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
852
1197
 
853
1198
  /// A titled group of rows. Six at most: a menu is for triage, and anything
854
1199
  /// deeper belongs in `pantheon brief`, which the last row opens.
855
- private func addRows(_ menu: NSMenu, _ title: String, _ items: [Item]) {
1200
+ private func addRows(_ menu: NSMenu, _ title: String, _ items: [Item], team: Bool = false, detail: String = "") {
856
1201
  guard !items.isEmpty else { return }
857
1202
  let header = NSMenuItem(title: title, action: nil, keyEquivalent: "")
858
1203
  header.isEnabled = false
859
- header.attributedTitle = NSAttributedString(string: title, attributes: [
860
- .font: NSFont.systemFont(ofSize: 11, weight: .semibold),
861
- .foregroundColor: NSColor.secondaryLabelColor,
862
- ])
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
+ }
863
1214
  menu.addItem(header)
864
- for item in items.prefix(6) {
1215
+ for item in items.prefix(team ? 8 : 6) {
865
1216
  let mi = NSMenuItem(title: item.title, action: nil, keyEquivalent: "")
866
1217
  mi.toolTip = item.detail
867
- mi.view = RowView(item: item, width: RowView.rowWidth,
1218
+ mi.view = RowView(item: item, width: RowView.rowWidth, inTeamSection: team,
868
1219
  onOpen: { [weak self] it in self?.showDetail(it) },
869
1220
  onCopy: { [weak self] it in self?.copyPrompt(it) })
870
1221
  menu.addItem(mi)
871
1222
  }
872
- if items.count > 6 {
873
- 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: "")
874
1226
  more.isEnabled = false
875
1227
  menu.addItem(more)
876
1228
  }
877
1229
  }
878
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
+
879
1243
  private func makeItem(_ title: String, _ sel: Selector) -> NSMenuItem {
880
1244
  let mi = NSMenuItem(title: title, action: sel, keyEquivalent: "")
881
1245
  mi.target = self
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@join-pantheon/cli",
3
- "version": "0.25.1",
3
+ "version": "0.25.3",
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": {