@join-pantheon/cli 0.24.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,12 +10,18 @@
10
10
  * truth and this file never grows business logic. No network of its own, no
11
11
  * state of its own, no credentials — it cannot leak what it never holds.
12
12
  *
13
+ * The menu is also the daily review surface: every open thing — decisions for
14
+ * this human, what their side owes teammates, what teammates owe them — is a
15
+ * row with a copy button, and opens a centred panel with the whole text and the
16
+ * actions that settle it. Before that it was a status light that said "no
17
+ * decisions need your input" and left nothing to look at.
18
+ *
13
19
  * pantheon-tray <absolute-path-to-pantheon> [poll-seconds]
14
20
  */
15
21
  import AppKit
16
22
 
17
23
  let argv = CommandLine.arguments
18
- if argv.count == 2, argv[1] == "version" { print("1"); exit(0) }
24
+ if argv.count == 2, argv[1] == "version" { print("2"); exit(0) }
19
25
  guard argv.count >= 2 else {
20
26
  fputs("usage: pantheon-tray <path-to-pantheon> [poll-seconds]\n", stderr)
21
27
  exit(64)
@@ -43,9 +49,51 @@ struct TrayState {
43
49
  var surfaceBundleID = ""
44
50
  var decisionsPrompt = ""
45
51
  var decisions: [(id: String, body: String, detail: String, prompt: String)] = []
52
+ var items: [Item] = []
46
53
  var version = ""
47
54
  }
48
55
 
56
+ /// One open thing, as the menu shows it and the panel expands it.
57
+ struct Item {
58
+ let id: String
59
+ let kind: String // decision | owed | waiting
60
+ let title: String
61
+ let headline: String
62
+ let detail: String
63
+ let prompt: String
64
+ let who: String // member id, or "you"
65
+ let when: Double // ms since epoch
66
+ let goal: String
67
+ let ref: String
68
+ let space: String
69
+
70
+ var isDecision: Bool { kind == "decision" }
71
+ var heading: String {
72
+ switch kind {
73
+ case "decision": return "Needs your decision"
74
+ case "owed": return "Your side owes this"
75
+ default: return "Waiting on \(who)"
76
+ }
77
+ }
78
+ /// "praneet · 2d ago · pantheon"
79
+ var subtitle: String {
80
+ var parts: [String] = []
81
+ // Who is asking, on every kind. A decision row used to say only "11h ago",
82
+ // which is the one fact a reader could already guess.
83
+ if kind == "owed" { parts.append("you owe") } else if !who.isEmpty { parts.append(who) }
84
+ if when > 0 { parts.append(Item.age(when)) }
85
+ if !space.isEmpty { parts.append(space) }
86
+ return parts.joined(separator: " · ")
87
+ }
88
+ static func age(_ ms: Double) -> String {
89
+ let secs = max(0, Date().timeIntervalSince1970 - ms / 1000)
90
+ if secs < 90 { return "just now" }
91
+ if secs < 5400 { return "\(Int(secs / 60))m ago" }
92
+ if secs < 172_800 { return "\(Int(secs / 3600))h ago" }
93
+ return "\(Int(secs / 86_400))d ago"
94
+ }
95
+ }
96
+
49
97
  /// Run a command with a hard timeout. The menu bar must never beachball
50
98
  /// because a laptop's relay is asleep — tray-status has its own network
51
99
  /// timeouts, this bounds the whole process as the backstop.
@@ -88,6 +136,21 @@ func fetchState() -> TrayState {
88
136
  s.surfaceName = j["surface_name"] as? String ?? "agent"
89
137
  s.surfaceBundleID = j["surface_bundle_id"] as? String ?? ""
90
138
  s.decisionsPrompt = j["decisions_prompt"] as? String ?? ""
139
+ for i in (j["items"] as? [[String: Any]] ?? []) {
140
+ s.items.append(Item(
141
+ id: i["id"] as? String ?? "",
142
+ kind: i["kind"] as? String ?? "decision",
143
+ title: i["title"] as? String ?? "",
144
+ headline: i["headline"] as? String ?? i["title"] as? String ?? "",
145
+ detail: i["detail"] as? String ?? i["title"] as? String ?? "",
146
+ prompt: i["prompt"] as? String ?? "",
147
+ who: i["who"] as? String ?? "",
148
+ when: (i["when"] as? Double) ?? Double(i["when"] as? Int ?? 0),
149
+ goal: i["goal"] as? String ?? "",
150
+ ref: i["ref"] as? String ?? "",
151
+ space: i["space"] as? String ?? ""
152
+ ))
153
+ }
91
154
  for d in (j["decisions"] as? [[String: Any]] ?? []) {
92
155
  s.decisions.append((
93
156
  id: d["id"] as? String ?? "",
@@ -99,6 +162,139 @@ func fetchState() -> TrayState {
99
162
  return s
100
163
  }
101
164
 
165
+
166
+ /**
167
+ * One open thing, as a menu row: title, a dim second line, and a copy control on
168
+ * the right. Clicking the row opens the detail panel; clicking the copy glyph
169
+ * puts the agent prompt on the clipboard and LEAVES THE MENU OPEN, so a person
170
+ * reviewing four things can take all four without reopening the menu each time.
171
+ *
172
+ * Drawn by hand rather than assembled from controls: a menu runs its own event
173
+ * loop, and hit-testing two zones in one view is far more predictable there than
174
+ * nesting buttons inside an NSMenuItem.
175
+ */
176
+ final class RowView: NSView {
177
+ private let item: Item
178
+ private let onOpen: (Item) -> Void
179
+ private let onCopy: (Item) -> Void
180
+ private var hovering = false
181
+ private var overCopy = false
182
+ private var copied = false
183
+
184
+ private let titleLines: Int
185
+ private static let titleFont = NSFont.systemFont(ofSize: 13, weight: .medium)
186
+ private static let lineHeight: CGFloat = 17
187
+
188
+ /// How many lines this title needs, at most two. A one-line row truncated
189
+ /// mid-word ("Sid asked: Sid is asking: is this work in producti…") hid the
190
+ /// only part worth reading; two lines carry almost every real title whole.
191
+ static func lines(for text: String, width: CGFloat) -> Int {
192
+ let para = NSMutableParagraphStyle()
193
+ para.lineBreakMode = .byWordWrapping
194
+ let h = (text as NSString).boundingRect(
195
+ with: NSSize(width: width, height: .greatestFiniteMagnitude),
196
+ options: [.usesLineFragmentOrigin, .usesFontLeading],
197
+ attributes: [.font: titleFont, .paragraphStyle: para]).height
198
+ return max(1, min(2, Int(ceil(h / lineHeight))))
199
+ }
200
+
201
+ init(item: Item, width: CGFloat, onOpen: @escaping (Item) -> Void, onCopy: @escaping (Item) -> Void) {
202
+ self.item = item
203
+ self.onOpen = onOpen
204
+ self.onCopy = onCopy
205
+ self.titleLines = RowView.lines(for: item.title, width: width - 14 - 48)
206
+ let textHeight = CGFloat(titleLines) * RowView.lineHeight
207
+ super.init(frame: NSRect(x: 0, y: 0, width: width, height: textHeight + (item.subtitle.isEmpty ? 13 : 29)))
208
+ setAccessibilityRole(.button)
209
+ setAccessibilityLabel("\(item.title). \(item.subtitle). Return opens it; the button on the right copies the question.")
210
+ }
211
+ required init?(coder: NSCoder) { return nil }
212
+
213
+ private var copyRect: NSRect { NSRect(x: bounds.maxX - 42, y: bounds.midY - 12, width: 28, height: 24) }
214
+
215
+
216
+ override func updateTrackingAreas() {
217
+ super.updateTrackingAreas()
218
+ for a in trackingAreas { removeTrackingArea(a) }
219
+ addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .activeAlways], owner: self, userInfo: ["zone": "row"]))
220
+ addTrackingArea(NSTrackingArea(rect: copyRect, options: [.mouseEnteredAndExited, .activeAlways], owner: self, userInfo: ["zone": "copy"]))
221
+ }
222
+ private func zone(_ e: NSEvent) -> String { (e.trackingArea?.userInfo?["zone"] as? String) ?? "row" }
223
+ override func mouseEntered(with e: NSEvent) {
224
+ if zone(e) == "copy" { overCopy = true } else { hovering = true }
225
+ needsDisplay = true
226
+ }
227
+ override func mouseExited(with e: NSEvent) {
228
+ if zone(e) == "copy" { overCopy = false } else { hovering = false; overCopy = false }
229
+ needsDisplay = true
230
+ }
231
+ override func mouseUp(with e: NSEvent) {
232
+ if copyRect.contains(convert(e.locationInWindow, from: nil)) {
233
+ onCopy(item)
234
+ copied = true
235
+ needsDisplay = true
236
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) { [weak self] in self?.copied = false; self?.needsDisplay = true }
237
+ return
238
+ }
239
+ enclosingMenuItem?.menu?.cancelTracking()
240
+ onOpen(item)
241
+ }
242
+
243
+ override func draw(_ dirtyRect: NSRect) {
244
+ let selected = hovering
245
+ if selected {
246
+ NSColor.selectedContentBackgroundColor.setFill()
247
+ NSBezierPath(roundedRect: bounds.insetBy(dx: 5, dy: 1), xRadius: 5, yRadius: 5).fill()
248
+ }
249
+ let fg: NSColor = selected ? .selectedMenuItemTextColor : .labelColor
250
+ let dim: NSColor = selected ? NSColor.selectedMenuItemTextColor.withAlphaComponent(0.7) : .secondaryLabelColor
251
+ let para = NSMutableParagraphStyle()
252
+ para.lineBreakMode = .byWordWrapping // break between words, not inside one
253
+ para.lineSpacing = 0
254
+ let flat = NSMutableParagraphStyle()
255
+ flat.lineBreakMode = .byTruncatingTail
256
+ let textWidth = bounds.width - 14 - 48
257
+ let hasSub = !item.subtitle.isEmpty
258
+ let titleHeight = CGFloat(titleLines) * RowView.lineHeight
259
+ let titleY = bounds.maxY - 7 - titleHeight
260
+ let cell = NSTextFieldCell() // gives the last visible line its ellipsis
261
+ cell.attributedStringValue = NSAttributedString(string: item.title, attributes: [
262
+ .font: RowView.titleFont, .foregroundColor: fg, .paragraphStyle: para,
263
+ ])
264
+ cell.truncatesLastVisibleLine = true
265
+ cell.wraps = true
266
+ cell.drawInterior(withFrame: NSRect(x: 12, y: titleY, width: textWidth, height: titleHeight), in: self)
267
+ if hasSub {
268
+ NSAttributedString(string: item.subtitle, attributes: [
269
+ .font: NSFont.systemFont(ofSize: 11), .foregroundColor: dim, .paragraphStyle: flat,
270
+ ]).draw(in: NSRect(x: 14, y: bounds.minY + 6, width: textWidth, height: 14))
271
+ }
272
+
273
+ // the copy control
274
+ if overCopy || copied {
275
+ (selected ? NSColor.white.withAlphaComponent(0.22) : NSColor.labelColor.withAlphaComponent(0.10)).setFill()
276
+ NSBezierPath(roundedRect: copyRect, xRadius: 5, yRadius: 5).fill()
277
+ }
278
+ let glyphName = copied ? "checkmark" : "doc.on.doc"
279
+ let tint: NSColor = copied ? (selected ? .selectedMenuItemTextColor : .systemGreen) : (selected ? .selectedMenuItemTextColor : .secondaryLabelColor)
280
+ if let sym = NSImage(systemSymbolName: glyphName, accessibilityDescription: copied ? "Copied" : "Copy the question") {
281
+ let cfg = NSImage.SymbolConfiguration(pointSize: 12, weight: .medium)
282
+ let img = sym.withSymbolConfiguration(cfg) ?? sym
283
+ let size = img.size
284
+ let box = NSRect(x: copyRect.midX - size.width / 2, y: copyRect.midY - size.height / 2, width: size.width, height: size.height)
285
+ tint.set()
286
+ img.isTemplate = true
287
+ img.draw(in: box, from: .zero, operation: .sourceOver, fraction: 1.0)
288
+ NSColor.clear.set()
289
+ } else {
290
+ let p2 = NSMutableParagraphStyle(); p2.alignment = .center
291
+ NSAttributedString(string: copied ? "ok" : "copy", attributes: [
292
+ .font: NSFont.systemFont(ofSize: 10, weight: .medium), .foregroundColor: tint, .paragraphStyle: p2,
293
+ ]).draw(in: NSRect(x: copyRect.minX - 6, y: copyRect.midY - 6, width: copyRect.width + 12, height: 13))
294
+ }
295
+ }
296
+ }
297
+
102
298
  final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
103
299
  private var item: NSStatusItem!
104
300
  private var state = TrayState()
@@ -222,21 +418,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
222
418
  : "Connection unavailable · \(state.unavailableSpaces.joined(separator: ", "))")
223
419
  menu.addItem(.separator())
224
420
  if !state.decisionsOk {
225
- info("Some decisions could not be checked")
226
- info("Refresh to try again. The count may be incomplete.")
421
+ info("Some of this could not be checked")
422
+ info("Refresh to try again — the list may be incomplete")
227
423
  }
228
- if state.decisions.isEmpty {
229
- if state.decisionsOk { info("No decisions need your input") }
424
+ let decisions = state.items.filter { $0.kind == "decision" }
425
+ let owed = state.items.filter { $0.kind == "owed" }
426
+ let waiting = state.items.filter { $0.kind == "waiting" }
427
+ if state.items.isEmpty {
428
+ if state.decisionsOk { info("Nothing open — nothing needs you, nothing owed either way") }
230
429
  } else {
231
- let noun = state.decisions.count == 1 ? "decision needs" : "decisions need"
232
- info("\(state.decisions.count) \(noun) your input")
233
- for d in state.decisions.prefix(5) {
234
- let decision = makeItem(d.body.count > 72 ? String(d.body.prefix(71)) + "…" : d.body, #selector(openDecision(_:)))
235
- decision.toolTip = d.detail
236
- decision.representedObject = [d.id, d.prompt]
237
- menu.addItem(decision)
430
+ addRows(menu, decisions.count == 1 ? "1 decision needs you" : "\(decisions.count) decisions need you", decisions)
431
+ addRows(menu, owed.count == 1 ? "You owe 1 thing" : "You owe \(owed.count) things", owed)
432
+ // Grouped by person, because "waiting on praneet" is what someone
433
+ // actually wants to know before they chase it.
434
+ for who in orderedPeers(waiting) {
435
+ let theirs = waiting.filter { $0.who == who }
436
+ addRows(menu, theirs.count == 1 ? "Waiting on \(who)" : "Waiting on \(who) · \(theirs.count)", theirs)
437
+ }
438
+ if !decisions.isEmpty {
439
+ menu.addItem(makeItem(decisions.count > 1 ? "Review all \(decisions.count) in \(state.surfaceName)" : "Review it in \(state.surfaceName)", #selector(openAllDecisions)))
238
440
  }
239
- menu.addItem(makeItem("\(state.decisions.count > 5 ? "Review all \(state.decisions.count) decisions" : "Review decisions") in \(state.surfaceName)", #selector(openAllDecisions)))
240
441
  }
241
442
  menu.addItem(.separator())
242
443
  info(state.workerRunning ? "Unattended work is on" : state.workerInstalled ? "Unattended work is paused" : "Unattended work is not set up")
@@ -274,6 +475,399 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
274
475
  menu.addItem(makeItem("Quit pantheon menu bar", #selector(quit)))
275
476
  }
276
477
 
478
+
479
+ // MARK: the detail panel — the centred window a row opens
480
+
481
+ private var detailPanel: NSPanel?
482
+ private var panelItem: Item?
483
+ private var panelStatus: NSTextField?
484
+ private var panelText: NSTextView?
485
+ private var panelBodyHeight: NSLayoutConstraint?
486
+ private var panelHeadline: NSTextField?
487
+ private var panelRendered: NSAttributedString? // the act laid out for a person
488
+ private var panelRaw: String = "" // the act exactly as it was sent
489
+ private var panelShowingRaw = false
490
+ private var panelRawToggle: NSButton?
491
+
492
+ private func label(_ text: String, size: CGFloat, weight: NSFont.Weight, color: NSColor, width: CGFloat) -> NSTextField {
493
+ let l = NSTextField(labelWithString: text)
494
+ l.font = .systemFont(ofSize: size, weight: weight)
495
+ l.textColor = color
496
+ l.lineBreakMode = .byWordWrapping
497
+ l.maximumNumberOfLines = 0
498
+ l.preferredMaxLayoutWidth = width
499
+ l.translatesAutoresizingMaskIntoConstraints = false
500
+ return l
501
+ }
502
+
503
+ /// How tall the body should be for this much text, between a compact minimum
504
+ /// and a cap that keeps the panel on screen.
505
+ private func bodyHeight(_ text: NSAttributedString, width: CGFloat) -> CGFloat {
506
+ let measured = text.boundingRect(with: NSSize(width: width - 4, height: .greatestFiniteMagnitude),
507
+ options: [.usesLineFragmentOrigin, .usesFontLeading]).height
508
+ return min(max(ceil(measured) + 22, 96), 400)
509
+ }
510
+
511
+ private func plain(_ text: String) -> NSAttributedString {
512
+ let para = NSMutableParagraphStyle()
513
+ para.paragraphSpacing = 10
514
+ para.lineSpacing = 1.5
515
+ return NSAttributedString(string: text, attributes: [
516
+ .font: NSFont.systemFont(ofSize: 13), .foregroundColor: NSColor.labelColor, .paragraphStyle: para,
517
+ ])
518
+ }
519
+
520
+ /**
521
+ * The act as blocks: paragraphs, the options as bullets, shell commands in a
522
+ * monospace run. The parsing is the CLI's (`pantheon tray-detail`), because it
523
+ * is testable there and this file stays a renderer.
524
+ */
525
+ private func render(_ blocks: [[String: Any]]) -> NSAttributedString {
526
+ let out = NSMutableAttributedString()
527
+ let body = NSMutableParagraphStyle()
528
+ body.paragraphSpacing = 12
529
+ body.lineSpacing = 1.5
530
+ let bullet = NSMutableParagraphStyle()
531
+ bullet.paragraphSpacing = 5
532
+ bullet.lineSpacing = 1.5
533
+ bullet.headIndent = 20
534
+ bullet.firstLineHeadIndent = 6
535
+ let code = NSMutableParagraphStyle()
536
+ code.paragraphSpacing = 2
537
+ code.headIndent = 20
538
+ code.firstLineHeadIndent = 12
539
+
540
+ for block in blocks {
541
+ let kind = block["kind"] as? String ?? "para"
542
+ if kind == "para", let text = block["text"] as? String {
543
+ out.append(NSAttributedString(string: text + "\n", attributes: [
544
+ .font: NSFont.systemFont(ofSize: 13), .foregroundColor: NSColor.labelColor, .paragraphStyle: body,
545
+ ]))
546
+ } else if kind == "bullets", let items = block["items"] as? [String] {
547
+ for item in items {
548
+ out.append(NSAttributedString(string: "• " + item + "\n", attributes: [
549
+ .font: NSFont.systemFont(ofSize: 13), .foregroundColor: NSColor.labelColor, .paragraphStyle: bullet,
550
+ ]))
551
+ }
552
+ out.append(NSAttributedString(string: "\n", attributes: [.font: NSFont.systemFont(ofSize: 5)]))
553
+ } else if kind == "code", let lines = block["lines"] as? [String] {
554
+ for line in lines {
555
+ out.append(NSAttributedString(string: line + "\n", attributes: [
556
+ .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular),
557
+ .foregroundColor: NSColor.labelColor,
558
+ .backgroundColor: NSColor.labelColor.withAlphaComponent(0.06),
559
+ .paragraphStyle: code,
560
+ ]))
561
+ }
562
+ out.append(NSAttributedString(string: "\n", attributes: [.font: NSFont.systemFont(ofSize: 5)]))
563
+ }
564
+ }
565
+ return out
566
+ }
567
+
568
+ private func showBody(_ text: NSAttributedString, width: CGFloat) {
569
+ guard let tv = panelText else { return }
570
+ tv.textStorage?.setAttributedString(text)
571
+ panelBodyHeight?.constant = bodyHeight(text, width: width)
572
+ if let p = detailPanel {
573
+ p.contentView?.layoutSubtreeIfNeeded()
574
+ let h = min(max((p.contentView?.fittingSize.height ?? 420) + 30, 300), 680)
575
+ let f = p.frame
576
+ p.setFrame(NSRect(x: f.minX, y: f.maxY - h, width: f.width, height: h), display: true, animate: false)
577
+ }
578
+ }
579
+
580
+ /**
581
+ * The relay's obligation body is ONE clipped line — enough for a menu row, not
582
+ * for a panel that says "here is the whole thing". So fetch the act behind it
583
+ * and lay it out. On demand, only while a panel is open.
584
+ */
585
+ private func loadDetail(_ item: Item, width: CGFloat) {
586
+ guard !item.ref.isEmpty else { return }
587
+ DispatchQueue.global().async {
588
+ let r = run(pantheonBin, ["tray-detail", item.ref], timeout: 20)
589
+ guard let out = r?.out, r?.code == 0,
590
+ let data = out.data(using: .utf8),
591
+ let j = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
592
+ j["ok"] as? Bool == true else { return }
593
+ let blocks = j["blocks"] as? [[String: Any]] ?? []
594
+ let rendered = self.render(blocks)
595
+ let raw = j["raw"] as? String ?? ""
596
+ let headline = j["headline"] as? String ?? ""
597
+ DispatchQueue.main.async {
598
+ guard self.panelItem?.id == item.id else { return }
599
+ self.panelRendered = rendered
600
+ self.panelRaw = raw
601
+ self.panelRawToggle?.isHidden = raw.isEmpty
602
+ if !headline.isEmpty { self.panelHeadline?.stringValue = headline }
603
+ if !self.panelShowingRaw && rendered.length > 0 { self.showBody(rendered, width: width) }
604
+ }
605
+ }
606
+ }
607
+
608
+ /// Reformatting someone else's words is only honest if the original is one click away.
609
+ @objc private func panelToggleRaw() {
610
+ guard let item = panelItem else { return }
611
+ panelShowingRaw.toggle()
612
+ panelRawToggle?.title = panelShowingRaw ? "Show it laid out" : "Show it as sent"
613
+ let width: CGFloat = 620 - 52
614
+ if panelShowingRaw { showBody(plain(panelRaw.isEmpty ? item.detail : panelRaw), width: width) }
615
+ else if let r = panelRendered { showBody(r, width: width) }
616
+ }
617
+
618
+ /**
619
+ * Everything about one open thing, in the middle of the screen: who it is
620
+ * from, the whole text (a menu row can only ever show a clipped line), and the
621
+ * actions that settle it. The menu answers "is anything waiting"; this answers
622
+ * "what is it, and what do I do about it" without a terminal.
623
+ */
624
+ func showDetail(_ item: Item) {
625
+ detailPanel?.close()
626
+ panelItem = item
627
+ let width: CGFloat = 620
628
+ let inner = width - 52
629
+ let panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: width, height: 470),
630
+ styleMask: [.titled, .closable, .fullSizeContentView], backing: .buffered, defer: false)
631
+ panel.title = "Pantheon"
632
+ panel.titlebarAppearsTransparent = true
633
+ panel.isMovableByWindowBackground = true
634
+ panel.level = .floating
635
+ panel.isReleasedWhenClosed = false
636
+ panel.hidesOnDeactivate = false
637
+
638
+ let effect = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: width, height: 470))
639
+ effect.material = .popover
640
+ effect.blendingMode = .behindWindow
641
+ effect.state = .active
642
+ effect.autoresizingMask = [.width, .height]
643
+
644
+ let stack = NSStackView()
645
+ stack.orientation = .vertical
646
+ stack.alignment = .leading
647
+ stack.spacing = 9
648
+ stack.translatesAutoresizingMaskIntoConstraints = false
649
+ effect.addSubview(stack)
650
+
651
+ var eyebrow = item.heading.uppercased()
652
+ if item.when > 0 { eyebrow += " · " + Item.age(item.when) }
653
+ if !item.space.isEmpty { eyebrow += " · " + item.space }
654
+ let eye = label(eyebrow, size: 11, weight: .semibold, color: .secondaryLabelColor, width: inner)
655
+ stack.addArrangedSubview(eye)
656
+ let headline = label(item.headline, size: 17, weight: .semibold, color: .labelColor, width: inner)
657
+ panelHeadline = headline
658
+ stack.addArrangedSubview(headline)
659
+
660
+ // the whole text, selectable and scrollable — a decision is often several paragraphs
661
+ let scroll = NSScrollView()
662
+ scroll.hasVerticalScroller = true
663
+ scroll.drawsBackground = false
664
+ scroll.borderType = .noBorder
665
+ scroll.translatesAutoresizingMaskIntoConstraints = false
666
+ let tv = NSTextView()
667
+ tv.isEditable = false
668
+ tv.isSelectable = true
669
+ tv.drawsBackground = false
670
+ tv.font = .systemFont(ofSize: 13)
671
+ tv.textColor = .labelColor
672
+ tv.textStorage?.setAttributedString(plain(item.detail))
673
+ tv.textContainerInset = NSSize(width: 0, height: 6)
674
+ tv.isVerticallyResizable = true
675
+ tv.isHorizontallyResizable = false
676
+ tv.autoresizingMask = [.width]
677
+ tv.textContainer?.widthTracksTextView = true
678
+ scroll.documentView = tv
679
+ panelText = tv
680
+ stack.addArrangedSubview(scroll)
681
+ stack.setCustomSpacing(14, after: stack.arrangedSubviews[1])
682
+
683
+ var meta = item.isDecision ? "decision \(item.id)" : "obligation \(item.id)"
684
+ if !item.goal.isEmpty { meta += " · goal \(item.goal)" }
685
+ stack.addArrangedSubview(label(meta, size: 11, weight: .regular, color: .tertiaryLabelColor, width: inner))
686
+
687
+ let status = label("", size: 11, weight: .medium, color: .secondaryLabelColor, width: inner)
688
+ status.isHidden = true
689
+ panelStatus = status
690
+ stack.addArrangedSubview(status)
691
+
692
+ let row = NSStackView()
693
+ row.orientation = .horizontal
694
+ row.spacing = 10
695
+ row.translatesAutoresizingMaskIntoConstraints = false
696
+ let open = NSButton(title: "Open in \(state.surfaceName)", target: self, action: #selector(panelOpen))
697
+ open.bezelStyle = .rounded
698
+ open.keyEquivalent = "\r"
699
+ row.addArrangedSubview(open)
700
+ let copy = NSButton(title: "Copy the question", target: self, action: #selector(panelCopy))
701
+ copy.bezelStyle = .rounded
702
+ row.addArrangedSubview(copy)
703
+ if item.isDecision {
704
+ let done = NSButton(title: "Mark done", target: self, action: #selector(panelDone))
705
+ done.bezelStyle = .rounded
706
+ done.toolTip = "Answer it as handled, so it stops asking"
707
+ row.addArrangedSubview(done)
708
+ }
709
+ let thread = NSButton(title: "Show the thread", target: self, action: #selector(panelThread))
710
+ thread.bezelStyle = .rounded
711
+ row.addArrangedSubview(thread)
712
+ let rawToggle = NSButton(title: "Show it as sent", target: self, action: #selector(panelToggleRaw))
713
+ rawToggle.bezelStyle = .rounded
714
+ rawToggle.toolTip = "The act exactly as your teammate's agent wrote it"
715
+ rawToggle.isHidden = true
716
+ panelRawToggle = rawToggle
717
+ row.addArrangedSubview(rawToggle)
718
+ let close = NSButton(title: "Close", target: self, action: #selector(panelClose))
719
+ close.bezelStyle = .rounded
720
+ close.keyEquivalent = "\u{1b}"
721
+ row.addArrangedSubview(close)
722
+ stack.addArrangedSubview(row)
723
+
724
+ NSLayoutConstraint.activate([
725
+ stack.leadingAnchor.constraint(equalTo: effect.leadingAnchor, constant: 26),
726
+ stack.trailingAnchor.constraint(equalTo: effect.trailingAnchor, constant: -26),
727
+ stack.topAnchor.constraint(equalTo: effect.topAnchor, constant: 36),
728
+ stack.bottomAnchor.constraint(equalTo: effect.bottomAnchor, constant: -22),
729
+ scroll.widthAnchor.constraint(equalTo: stack.widthAnchor),
730
+ row.widthAnchor.constraint(lessThanOrEqualTo: stack.widthAnchor),
731
+ ])
732
+ let bh = scroll.heightAnchor.constraint(equalToConstant: bodyHeight(plain(item.detail), width: inner))
733
+ bh.isActive = true
734
+ panelBodyHeight = bh
735
+
736
+ panel.contentView = effect
737
+ // Sized to what it holds: a one-line question should not open a half-empty window.
738
+ effect.layoutSubtreeIfNeeded()
739
+ let height = min(max(effect.fittingSize.height + 30, 300), 660)
740
+ panel.setContentSize(NSSize(width: width, height: height))
741
+ panel.center()
742
+ detailPanel = panel
743
+ NSApp.activate(ignoringOtherApps: true)
744
+ panel.makeKeyAndOrderFront(nil)
745
+ panelRendered = nil
746
+ panelRaw = ""
747
+ panelShowingRaw = false
748
+ loadDetail(item, width: inner)
749
+ }
750
+
751
+ private func setStatus(_ text: String) {
752
+ panelStatus?.stringValue = text
753
+ panelStatus?.isHidden = text.isEmpty
754
+ }
755
+
756
+ @objc private func panelClose() {
757
+ detailPanel?.close()
758
+ detailPanel = nil
759
+ panelText = nil
760
+ panelBodyHeight = nil
761
+ panelItem = nil
762
+ panelHeadline = nil
763
+ panelRendered = nil
764
+ panelRawToggle = nil
765
+ panelRaw = ""
766
+ panelShowingRaw = false
767
+ }
768
+ @objc private func panelCopy() {
769
+ guard let item = panelItem else { return }
770
+ copyPrompt(item)
771
+ setStatus("Copied. Paste it into your agent.")
772
+ }
773
+ @objc private func panelOpen() {
774
+ guard let item = panelItem else { return }
775
+ panelClose()
776
+ openItem(item)
777
+ }
778
+ @objc private func panelThread() {
779
+ guard let item = panelItem else { return }
780
+ panelClose()
781
+ showThread(item)
782
+ }
783
+ /// Answer the decision as handled, with no terminal — the same action the alert's Done takes.
784
+ @objc private func panelDone() {
785
+ guard let item = panelItem, item.isDecision else { return }
786
+ setStatus("Marking it done…")
787
+ var payload: [String: Any] = ["action": "done", "decision": item.id]
788
+ if !item.ref.isEmpty { payload["act"] = item.ref }
789
+ guard writeAction(payload) else {
790
+ setStatus("Could not write the action. Use Show the thread instead.")
791
+ return
792
+ }
793
+ DispatchQueue.global().async {
794
+ let r = run(pantheonBin, ["explain", "--queued"], timeout: 25)
795
+ DispatchQueue.main.async {
796
+ self.setStatus(r?.code == 0
797
+ ? "Done — it is no longer waiting on you."
798
+ : "Could not mark it done. Use Show the thread instead.")
799
+ self.refresh()
800
+ }
801
+ }
802
+ }
803
+
804
+ // MARK: item actions, shared by the menu rows and the panel
805
+
806
+ private func binDir() -> String {
807
+ (Bundle.main.executablePath as NSString?)?.deletingLastPathComponent
808
+ ?? URL(fileURLWithPath: CommandLine.arguments[0]).deletingLastPathComponent().path
809
+ }
810
+ /// What `pantheon explain --queued` reads. Same file the desktop alert writes.
811
+ private func writeAction(_ payload: [String: Any]) -> Bool {
812
+ guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return false }
813
+ return (try? data.write(to: URL(fileURLWithPath: binDir() + "/.hud-action.json"), options: .atomic)) != nil
814
+ }
815
+ func copyPrompt(_ item: Item) {
816
+ let pb = NSPasteboard.general
817
+ pb.clearContents()
818
+ pb.setString(item.prompt, forType: .string)
819
+ }
820
+ /// Open the agent with this item's question already written out.
821
+ func openItem(_ item: Item) {
822
+ copyPrompt(item)
823
+ if writeAction(["action": "open", "prompt": item.prompt]) {
824
+ openCommand("pantheon-explain.command")
825
+ } else {
826
+ reportFailure("Could not open your agent. The question is copied; paste it into your agent.")
827
+ }
828
+ }
829
+ /// The act behind it, in Terminal, with the reply menu.
830
+ func showThread(_ item: Item) {
831
+ var payload: [String: Any] = ["action": "details"]
832
+ if !item.ref.isEmpty { payload["act"] = item.ref }
833
+ if item.isDecision { payload["decision"] = item.id }
834
+ if writeAction(payload) { openCommand("pantheon-details.command") }
835
+ else { reportFailure("Could not open Terminal. Run pantheon doctor to check your setup.") }
836
+ }
837
+
838
+ /// Peers in the order their oldest unanswered thing arrived — longest wait first.
839
+ private func orderedPeers(_ items: [Item]) -> [String] {
840
+ var seen: [String] = []
841
+ for i in items.sorted(by: { $0.when < $1.when }) where !seen.contains(i.who) { seen.append(i.who) }
842
+ return seen
843
+ }
844
+
845
+ /// A titled group of rows. Six at most: a menu is for triage, and anything
846
+ /// deeper belongs in `pantheon brief`, which the last row opens.
847
+ private func addRows(_ menu: NSMenu, _ title: String, _ items: [Item]) {
848
+ guard !items.isEmpty else { return }
849
+ let header = NSMenuItem(title: title, action: nil, keyEquivalent: "")
850
+ header.isEnabled = false
851
+ header.attributedTitle = NSAttributedString(string: title, attributes: [
852
+ .font: NSFont.systemFont(ofSize: 11, weight: .semibold),
853
+ .foregroundColor: NSColor.secondaryLabelColor,
854
+ ])
855
+ menu.addItem(header)
856
+ for item in items.prefix(6) {
857
+ let mi = NSMenuItem(title: item.title, action: nil, keyEquivalent: "")
858
+ mi.toolTip = item.detail
859
+ mi.view = RowView(item: item, width: 430,
860
+ onOpen: { [weak self] it in self?.showDetail(it) },
861
+ onCopy: { [weak self] it in self?.copyPrompt(it) })
862
+ menu.addItem(mi)
863
+ }
864
+ if items.count > 6 {
865
+ let more = NSMenuItem(title: "…and \(items.count - 6) more", action: nil, keyEquivalent: "")
866
+ more.isEnabled = false
867
+ menu.addItem(more)
868
+ }
869
+ }
870
+
277
871
  private func makeItem(_ title: String, _ sel: Selector) -> NSMenuItem {
278
872
  let mi = NSMenuItem(title: title, action: sel, keyEquivalent: "")
279
873
  mi.target = self
@@ -307,10 +901,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
307
901
  @objc private func openStatus() { openCommand("pantheon-status.command") }
308
902
  @objc private func openDoctor() { openCommand("pantheon-doctor.command") }
309
903
  @objc private func openApprovals() { openCommand("pantheon-approvals.command") }
310
- @objc private func openDecision(_ sender: NSMenuItem) {
311
- let pair = sender.representedObject as? [String] ?? []
312
- openAgent(id: pair.first ?? "all", prompt: pair.count > 1 ? pair[1] : "")
313
- }
314
904
  @objc private func openAllDecisions() { openAgent(id: "all", prompt: state.decisionsPrompt) }
315
905
  /**
316
906
  * The handoff: queue the chosen decision id for `pantheon explain --queued`,