@join-pantheon/cli 0.25.2 → 0.26.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.
- package/README.md +43 -5
- package/dist/agents.js +155 -14
- package/dist/cli.js +32 -34
- package/dist/config.js +26 -1
- package/dist/decision-ui.js +13 -3
- package/dist/doctor.js +22 -3
- package/dist/hooks-install.js +58 -0
- package/dist/install.js +78 -13
- package/dist/machine.js +11 -0
- package/dist/measure.js +17 -3
- package/dist/notice.js +5 -2
- package/dist/notifier.js +1 -0
- package/dist/notify.js +12 -2
- package/dist/persona.js +1 -1
- package/dist/profile.js +4 -1
- package/dist/run-failure.js +15 -0
- package/dist/setup-guide.js +1 -1
- package/dist/tray.js +6 -4
- package/dist/worker.js +14 -6
- package/helper/macos/menubar.swift +387 -27
- package/package.json +1 -1
|
@@ -50,6 +50,8 @@ struct TrayState {
|
|
|
50
50
|
var decisionsPrompt = ""
|
|
51
51
|
var decisions: [(id: String, body: String, detail: String, prompt: String)] = []
|
|
52
52
|
var items: [Item] = []
|
|
53
|
+
/// every team this machine is in, joined order; more than one and the board is drawn per team
|
|
54
|
+
var spaces: [String] = []
|
|
53
55
|
var version = ""
|
|
54
56
|
}
|
|
55
57
|
|
|
@@ -75,14 +77,15 @@ struct Item {
|
|
|
75
77
|
default: return "Waiting on \(who)"
|
|
76
78
|
}
|
|
77
79
|
}
|
|
78
|
-
/// "praneet · 2d ago · pantheon"
|
|
79
|
-
var subtitle: String {
|
|
80
|
+
/// "praneet · 2d ago · pantheon" — or without the team, when the section heading already says it
|
|
81
|
+
var subtitle: String { subtitle(showSpace: true) }
|
|
82
|
+
func subtitle(showSpace: Bool) -> String {
|
|
80
83
|
var parts: [String] = []
|
|
81
84
|
// Who is asking, on every kind. A decision row used to say only "11h ago",
|
|
82
85
|
// which is the one fact a reader could already guess.
|
|
83
86
|
if kind == "owed" { parts.append("you owe") } else if !who.isEmpty { parts.append(who) }
|
|
84
87
|
if when > 0 { parts.append(Item.age(when)) }
|
|
85
|
-
if !space.isEmpty { parts.append(space) }
|
|
88
|
+
if showSpace && !space.isEmpty { parts.append(space) }
|
|
86
89
|
return parts.joined(separator: " · ")
|
|
87
90
|
}
|
|
88
91
|
static func age(_ ms: Double) -> String {
|
|
@@ -136,6 +139,7 @@ func fetchState() -> TrayState {
|
|
|
136
139
|
s.surfaceName = j["surface_name"] as? String ?? "agent"
|
|
137
140
|
s.surfaceBundleID = j["surface_bundle_id"] as? String ?? ""
|
|
138
141
|
s.decisionsPrompt = j["decisions_prompt"] as? String ?? ""
|
|
142
|
+
s.spaces = j["spaces"] as? [String] ?? []
|
|
139
143
|
for i in (j["items"] as? [[String: Any]] ?? []) {
|
|
140
144
|
s.items.append(Item(
|
|
141
145
|
id: i["id"] as? String ?? "",
|
|
@@ -182,12 +186,24 @@ final class RowView: NSView {
|
|
|
182
186
|
private var copied = false
|
|
183
187
|
|
|
184
188
|
private let titleLines: Int
|
|
189
|
+
/// Inside a team section rows are indented and a decision carries a dot — "what needs
|
|
190
|
+
/// you" is then read off the dots, since the kind headings are gone. Single-team
|
|
191
|
+
/// boards keep their headings and neither.
|
|
192
|
+
private let leading: CGFloat
|
|
193
|
+
private let dot: Bool
|
|
194
|
+
private let sub: String
|
|
185
195
|
/// The row's MINIMUM width — the menu is at least this wide. The row then
|
|
186
196
|
/// stretches to the menu's real width (macOS adds its own trailing column
|
|
187
197
|
/// for submenu chevrons), so the copy control sits at the edge instead of
|
|
188
198
|
/// floating fifty points left of it over a blank gutter, which is what a
|
|
189
199
|
/// fixed-width row did at every width we tried.
|
|
190
200
|
static let rowWidth: CGFloat = 430
|
|
201
|
+
/// The width the menu actually gave the last row drawn. Rows are 430 wide until the
|
|
202
|
+
/// menu stretches them (a trailing column for submenu chevrons, ~35pt here); a title
|
|
203
|
+
/// measured at 430 that then fits one line at 465 left a blank line above its
|
|
204
|
+
/// subtitle. So heights are measured against the width last seen, and the first open
|
|
205
|
+
/// of a session — before any row has been drawn — splits any leftover evenly instead.
|
|
206
|
+
static var knownWidth: CGFloat?
|
|
191
207
|
private static let titleFont = NSFont.systemFont(ofSize: 13, weight: .medium)
|
|
192
208
|
private static let lineHeight: CGFloat = 17
|
|
193
209
|
|
|
@@ -207,16 +223,19 @@ final class RowView: NSView {
|
|
|
207
223
|
return max(1, min(3, Int(ceil(h / lineHeight))))
|
|
208
224
|
}
|
|
209
225
|
|
|
210
|
-
init(item: Item, width: CGFloat, onOpen: @escaping (Item) -> Void, onCopy: @escaping (Item) -> Void) {
|
|
226
|
+
init(item: Item, width: CGFloat, inTeamSection: Bool = false, onOpen: @escaping (Item) -> Void, onCopy: @escaping (Item) -> Void) {
|
|
211
227
|
self.item = item
|
|
212
228
|
self.onOpen = onOpen
|
|
213
229
|
self.onCopy = onCopy
|
|
214
|
-
self.
|
|
230
|
+
self.leading = inTeamSection ? 26 : 12
|
|
231
|
+
self.dot = inTeamSection && item.isDecision
|
|
232
|
+
self.sub = item.subtitle(showSpace: !inTeamSection)
|
|
233
|
+
self.titleLines = RowView.lines(for: item.title, width: (RowView.knownWidth ?? width) - RowView.textInsets - (leading - 12))
|
|
215
234
|
let textHeight = CGFloat(titleLines) * RowView.lineHeight
|
|
216
|
-
super.init(frame: NSRect(x: 0, y: 0, width: width, height: textHeight + (
|
|
235
|
+
super.init(frame: NSRect(x: 0, y: 0, width: width, height: textHeight + (sub.isEmpty ? 13 : 29)))
|
|
217
236
|
autoresizingMask = [.width] // fill the menu: the text runs to the control, the control to the edge
|
|
218
237
|
setAccessibilityRole(.button)
|
|
219
|
-
setAccessibilityLabel("\(item.title). \(
|
|
238
|
+
setAccessibilityLabel("\(item.title). \(sub). Return opens it; the button on the right copies the question.")
|
|
220
239
|
}
|
|
221
240
|
required init?(coder: NSCoder) { return nil }
|
|
222
241
|
|
|
@@ -265,21 +284,33 @@ final class RowView: NSView {
|
|
|
265
284
|
para.lineSpacing = 0
|
|
266
285
|
let flat = NSMutableParagraphStyle()
|
|
267
286
|
flat.lineBreakMode = .byTruncatingTail
|
|
268
|
-
let textWidth = bounds.width - RowView.textInsets
|
|
269
|
-
|
|
270
|
-
let
|
|
271
|
-
|
|
287
|
+
let textWidth = bounds.width - RowView.textInsets - (leading - 12)
|
|
288
|
+
if RowView.knownWidth != bounds.width { RowView.knownWidth = bounds.width }
|
|
289
|
+
let hasSub = !sub.isEmpty
|
|
290
|
+
// Lay out for the lines the title really needs at this width. Whatever the row
|
|
291
|
+
// was allocated beyond that is split above and below, so title and subtitle
|
|
292
|
+
// stay together and the row reads as slightly taller, never as a gap.
|
|
293
|
+
let actual = min(titleLines, RowView.lines(for: item.title, width: textWidth))
|
|
294
|
+
let titleHeight = CGFloat(actual) * RowView.lineHeight
|
|
295
|
+
let spare = (CGFloat(titleLines) - CGFloat(actual)) * RowView.lineHeight / 2
|
|
296
|
+
let titleY = bounds.maxY - 7 - spare - titleHeight
|
|
272
297
|
let cell = NSTextFieldCell() // gives the last visible line its ellipsis
|
|
273
298
|
cell.attributedStringValue = NSAttributedString(string: item.title, attributes: [
|
|
274
299
|
.font: RowView.titleFont, .foregroundColor: fg, .paragraphStyle: para,
|
|
275
300
|
])
|
|
276
301
|
cell.truncatesLastVisibleLine = true
|
|
277
302
|
cell.wraps = true
|
|
278
|
-
cell.drawInterior(withFrame: NSRect(x:
|
|
303
|
+
cell.drawInterior(withFrame: NSRect(x: leading, y: titleY, width: textWidth, height: titleHeight), in: self)
|
|
279
304
|
if hasSub {
|
|
280
|
-
NSAttributedString(string:
|
|
305
|
+
NSAttributedString(string: sub, attributes: [
|
|
281
306
|
.font: NSFont.systemFont(ofSize: 11), .foregroundColor: dim, .paragraphStyle: flat,
|
|
282
|
-
]).draw(in: NSRect(x:
|
|
307
|
+
]).draw(in: NSRect(x: leading + 2, y: bounds.minY + 6 + spare, width: textWidth, height: 14))
|
|
308
|
+
}
|
|
309
|
+
if dot {
|
|
310
|
+
// the one mark that says "this needs you", where the kind heading used to;
|
|
311
|
+
// centred on the title's first line
|
|
312
|
+
(selected ? NSColor.selectedMenuItemTextColor : NSColor.systemBlue).setFill()
|
|
313
|
+
NSBezierPath(ovalIn: NSRect(x: 12, y: titleY + titleHeight - RowView.lineHeight / 2 - 3, width: 6, height: 6)).fill()
|
|
283
314
|
}
|
|
284
315
|
|
|
285
316
|
// the copy control
|
|
@@ -307,6 +338,115 @@ final class RowView: NSView {
|
|
|
307
338
|
}
|
|
308
339
|
}
|
|
309
340
|
|
|
341
|
+
/// A section heading or summary line drawn by us, not as a disabled menu item.
|
|
342
|
+
/// A disabled item is rendered dim by the system whatever colour it is given, so
|
|
343
|
+
/// the team names and "2 need you" — the words a person scans for — were the
|
|
344
|
+
/// faintest text in the menu. This draws the lead in the reading colour and the
|
|
345
|
+
/// rest one step down, at the same left edge as the rows' dots.
|
|
346
|
+
final class HeaderView: NSView {
|
|
347
|
+
private let lead: String
|
|
348
|
+
private let rest: String
|
|
349
|
+
private let bright: Bool
|
|
350
|
+
init(lead: String, rest: String, width: CGFloat, bright: Bool = true) {
|
|
351
|
+
self.lead = lead; self.rest = rest; self.bright = bright
|
|
352
|
+
super.init(frame: NSRect(x: 0, y: 0, width: width, height: 22))
|
|
353
|
+
autoresizingMask = [.width]
|
|
354
|
+
}
|
|
355
|
+
required init?(coder: NSCoder) { return nil }
|
|
356
|
+
override func draw(_ dirtyRect: NSRect) {
|
|
357
|
+
let text = NSMutableAttributedString(string: lead, attributes: [
|
|
358
|
+
.font: NSFont.systemFont(ofSize: 12, weight: .semibold),
|
|
359
|
+
.foregroundColor: bright ? NSColor.labelColor : NSColor.secondaryLabelColor,
|
|
360
|
+
])
|
|
361
|
+
if !rest.isEmpty {
|
|
362
|
+
text.append(NSAttributedString(string: " " + rest, attributes: [
|
|
363
|
+
.font: NSFont.systemFont(ofSize: 12, weight: .regular),
|
|
364
|
+
.foregroundColor: NSColor.labelColor.withAlphaComponent(0.72),
|
|
365
|
+
]))
|
|
366
|
+
}
|
|
367
|
+
let para = NSMutableParagraphStyle(); para.lineBreakMode = .byTruncatingTail
|
|
368
|
+
text.addAttribute(.paragraphStyle, value: para, range: NSRange(location: 0, length: text.length))
|
|
369
|
+
text.draw(in: NSRect(x: 12, y: bounds.minY + 3, width: bounds.width - 24, height: 16))
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/*
|
|
374
|
+
* The board: an opaque, dark panel under the icon.
|
|
375
|
+
*
|
|
376
|
+
* It was an NSMenu. A menu is translucent by the system's decision — the
|
|
377
|
+
* wallpaper tints it, the text sits on whatever is behind it — and there is no
|
|
378
|
+
* switch to make one opaque. The person reading this every day asked for the
|
|
379
|
+
* clarity of a dark card, the way Cursor's notification list is drawn; that is a
|
|
380
|
+
* panel, not a menu. The rows and headings are the same views the menu used; the
|
|
381
|
+
* panel gives them a solid background, a fixed width, and one click to dismiss.
|
|
382
|
+
* A right-click on the icon still opens the classic menu — keyboard users and
|
|
383
|
+
* anything that ever goes wrong with the panel have a way in.
|
|
384
|
+
*/
|
|
385
|
+
final class BoardPanel: NSPanel {
|
|
386
|
+
var onEscape: (() -> Void)?
|
|
387
|
+
override var canBecomeKey: Bool { true }
|
|
388
|
+
override func cancelOperation(_ sender: Any?) { onEscape?() }
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
final class BoardBackground: NSView {
|
|
392
|
+
override func draw(_ dirtyRect: NSRect) {
|
|
393
|
+
let path = NSBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), xRadius: 12, yRadius: 12)
|
|
394
|
+
NSColor(calibratedWhite: 0.115, alpha: 1).setFill()
|
|
395
|
+
path.fill()
|
|
396
|
+
NSColor(calibratedWhite: 1, alpha: 0.13).setStroke()
|
|
397
|
+
path.lineWidth = 1
|
|
398
|
+
path.stroke()
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/// A one-line action or note in the board: a title, an optional trailing hint,
|
|
403
|
+
/// a hover highlight when it does something, and nothing at all when it does not.
|
|
404
|
+
final class ActionRow: NSView {
|
|
405
|
+
private let title: String
|
|
406
|
+
private let hint: String
|
|
407
|
+
private let onClick: (() -> Void)?
|
|
408
|
+
private var hovering = false
|
|
409
|
+
init(_ title: String, hint: String = "", width: CGFloat, onClick: (() -> Void)? = nil) {
|
|
410
|
+
self.title = title; self.hint = hint; self.onClick = onClick
|
|
411
|
+
super.init(frame: NSRect(x: 0, y: 0, width: width, height: 26))
|
|
412
|
+
autoresizingMask = [.width]
|
|
413
|
+
if onClick != nil { setAccessibilityRole(.button); setAccessibilityLabel(title) }
|
|
414
|
+
}
|
|
415
|
+
required init?(coder: NSCoder) { return nil }
|
|
416
|
+
override func updateTrackingAreas() {
|
|
417
|
+
super.updateTrackingAreas()
|
|
418
|
+
for a in trackingAreas { removeTrackingArea(a) }
|
|
419
|
+
if onClick != nil { addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .activeAlways], owner: self, userInfo: nil)) }
|
|
420
|
+
}
|
|
421
|
+
override func mouseEntered(with e: NSEvent) { hovering = true; needsDisplay = true }
|
|
422
|
+
override func mouseExited(with e: NSEvent) { hovering = false; needsDisplay = true }
|
|
423
|
+
override func mouseUp(with e: NSEvent) { onClick?() }
|
|
424
|
+
override func draw(_ dirtyRect: NSRect) {
|
|
425
|
+
if hovering {
|
|
426
|
+
NSColor.selectedContentBackgroundColor.setFill()
|
|
427
|
+
NSBezierPath(roundedRect: bounds.insetBy(dx: 5, dy: 1), xRadius: 5, yRadius: 5).fill()
|
|
428
|
+
}
|
|
429
|
+
let fg: NSColor = onClick == nil ? .secondaryLabelColor : hovering ? .selectedMenuItemTextColor : .labelColor
|
|
430
|
+
let para = NSMutableParagraphStyle(); para.lineBreakMode = .byTruncatingTail
|
|
431
|
+
NSAttributedString(string: title, attributes: [.font: NSFont.systemFont(ofSize: 13), .foregroundColor: fg, .paragraphStyle: para])
|
|
432
|
+
.draw(in: NSRect(x: 12, y: bounds.midY - 8, width: bounds.width - 24 - (hint.isEmpty ? 0 : 80), height: 17))
|
|
433
|
+
if !hint.isEmpty {
|
|
434
|
+
let h = NSMutableParagraphStyle(); h.alignment = .right
|
|
435
|
+
NSAttributedString(string: hint, attributes: [.font: NSFont.systemFont(ofSize: 12), .foregroundColor: hovering ? NSColor.selectedMenuItemTextColor.withAlphaComponent(0.8) : NSColor.tertiaryLabelColor, .paragraphStyle: h])
|
|
436
|
+
.draw(in: NSRect(x: bounds.maxX - 12 - 160, y: bounds.midY - 8, width: 160, height: 17))
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
final class BoardSeparator: NSView {
|
|
442
|
+
init(width: CGFloat) { super.init(frame: NSRect(x: 0, y: 0, width: width, height: 9)); autoresizingMask = [.width] }
|
|
443
|
+
required init?(coder: NSCoder) { return nil }
|
|
444
|
+
override func draw(_ dirtyRect: NSRect) {
|
|
445
|
+
NSColor(calibratedWhite: 1, alpha: 0.12).setFill()
|
|
446
|
+
NSRect(x: 12, y: bounds.midY, width: bounds.width - 24, height: 1).fill()
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
310
450
|
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
311
451
|
private var item: NSStatusItem!
|
|
312
452
|
private var state = TrayState()
|
|
@@ -322,7 +462,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
322
462
|
item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
323
463
|
let menu = NSMenu()
|
|
324
464
|
menu.delegate = self
|
|
325
|
-
|
|
465
|
+
classicMenu = menu
|
|
466
|
+
// Left click: the board. Right click: the classic menu, which stays exactly as it was.
|
|
467
|
+
item.button?.target = self
|
|
468
|
+
item.button?.action = #selector(iconClicked(_:))
|
|
469
|
+
item.button?.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
|
326
470
|
paint()
|
|
327
471
|
refresh()
|
|
328
472
|
timer = Timer.scheduledTimer(withTimeInterval: pollSeconds, repeats: true) { [weak self] _ in
|
|
@@ -341,8 +485,172 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
341
485
|
self.refreshing = false
|
|
342
486
|
self.paint()
|
|
343
487
|
if !self.menuOpen, let menu = self.item.menu { self.menuNeedsUpdate(menu) }
|
|
488
|
+
// The board redraws only when what it shows has changed, so a poll never
|
|
489
|
+
// pulls a row out from under the pointer for nothing.
|
|
490
|
+
if self.boardPanel?.isVisible == true && self.boardSignature != self.signature() { self.rebuildBoard() }
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// MARK: the board
|
|
496
|
+
|
|
497
|
+
private var classicMenu: NSMenu?
|
|
498
|
+
private var boardPanel: BoardPanel?
|
|
499
|
+
private var boardSignature = ""
|
|
500
|
+
private var boardMonitor: Any?
|
|
501
|
+
static let boardWidth: CGFloat = 470
|
|
502
|
+
|
|
503
|
+
private func signature() -> String {
|
|
504
|
+
"\(state.items.map { $0.id }.joined(separator: ","))|\(state.decisions.count)|\(state.online)|\(state.members)|\(state.workerRunning)|\(state.relayOk)|\(actionError ?? "")|\(workerAction ?? "")"
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
@objc private func iconClicked(_ sender: Any?) {
|
|
508
|
+
if NSApp.currentEvent?.type == .rightMouseUp, let menu = classicMenu {
|
|
509
|
+
closeBoard()
|
|
510
|
+
item.menu = menu
|
|
511
|
+
item.button?.performClick(nil)
|
|
512
|
+
item.menu = nil
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
if boardPanel?.isVisible == true { closeBoard() } else { openBoard() }
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private func openBoard() {
|
|
519
|
+
refresh()
|
|
520
|
+
let panel = boardPanel ?? makeBoardPanel()
|
|
521
|
+
boardPanel = panel
|
|
522
|
+
rebuildBoard()
|
|
523
|
+
positionBoard(panel)
|
|
524
|
+
panel.makeKeyAndOrderFront(nil)
|
|
525
|
+
item.button?.highlight(true)
|
|
526
|
+
// A click anywhere else closes it, the way a menu closes — but without a menu's
|
|
527
|
+
// grip on the event loop, so the rest of the Mac keeps working while it is up.
|
|
528
|
+
boardMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] _ in self?.closeBoard() }
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
func closeBoard() {
|
|
532
|
+
if let m = boardMonitor { NSEvent.removeMonitor(m); boardMonitor = nil }
|
|
533
|
+
boardPanel?.orderOut(nil)
|
|
534
|
+
item.button?.highlight(false)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private func makeBoardPanel() -> BoardPanel {
|
|
538
|
+
let panel = BoardPanel(contentRect: NSRect(x: 0, y: 0, width: AppDelegate.boardWidth, height: 200),
|
|
539
|
+
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
|
|
540
|
+
panel.isOpaque = false
|
|
541
|
+
panel.backgroundColor = .clear
|
|
542
|
+
panel.hasShadow = true
|
|
543
|
+
panel.level = .popUpMenu
|
|
544
|
+
panel.isReleasedWhenClosed = false
|
|
545
|
+
panel.hidesOnDeactivate = false
|
|
546
|
+
panel.appearance = NSAppearance(named: .darkAqua) // the dark card, whatever the system is set to
|
|
547
|
+
panel.onEscape = { [weak self] in self?.closeBoard() }
|
|
548
|
+
panel.contentView = BoardBackground(frame: panel.contentRect(forFrameRect: panel.frame))
|
|
549
|
+
return panel
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private func positionBoard(_ panel: BoardPanel) {
|
|
553
|
+
guard let button = item.button, let win = button.window, let screen = win.screen ?? NSScreen.main else { return }
|
|
554
|
+
let anchor = win.convertToScreen(button.convert(button.bounds, to: nil))
|
|
555
|
+
let size = panel.frame.size
|
|
556
|
+
var x = anchor.midX - size.width / 2
|
|
557
|
+
x = max(screen.visibleFrame.minX + 8, min(x, screen.visibleFrame.maxX - size.width - 8))
|
|
558
|
+
let y = anchor.minY - 6 - size.height
|
|
559
|
+
panel.setFrameOrigin(NSPoint(x: x, y: y))
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/// Everything the classic menu shows, laid out as a card: the summary, one
|
|
563
|
+
/// section per team (or the kind headings for a single team), then the actions.
|
|
564
|
+
private func rebuildBoard() {
|
|
565
|
+
guard let panel = boardPanel, let bg = panel.contentView else { return }
|
|
566
|
+
boardSignature = signature()
|
|
567
|
+
bg.subviews.forEach { $0.removeFromSuperview() }
|
|
568
|
+
let w = AppDelegate.boardWidth
|
|
569
|
+
var rows: [NSView] = []
|
|
570
|
+
let note = { (text: String) in rows.append(ActionRow(text, width: w)) }
|
|
571
|
+
let act = { (text: String, hint: String, f: @escaping () -> Void) in rows.append(ActionRow(text, hint: hint, width: w, onClick: { [weak self] in self?.closeBoard(); f() })) }
|
|
572
|
+
let sep = { rows.append(BoardSeparator(width: w)) }
|
|
573
|
+
let openRow: (Item) -> Void = { [weak self] it in self?.closeBoard(); self?.showDetail(it) }
|
|
574
|
+
let copyRow: (Item) -> Void = { [weak self] it in self?.copyPrompt(it) }
|
|
575
|
+
let section = { (title: String, detail: String, items: [Item], team: Bool) in
|
|
576
|
+
guard !items.isEmpty else { return }
|
|
577
|
+
rows.append(HeaderView(lead: title, rest: detail, width: w, bright: team))
|
|
578
|
+
for it in items.prefix(team ? 8 : 6) { rows.append(RowView(item: it, width: w, inTeamSection: team, onOpen: openRow, onCopy: copyRow)) }
|
|
579
|
+
if items.count > (team ? 8 : 6) { rows.append(ActionRow("…and \(items.count - (team ? 8 : 6)) more", width: w)) }
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if !hasLoaded {
|
|
583
|
+
note("Checking Pantheon…")
|
|
584
|
+
} else if !state.reachable {
|
|
585
|
+
note("Unable to check Pantheon status"); note("Try Refresh now, or Check connection below")
|
|
586
|
+
} else if !state.configured {
|
|
587
|
+
note("Connect with your team"); note("Run pantheon start, or join with an invite link")
|
|
588
|
+
} else {
|
|
589
|
+
let teams = state.spaces.count > 1 ? state.spaces : []
|
|
590
|
+
let decisions = state.items.filter { $0.kind == "decision" }
|
|
591
|
+
let owed = state.items.filter { $0.kind == "owed" }
|
|
592
|
+
let waiting = state.items.filter { $0.kind == "waiting" }
|
|
593
|
+
let need = decisions.isEmpty ? "Nothing needs you" : "\(decisions.count) need\(decisions.count == 1 ? "s" : "") you"
|
|
594
|
+
let where_ = teams.isEmpty ? state.space : "\(teams.count) teams"
|
|
595
|
+
rows.append(HeaderView(lead: need, rest: "\(state.member) · \(where_) · " + (state.relayOk ? "\(state.online) of \(state.members) online" : "connection unavailable"), width: w))
|
|
596
|
+
if !state.relayOk { note("Could not reach: \(state.unavailableSpaces.joined(separator: ", "))") }
|
|
597
|
+
if !state.decisionsOk { note("Some of this could not be checked — the list may be incomplete") }
|
|
598
|
+
sep()
|
|
599
|
+
if !teams.isEmpty {
|
|
600
|
+
for (i, team) in teams.enumerated() {
|
|
601
|
+
if i > 0 { sep() }
|
|
602
|
+
let mine = state.items.filter { $0.space == team }
|
|
603
|
+
let ordered = mine.filter { $0.kind == "decision" } + mine.filter { $0.kind == "owed" } + mine.filter { $0.kind == "waiting" }
|
|
604
|
+
if state.unavailableSpaces.contains(team) { rows.append(HeaderView(lead: team, rest: "could not be checked", width: w, bright: false)) }
|
|
605
|
+
else if ordered.isEmpty { rows.append(HeaderView(lead: team, rest: "nothing open", width: w, bright: false)) }
|
|
606
|
+
else { section(team, counts(ordered), ordered, true) }
|
|
607
|
+
}
|
|
608
|
+
} else if state.items.isEmpty {
|
|
609
|
+
if state.decisionsOk { note("Nothing open — nothing needs you, nothing owed either way") }
|
|
610
|
+
} else {
|
|
611
|
+
section(decisions.count == 1 ? "1 decision needs you" : "\(decisions.count) decisions need you", "", decisions, false)
|
|
612
|
+
section(owed.count == 1 ? "You owe 1 thing" : "You owe \(owed.count) things", "", owed, false)
|
|
613
|
+
for who in orderedPeers(waiting) {
|
|
614
|
+
let theirs = waiting.filter { $0.who == who }
|
|
615
|
+
section(theirs.count == 1 ? "Waiting on \(who)" : "Waiting on \(who) · \(theirs.count)", "", theirs, false)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if !decisions.isEmpty {
|
|
619
|
+
sep()
|
|
620
|
+
act(decisions.count > 1 ? "Review all \(decisions.count) in \(state.surfaceName)" : "Review it in \(state.surfaceName)", "") { [weak self] in self?.openAllDecisions() }
|
|
344
621
|
}
|
|
622
|
+
sep()
|
|
623
|
+
if let action = workerAction { note(action) }
|
|
624
|
+
else if state.workerRunning { act("Pause unattended work", state.workerState) { [weak self] in self?.pauseWorker() } }
|
|
625
|
+
else if state.workerInstalled { act("Resume unattended work", "paused") { [weak self] in self?.resumeWorker() } }
|
|
626
|
+
else { note("Unattended work is not set up — pantheon service install worker") }
|
|
345
627
|
}
|
|
628
|
+
if let error = actionError { sep(); note(error) }
|
|
629
|
+
sep()
|
|
630
|
+
act("Open status in Terminal", "") { [weak self] in self?.openStatus() }
|
|
631
|
+
act("Check connection in Terminal", "version \(state.version)") { [weak self] in self?.openDoctor() }
|
|
632
|
+
act("View decision details in Terminal", "") { [weak self] in self?.openApprovals() }
|
|
633
|
+
act("Refresh now", "") { [weak self] in self?.refresh() }
|
|
634
|
+
sep()
|
|
635
|
+
act("Quit pantheon menu bar", "") { [weak self] in self?.quit() }
|
|
636
|
+
|
|
637
|
+
// Lay the rows out top to bottom at their own heights, in a card no taller than the screen.
|
|
638
|
+
let total = rows.reduce(CGFloat(16)) { $0 + $1.frame.height }
|
|
639
|
+
let maxH = (panel.screen ?? NSScreen.main)?.visibleFrame.height ?? 900
|
|
640
|
+
let height = min(total, maxH - 24)
|
|
641
|
+
panel.setContentSize(NSSize(width: w, height: height))
|
|
642
|
+
bg.frame = NSRect(x: 0, y: 0, width: w, height: height)
|
|
643
|
+
let doc = NSView(frame: NSRect(x: 0, y: 0, width: w, height: total))
|
|
644
|
+
var y = total - 8
|
|
645
|
+
for v in rows { y -= v.frame.height; v.frame = NSRect(x: 0, y: y, width: w, height: v.frame.height); doc.addSubview(v) }
|
|
646
|
+
let scroll = NSScrollView(frame: NSRect(x: 0, y: 0, width: w, height: height))
|
|
647
|
+
scroll.drawsBackground = false
|
|
648
|
+
scroll.hasVerticalScroller = total > height
|
|
649
|
+
scroll.documentView = doc
|
|
650
|
+
scroll.autoresizingMask = [.width, .height]
|
|
651
|
+
bg.addSubview(scroll)
|
|
652
|
+
if total > height { doc.scroll(NSPoint(x: 0, y: total)) } // start at the top
|
|
653
|
+
if panel.isVisible { positionBoard(panel) }
|
|
346
654
|
}
|
|
347
655
|
|
|
348
656
|
// Keep targets stable while a person is navigating the menu. The badge can
|
|
@@ -424,6 +732,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
424
732
|
info("Connect with your team")
|
|
425
733
|
info("Run pantheon start, or join with an invite link")
|
|
426
734
|
} else {
|
|
735
|
+
let teams = state.spaces.count > 1 ? state.spaces : []
|
|
736
|
+
let decisions = state.items.filter { $0.kind == "decision" }
|
|
737
|
+
let owed = state.items.filter { $0.kind == "owed" }
|
|
738
|
+
let waiting = state.items.filter { $0.kind == "waiting" }
|
|
739
|
+
if !teams.isEmpty {
|
|
740
|
+
// Several teams: the board is drawn per team, and "what needs you" is read
|
|
741
|
+
// off the dots. One mixed list with the team as a grey suffix on every row
|
|
742
|
+
// was the version a person with two teams called impossible to follow.
|
|
743
|
+
let summary = NSMenuItem(title: state.member, action: nil, keyEquivalent: "")
|
|
744
|
+
summary.isEnabled = false
|
|
745
|
+
summary.view = HeaderView(lead: decisions.isEmpty ? "Nothing needs you" : "\(decisions.count) need\(decisions.count == 1 ? "s" : "") you",
|
|
746
|
+
rest: "\(state.member) · \(teams.count) teams · " + (state.relayOk ? "\(state.online) of \(state.members) online" : "connection unavailable"),
|
|
747
|
+
width: RowView.rowWidth)
|
|
748
|
+
menu.addItem(summary)
|
|
749
|
+
if !state.relayOk { info("Could not reach: \(state.unavailableSpaces.joined(separator: ", "))") }
|
|
750
|
+
menu.addItem(.separator())
|
|
751
|
+
for (index, team) in teams.enumerated() {
|
|
752
|
+
if index > 0 { menu.addItem(.separator()) }
|
|
753
|
+
let rows = state.items.filter { $0.space == team }
|
|
754
|
+
let ordered = rows.filter { $0.kind == "decision" } + rows.filter { $0.kind == "owed" } + rows.filter { $0.kind == "waiting" }
|
|
755
|
+
if state.unavailableSpaces.contains(team) {
|
|
756
|
+
let h = NSMenuItem(title: team, action: nil, keyEquivalent: ""); h.isEnabled = false
|
|
757
|
+
h.view = HeaderView(lead: team, rest: "could not be checked", width: RowView.rowWidth, bright: false); menu.addItem(h)
|
|
758
|
+
} else if ordered.isEmpty {
|
|
759
|
+
let h = NSMenuItem(title: team, action: nil, keyEquivalent: ""); h.isEnabled = false
|
|
760
|
+
h.view = HeaderView(lead: team, rest: "nothing open", width: RowView.rowWidth, bright: false); menu.addItem(h)
|
|
761
|
+
} else {
|
|
762
|
+
addRows(menu, team, ordered, team: true, detail: counts(ordered))
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
if !decisions.isEmpty {
|
|
766
|
+
menu.addItem(.separator())
|
|
767
|
+
menu.addItem(makeItem(decisions.count > 1 ? "Review all \(decisions.count) in \(state.surfaceName)" : "Review it in \(state.surfaceName)", #selector(openAllDecisions)))
|
|
768
|
+
}
|
|
769
|
+
} else {
|
|
427
770
|
info("\(state.member) · \(state.space)")
|
|
428
771
|
info(state.relayOk
|
|
429
772
|
? "Connected · \(state.online) of \(state.members) members online"
|
|
@@ -433,9 +776,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
433
776
|
info("Some of this could not be checked")
|
|
434
777
|
info("Refresh to try again — the list may be incomplete")
|
|
435
778
|
}
|
|
436
|
-
let decisions = state.items.filter { $0.kind == "decision" }
|
|
437
|
-
let owed = state.items.filter { $0.kind == "owed" }
|
|
438
|
-
let waiting = state.items.filter { $0.kind == "waiting" }
|
|
439
779
|
if state.items.isEmpty {
|
|
440
780
|
if state.decisionsOk { info("Nothing open — nothing needs you, nothing owed either way") }
|
|
441
781
|
} else {
|
|
@@ -451,6 +791,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
451
791
|
menu.addItem(makeItem(decisions.count > 1 ? "Review all \(decisions.count) in \(state.surfaceName)" : "Review it in \(state.surfaceName)", #selector(openAllDecisions)))
|
|
452
792
|
}
|
|
453
793
|
}
|
|
794
|
+
}
|
|
454
795
|
menu.addItem(.separator())
|
|
455
796
|
info(state.workerRunning ? "Unattended work is on" : state.workerInstalled ? "Unattended work is paused" : "Unattended work is not set up")
|
|
456
797
|
if !state.workerState.isEmpty && state.workerRunning { info(state.workerState) }
|
|
@@ -856,30 +1197,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
856
1197
|
|
|
857
1198
|
/// A titled group of rows. Six at most: a menu is for triage, and anything
|
|
858
1199
|
/// deeper belongs in `pantheon brief`, which the last row opens.
|
|
859
|
-
private func addRows(_ menu: NSMenu, _ title: String, _ items: [Item]) {
|
|
1200
|
+
private func addRows(_ menu: NSMenu, _ title: String, _ items: [Item], team: Bool = false, detail: String = "") {
|
|
860
1201
|
guard !items.isEmpty else { return }
|
|
861
1202
|
let header = NSMenuItem(title: title, action: nil, keyEquivalent: "")
|
|
862
1203
|
header.isEnabled = false
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
1204
|
+
if team {
|
|
1205
|
+
// A team heading is drawn by us: the name in the reading colour, the counts
|
|
1206
|
+
// a step down — a disabled item would render both as the dimmest text here.
|
|
1207
|
+
header.view = HeaderView(lead: title, rest: detail, width: RowView.rowWidth)
|
|
1208
|
+
} else {
|
|
1209
|
+
header.attributedTitle = NSAttributedString(string: title, attributes: [
|
|
1210
|
+
.font: NSFont.systemFont(ofSize: 11, weight: .semibold),
|
|
1211
|
+
.foregroundColor: NSColor.secondaryLabelColor,
|
|
1212
|
+
])
|
|
1213
|
+
}
|
|
867
1214
|
menu.addItem(header)
|
|
868
|
-
for item in items.prefix(6) {
|
|
1215
|
+
for item in items.prefix(team ? 8 : 6) {
|
|
869
1216
|
let mi = NSMenuItem(title: item.title, action: nil, keyEquivalent: "")
|
|
870
1217
|
mi.toolTip = item.detail
|
|
871
|
-
mi.view = RowView(item: item, width: RowView.rowWidth,
|
|
1218
|
+
mi.view = RowView(item: item, width: RowView.rowWidth, inTeamSection: team,
|
|
872
1219
|
onOpen: { [weak self] it in self?.showDetail(it) },
|
|
873
1220
|
onCopy: { [weak self] it in self?.copyPrompt(it) })
|
|
874
1221
|
menu.addItem(mi)
|
|
875
1222
|
}
|
|
876
|
-
|
|
877
|
-
|
|
1223
|
+
let shown = team ? 8 : 6
|
|
1224
|
+
if items.count > shown {
|
|
1225
|
+
let more = NSMenuItem(title: "…and \(items.count - shown) more", action: nil, keyEquivalent: "")
|
|
878
1226
|
more.isEnabled = false
|
|
879
1227
|
menu.addItem(more)
|
|
880
1228
|
}
|
|
881
1229
|
}
|
|
882
1230
|
|
|
1231
|
+
/// "5 need you · 1 owed · 2 waiting" — only the parts that are not zero.
|
|
1232
|
+
private func counts(_ rows: [Item]) -> String {
|
|
1233
|
+
let need = rows.filter { $0.kind == "decision" }.count
|
|
1234
|
+
let owed = rows.filter { $0.kind == "owed" }.count
|
|
1235
|
+
let waiting = rows.filter { $0.kind == "waiting" }.count
|
|
1236
|
+
var parts: [String] = []
|
|
1237
|
+
if need > 0 { parts.append("\(need) need\(need == 1 ? "s" : "") you") }
|
|
1238
|
+
if owed > 0 { parts.append("\(owed) owed") }
|
|
1239
|
+
if waiting > 0 { parts.append("\(waiting) waiting") }
|
|
1240
|
+
return parts.joined(separator: " · ")
|
|
1241
|
+
}
|
|
1242
|
+
|
|
883
1243
|
private func makeItem(_ title: String, _ sel: Selector) -> NSMenuItem {
|
|
884
1244
|
let mi = NSMenuItem(title: title, action: sel, keyEquivalent: "")
|
|
885
1245
|
mi.target = self
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@join-pantheon/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
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": {
|