@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.
- package/README.md +4 -2
- package/dist/agent-outage.js +87 -0
- package/dist/agents.js +59 -20
- package/dist/claude-confine.js +21 -4
- package/dist/cli.js +260 -57
- package/dist/client.js +3 -3
- package/dist/config.js +1 -0
- package/dist/dbus.js +418 -0
- package/dist/decision-ui.js +89 -0
- package/dist/doctor.js +3 -3
- package/dist/email.js +21 -0
- package/dist/file-lock.js +3 -3
- package/dist/git-safe.js +274 -0
- package/dist/hooks-install.js +6 -1
- package/dist/install.js +21 -0
- package/dist/kiro-files.js +20 -4
- package/dist/mcp-instructions.js +2 -2
- package/dist/nonces.js +5 -3
- package/dist/notice.js +2 -1
- package/dist/notifier.js +36 -1
- package/dist/onboard-defaults.js +18 -0
- package/dist/persona.js +28 -2
- package/dist/plans.js +12 -3
- package/dist/profile.js +23 -5
- package/dist/relay.js +61 -16
- package/dist/roster-file.js +3 -0
- package/dist/run-failure.js +2 -1
- package/dist/safe-write.js +55 -0
- package/dist/setup-guide.js +9 -1
- package/dist/spend.js +65 -5
- package/dist/tray.js +53 -6
- package/dist/worker.js +890 -280
- package/helper/macos/menubar.swift +375 -112
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
let
|
|
616
|
-
let
|
|
617
|
-
let
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
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" :
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
let
|
|
761
|
-
let
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
|
|
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
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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…")
|