@join-pantheon/cli 0.30.0 → 0.31.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 {
@@ -70,8 +74,21 @@ struct Item {
70
74
  let space: String
71
75
  /// whose human must decide this: this member for their own decision, the teammate for theirs, "" when it is not a decision
72
76
  let decider: String
77
+ /// one-click answers for a question this machine asked its own person (may a teammate wake it, may runs use commands)
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
73
85
 
74
86
  var isDecision: Bool { kind == "decision" }
87
+ /// A tray-status from before lanes — or with a lane this helper does not know —
88
+ /// draws as it did before lanes: a decision or owed row is a card, a wait is not.
89
+ /// An unknown lane must never hide a row.
90
+ var effectiveLane: String { Item.lanes.contains(lane) ? lane : (kind == "waiting" ? "waiting" : "now") }
91
+ static let lanes: Set<String> = ["now", "handling", "waiting", "older"]
75
92
  var heading: String {
76
93
  switch kind {
77
94
  case "decision": return "Needs your decision"
@@ -85,10 +102,17 @@ struct Item {
85
102
  var parts: [String] = []
86
103
  // Who is asking, on every kind. A decision row used to say only "11h ago",
87
104
  // which is the one fact a reader could already guess.
88
- if kind == "owed" { parts.append("you owe") } else if !who.isEmpty { parts.append(who) }
105
+ // Who has it, by where the board put it: a card is on this person; a handled row is an agent's.
106
+ if kind == "owed" {
107
+ parts.append(effectiveLane == "handling" ? (claimed ? "your agent is on it" : "your worker picks it up") : "waiting on you")
108
+ } else if !who.isEmpty { parts.append(who) }
89
109
  if when > 0 { parts.append(Item.age(when)) }
90
110
  if showSpace && !space.isEmpty { parts.append(space) }
91
- return parts.joined(separator: " · ")
111
+ let head = parts.joined(separator: " · ")
112
+ // The request a decision is holding up, said once, after who and when.
113
+ guard !context.isEmpty else { return head }
114
+ let c = context.count > 70 ? String(context.prefix(69)) + "…" : context
115
+ return head + " · for: " + c
92
116
  }
93
117
  static func age(_ ms: Double) -> String {
94
118
  let secs = max(0, Date().timeIntervalSince1970 - ms / 1000)
@@ -155,7 +179,14 @@ func fetchState() -> TrayState {
155
179
  goal: i["goal"] as? String ?? "",
156
180
  ref: i["ref"] as? String ?? "",
157
181
  space: i["space"] as? String ?? "",
158
- decider: i["decider"] as? String ?? ""
182
+ decider: i["decider"] as? String ?? "",
183
+ choices: (i["choices"] as? [[String: Any]] ?? []).compactMap { c in
184
+ guard let l = c["label"] as? String, let a = c["answer"] as? String else { return nil }
185
+ return (label: l, answer: a)
186
+ },
187
+ lane: i["lane"] as? String ?? "",
188
+ context: i["context"] as? String ?? "",
189
+ claimed: i["claimed"] as? Bool ?? false
159
190
  ))
160
191
  }
161
192
  for d in (j["decisions"] as? [[String: Any]] ?? []) {
@@ -180,6 +211,29 @@ func fetchState() -> TrayState {
180
211
  * loop, and hit-testing two zones in one view is far more predictable there than
181
212
  * nesting buttons inside an NSMenuItem.
182
213
  */
214
+ /// "Later": a card put away until tomorrow morning, on this machine only — a
215
+ /// view preference, never an answer. Kept in the menu bar's own defaults.
216
+ enum Later {
217
+ private static let key = "pantheon.later"
218
+ /// The ids put away and not yet due, with when they come back (seconds since 1970).
219
+ static func active() -> [String: Double] {
220
+ let now = Date().timeIntervalSince1970
221
+ let all = (UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:]
222
+ let live = all.filter { $0.value > now }
223
+ if live.count != all.count { UserDefaults.standard.set(live, forKey: key) }
224
+ return live
225
+ }
226
+ /// Back at 9:00 tomorrow, local time.
227
+ static func snooze(_ id: String) {
228
+ let cal = Calendar.current
229
+ let tomorrow = cal.date(byAdding: .day, value: 1, to: Date()) ?? Date().addingTimeInterval(86_400)
230
+ let at = cal.date(bySettingHour: 9, minute: 0, second: 0, of: tomorrow) ?? tomorrow
231
+ var all = active(); all[id] = at.timeIntervalSince1970
232
+ UserDefaults.standard.set(all, forKey: key)
233
+ }
234
+ static func bring(_ id: String) { var all = active(); all.removeValue(forKey: id); UserDefaults.standard.set(all, forKey: key) }
235
+ }
236
+
183
237
  final class RowView: NSView {
184
238
  private let item: Item
185
239
  private let onOpen: (Item) -> Void
@@ -226,13 +280,13 @@ final class RowView: NSView {
226
280
  return max(1, min(3, Int(ceil(h / lineHeight))))
227
281
  }
228
282
 
229
- init(item: Item, width: CGFloat, inTeamSection: Bool = false, onOpen: @escaping (Item) -> Void, onCopy: @escaping (Item) -> Void) {
283
+ init(item: Item, width: CGFloat, inTeamSection: Bool = false, showSpace: Bool? = nil, onOpen: @escaping (Item) -> Void, onCopy: @escaping (Item) -> Void) {
230
284
  self.item = item
231
285
  self.onOpen = onOpen
232
286
  self.onCopy = onCopy
233
287
  self.leading = inTeamSection ? 26 : 12
234
288
  self.dot = inTeamSection && item.isDecision
235
- self.sub = item.subtitle(showSpace: !inTeamSection)
289
+ self.sub = item.subtitle(showSpace: showSpace ?? !inTeamSection)
236
290
  self.titleLines = RowView.lines(for: item.title, width: (RowView.knownWidth ?? width) - RowView.textInsets - (leading - 12))
237
291
  let textHeight = CGFloat(titleLines) * RowView.lineHeight
238
292
  super.init(frame: NSRect(x: 0, y: 0, width: width, height: textHeight + (sub.isEmpty ? 13 : 29)))
@@ -463,6 +517,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
463
517
  func applicationDidFinishLaunching(_ note: Notification) {
464
518
  NSApp.setActivationPolicy(.accessory)
465
519
  item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
520
+ // (After the item exists: the board reads its button for placement.)
521
+ if let out = renderTo { renderBoard(to: out); exit(0) }
466
522
  let menu = NSMenu()
467
523
  menu.delegate = self
468
524
  classicMenu = menu
@@ -513,12 +569,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
513
569
 
514
570
  private var classicMenu: NSMenu?
515
571
  private var boardPanel: BoardPanel?
572
+ /// which of the board's counted lines are open ("handling", "waiting", "older", "later", "more")
573
+ private var boardOpen: Set<String> = []
516
574
  private var boardSignature = ""
517
575
  private var boardMonitor: Any?
518
576
  static let boardWidth: CGFloat = 470
519
577
 
578
+ /// The cards: what needs this person now, less what they put away for later.
579
+ /// The badge, the tooltip and both boards' headers all count these, so they agree.
580
+ private var boardCards: [Item] {
581
+ let later = Later.active()
582
+ return state.items.filter { $0.effectiveLane == "now" && later[$0.id] == nil }
583
+ }
584
+
520
585
  private func signature() -> String {
521
- "\(state.items.map { $0.id }.joined(separator: ","))|\(state.decisions.count)|\(state.online)|\(state.members)|\(state.workerRunning)|\(state.relayOk)|\(actionError ?? "")|\(workerAction ?? "")"
586
+ // Everything the board draws from, not just which ids: a row moving lane (out
587
+ // of the worker's window), a new repeat, a claim, or a Later coming due all
588
+ // change what an open board must show.
589
+ "\(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 ?? "")"
522
590
  }
523
591
 
524
592
  @objc private func iconClicked(_ sender: Any?) {
@@ -551,6 +619,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
551
619
  item.button?.highlight(false)
552
620
  }
553
621
 
622
+ /// One card as a row of the classic menu.
623
+ private func menuRow(_ it: Item, showSpace: Bool) -> NSMenuItem {
624
+ let mi = NSMenuItem(title: it.title, action: nil, keyEquivalent: "")
625
+ mi.toolTip = it.detail
626
+ mi.view = RowView(item: it, width: RowView.rowWidth, showSpace: showSpace,
627
+ onOpen: { [weak self] x in self?.showDetail(x) },
628
+ onCopy: { [weak self] x in self?.copyPrompt(x) })
629
+ return mi
630
+ }
631
+
632
+ /// Open or close one of the board's counted lines, and redraw.
633
+ private func toggleBoardLine(_ key: String) {
634
+ if boardOpen.contains(key) { boardOpen.remove(key) } else { boardOpen.insert(key) }
635
+ rebuildBoard()
636
+ }
637
+
638
+ /// The board as it would open now, drawn into a PNG (render mode, above).
639
+ func renderBoard(to path: String) {
640
+ state = fetchState()
641
+ hasLoaded = true
642
+ boardOpen = Set((ProcessInfo.processInfo.environment["PANTHEON_TRAY_OPEN"] ?? "").split(separator: ",").map(String.init))
643
+ let panel = makeBoardPanel()
644
+ boardPanel = panel
645
+ rebuildBoard()
646
+ guard let view = panel.contentView, let rep = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return }
647
+ view.cacheDisplay(in: view.bounds, to: rep)
648
+ try? rep.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path))
649
+ }
650
+
554
651
  private func makeBoardPanel() -> BoardPanel {
555
652
  let panel = BoardPanel(contentRect: NSRect(x: 0, y: 0, width: AppDelegate.boardWidth, height: 200),
556
653
  styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
@@ -589,52 +686,55 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
589
686
  let sep = { rows.append(BoardSeparator(width: w)) }
590
687
  let openRow: (Item) -> Void = { [weak self] it in self?.closeBoard(); self?.showDetail(it) }
591
688
  let copyRow: (Item) -> Void = { [weak self] it in self?.copyPrompt(it) }
592
- let section = { (title: String, detail: String, items: [Item], team: Bool) in
593
- guard !items.isEmpty else { return }
594
- rows.append(HeaderView(lead: title, rest: detail, width: w, bright: team))
595
- for it in items.prefix(team ? 8 : 6) { rows.append(RowView(item: it, width: w, inTeamSection: team, onOpen: openRow, onCopy: copyRow)) }
596
- if items.count > (team ? 8 : 6) { rows.append(ActionRow("…and \(items.count - (team ? 8 : 6)) more", width: w)) }
597
- }
598
-
599
689
  if !hasLoaded {
600
690
  note("Checking Pantheon…")
601
691
  } else if !state.reachable {
602
- note("Unable to check Pantheon status"); note("Try Refresh now, or Check connection below")
692
+ note("Unable to check Pantheon status"); note("Open More below: Refresh now, or Check connection")
603
693
  } else if !state.configured {
604
694
  note("Connect with your team"); note("Run pantheon start, or join with an invite link")
605
695
  } else {
606
- let teams = state.spaces.count > 1 ? state.spaces : []
607
- let decisions = state.items.filter { $0.kind == "decision" }
608
- let owed = state.items.filter { $0.kind == "owed" }
609
- let waiting = state.items.filter { $0.kind == "waiting" }
610
- let need = decisions.isEmpty ? "Nothing needs you" : "\(decisions.count) need\(decisions.count == 1 ? "s" : "") you"
611
- let where_ = teams.isEmpty ? state.space : "\(teams.count) teams"
696
+ // One board, no noise (tray.ts arrangeBoard): every card is something this
697
+ // person has to do, each once, across every team; the rest is one line each,
698
+ // opened on demand. No cap — nothing that needs them is hidden.
699
+ let multi = state.spaces.count > 1
700
+ let later = Later.active()
701
+ let cards = boardCards
702
+ let putAway = state.items.filter { $0.effectiveLane == "now" && later[$0.id] != nil }
703
+ let handling = state.items.filter { $0.effectiveLane == "handling" }
704
+ let waiting = state.items.filter { $0.effectiveLane == "waiting" }
705
+ let older = state.items.filter { $0.effectiveLane == "older" }
706
+ let need = cards.isEmpty ? "Nothing needs you" : "\(cards.count) need\(cards.count == 1 ? "s" : "") you"
707
+ let where_ = multi ? "\(state.spaces.count) teams" : state.space
612
708
  rows.append(HeaderView(lead: need, rest: "\(state.member) · \(where_) · " + (state.relayOk ? "\(state.online) of \(state.members) online" : "connection unavailable"), width: w))
613
709
  if !state.relayOk { note("Could not reach: \(state.unavailableSpaces.joined(separator: ", "))") }
614
710
  if !state.decisionsOk { note("Some of this could not be checked — the list may be incomplete") }
615
711
  sep()
616
- if !teams.isEmpty {
617
- for (i, team) in teams.enumerated() {
618
- if i > 0 { sep() }
619
- let mine = state.items.filter { $0.space == team }
620
- let ordered = mine.filter { $0.kind == "decision" } + mine.filter { $0.kind == "owed" } + mine.filter { $0.kind == "waiting" }
621
- if state.unavailableSpaces.contains(team) { rows.append(HeaderView(lead: team, rest: "could not be checked", width: w, bright: false)) }
622
- else if ordered.isEmpty { rows.append(HeaderView(lead: team, rest: "nothing open", width: w, bright: false)) }
623
- else { section(team, counts(ordered), ordered, true) }
624
- }
625
- } else if state.items.isEmpty {
626
- if state.decisionsOk { note("Nothing open — nothing needs you, nothing owed either way") }
627
- } else {
628
- section(decisions.count == 1 ? "1 decision needs you" : "\(decisions.count) decisions need you", "", decisions, false)
629
- section(owed.count == 1 ? "You owe 1 thing" : "You owe \(owed.count) things", "", owed, false)
630
- for who in orderedPeers(waiting) {
631
- let theirs = waiting.filter { $0.who == who }
632
- section(theirs.count == 1 ? "Waiting on \(who)" : "Waiting on \(who) · \(theirs.count)", "", theirs, false)
633
- }
712
+ for it in cards { rows.append(RowView(item: it, width: w, showSpace: multi, onOpen: openRow, onCopy: copyRow)) }
713
+ // The heading already says nothing needs you: said again only what it does not.
714
+ if cards.isEmpty && state.decisionsOk && handling.isEmpty && waiting.isEmpty && older.isEmpty && putAway.isEmpty {
715
+ note("All clear — nothing open for your agents either")
716
+ }
717
+ // The rest, counted: a line each, opened in place. (A plain loop: a local
718
+ // closure capturing both `rows` and a weak self crashed the optimised build.)
719
+ let lanes: [(key: String, title: String, items: [Item])] = [
720
+ ("handling", "Your agents are handling · \(handling.count)", handling),
721
+ ("waiting", "Waiting on teammates · \(waiting.count)", waiting),
722
+ ("older", "Older than a week · \(older.count)", older),
723
+ ("later", "Later · \(putAway.count)", putAway),
724
+ ]
725
+ if lanes.contains(where: { !$0.items.isEmpty }) { sep() }
726
+ for lane in lanes where !lane.items.isEmpty {
727
+ let open = boardOpen.contains(lane.key)
728
+ let key = lane.key
729
+ rows.append(ActionRow(lane.title, hint: open ? "hide" : "show", width: w, onClick: { [weak self] in self?.toggleBoardLine(key) }))
730
+ // Indented under their line, so an opened lane never reads as more cards.
731
+ if open { for it in lane.items { rows.append(RowView(item: it, width: w, inTeamSection: true, showSpace: multi, onOpen: openRow, onCopy: copyRow)) } }
634
732
  }
635
- if !decisions.isEmpty {
733
+ // The prompt it hands over asks about every question waiting on this person,
734
+ // put away or not, so the count is of all of them.
735
+ if state.decisions.count > 1 {
636
736
  sep()
637
- act(decisions.count > 1 ? "Review all \(decisions.count) in \(state.surfaceName)" : "Review it in \(state.surfaceName)", "") { [weak self] in self?.openAllDecisions() }
737
+ act("Review all \(state.decisions.count) questions in \(state.surfaceName)", "") { [weak self] in self?.openAllDecisions() }
638
738
  }
639
739
  sep()
640
740
  if let action = workerAction { note(action) }
@@ -644,12 +744,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
644
744
  }
645
745
  if let error = actionError { sep(); note(error) }
646
746
  sep()
647
- act("Open status in Terminal", "") { [weak self] in self?.openStatus() }
648
- act("Check connection in Terminal", "version \(state.version)") { [weak self] in self?.openDoctor() }
649
- act("View decision details in Terminal", "") { [weak self] in self?.openApprovals() }
650
- act("Refresh now", "") { [weak self] in self?.refresh() }
651
- sep()
652
- act("Quit pantheon menu bar", "") { [weak self] in self?.quit() }
747
+ // The terminal tools, out of the way until asked for.
748
+ let moreOpen = boardOpen.contains("more")
749
+ rows.append(ActionRow("More", hint: moreOpen ? "hide" : "version \(state.version)", width: w, onClick: { [weak self] in self?.toggleBoardLine("more") }))
750
+ if moreOpen {
751
+ act("Open status in Terminal", "") { [weak self] in self?.openStatus() }
752
+ act("Check connection in Terminal", "version \(state.version)") { [weak self] in self?.openDoctor() }
753
+ act("View decision details in Terminal", "") { [weak self] in self?.openApprovals() }
754
+ act("Refresh now", "") { [weak self] in self?.refresh() }
755
+ act("Quit pantheon menu bar", "") { [weak self] in self?.quit() }
756
+ }
653
757
 
654
758
  // Lay the rows out top to bottom at their own heights, in a card no taller than the screen.
655
759
  let total = rows.reduce(CGFloat(16)) { $0 + $1.frame.height }
@@ -722,9 +826,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
722
826
  let mode: MarkMode = !state.configured || !state.relayOk ? .off : state.workerRunning ? .full : .idle
723
827
  item.button?.image = markImage(mode)
724
828
  let unknown = hasLoaded && (!state.reachable || (state.configured && !state.decisionsOk))
725
- item.button?.title = actionError != nil ? " !" : state.decisions.isEmpty ? (unknown ? " !" : "") : " \(state.decisions.count)\(unknown ? "+" : "")"
829
+ let need = hasLoaded ? boardCards.count : 0
830
+ item.button?.title = actionError != nil ? " !" : need == 0 ? (unknown ? " !" : "") : " \(need)\(unknown ? "+" : "")"
726
831
  item.button?.imagePosition = .imageLeading
727
- 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"
832
+ 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"
728
833
  item.button?.toolTip = "Pantheon — " + (actionError ?? summary)
729
834
  item.button?.setAccessibilityLabel("Pantheon — " + (actionError ?? summary))
730
835
  }
@@ -749,65 +854,46 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
749
854
  info("Connect with your team")
750
855
  info("Run pantheon start, or join with an invite link")
751
856
  } else {
752
- let teams = state.spaces.count > 1 ? state.spaces : []
753
- let decisions = state.items.filter { $0.kind == "decision" }
754
- let owed = state.items.filter { $0.kind == "owed" }
755
- let waiting = state.items.filter { $0.kind == "waiting" }
756
- if !teams.isEmpty {
757
- // Several teams: the board is drawn per team, and "what needs you" is read
758
- // off the dots. One mixed list with the team as a grey suffix on every row
759
- // was the version a person with two teams called impossible to follow.
760
- let summary = NSMenuItem(title: state.member, action: nil, keyEquivalent: "")
761
- summary.isEnabled = false
762
- summary.view = HeaderView(lead: decisions.isEmpty ? "Nothing needs you" : "\(decisions.count) need\(decisions.count == 1 ? "s" : "") you",
763
- rest: "\(state.member) · \(teams.count) teams · " + (state.relayOk ? "\(state.online) of \(state.members) online" : "connection unavailable"),
764
- width: RowView.rowWidth)
765
- menu.addItem(summary)
766
- if !state.relayOk { info("Could not reach: \(state.unavailableSpaces.joined(separator: ", "))") }
767
- menu.addItem(.separator())
768
- for (index, team) in teams.enumerated() {
769
- if index > 0 { menu.addItem(.separator()) }
770
- let rows = state.items.filter { $0.space == team }
771
- let ordered = rows.filter { $0.kind == "decision" } + rows.filter { $0.kind == "owed" } + rows.filter { $0.kind == "waiting" }
772
- if state.unavailableSpaces.contains(team) {
773
- let h = NSMenuItem(title: team, action: nil, keyEquivalent: ""); h.isEnabled = false
774
- h.view = HeaderView(lead: team, rest: "could not be checked", width: RowView.rowWidth, bright: false); menu.addItem(h)
775
- } else if ordered.isEmpty {
776
- let h = NSMenuItem(title: team, action: nil, keyEquivalent: ""); h.isEnabled = false
777
- h.view = HeaderView(lead: team, rest: "nothing open", width: RowView.rowWidth, bright: false); menu.addItem(h)
778
- } else {
779
- addRows(menu, team, ordered, team: true, detail: counts(ordered))
780
- }
781
- }
782
- if !decisions.isEmpty {
783
- menu.addItem(.separator())
784
- menu.addItem(makeItem(decisions.count > 1 ? "Review all \(decisions.count) in \(state.surfaceName)" : "Review it in \(state.surfaceName)", #selector(openAllDecisions)))
785
- }
786
- } else {
787
- info("\(state.member) · \(state.space)")
788
- info(state.relayOk
789
- ? "Connected · \(state.online) of \(state.members) members online"
790
- : "Connection unavailable · \(state.unavailableSpaces.joined(separator: ", "))")
857
+ // The same board as the panel (rebuildBoard), as a menu: the cards, then the
858
+ // rest as a submenu each.
859
+ let multi = state.spaces.count > 1
860
+ let later = Later.active()
861
+ let cards = boardCards
862
+ let putAway = state.items.filter { $0.effectiveLane == "now" && later[$0.id] != nil }
863
+ let handling = state.items.filter { $0.effectiveLane == "handling" }
864
+ let waiting = state.items.filter { $0.effectiveLane == "waiting" }
865
+ let older = state.items.filter { $0.effectiveLane == "older" }
866
+ let summary = NSMenuItem(title: state.member, action: nil, keyEquivalent: "")
867
+ summary.isEnabled = false
868
+ summary.view = HeaderView(lead: cards.isEmpty ? "Nothing needs you" : "\(cards.count) need\(cards.count == 1 ? "s" : "") you",
869
+ rest: "\(state.member) · " + (multi ? "\(state.spaces.count) teams" : state.space) + " · " + (state.relayOk ? "\(state.online) of \(state.members) online" : "connection unavailable"),
870
+ width: RowView.rowWidth)
871
+ menu.addItem(summary)
872
+ if !state.relayOk { info("Could not reach: \(state.unavailableSpaces.joined(separator: ", "))") }
873
+ if !state.decisionsOk { info("Some of this could not be checked — the list may be incomplete") }
791
874
  menu.addItem(.separator())
792
- if !state.decisionsOk {
793
- info("Some of this could not be checked")
794
- info("Refresh to try again — the list may be incomplete")
875
+ let row = { (it: Item) -> NSMenuItem in self.menuRow(it, showSpace: multi) }
876
+ for it in cards { menu.addItem(row(it)) }
877
+ if cards.isEmpty && state.decisionsOk && handling.isEmpty && waiting.isEmpty && older.isEmpty && putAway.isEmpty {
878
+ info("All clear — nothing open for your agents either")
795
879
  }
796
- if state.items.isEmpty {
797
- if state.decisionsOk { info("Nothing open — nothing needs you, nothing owed either way") }
798
- } else {
799
- addRows(menu, decisions.count == 1 ? "1 decision needs you" : "\(decisions.count) decisions need you", decisions)
800
- addRows(menu, owed.count == 1 ? "You owe 1 thing" : "You owe \(owed.count) things", owed)
801
- // Grouped by person, because "waiting on praneet" is what someone
802
- // actually wants to know before they chase it.
803
- for who in orderedPeers(waiting) {
804
- let theirs = waiting.filter { $0.who == who }
805
- addRows(menu, theirs.count == 1 ? "Waiting on \(who)" : "Waiting on \(who) · \(theirs.count)", theirs)
806
- }
807
- if !decisions.isEmpty {
808
- menu.addItem(makeItem(decisions.count > 1 ? "Review all \(decisions.count) in \(state.surfaceName)" : "Review it in \(state.surfaceName)", #selector(openAllDecisions)))
809
- }
880
+ let lanes: [(String, [Item])] = [
881
+ ("Your agents are handling · \(handling.count)", handling),
882
+ ("Waiting on teammates · \(waiting.count)", waiting),
883
+ ("Older than a week · \(older.count)", older),
884
+ ("Later · \(putAway.count)", putAway),
885
+ ].filter { !$0.1.isEmpty }
886
+ if !lanes.isEmpty { menu.addItem(.separator()) }
887
+ for (title, items) in lanes {
888
+ let mi = NSMenuItem(title: title, action: nil, keyEquivalent: "")
889
+ let sub = NSMenu()
890
+ for it in items { sub.addItem(row(it)) }
891
+ mi.submenu = sub
892
+ menu.addItem(mi)
810
893
  }
894
+ if state.decisions.count > 1 {
895
+ menu.addItem(.separator())
896
+ menu.addItem(makeItem("Review all \(state.decisions.count) questions in \(state.surfaceName)", #selector(openAllDecisions)))
811
897
  }
812
898
  menu.addItem(.separator())
813
899
  info(state.workerRunning ? "Unattended work is on" : state.workerInstalled ? "Unattended work is paused" : "Unattended work is not set up")
@@ -1189,6 +1275,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1189
1275
  rows.spacing = 8
1190
1276
  rows.translatesAutoresizingMaskIntoConstraints = false
1191
1277
 
1278
+ // A question this machine asked its own person: answered in one click, here.
1279
+ if item.isDecision && !item.choices.isEmpty {
1280
+ let row0 = NSStackView()
1281
+ row0.orientation = .horizontal
1282
+ row0.spacing = 10
1283
+ for (n, c) in item.choices.enumerated() {
1284
+ let b = button(c.label, #selector(panelChoose(_:)), "Answers it on this machine: \(c.answer)")
1285
+ b.tag = n
1286
+ // No Return key here: a habitual Enter must never grant "Always". A
1287
+ // permission is a click on the words that give it.
1288
+ b.keyEquivalent = ""
1289
+ row0.addArrangedSubview(b)
1290
+ }
1291
+ rows.addArrangedSubview(row0)
1292
+ }
1293
+
1192
1294
  let row1 = NSStackView()
1193
1295
  row1.orientation = .horizontal
1194
1296
  row1.spacing = 10
@@ -1197,6 +1299,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1197
1299
  if item.isDecision {
1198
1300
  row1.addArrangedSubview(button("Mark done", #selector(panelDone), "Tells the agent you settled this in person, so it stops asking."))
1199
1301
  }
1302
+ // Put away until tomorrow morning, on this machine — nothing is sent.
1303
+ if item.effectiveLane == "now" {
1304
+ let away = Later.active()[item.id] != nil
1305
+ row1.addArrangedSubview(button(away ? "Bring back" : "Later", #selector(panelLater),
1306
+ 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."))
1307
+ }
1200
1308
  row1.addArrangedSubview(button("Show the thread", #selector(panelThread), "Every act in this goal, in Terminal, from the team's folder."))
1201
1309
  rows.addArrangedSubview(row1)
1202
1310
 
@@ -1319,6 +1427,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1319
1427
  switch title { case "Answer": return "ANSWER"; case "Ask back": return "ASK"; default: return "NOTE" }
1320
1428
  }
1321
1429
 
1430
+ /// One of the question's own answers, sent as its ANSWER — the same local path
1431
+ /// a typed reply takes, so a grant it carries takes effect on this machine.
1432
+ @objc private func panelChoose(_ sender: NSButton) {
1433
+ guard let item = panelItem, sender.tag >= 0, sender.tag < item.choices.count else { return }
1434
+ let c = item.choices[sender.tag]
1435
+ var payload: [String: Any] = ["action": "reply", "kind": "ANSWER", "body": c.answer, "goal": item.goal, "space": item.space, "decision": item.id]
1436
+ if !item.ref.isEmpty { payload["act"] = item.ref }
1437
+ guard writeAction(payload) else { setStatus("Could not write the action. Use Show the thread instead."); return }
1438
+ setStatus("Answering…")
1439
+ sender.isEnabled = false
1440
+ DispatchQueue.global().async {
1441
+ let r = run(pantheonBin, ["explain", "--queued"], timeout: 25)
1442
+ DispatchQueue.main.async {
1443
+ sender.isEnabled = true
1444
+ let out = (r?.out ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
1445
+ if r?.code == 0 {
1446
+ self.setStatus("\(c.label) — done.")
1447
+ self.refresh()
1448
+ } else {
1449
+ self.setStatus(out.hasPrefix("not sent: ") ? String(out.dropFirst("not sent: ".count)) : "the relay could not be reached")
1450
+ }
1451
+ }
1452
+ }
1453
+ }
1454
+
1322
1455
  /// Send the words in the box as the chosen act. The CLI does the sending and
1323
1456
  /// prints "sent KIND id=… seq=…" or "not sent: why" on stdout; both are shown.
1324
1457
  @objc private func panelSend(_ sender: Any?) {
@@ -1394,6 +1527,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1394
1527
  showThread(item)
1395
1528
  }
1396
1529
  /// Answer the decision as handled, with no terminal — the same action the alert's Done takes.
1530
+ @objc private func panelLater() {
1531
+ guard let item = panelItem else { return }
1532
+ if Later.active()[item.id] != nil { Later.bring(item.id) } else { Later.snooze(item.id) }
1533
+ detailPanel?.close()
1534
+ if boardPanel?.isVisible == true { rebuildBoard() }
1535
+ }
1536
+
1397
1537
  @objc private func panelDone() {
1398
1538
  guard let item = panelItem, item.isDecision else { return }
1399
1539
  setStatus("Marking it done…")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@join-pantheon/cli",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
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": {