@join-pantheon/cli 0.31.0 → 0.31.2-rc.1

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.
@@ -26,7 +26,11 @@ guard argv.count >= 2 else {
26
26
  fputs("usage: pantheon-tray <path-to-pantheon> [poll-seconds]\n", stderr)
27
27
  exit(64)
28
28
  }
29
- let pantheonBin = argv[1]
29
+ /// `pantheon-tray render <path-to-pantheon> <out.png>`: draw the board from tray-status into
30
+ /// a PNG and exit — the layout can be checked without anyone clicking the icon.
31
+ /// PANTHEON_TRAY_OPEN="handling,waiting,…" opens those counted lines in the picture.
32
+ let renderTo: String? = argv.count >= 4 && argv[1] == "render" ? argv[3] : nil
33
+ let pantheonBin = renderTo != nil ? argv[2] : argv[1]
30
34
  let pollSeconds = argv.count >= 3 ? (Double(argv[2]) ?? 15) : 15
31
35
 
32
36
  struct TrayState {
@@ -72,8 +76,21 @@ struct Item {
72
76
  let decider: String
73
77
  /// one-click answers for a question this machine asked its own person (may a teammate wake it, may runs use commands)
74
78
  var choices: [(label: String, answer: String)] = []
79
+ /// where the board draws it (tray.ts arrangeBoard): now (a card), handling, waiting, older
80
+ var lane: String = ""
81
+ /// what else this card stands for — the teammate's request a decision is holding up
82
+ var context: String = ""
83
+ /// a session of this person's has claimed it (handling: an agent is on it, not just queued)
84
+ var claimed: Bool = false
85
+ /// a delivery this person's agent committed to — theirs, not a teammate's request
86
+ var promise: Bool = false
75
87
 
76
88
  var isDecision: Bool { kind == "decision" }
89
+ /// A tray-status from before lanes — or with a lane this helper does not know —
90
+ /// draws as it did before lanes: a decision or owed row is a card, a wait is not.
91
+ /// An unknown lane must never hide a row.
92
+ var effectiveLane: String { Item.lanes.contains(lane) ? lane : (kind == "waiting" ? "waiting" : "now") }
93
+ static let lanes: Set<String> = ["now", "handling", "waiting", "older"]
77
94
  var heading: String {
78
95
  switch kind {
79
96
  case "decision": return "Needs your decision"
@@ -87,10 +104,17 @@ struct Item {
87
104
  var parts: [String] = []
88
105
  // Who is asking, on every kind. A decision row used to say only "11h ago",
89
106
  // which is the one fact a reader could already guess.
90
- if kind == "owed" { parts.append("you owe") } else if !who.isEmpty { parts.append(who) }
107
+ // Who has it, by where the board put it: a card is on this person; a handled row is an agent's.
108
+ if kind == "owed" {
109
+ parts.append(promise ? "your agent committed to it" : effectiveLane == "handling" ? (claimed ? "your agent is on it" : "your worker picks it up") : "waiting on you")
110
+ } else if !who.isEmpty { parts.append(who) }
91
111
  if when > 0 { parts.append(Item.age(when)) }
92
112
  if showSpace && !space.isEmpty { parts.append(space) }
93
- return parts.joined(separator: " · ")
113
+ let head = parts.joined(separator: " · ")
114
+ // The request a decision is holding up, said once, after who and when.
115
+ guard !context.isEmpty else { return head }
116
+ let c = context.count > 70 ? String(context.prefix(69)) + "…" : context
117
+ return head + " · for: " + c
94
118
  }
95
119
  static func age(_ ms: Double) -> String {
96
120
  let secs = max(0, Date().timeIntervalSince1970 - ms / 1000)
@@ -161,7 +185,11 @@ func fetchState() -> TrayState {
161
185
  choices: (i["choices"] as? [[String: Any]] ?? []).compactMap { c in
162
186
  guard let l = c["label"] as? String, let a = c["answer"] as? String else { return nil }
163
187
  return (label: l, answer: a)
164
- }
188
+ },
189
+ lane: i["lane"] as? String ?? "",
190
+ context: i["context"] as? String ?? "",
191
+ claimed: i["claimed"] as? Bool ?? false,
192
+ promise: i["promise"] as? Bool ?? false
165
193
  ))
166
194
  }
167
195
  for d in (j["decisions"] as? [[String: Any]] ?? []) {
@@ -186,6 +214,29 @@ func fetchState() -> TrayState {
186
214
  * loop, and hit-testing two zones in one view is far more predictable there than
187
215
  * nesting buttons inside an NSMenuItem.
188
216
  */
217
+ /// "Later": a card put away until tomorrow morning, on this machine only — a
218
+ /// view preference, never an answer. Kept in the menu bar's own defaults.
219
+ enum Later {
220
+ private static let key = "pantheon.later"
221
+ /// The ids put away and not yet due, with when they come back (seconds since 1970).
222
+ static func active() -> [String: Double] {
223
+ let now = Date().timeIntervalSince1970
224
+ let all = (UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:]
225
+ let live = all.filter { $0.value > now }
226
+ if live.count != all.count { UserDefaults.standard.set(live, forKey: key) }
227
+ return live
228
+ }
229
+ /// Back at 9:00 tomorrow, local time.
230
+ static func snooze(_ id: String) {
231
+ let cal = Calendar.current
232
+ let tomorrow = cal.date(byAdding: .day, value: 1, to: Date()) ?? Date().addingTimeInterval(86_400)
233
+ let at = cal.date(bySettingHour: 9, minute: 0, second: 0, of: tomorrow) ?? tomorrow
234
+ var all = active(); all[id] = at.timeIntervalSince1970
235
+ UserDefaults.standard.set(all, forKey: key)
236
+ }
237
+ static func bring(_ id: String) { var all = active(); all.removeValue(forKey: id); UserDefaults.standard.set(all, forKey: key) }
238
+ }
239
+
189
240
  final class RowView: NSView {
190
241
  private let item: Item
191
242
  private let onOpen: (Item) -> Void
@@ -232,13 +283,13 @@ final class RowView: NSView {
232
283
  return max(1, min(3, Int(ceil(h / lineHeight))))
233
284
  }
234
285
 
235
- init(item: Item, width: CGFloat, inTeamSection: Bool = false, onOpen: @escaping (Item) -> Void, onCopy: @escaping (Item) -> Void) {
286
+ init(item: Item, width: CGFloat, inTeamSection: Bool = false, showSpace: Bool? = nil, onOpen: @escaping (Item) -> Void, onCopy: @escaping (Item) -> Void) {
236
287
  self.item = item
237
288
  self.onOpen = onOpen
238
289
  self.onCopy = onCopy
239
290
  self.leading = inTeamSection ? 26 : 12
240
291
  self.dot = inTeamSection && item.isDecision
241
- self.sub = item.subtitle(showSpace: !inTeamSection)
292
+ self.sub = item.subtitle(showSpace: showSpace ?? !inTeamSection)
242
293
  self.titleLines = RowView.lines(for: item.title, width: (RowView.knownWidth ?? width) - RowView.textInsets - (leading - 12))
243
294
  let textHeight = CGFloat(titleLines) * RowView.lineHeight
244
295
  super.init(frame: NSRect(x: 0, y: 0, width: width, height: textHeight + (sub.isEmpty ? 13 : 29)))
@@ -379,6 +430,48 @@ final class HeaderView: NSView {
379
430
  }
380
431
  }
381
432
 
433
+ /// A team's section on a board that spans several teams: a mark in the team's own
434
+ /// colour, its name, and how much of it needs this person — so each team reads as
435
+ /// its own block, not a suffix on every row.
436
+ final class BoardSpacer: NSView {
437
+ init(height: CGFloat, width: CGFloat) { super.init(frame: NSRect(x: 0, y: 0, width: width, height: height)); autoresizingMask = [.width] }
438
+ required init?(coder: NSCoder) { return nil }
439
+ }
440
+
441
+ final class TeamHeaderView: NSView {
442
+ private let name: String
443
+ private let detail: String
444
+ private let tint: NSColor
445
+ init(name: String, detail: String, tint: NSColor, width: CGFloat) {
446
+ self.name = name; self.detail = detail; self.tint = tint
447
+ super.init(frame: NSRect(x: 0, y: 0, width: width, height: 30))
448
+ autoresizingMask = [.width]
449
+ setAccessibilityRole(.staticText)
450
+ setAccessibilityLabel("\(name): \(detail)")
451
+ }
452
+ required init?(coder: NSCoder) { return nil }
453
+ static let tints: [NSColor] = [
454
+ NSColor(calibratedRed: 0.32, green: 0.74, blue: 0.76, alpha: 1),
455
+ NSColor(calibratedRed: 0.85, green: 0.74, blue: 0.42, alpha: 1),
456
+ NSColor(calibratedRed: 0.89, green: 0.57, blue: 0.42, alpha: 1),
457
+ NSColor(calibratedRed: 0.62, green: 0.56, blue: 0.86, alpha: 1),
458
+ NSColor(calibratedRed: 0.52, green: 0.78, blue: 0.52, alpha: 1),
459
+ ]
460
+ override func draw(_ dirtyRect: NSRect) {
461
+ let band = NSRect(x: 6, y: bounds.minY + 2, width: bounds.width - 12, height: bounds.height - 4)
462
+ tint.withAlphaComponent(0.10).setFill()
463
+ NSBezierPath(roundedRect: band, xRadius: 6, yRadius: 6).fill()
464
+ tint.setFill()
465
+ NSBezierPath(roundedRect: NSRect(x: 12, y: bounds.midY - 6, width: 4, height: 12), xRadius: 2, yRadius: 2).fill()
466
+ let para = NSMutableParagraphStyle(); para.lineBreakMode = .byTruncatingTail
467
+ NSAttributedString(string: name.uppercased(), attributes: [.font: NSFont.systemFont(ofSize: 11.5, weight: .bold), .kern: 0.6, .foregroundColor: NSColor.labelColor, .paragraphStyle: para])
468
+ .draw(in: NSRect(x: 24, y: bounds.midY - 8, width: bounds.width * 0.6, height: 16))
469
+ let r = NSMutableParagraphStyle(); r.alignment = .right
470
+ NSAttributedString(string: detail, attributes: [.font: NSFont.systemFont(ofSize: 12), .foregroundColor: NSColor.secondaryLabelColor, .paragraphStyle: r])
471
+ .draw(in: NSRect(x: bounds.maxX - 14 - 220, y: bounds.midY - 8, width: 220, height: 16))
472
+ }
473
+ }
474
+
382
475
  /*
383
476
  * The board: an opaque, dark panel under the icon.
384
477
  *
@@ -469,6 +562,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
469
562
  func applicationDidFinishLaunching(_ note: Notification) {
470
563
  NSApp.setActivationPolicy(.accessory)
471
564
  item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
565
+ // (After the item exists: the board reads its button for placement.)
566
+ if let out = renderTo { renderBoard(to: out); exit(0) }
472
567
  let menu = NSMenu()
473
568
  menu.delegate = self
474
569
  classicMenu = menu
@@ -519,12 +614,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
519
614
 
520
615
  private var classicMenu: NSMenu?
521
616
  private var boardPanel: BoardPanel?
617
+ /// which of the board's counted lines are open ("handling", "waiting", "older", "later", "more")
618
+ private var boardOpen: Set<String> = []
522
619
  private var boardSignature = ""
523
620
  private var boardMonitor: Any?
524
621
  static let boardWidth: CGFloat = 470
525
622
 
623
+ /// The cards: what needs this person now, less what they put away for later.
624
+ /// The badge, the tooltip and both boards' headers all count these, so they agree.
625
+ private var boardCards: [Item] {
626
+ let later = Later.active()
627
+ return state.items.filter { $0.effectiveLane == "now" && later[$0.id] == nil }
628
+ }
629
+
526
630
  private func signature() -> String {
527
- "\(state.items.map { $0.id }.joined(separator: ","))|\(state.decisions.count)|\(state.online)|\(state.members)|\(state.workerRunning)|\(state.relayOk)|\(actionError ?? "")|\(workerAction ?? "")"
631
+ // Everything the board draws from, not just which ids: a row moving lane (out
632
+ // of the worker's window), a new repeat, a claim, or a Later coming due all
633
+ // change what an open board must show.
634
+ "\(state.items.map { "\($0.id):\($0.effectiveLane):\($0.claimed):\($0.context)" }.joined(separator: ","))|\(Later.active().keys.sorted().joined(separator: ","))|\(state.decisions.count)|\(state.online)|\(state.members)|\(state.workerRunning)|\(state.relayOk)|\(actionError ?? "")|\(workerAction ?? "")"
528
635
  }
529
636
 
530
637
  @objc private func iconClicked(_ sender: Any?) {
@@ -557,6 +664,54 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
557
664
  item.button?.highlight(false)
558
665
  }
559
666
 
667
+ /// One card as a row of the classic menu.
668
+ private func menuRow(_ it: Item, showSpace: Bool) -> NSMenuItem {
669
+ let mi = NSMenuItem(title: it.title, action: nil, keyEquivalent: "")
670
+ mi.toolTip = it.detail
671
+ mi.view = RowView(item: it, width: RowView.rowWidth, showSpace: showSpace,
672
+ onOpen: { [weak self] x in self?.showDetail(x) },
673
+ onCopy: { [weak self] x in self?.copyPrompt(x) })
674
+ return mi
675
+ }
676
+
677
+ /// Open or close one of the board's counted lines, and redraw.
678
+ private func toggleBoardLine(_ key: String) {
679
+ if boardOpen.contains(key) { boardOpen.remove(key) } else { boardOpen.insert(key) }
680
+ rebuildBoard()
681
+ }
682
+
683
+ /// The board as it would open now, drawn into a PNG (render mode, above).
684
+ func renderBoard(to path: String) {
685
+ state = fetchState()
686
+ hasLoaded = true
687
+ boardOpen = Set((ProcessInfo.processInfo.environment["PANTHEON_TRAY_OPEN"] ?? "").split(separator: ",").map(String.init))
688
+ let panel = makeBoardPanel()
689
+ boardPanel = panel
690
+ rebuildBoard()
691
+ var target: NSView? = panel.contentView
692
+ // PANTHEON_TRAY_PANEL=sent draws the first card's detail panel just after a
693
+ // reply was sent — the state that overlapped (2 Oct) — instead of the board.
694
+ if ProcessInfo.processInfo.environment["PANTHEON_TRAY_PANEL"] == "sent", let first = state.items.first(where: { $0.isDecision }) {
695
+ showDetail(first)
696
+ panelReplyManually()
697
+ setStatus("Answered — it is no longer waiting on you.")
698
+ exitReplyMode()
699
+ panelRetract?.isHidden = false
700
+ panelStatusRow?.isHidden = false
701
+ refitPanel()
702
+ target = detailPanel?.contentView
703
+ }
704
+ // PANTHEON_TRAY_PANEL=open draws the first card's panel once its full text has loaded.
705
+ if ProcessInfo.processInfo.environment["PANTHEON_TRAY_PANEL"] == "open", let first = state.items.first(where: { $0.isDecision }) {
706
+ showDetail(first)
707
+ RunLoop.main.run(until: Date().addingTimeInterval(2.5))
708
+ target = detailPanel?.contentView
709
+ }
710
+ guard let view = target, let rep = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return }
711
+ view.cacheDisplay(in: view.bounds, to: rep)
712
+ try? rep.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path))
713
+ }
714
+
560
715
  private func makeBoardPanel() -> BoardPanel {
561
716
  let panel = BoardPanel(contentRect: NSRect(x: 0, y: 0, width: AppDelegate.boardWidth, height: 200),
562
717
  styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
@@ -595,52 +750,69 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
595
750
  let sep = { rows.append(BoardSeparator(width: w)) }
596
751
  let openRow: (Item) -> Void = { [weak self] it in self?.closeBoard(); self?.showDetail(it) }
597
752
  let copyRow: (Item) -> Void = { [weak self] it in self?.copyPrompt(it) }
598
- let section = { (title: String, detail: String, items: [Item], team: Bool) in
599
- guard !items.isEmpty else { return }
600
- rows.append(HeaderView(lead: title, rest: detail, width: w, bright: team))
601
- for it in items.prefix(team ? 8 : 6) { rows.append(RowView(item: it, width: w, inTeamSection: team, onOpen: openRow, onCopy: copyRow)) }
602
- if items.count > (team ? 8 : 6) { rows.append(ActionRow("…and \(items.count - (team ? 8 : 6)) more", width: w)) }
603
- }
604
-
605
753
  if !hasLoaded {
606
754
  note("Checking Pantheon…")
607
755
  } else if !state.reachable {
608
- note("Unable to check Pantheon status"); note("Try Refresh now, or Check connection below")
756
+ note("Unable to check Pantheon status"); note("Open More below: Refresh now, or Check connection")
609
757
  } else if !state.configured {
610
758
  note("Connect with your team"); note("Run pantheon start, or join with an invite link")
611
759
  } else {
612
- let teams = state.spaces.count > 1 ? state.spaces : []
613
- let decisions = state.items.filter { $0.kind == "decision" }
614
- let owed = state.items.filter { $0.kind == "owed" }
615
- let waiting = state.items.filter { $0.kind == "waiting" }
616
- let need = decisions.isEmpty ? "Nothing needs you" : "\(decisions.count) need\(decisions.count == 1 ? "s" : "") you"
617
- let where_ = teams.isEmpty ? state.space : "\(teams.count) teams"
760
+ // One board, no noise (tray.ts arrangeBoard): every card is something this
761
+ // person has to do, each once, across every team; the rest is one line each,
762
+ // opened on demand. No cap — nothing that needs them is hidden.
763
+ let multi = state.spaces.count > 1
764
+ let later = Later.active()
765
+ let cards = boardCards
766
+ let putAway = state.items.filter { $0.effectiveLane == "now" && later[$0.id] != nil }
767
+ let handling = state.items.filter { $0.effectiveLane == "handling" }
768
+ let waiting = state.items.filter { $0.effectiveLane == "waiting" }
769
+ let older = state.items.filter { $0.effectiveLane == "older" }
770
+ let need = cards.isEmpty ? "Nothing needs you" : "\(cards.count) need\(cards.count == 1 ? "s" : "") you"
771
+ let where_ = multi ? "\(state.spaces.count) teams" : state.space
618
772
  rows.append(HeaderView(lead: need, rest: "\(state.member) · \(where_) · " + (state.relayOk ? "\(state.online) of \(state.members) online" : "connection unavailable"), width: w))
619
773
  if !state.relayOk { note("Could not reach: \(state.unavailableSpaces.joined(separator: ", "))") }
620
774
  if !state.decisionsOk { note("Some of this could not be checked — the list may be incomplete") }
621
775
  sep()
622
- if !teams.isEmpty {
623
- for (i, team) in teams.enumerated() {
624
- if i > 0 { sep() }
625
- let mine = state.items.filter { $0.space == team }
626
- let ordered = mine.filter { $0.kind == "decision" } + mine.filter { $0.kind == "owed" } + mine.filter { $0.kind == "waiting" }
627
- if state.unavailableSpaces.contains(team) { rows.append(HeaderView(lead: team, rest: "could not be checked", width: w, bright: false)) }
628
- else if ordered.isEmpty { rows.append(HeaderView(lead: team, rest: "nothing open", width: w, bright: false)) }
629
- else { section(team, counts(ordered), ordered, true) }
776
+ // The heading already says nothing needs you: said again only what it does not.
777
+ if cards.isEmpty && state.decisionsOk && handling.isEmpty && waiting.isEmpty && older.isEmpty && putAway.isEmpty {
778
+ note("All clear — nothing open for your agents either")
779
+ }
780
+ // One block per team when there are several (Sid, 2 Oct: "it should
781
+ // differentiate each space — here it's all dumped together"): the team's
782
+ // header in its own colour, its cards, then its own counted lines. One team
783
+ // keeps the plain board. (Plain loops: a local closure capturing both `rows`
784
+ // and a weak self crashed the optimised build.)
785
+ let teams: [String] = multi ? state.spaces : [""]
786
+ for (ti, team) in teams.enumerated() {
787
+ let mine = { (xs: [Item]) in multi ? xs.filter { $0.space == team } : xs }
788
+ let tCards = mine(cards), tHandling = mine(handling), tWaiting = mine(waiting), tOlder = mine(older), tLater = mine(putAway)
789
+ if multi {
790
+ let restCount = tHandling.count + tWaiting.count + tOlder.count + tLater.count
791
+ let detail = !tCards.isEmpty ? "\(tCards.count) need\(tCards.count == 1 ? "s" : "") you" : restCount > 0 ? "nothing needs you" : "all clear"
792
+ if ti > 0 { rows.append(BoardSpacer(height: 6, width: w)) }
793
+ rows.append(TeamHeaderView(name: team, detail: detail, tint: TeamHeaderView.tints[ti % TeamHeaderView.tints.count], width: w))
630
794
  }
631
- } else if state.items.isEmpty {
632
- if state.decisionsOk { note("Nothing open — nothing needs you, nothing owed either way") }
633
- } else {
634
- section(decisions.count == 1 ? "1 decision needs you" : "\(decisions.count) decisions need you", "", decisions, false)
635
- section(owed.count == 1 ? "You owe 1 thing" : "You owe \(owed.count) things", "", owed, false)
636
- for who in orderedPeers(waiting) {
637
- let theirs = waiting.filter { $0.who == who }
638
- section(theirs.count == 1 ? "Waiting on \(who)" : "Waiting on \(who) · \(theirs.count)", "", theirs, false)
795
+ for it in tCards { rows.append(RowView(item: it, width: w, showSpace: false, onOpen: openRow, onCopy: copyRow)) }
796
+ let lanes: [(key: String, title: String, items: [Item])] = [
797
+ ("handling", "Your agents are handling · \(tHandling.count)", tHandling),
798
+ ("waiting", "Waiting on teammates · \(tWaiting.count)", tWaiting),
799
+ ("older", "Older than a week · \(tOlder.count)", tOlder),
800
+ ("later", "Later · \(tLater.count)", tLater),
801
+ ]
802
+ if !multi && lanes.contains(where: { !$0.items.isEmpty }) { sep() }
803
+ for lane in lanes where !lane.items.isEmpty {
804
+ let key = multi ? "\(lane.key)|\(team)" : lane.key
805
+ let open = boardOpen.contains(key)
806
+ rows.append(ActionRow(lane.title, hint: open ? "hide" : "show", width: w, onClick: { [weak self] in self?.toggleBoardLine(key) }))
807
+ // Indented under their line, so an opened lane never reads as more cards.
808
+ if open { for it in lane.items { rows.append(RowView(item: it, width: w, inTeamSection: true, showSpace: false, onOpen: openRow, onCopy: copyRow)) } }
639
809
  }
640
810
  }
641
- if !decisions.isEmpty {
811
+ // The prompt it hands over asks about every question waiting on this person,
812
+ // put away or not, so the count is of all of them.
813
+ if state.decisions.count > 1 {
642
814
  sep()
643
- act(decisions.count > 1 ? "Review all \(decisions.count) in \(state.surfaceName)" : "Review it in \(state.surfaceName)", "") { [weak self] in self?.openAllDecisions() }
815
+ act("Review all \(state.decisions.count) questions in \(state.surfaceName)", "") { [weak self] in self?.openAllDecisions() }
644
816
  }
645
817
  sep()
646
818
  if let action = workerAction { note(action) }
@@ -650,12 +822,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
650
822
  }
651
823
  if let error = actionError { sep(); note(error) }
652
824
  sep()
653
- act("Open status in Terminal", "") { [weak self] in self?.openStatus() }
654
- act("Check connection in Terminal", "version \(state.version)") { [weak self] in self?.openDoctor() }
655
- act("View decision details in Terminal", "") { [weak self] in self?.openApprovals() }
656
- act("Refresh now", "") { [weak self] in self?.refresh() }
657
- sep()
658
- act("Quit pantheon menu bar", "") { [weak self] in self?.quit() }
825
+ // The terminal tools, out of the way until asked for.
826
+ let moreOpen = boardOpen.contains("more")
827
+ rows.append(ActionRow("More", hint: moreOpen ? "hide" : "version \(state.version)", width: w, onClick: { [weak self] in self?.toggleBoardLine("more") }))
828
+ if moreOpen {
829
+ act("Open status in Terminal", "") { [weak self] in self?.openStatus() }
830
+ act("Check connection in Terminal", "version \(state.version)") { [weak self] in self?.openDoctor() }
831
+ act("View decision details in Terminal", "") { [weak self] in self?.openApprovals() }
832
+ act("Refresh now", "") { [weak self] in self?.refresh() }
833
+ act("Quit pantheon menu bar", "") { [weak self] in self?.quit() }
834
+ }
659
835
 
660
836
  // Lay the rows out top to bottom at their own heights, in a card no taller than the screen.
661
837
  let total = rows.reduce(CGFloat(16)) { $0 + $1.frame.height }
@@ -728,9 +904,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
728
904
  let mode: MarkMode = !state.configured || !state.relayOk ? .off : state.workerRunning ? .full : .idle
729
905
  item.button?.image = markImage(mode)
730
906
  let unknown = hasLoaded && (!state.reachable || (state.configured && !state.decisionsOk))
731
- item.button?.title = actionError != nil ? " !" : state.decisions.isEmpty ? (unknown ? " !" : "") : " \(state.decisions.count)\(unknown ? "+" : "")"
907
+ let need = hasLoaded ? boardCards.count : 0
908
+ item.button?.title = actionError != nil ? " !" : need == 0 ? (unknown ? " !" : "") : " \(need)\(unknown ? "+" : "")"
732
909
  item.button?.imagePosition = .imageLeading
733
- let summary = !hasLoaded ? "Checking status…" : !state.reachable ? "Unable to check status" : !state.configured ? "Not joined to a team yet" : !state.decisionsOk ? "Unable to check every decision" : state.decisions.isEmpty ? "No decisions need your input" : "\(state.decisions.count) decisions need your input"
910
+ let summary = !hasLoaded ? "Checking status…" : !state.reachable ? "Unable to check status" : !state.configured ? "Not joined to a team yet" : !state.decisionsOk ? "Unable to check every decision" : need == 0 ? "Nothing needs you" : "\(need) need\(need == 1 ? "s" : "") you"
734
911
  item.button?.toolTip = "Pantheon — " + (actionError ?? summary)
735
912
  item.button?.setAccessibilityLabel("Pantheon — " + (actionError ?? summary))
736
913
  }
@@ -755,65 +932,46 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
755
932
  info("Connect with your team")
756
933
  info("Run pantheon start, or join with an invite link")
757
934
  } else {
758
- let teams = state.spaces.count > 1 ? state.spaces : []
759
- let decisions = state.items.filter { $0.kind == "decision" }
760
- let owed = state.items.filter { $0.kind == "owed" }
761
- let waiting = state.items.filter { $0.kind == "waiting" }
762
- if !teams.isEmpty {
763
- // Several teams: the board is drawn per team, and "what needs you" is read
764
- // off the dots. One mixed list with the team as a grey suffix on every row
765
- // was the version a person with two teams called impossible to follow.
766
- let summary = NSMenuItem(title: state.member, action: nil, keyEquivalent: "")
767
- summary.isEnabled = false
768
- summary.view = HeaderView(lead: decisions.isEmpty ? "Nothing needs you" : "\(decisions.count) need\(decisions.count == 1 ? "s" : "") you",
769
- rest: "\(state.member) · \(teams.count) teams · " + (state.relayOk ? "\(state.online) of \(state.members) online" : "connection unavailable"),
770
- width: RowView.rowWidth)
771
- menu.addItem(summary)
772
- if !state.relayOk { info("Could not reach: \(state.unavailableSpaces.joined(separator: ", "))") }
773
- menu.addItem(.separator())
774
- for (index, team) in teams.enumerated() {
775
- if index > 0 { menu.addItem(.separator()) }
776
- let rows = state.items.filter { $0.space == team }
777
- let ordered = rows.filter { $0.kind == "decision" } + rows.filter { $0.kind == "owed" } + rows.filter { $0.kind == "waiting" }
778
- if state.unavailableSpaces.contains(team) {
779
- let h = NSMenuItem(title: team, action: nil, keyEquivalent: ""); h.isEnabled = false
780
- h.view = HeaderView(lead: team, rest: "could not be checked", width: RowView.rowWidth, bright: false); menu.addItem(h)
781
- } else if ordered.isEmpty {
782
- let h = NSMenuItem(title: team, action: nil, keyEquivalent: ""); h.isEnabled = false
783
- h.view = HeaderView(lead: team, rest: "nothing open", width: RowView.rowWidth, bright: false); menu.addItem(h)
784
- } else {
785
- addRows(menu, team, ordered, team: true, detail: counts(ordered))
786
- }
787
- }
788
- if !decisions.isEmpty {
789
- menu.addItem(.separator())
790
- menu.addItem(makeItem(decisions.count > 1 ? "Review all \(decisions.count) in \(state.surfaceName)" : "Review it in \(state.surfaceName)", #selector(openAllDecisions)))
791
- }
792
- } else {
793
- info("\(state.member) · \(state.space)")
794
- info(state.relayOk
795
- ? "Connected · \(state.online) of \(state.members) members online"
796
- : "Connection unavailable · \(state.unavailableSpaces.joined(separator: ", "))")
935
+ // The same board as the panel (rebuildBoard), as a menu: the cards, then the
936
+ // rest as a submenu each.
937
+ let multi = state.spaces.count > 1
938
+ let later = Later.active()
939
+ let cards = boardCards
940
+ let putAway = state.items.filter { $0.effectiveLane == "now" && later[$0.id] != nil }
941
+ let handling = state.items.filter { $0.effectiveLane == "handling" }
942
+ let waiting = state.items.filter { $0.effectiveLane == "waiting" }
943
+ let older = state.items.filter { $0.effectiveLane == "older" }
944
+ let summary = NSMenuItem(title: state.member, action: nil, keyEquivalent: "")
945
+ summary.isEnabled = false
946
+ summary.view = HeaderView(lead: cards.isEmpty ? "Nothing needs you" : "\(cards.count) need\(cards.count == 1 ? "s" : "") you",
947
+ rest: "\(state.member) · " + (multi ? "\(state.spaces.count) teams" : state.space) + " · " + (state.relayOk ? "\(state.online) of \(state.members) online" : "connection unavailable"),
948
+ width: RowView.rowWidth)
949
+ menu.addItem(summary)
950
+ if !state.relayOk { info("Could not reach: \(state.unavailableSpaces.joined(separator: ", "))") }
951
+ if !state.decisionsOk { info("Some of this could not be checked — the list may be incomplete") }
797
952
  menu.addItem(.separator())
798
- if !state.decisionsOk {
799
- info("Some of this could not be checked")
800
- info("Refresh to try again — the list may be incomplete")
953
+ let row = { (it: Item) -> NSMenuItem in self.menuRow(it, showSpace: multi) }
954
+ for it in cards { menu.addItem(row(it)) }
955
+ if cards.isEmpty && state.decisionsOk && handling.isEmpty && waiting.isEmpty && older.isEmpty && putAway.isEmpty {
956
+ info("All clear — nothing open for your agents either")
801
957
  }
802
- if state.items.isEmpty {
803
- if state.decisionsOk { info("Nothing open — nothing needs you, nothing owed either way") }
804
- } else {
805
- addRows(menu, decisions.count == 1 ? "1 decision needs you" : "\(decisions.count) decisions need you", decisions)
806
- addRows(menu, owed.count == 1 ? "You owe 1 thing" : "You owe \(owed.count) things", owed)
807
- // Grouped by person, because "waiting on praneet" is what someone
808
- // actually wants to know before they chase it.
809
- for who in orderedPeers(waiting) {
810
- let theirs = waiting.filter { $0.who == who }
811
- addRows(menu, theirs.count == 1 ? "Waiting on \(who)" : "Waiting on \(who) · \(theirs.count)", theirs)
812
- }
813
- if !decisions.isEmpty {
814
- menu.addItem(makeItem(decisions.count > 1 ? "Review all \(decisions.count) in \(state.surfaceName)" : "Review it in \(state.surfaceName)", #selector(openAllDecisions)))
815
- }
958
+ let lanes: [(String, [Item])] = [
959
+ ("Your agents are handling · \(handling.count)", handling),
960
+ ("Waiting on teammates · \(waiting.count)", waiting),
961
+ ("Older than a week · \(older.count)", older),
962
+ ("Later · \(putAway.count)", putAway),
963
+ ].filter { !$0.1.isEmpty }
964
+ if !lanes.isEmpty { menu.addItem(.separator()) }
965
+ for (title, items) in lanes {
966
+ let mi = NSMenuItem(title: title, action: nil, keyEquivalent: "")
967
+ let sub = NSMenu()
968
+ for it in items { sub.addItem(row(it)) }
969
+ mi.submenu = sub
970
+ menu.addItem(mi)
816
971
  }
972
+ if state.decisions.count > 1 {
973
+ menu.addItem(.separator())
974
+ menu.addItem(makeItem("Review all \(state.decisions.count) questions in \(state.surfaceName)", #selector(openAllDecisions)))
817
975
  }
818
976
  menu.addItem(.separator())
819
977
  info(state.workerRunning ? "Unattended work is on" : state.workerInstalled ? "Unattended work is paused" : "Unattended work is not set up")
@@ -860,6 +1018,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
860
1018
  private var panelText: NSTextView?
861
1019
  private var panelBodyHeight: NSLayoutConstraint?
862
1020
  private var panelHeadline: NSTextField?
1021
+ private var panelEyebrow: NSTextField?
1022
+ /// The decision's own stated options ("(a) … (b) …"), as one-click answers.
1023
+ private var panelOptions: NSStackView?
1024
+ private var panelOptionList: [(key: String, text: String)] = []
863
1025
  private var panelRendered: NSAttributedString? // the act laid out for a person
864
1026
  private var panelRaw: String = "" // the act exactly as it was sent
865
1027
  private var panelShowingRaw = false
@@ -956,12 +1118,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
956
1118
  guard let tv = panelText else { return }
957
1119
  tv.textStorage?.setAttributedString(text)
958
1120
  panelBodyHeight?.constant = bodyHeight(text, width: width)
959
- if let p = detailPanel {
960
- p.contentView?.layoutSubtreeIfNeeded()
961
- let h = min(max((p.contentView?.fittingSize.height ?? 420) + 30, 300), 680)
962
- let f = p.frame
963
- p.setFrame(NSRect(x: f.minX, y: f.maxY - h, width: f.width, height: h), display: true, animate: false)
1121
+ refitPanel()
1122
+ }
1123
+
1124
+ /// The panel is measured again whenever a row shows or hides — the reply box,
1125
+ /// the status line, Retract, the buttons coming back. It was sized once, when it
1126
+ /// opened, so after a send the status row and the returning buttons outgrew it
1127
+ /// and AppKit drew them on top of each other (2 Oct). The top edge stays put; past
1128
+ /// the tallest panel, the body gives up height before anything can overlap.
1129
+ private func refitPanel() {
1130
+ guard let p = detailPanel, let v = p.contentView else { return }
1131
+ v.layoutSubtreeIfNeeded()
1132
+ var need = v.fittingSize.height + 30
1133
+ if need > 680, let bh = panelBodyHeight {
1134
+ bh.constant = max(80, bh.constant - (need - 680))
1135
+ v.layoutSubtreeIfNeeded()
1136
+ need = v.fittingSize.height + 30
964
1137
  }
1138
+ let h = min(max(need, 300), 680)
1139
+ let f = p.frame
1140
+ p.setFrame(NSRect(x: f.minX, y: f.maxY - h, width: f.width, height: h), display: true, animate: false)
965
1141
  }
966
1142
 
967
1143
  /**
@@ -972,7 +1148,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
972
1148
  private func loadDetail(_ item: Item, width: CGFloat) {
973
1149
  guard !item.ref.isEmpty else { return }
974
1150
  DispatchQueue.global().async {
975
- let r = run(pantheonBin, ["tray-detail", item.ref], timeout: 20)
1151
+ // The item's own team, so a decision in another team is found and shown whole.
1152
+ let r = run(pantheonBin, ["tray-detail", item.ref] + (item.space.isEmpty ? [] : ["--space", item.space]), timeout: 20)
976
1153
  guard let out = r?.out, r?.code == 0,
977
1154
  let data = out.data(using: .utf8),
978
1155
  let j = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
@@ -981,8 +1158,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
981
1158
  let rendered = self.render(blocks)
982
1159
  let raw = j["raw"] as? String ?? ""
983
1160
  let headline = j["headline"] as? String ?? ""
1161
+ let asker = j["asker"] as? String ?? ""
1162
+ let options: [(key: String, text: String)] = (j["options"] as? [[String: Any]] ?? []).compactMap { o in
1163
+ guard let k = o["key"] as? String, let t = o["text"] as? String else { return nil }
1164
+ return (key: k, text: t)
1165
+ }
984
1166
  DispatchQueue.main.async {
985
1167
  guard self.panelItem?.id == item.id else { return }
1168
+ // Who is asking, said plainly: "from your worker", never "sid escalated" to sid.
1169
+ if !asker.isEmpty, let eye = self.panelEyebrow, !eye.stringValue.contains("FROM ") { eye.stringValue += " · FROM " + asker.uppercased() }
1170
+ if item.isDecision && item.choices.isEmpty && options.count >= 2, let box = self.panelOptions {
1171
+ self.panelOptionList = options
1172
+ box.arrangedSubviews.forEach { $0.removeFromSuperview() }
1173
+ let note = NSTextField(labelWithString: "Answer with one of its options:")
1174
+ note.font = .systemFont(ofSize: 12, weight: .medium)
1175
+ note.textColor = .secondaryLabelColor
1176
+ box.addArrangedSubview(note)
1177
+ for (n, o) in options.enumerated() {
1178
+ let title = "(\(o.key)) " + (o.text.count > 78 ? String(o.text.prefix(77)) + "…" : o.text)
1179
+ let b = NSButton(title: title, target: self, action: #selector(self.panelChooseOption(_:)))
1180
+ b.bezelStyle = .rounded
1181
+ b.tag = n
1182
+ b.keyEquivalent = ""
1183
+ b.toolTip = "Sends \"(\(o.key)) \(o.text)\" as your answer"
1184
+ box.addArrangedSubview(b)
1185
+ }
1186
+ box.isHidden = false
1187
+ self.refitPanel()
1188
+ }
986
1189
  self.panelRendered = rendered
987
1190
  self.panelRaw = raw
988
1191
  self.panelRawButton?.isHidden = raw.isEmpty
@@ -1039,6 +1242,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1039
1242
  if item.when > 0 { eyebrow += " · " + Item.age(item.when) }
1040
1243
  if !item.space.isEmpty { eyebrow += " · " + item.space }
1041
1244
  let eye = label(eyebrow, size: 11, weight: .semibold, color: .secondaryLabelColor, width: inner)
1245
+ panelEyebrow = eye
1042
1246
  stack.addArrangedSubview(eye)
1043
1247
  let headline = label(item.headline, size: 17, weight: .semibold, color: .labelColor, width: inner)
1044
1248
  panelHeadline = headline
@@ -1195,6 +1399,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1195
1399
  rows.spacing = 8
1196
1400
  rows.translatesAutoresizingMaskIntoConstraints = false
1197
1401
 
1402
+ // The decision's own options, filled in once its full text has loaded.
1403
+ if item.isDecision && item.choices.isEmpty {
1404
+ let opts = NSStackView()
1405
+ opts.orientation = .vertical
1406
+ opts.alignment = .leading
1407
+ opts.spacing = 6
1408
+ opts.isHidden = true
1409
+ panelOptions = opts
1410
+ rows.addArrangedSubview(opts)
1411
+ }
1412
+
1198
1413
  // A question this machine asked its own person: answered in one click, here.
1199
1414
  if item.isDecision && !item.choices.isEmpty {
1200
1415
  let row0 = NSStackView()
@@ -1219,6 +1434,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1219
1434
  if item.isDecision {
1220
1435
  row1.addArrangedSubview(button("Mark done", #selector(panelDone), "Tells the agent you settled this in person, so it stops asking."))
1221
1436
  }
1437
+ // Put away until tomorrow morning, on this machine — nothing is sent.
1438
+ if item.effectiveLane == "now" {
1439
+ let away = Later.active()[item.id] != nil
1440
+ row1.addArrangedSubview(button(away ? "Bring back" : "Later", #selector(panelLater),
1441
+ away ? "Puts it back on the board now." : "Puts it away until 9:00 tomorrow, on this machine only. Nothing is sent; it is still owed."))
1442
+ }
1222
1443
  row1.addArrangedSubview(button("Show the thread", #selector(panelThread), "Every act in this goal, in Terminal, from the team's folder."))
1223
1444
  rows.addArrangedSubview(row1)
1224
1445
 
@@ -1274,6 +1495,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1274
1495
  panelStatus?.stringValue = text
1275
1496
  panelStatus?.isHidden = text.isEmpty
1276
1497
  panelStatusRow?.isHidden = text.isEmpty && (panelRetract?.isHidden ?? true)
1498
+ refitPanel()
1277
1499
  }
1278
1500
 
1279
1501
  @objc private func panelClose() {
@@ -1294,6 +1516,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1294
1516
  panelReplyKind = nil
1295
1517
  panelSendButton = nil
1296
1518
  panelRetract = nil
1519
+ panelEyebrow = nil
1520
+ panelOptions = nil
1521
+ panelOptionList = []
1297
1522
  panelLastSent = ""
1298
1523
  panelRaw = ""
1299
1524
  panelShowingRaw = false
@@ -1319,7 +1544,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1319
1544
  panelReplyRow?.isHidden = false
1320
1545
  if let c = panelBodyHeight, c.constant > 140 { c.constant -= 20 }
1321
1546
  panelRetract?.isHidden = true
1322
- setStatus("")
1547
+ setStatus("") // refits
1323
1548
  if let tv = panelReply { detailPanel?.makeFirstResponder(tv) }
1324
1549
  }
1325
1550
  @objc private func panelCancelReply() { exitReplyMode() }
@@ -1330,6 +1555,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1330
1555
  panelReplyButton?.isHidden = false
1331
1556
  panelReplyButton?.keyEquivalent = "\r"
1332
1557
  if let c = panelBodyHeight, c.constant > 0 { c.constant += 20 }
1558
+ refitPanel()
1333
1559
  }
1334
1560
 
1335
1561
  /// What a person may send on this item. Answer only on their own decision:
@@ -1366,6 +1592,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1366
1592
  }
1367
1593
  }
1368
1594
 
1595
+ /// One of the decision's stated options, sent as its ANSWER — the same local path a typed answer takes.
1596
+ @objc private func panelChooseOption(_ sender: NSButton) {
1597
+ guard let item = panelItem, sender.tag >= 0, sender.tag < panelOptionList.count else { return }
1598
+ let o = panelOptionList[sender.tag]
1599
+ var payload: [String: Any] = ["action": "reply", "kind": "ANSWER", "body": "(\(o.key)) \(o.text)", "goal": item.goal, "space": item.space, "decision": item.id]
1600
+ if !item.ref.isEmpty { payload["act"] = item.ref }
1601
+ guard writeAction(payload) else { setStatus("Could not write the action. Use Reply manually instead."); return }
1602
+ setStatus("Answering…")
1603
+ panelOptions?.arrangedSubviews.forEach { ($0 as? NSButton)?.isEnabled = false }
1604
+ DispatchQueue.global().async {
1605
+ let r = run(pantheonBin, ["explain", "--queued"], timeout: 25)
1606
+ DispatchQueue.main.async {
1607
+ let out = (r?.out ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
1608
+ if r?.code == 0 {
1609
+ self.setStatus("Answered (\(o.key)) — it is no longer waiting on you.")
1610
+ self.refresh()
1611
+ } else {
1612
+ self.panelOptions?.arrangedSubviews.forEach { ($0 as? NSButton)?.isEnabled = true }
1613
+ self.setStatus(out.hasPrefix("not sent: ") ? String(out.dropFirst("not sent: ".count)) : "the relay could not be reached")
1614
+ }
1615
+ }
1616
+ }
1617
+ }
1618
+
1369
1619
  /// Send the words in the box as the chosen act. The CLI does the sending and
1370
1620
  /// prints "sent KIND id=… seq=…" or "not sent: why" on stdout; both are shown.
1371
1621
  @objc private func panelSend(_ sender: Any?) {
@@ -1395,7 +1645,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1395
1645
  self.setStatus(what)
1396
1646
  self.exitReplyMode()
1397
1647
  self.panelRetract?.isHidden = false
1398
- DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [weak self] in self?.panelRetract?.isHidden = true }
1648
+ self.panelStatusRow?.isHidden = false
1649
+ self.refitPanel()
1650
+ DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [weak self] in
1651
+ self?.panelRetract?.isHidden = true
1652
+ self?.panelStatusRow?.isHidden = (self?.panelStatus?.stringValue.isEmpty ?? true)
1653
+ self?.refitPanel()
1654
+ }
1399
1655
  self.refresh()
1400
1656
  } else {
1401
1657
  let why = out.hasPrefix("not sent: ") ? String(out.dropFirst("not sent: ".count)) : "the relay could not be reached"
@@ -1421,7 +1677,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1421
1677
  if r?.code == 0 {
1422
1678
  self.panelLastSent = ""
1423
1679
  self.panelRetract?.isHidden = true
1424
- self.setStatus("Retracted.")
1680
+ self.setStatus("Retracted.") // refits
1425
1681
  self.refresh()
1426
1682
  } else {
1427
1683
  let out = (r?.out ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1441,6 +1697,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1441
1697
  showThread(item)
1442
1698
  }
1443
1699
  /// Answer the decision as handled, with no terminal — the same action the alert's Done takes.
1700
+ @objc private func panelLater() {
1701
+ guard let item = panelItem else { return }
1702
+ if Later.active()[item.id] != nil { Later.bring(item.id) } else { Later.snooze(item.id) }
1703
+ detailPanel?.close()
1704
+ if boardPanel?.isVisible == true { rebuildBoard() }
1705
+ }
1706
+
1444
1707
  @objc private func panelDone() {
1445
1708
  guard let item = panelItem, item.isDecision else { return }
1446
1709
  setStatus("Marking it done…")