@join-pantheon/cli 0.25.1 → 0.25.3
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 +6 -1
- package/dist/admin-stats.js +21 -7
- package/dist/agents.js +3 -0
- package/dist/cli.js +10 -11
- package/dist/decision-ui.js +13 -3
- package/dist/doctor.js +26 -25
- package/dist/grants.js +40 -7
- package/dist/install.js +115 -32
- package/dist/mcp-instructions.js +1 -1
- package/dist/mcp.js +17 -5
- package/dist/measure.js +43 -17
- package/dist/notice.js +1 -1
- package/dist/relay.js +7 -4
- package/dist/setup-guide.js +11 -2
- package/dist/tray.js +5 -3
- package/helper/macos/menubar.swift +394 -30
- 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,9 +186,24 @@ final class RowView: NSView {
|
|
|
182
186
|
private var copied = false
|
|
183
187
|
|
|
184
188
|
private let titleLines: Int
|
|
185
|
-
///
|
|
186
|
-
///
|
|
187
|
-
|
|
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
|
|
195
|
+
/// The row's MINIMUM width — the menu is at least this wide. The row then
|
|
196
|
+
/// stretches to the menu's real width (macOS adds its own trailing column
|
|
197
|
+
/// for submenu chevrons), so the copy control sits at the edge instead of
|
|
198
|
+
/// floating fifty points left of it over a blank gutter, which is what a
|
|
199
|
+
/// fixed-width row did at every width we tried.
|
|
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?
|
|
188
207
|
private static let titleFont = NSFont.systemFont(ofSize: 13, weight: .medium)
|
|
189
208
|
private static let lineHeight: CGFloat = 17
|
|
190
209
|
|
|
@@ -204,15 +223,19 @@ final class RowView: NSView {
|
|
|
204
223
|
return max(1, min(3, Int(ceil(h / lineHeight))))
|
|
205
224
|
}
|
|
206
225
|
|
|
207
|
-
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) {
|
|
208
227
|
self.item = item
|
|
209
228
|
self.onOpen = onOpen
|
|
210
229
|
self.onCopy = onCopy
|
|
211
|
-
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))
|
|
212
234
|
let textHeight = CGFloat(titleLines) * RowView.lineHeight
|
|
213
|
-
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)))
|
|
236
|
+
autoresizingMask = [.width] // fill the menu: the text runs to the control, the control to the edge
|
|
214
237
|
setAccessibilityRole(.button)
|
|
215
|
-
setAccessibilityLabel("\(item.title). \(
|
|
238
|
+
setAccessibilityLabel("\(item.title). \(sub). Return opens it; the button on the right copies the question.")
|
|
216
239
|
}
|
|
217
240
|
required init?(coder: NSCoder) { return nil }
|
|
218
241
|
|
|
@@ -261,21 +284,33 @@ final class RowView: NSView {
|
|
|
261
284
|
para.lineSpacing = 0
|
|
262
285
|
let flat = NSMutableParagraphStyle()
|
|
263
286
|
flat.lineBreakMode = .byTruncatingTail
|
|
264
|
-
let textWidth = bounds.width - RowView.textInsets
|
|
265
|
-
|
|
266
|
-
let
|
|
267
|
-
|
|
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
|
|
268
297
|
let cell = NSTextFieldCell() // gives the last visible line its ellipsis
|
|
269
298
|
cell.attributedStringValue = NSAttributedString(string: item.title, attributes: [
|
|
270
299
|
.font: RowView.titleFont, .foregroundColor: fg, .paragraphStyle: para,
|
|
271
300
|
])
|
|
272
301
|
cell.truncatesLastVisibleLine = true
|
|
273
302
|
cell.wraps = true
|
|
274
|
-
cell.drawInterior(withFrame: NSRect(x:
|
|
303
|
+
cell.drawInterior(withFrame: NSRect(x: leading, y: titleY, width: textWidth, height: titleHeight), in: self)
|
|
275
304
|
if hasSub {
|
|
276
|
-
NSAttributedString(string:
|
|
305
|
+
NSAttributedString(string: sub, attributes: [
|
|
277
306
|
.font: NSFont.systemFont(ofSize: 11), .foregroundColor: dim, .paragraphStyle: flat,
|
|
278
|
-
]).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()
|
|
279
314
|
}
|
|
280
315
|
|
|
281
316
|
// the copy control
|
|
@@ -303,6 +338,115 @@ final class RowView: NSView {
|
|
|
303
338
|
}
|
|
304
339
|
}
|
|
305
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
|
+
|
|
306
450
|
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
307
451
|
private var item: NSStatusItem!
|
|
308
452
|
private var state = TrayState()
|
|
@@ -318,7 +462,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
318
462
|
item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
319
463
|
let menu = NSMenu()
|
|
320
464
|
menu.delegate = self
|
|
321
|
-
|
|
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])
|
|
322
470
|
paint()
|
|
323
471
|
refresh()
|
|
324
472
|
timer = Timer.scheduledTimer(withTimeInterval: pollSeconds, repeats: true) { [weak self] _ in
|
|
@@ -337,8 +485,172 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
337
485
|
self.refreshing = false
|
|
338
486
|
self.paint()
|
|
339
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() }
|
|
340
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") }
|
|
341
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) }
|
|
342
654
|
}
|
|
343
655
|
|
|
344
656
|
// Keep targets stable while a person is navigating the menu. The badge can
|
|
@@ -420,6 +732,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
420
732
|
info("Connect with your team")
|
|
421
733
|
info("Run pantheon start, or join with an invite link")
|
|
422
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 {
|
|
423
770
|
info("\(state.member) · \(state.space)")
|
|
424
771
|
info(state.relayOk
|
|
425
772
|
? "Connected · \(state.online) of \(state.members) members online"
|
|
@@ -429,9 +776,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
429
776
|
info("Some of this could not be checked")
|
|
430
777
|
info("Refresh to try again — the list may be incomplete")
|
|
431
778
|
}
|
|
432
|
-
let decisions = state.items.filter { $0.kind == "decision" }
|
|
433
|
-
let owed = state.items.filter { $0.kind == "owed" }
|
|
434
|
-
let waiting = state.items.filter { $0.kind == "waiting" }
|
|
435
779
|
if state.items.isEmpty {
|
|
436
780
|
if state.decisionsOk { info("Nothing open — nothing needs you, nothing owed either way") }
|
|
437
781
|
} else {
|
|
@@ -447,6 +791,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
447
791
|
menu.addItem(makeItem(decisions.count > 1 ? "Review all \(decisions.count) in \(state.surfaceName)" : "Review it in \(state.surfaceName)", #selector(openAllDecisions)))
|
|
448
792
|
}
|
|
449
793
|
}
|
|
794
|
+
}
|
|
450
795
|
menu.addItem(.separator())
|
|
451
796
|
info(state.workerRunning ? "Unattended work is on" : state.workerInstalled ? "Unattended work is paused" : "Unattended work is not set up")
|
|
452
797
|
if !state.workerState.isEmpty && state.workerRunning { info(state.workerState) }
|
|
@@ -852,30 +1197,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
852
1197
|
|
|
853
1198
|
/// A titled group of rows. Six at most: a menu is for triage, and anything
|
|
854
1199
|
/// deeper belongs in `pantheon brief`, which the last row opens.
|
|
855
|
-
private func addRows(_ menu: NSMenu, _ title: String, _ items: [Item]) {
|
|
1200
|
+
private func addRows(_ menu: NSMenu, _ title: String, _ items: [Item], team: Bool = false, detail: String = "") {
|
|
856
1201
|
guard !items.isEmpty else { return }
|
|
857
1202
|
let header = NSMenuItem(title: title, action: nil, keyEquivalent: "")
|
|
858
1203
|
header.isEnabled = false
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
+
}
|
|
863
1214
|
menu.addItem(header)
|
|
864
|
-
for item in items.prefix(6) {
|
|
1215
|
+
for item in items.prefix(team ? 8 : 6) {
|
|
865
1216
|
let mi = NSMenuItem(title: item.title, action: nil, keyEquivalent: "")
|
|
866
1217
|
mi.toolTip = item.detail
|
|
867
|
-
mi.view = RowView(item: item, width: RowView.rowWidth,
|
|
1218
|
+
mi.view = RowView(item: item, width: RowView.rowWidth, inTeamSection: team,
|
|
868
1219
|
onOpen: { [weak self] it in self?.showDetail(it) },
|
|
869
1220
|
onCopy: { [weak self] it in self?.copyPrompt(it) })
|
|
870
1221
|
menu.addItem(mi)
|
|
871
1222
|
}
|
|
872
|
-
|
|
873
|
-
|
|
1223
|
+
let shown = team ? 8 : 6
|
|
1224
|
+
if items.count > shown {
|
|
1225
|
+
let more = NSMenuItem(title: "…and \(items.count - shown) more", action: nil, keyEquivalent: "")
|
|
874
1226
|
more.isEnabled = false
|
|
875
1227
|
menu.addItem(more)
|
|
876
1228
|
}
|
|
877
1229
|
}
|
|
878
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
|
+
|
|
879
1243
|
private func makeItem(_ title: String, _ sel: Selector) -> NSMenuItem {
|
|
880
1244
|
let mi = NSMenuItem(title: title, action: sel, keyEquivalent: "")
|
|
881
1245
|
mi.target = self
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@join-pantheon/cli",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.3",
|
|
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": {
|