agentix-cli 0.65.0 → 0.66.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.
Files changed (44) hide show
  1. package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +29 -8
  2. package/apps/mac-voice/Sources/AgentXVoice/MiniOrbs.swift +219 -0
  3. package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +32 -3
  4. package/apps/mac-voice/Sources/AgentXVoice/PillBusy.swift +126 -0
  5. package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +53 -12
  6. package/apps/mac-voice/Sources/AgentXVoice/main.swift +49 -3
  7. package/dist/{agent-HUKCS3AK.js → agent-BQ5CCARE.js} +2 -2
  8. package/dist/{board-dashboard-S4V4WR2N.js → board-dashboard-K3P2M7A7.js} +813 -366
  9. package/dist/board-dashboard-K3P2M7A7.js.map +1 -0
  10. package/dist/{chunk-WWMFNLKD.js → chunk-2NYP4OQU.js} +3 -3
  11. package/dist/chunk-2NYP4OQU.js.map +1 -0
  12. package/dist/{chunk-AVDWFQZ5.js → chunk-6CFAF5CH.js} +2 -2
  13. package/dist/{chunk-OTTAKMGC.js → chunk-AWKZ3BF2.js} +3 -3
  14. package/dist/{chunk-HSJRI3NW.js → chunk-BBXBSM4R.js} +148 -148
  15. package/dist/chunk-BBXBSM4R.js.map +1 -0
  16. package/dist/{chunk-LEFRNHZF.js → chunk-EPTZ6JMA.js} +4 -4
  17. package/dist/chunk-JWDJMFU6.js +4 -0
  18. package/dist/chunk-JWDJMFU6.js.map +1 -0
  19. package/dist/{chunk-EOKOGQZG.js → chunk-YDHI2CJG.js} +2 -2
  20. package/dist/{chunk-ECBKTFTL.js → chunk-ZIEUP56T.js} +2 -2
  21. package/dist/cli.js +1 -1
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.js +1 -1
  24. package/dist/{program-DZCKFNHD.js → program-4W45H3J2.js} +2 -2
  25. package/dist/push-announce-PM5FZJRW.js +3 -0
  26. package/dist/push-announce-PM5FZJRW.js.map +1 -0
  27. package/dist/push-prefs-YOJNTOJF.js +2 -0
  28. package/dist/push-store-5VJ4FNI4.js +2 -0
  29. package/dist/{registry-P7IAAFCX.js → registry-TERSIHOC.js} +2 -2
  30. package/dist/registry-TERSIHOC.js.map +1 -0
  31. package/package.json +1 -1
  32. package/dist/board-dashboard-S4V4WR2N.js.map +0 -1
  33. package/dist/chunk-HSJRI3NW.js.map +0 -1
  34. package/dist/chunk-WWMFNLKD.js.map +0 -1
  35. package/dist/push-store-LXTYULUF.js +0 -2
  36. /package/dist/{agent-HUKCS3AK.js.map → agent-BQ5CCARE.js.map} +0 -0
  37. /package/dist/{chunk-AVDWFQZ5.js.map → chunk-6CFAF5CH.js.map} +0 -0
  38. /package/dist/{chunk-OTTAKMGC.js.map → chunk-AWKZ3BF2.js.map} +0 -0
  39. /package/dist/{chunk-LEFRNHZF.js.map → chunk-EPTZ6JMA.js.map} +0 -0
  40. /package/dist/{chunk-EOKOGQZG.js.map → chunk-YDHI2CJG.js.map} +0 -0
  41. /package/dist/{chunk-ECBKTFTL.js.map → chunk-ZIEUP56T.js.map} +0 -0
  42. /package/dist/{program-DZCKFNHD.js.map → program-4W45H3J2.js.map} +0 -0
  43. /package/dist/{push-store-LXTYULUF.js.map → push-prefs-YOJNTOJF.js.map} +0 -0
  44. /package/dist/{registry-P7IAAFCX.js.map → push-store-5VJ4FNI4.js.map} +0 -0
@@ -142,14 +142,35 @@ enum AgentClient {
142
142
  return try? JSONDecoder().decode(QueueState.self, from: data)
143
143
  }
144
144
 
145
- /// The agent the words are addressed to ("Writer, …"), or `target`.
146
- /// The daemon matches names, so the widget and config never disagree.
147
- /// Never throws: no daemon means nobody else to address.
148
- static func address(_ text: String, target: String) async -> String {
149
- guard let (data, _) = try? await post("/voice/address", ["text": text, "target": target], timeout: 2) else { return target }
150
- struct Reply: Decodable { let agentId: String? }
151
- let id = (try? JSONDecoder().decode(Reply.self, from: data))?.agentId ?? ""
152
- return id.isEmpty ? target : id
145
+ /// Who the words are for, from POST /voice/address: an agent on this
146
+ /// node or on a mesh peer, with how to show it. Older daemons send
147
+ /// only `agentId`.
148
+ struct Addressed: Decodable {
149
+ let agentID: String
150
+ let name: String?
151
+ /// "#RRGGBB"; without it the app derives the same one from the id.
152
+ let color: String?
153
+ let palette: AgentInfo.Palette?
154
+ /// The node it lives on: this node's id, or the peer's name.
155
+ let node: String?
156
+ /// On a mesh peer; /ask reaches it by the same id.
157
+ let remote: Bool?
158
+
159
+ enum CodingKeys: String, CodingKey { case agentID = "agentId", name, color, palette, node, remote }
160
+
161
+ var isRemote: Bool { remote ?? false }
162
+ }
163
+
164
+ /// The agent the words are addressed to ("Writer, …", or "Planner, …"
165
+ /// on another node), or `target`. The daemon matches names, so the
166
+ /// widget and config never disagree. Never throws: no daemon means
167
+ /// nobody else to address.
168
+ static func address(_ text: String, target: String) async -> Addressed {
169
+ let fallback = Addressed(agentID: target, name: nil, color: nil, palette: nil, node: nil, remote: nil)
170
+ guard let (data, _) = try? await post("/voice/address", ["text": text, "target": target], timeout: 2),
171
+ let reply = try? JSONDecoder().decode(Addressed.self, from: data),
172
+ !reply.agentID.isEmpty else { return fallback }
173
+ return reply
153
174
  }
154
175
 
155
176
  private static func post(_ path: String, _ body: [String: Any], timeout: TimeInterval) async throws -> (Data, URLResponse) {
@@ -0,0 +1,219 @@
1
+ import AppKit
2
+ import SwiftUI
3
+
4
+ /// The row of mini orbs in the pill: one per busy agent (PillBusy.swift).
5
+ ///
6
+ /// Each is a small glassy dot in the agent's colours with a cue for its
7
+ /// state: a ring going round while it thinks, a steady ring while its
8
+ /// answer waits to be spoken, a gentle pulse while it speaks, and a count
9
+ /// when more questions wait for it. It moves only while shown on screen
10
+ /// with something busy; Reduce Motion or "Animated orb" off gives still
11
+ /// orbs. Clicks go through to the pill, it never takes the keyboard, and
12
+ /// hovering an orb names the agent and its node.
13
+ @MainActor
14
+ final class MiniOrbsModel: ObservableObject {
15
+ struct Orb: Identifiable, Equatable {
16
+ var id: String { agentID }
17
+ let agentID: String
18
+ let activity: PillBusy.Activity
19
+ let queued: Int
20
+ let tint: NSColor
21
+ /// Five stops deep to light, or nil for shades of `tint`.
22
+ let colors: [NSColor]?
23
+ let tooltip: String
24
+ }
25
+
26
+ @Published var orbs: [Orb] = []
27
+ @Published var more = 0
28
+ @Published var onScreen = false
29
+ @Published var reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
30
+ @Published var animated = true
31
+
32
+ var still: Bool { reduceMotion || !animated }
33
+ /// Only thinking and speaking move; the timeline stops otherwise.
34
+ var paused: Bool {
35
+ still || !onScreen || !orbs.contains { $0.activity == .thinking || $0.activity == .speaking }
36
+ }
37
+ }
38
+
39
+ struct MiniOrbsView: View {
40
+ @ObservedObject var model: MiniOrbsModel
41
+
42
+ var body: some View {
43
+ let paused = model.paused
44
+ TimelineView(.animation(minimumInterval: 1.0 / 24, paused: paused)) { context in
45
+ let t = paused ? 0 : context.date.timeIntervalSinceReferenceDate
46
+ HStack(spacing: PillBusy.gap) {
47
+ ForEach(model.orbs) { orb in
48
+ MiniOrb(orb: orb, t: t, still: model.still)
49
+ .frame(width: PillBusy.diameter, height: PillBusy.diameter)
50
+ }
51
+ if model.more > 0 {
52
+ Text("+\(model.more)")
53
+ .font(.system(size: 10, weight: .semibold).monospacedDigit())
54
+ .foregroundStyle(.secondary)
55
+ .frame(width: PillBusy.overflowWidth, alignment: .leading)
56
+ }
57
+ }
58
+ .padding(.trailing, MiniOrbsHost.inset)
59
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
60
+ }
61
+ .accessibilityElement(children: .ignore)
62
+ .accessibilityLabel(Text(model.orbs.map(\.tooltip).joined(separator: "; ")))
63
+ }
64
+ }
65
+
66
+ /// One mini orb at time `t`.
67
+ struct MiniOrb: View {
68
+ let orb: MiniOrbsModel.Orb
69
+ let t: Double
70
+ let still: Bool
71
+
72
+ var body: some View {
73
+ let pulse = orb.activity == .speaking && !still ? 0.9 + 0.1 * OrbMath.speakingEnvelope(at: t) : 1
74
+ ZStack {
75
+ Circle()
76
+ .fill(RadialGradient(colors: [light, deep], center: UnitPoint(x: 0.38, y: 0.32),
77
+ startRadius: 0, endRadius: PillBusy.diameter * 0.62))
78
+ .overlay(Circle().fill(RadialGradient(colors: [.white.opacity(0.5), .white.opacity(0)],
79
+ center: UnitPoint(x: 0.34, y: 0.26),
80
+ startRadius: 0, endRadius: 5)))
81
+ .overlay(Circle().strokeBorder(Color.white.opacity(0.35), lineWidth: 0.5))
82
+ .scaleEffect(orb.activity == .queued ? 0.72 : 0.78 * pulse)
83
+ .opacity(orb.activity == .queued ? 0.7 : 1)
84
+ cue
85
+ if orb.queued > 0 {
86
+ Text("\(min(orb.queued, 9))")
87
+ .font(.system(size: 7.5, weight: .bold).monospacedDigit())
88
+ .foregroundStyle(.white)
89
+ .frame(width: 9, height: 9)
90
+ .background(Circle().fill(Color(nsColor: deepest)))
91
+ .overlay(Circle().strokeBorder(Color.white.opacity(0.8), lineWidth: 0.6))
92
+ .offset(x: 5, y: -6)
93
+ }
94
+ }
95
+ }
96
+
97
+ /// The state cue drawn around the dot.
98
+ @ViewBuilder private var cue: some View {
99
+ switch orb.activity {
100
+ case .thinking:
101
+ Circle()
102
+ .trim(from: 0, to: 0.3)
103
+ .stroke(Color(nsColor: ring), style: StrokeStyle(lineWidth: 1.6, lineCap: .round))
104
+ .rotationEffect(.radians(still ? -.pi / 2 : t * 3.4))
105
+ case .answering:
106
+ Circle().stroke(Color(nsColor: ring).opacity(0.85), lineWidth: 1.2)
107
+ case .speaking:
108
+ Circle().stroke(Color(nsColor: ring).opacity(0.5), lineWidth: 1)
109
+ .scaleEffect(still ? 1 : 0.94 + 0.06 * OrbMath.speakingEnvelope(at: t + 0.4))
110
+ case .queued:
111
+ EmptyView()
112
+ }
113
+ }
114
+
115
+ private var light: Color {
116
+ if let c = orb.colors, c.count == 5 { return Color(nsColor: c[3]) }
117
+ return Color(nsColor: orb.tint.blended(withFraction: 0.35, of: .white) ?? orb.tint)
118
+ }
119
+ private var deep: Color {
120
+ if let c = orb.colors, c.count == 5 { return Color(nsColor: c[1]) }
121
+ return Color(nsColor: orb.tint)
122
+ }
123
+ private var deepest: NSColor {
124
+ if let c = orb.colors, c.count == 5 { return c[0] }
125
+ return orb.tint.blended(withFraction: 0.3, of: .black) ?? orb.tint
126
+ }
127
+ /// The ring reads on the pill in both themes: the agent's mid colour.
128
+ private var ring: NSColor {
129
+ if let c = orb.colors, c.count == 5 { return c[2] }
130
+ return orb.tint
131
+ }
132
+ }
133
+
134
+ /// The row's view in the pill. Clicks go through it, as through the main
135
+ /// orb; hovering still shows each orb's name (tooltips are tracking
136
+ /// rects, not clicks). It never takes the keyboard.
137
+ @MainActor
138
+ final class MiniOrbsHost: NSHostingView<MiniOrbsView> {
139
+ let model: MiniOrbsModel
140
+ private var motionObserver: NSObjectProtocol?
141
+ private var tips: [NSView.ToolTipTag] = []
142
+
143
+ init() {
144
+ let model = MiniOrbsModel()
145
+ self.model = model
146
+ super.init(rootView: MiniOrbsView(model: model))
147
+ motionObserver = NSWorkspace.shared.notificationCenter.addObserver(
148
+ forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification,
149
+ object: nil, queue: .main) { [weak model] _ in
150
+ MainActor.assumeIsolated {
151
+ model?.reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
152
+ }
153
+ }
154
+ }
155
+
156
+ @available(*, unavailable)
157
+ required init(rootView: MiniOrbsView) { fatalError("use init()") }
158
+
159
+ @available(*, unavailable)
160
+ required init?(coder: NSCoder) { fatalError("not from a nib") }
161
+
162
+ /// Room right of the last orb for its queued badge.
163
+ static let inset: CGFloat = 3
164
+ /// The host's width for `count` busy agents.
165
+ static func width(_ count: Int) -> CGFloat { PillBusy.rowWidth(count) + inset * 2 }
166
+
167
+ override func hitTest(_ point: NSPoint) -> NSView? { nil }
168
+ override var acceptsFirstResponder: Bool { false }
169
+
170
+ /// Show these orbs, the "+n" for the rest. Cheap on every call:
171
+ /// SwiftUI redraws only what changed.
172
+ func show(_ orbs: [MiniOrbsModel.Orb], more: Int) {
173
+ if model.orbs != orbs { model.orbs = orbs }
174
+ if model.more != more { model.more = more }
175
+ placeTooltips()
176
+ }
177
+
178
+ func setOnScreen(_ on: Bool) {
179
+ if model.onScreen != on { model.onScreen = on }
180
+ }
181
+
182
+ func setAnimated(_ on: Bool) {
183
+ if model.animated != on { model.animated = on }
184
+ }
185
+
186
+ override func setFrameSize(_ newSize: NSSize) {
187
+ super.setFrameSize(newSize)
188
+ placeTooltips()
189
+ }
190
+
191
+ /// One tooltip rectangle per orb. SwiftUI's `.help` needs the view to
192
+ /// be hit, and this one lets clicks through, so AppKit's own tooltip
193
+ /// rectangles carry the names instead.
194
+ private func placeTooltips() {
195
+ tips.forEach(removeToolTip)
196
+ tips = []
197
+ tipRects = []
198
+ let count = model.orbs.count + model.more
199
+ let offsets = PillBusy.orbOffsets(count)
200
+ let width = PillBusy.rowWidth(count)
201
+ // The row is drawn against the right edge.
202
+ let left = bounds.width - Self.inset - width
203
+ let d = PillBusy.diameter
204
+ for (i, orb) in model.orbs.enumerated() where i < offsets.count {
205
+ let rect = NSRect(x: left + offsets[i], y: (bounds.height - d) / 2, width: d, height: d)
206
+ tips.append(addToolTip(rect, owner: self, userData: nil))
207
+ tipRects.append((rect, orb.tooltip))
208
+ }
209
+ }
210
+
211
+ private var tipRects: [(NSRect, String)] = []
212
+ }
213
+
214
+ extension MiniOrbsHost: NSViewToolTipOwner {
215
+ nonisolated func view(_ view: NSView, stringForToolTip tag: NSView.ToolTipTag,
216
+ point: NSPoint, userData data: UnsafeMutableRawPointer?) -> String {
217
+ MainActor.assumeIsolated { tipRects.first { $0.0.contains(point) }?.1 ?? "" }
218
+ }
219
+ }
@@ -21,6 +21,10 @@ final class Panel: NSPanel {
21
21
 
22
22
  private let label = NSTextField(labelWithString: "")
23
23
  let orb = PillOrb(diameter: Panel.orbDiameter, frameSize: Panel.orbFrame)
24
+ /// One small orb per busy agent while more than one is (MiniOrbs.swift).
25
+ let miniOrbs = MiniOrbsHost()
26
+ /// Busy agents shown in the mini row; 0 hides it.
27
+ private(set) var busyCount = 0
24
28
  private let closeButton = NSButton()
25
29
  /// The orb, the words and the close button: the pill itself, which
26
30
  /// stays where it is while the widget grows into an answer.
@@ -241,6 +245,8 @@ final class Panel: NSPanel {
241
245
  closeButton.action = #selector(closeClicked)
242
246
  closeButton.isHidden = true
243
247
  row.addSubview(closeButton)
248
+ miniOrbs.isHidden = true
249
+ row.addSubview(miniOrbs)
244
250
  surface.layoutContent = { [weak self] bounds in self?.layoutContent(bounds) }
245
251
  MainActor.assumeIsolated { layoutContent(surface.bounds) }
246
252
  surface.addTrackingArea(NSTrackingArea(rect: .zero,
@@ -269,7 +275,11 @@ final class Panel: NSPanel {
269
275
  let above = growth.above
270
276
  row.frame = NSRect(x: 0, y: above ? 0 : answerHeight, width: bounds.width, height: h)
271
277
  closeButton.frame.origin.x = bounds.width - 28
272
- let clipWidth = bounds.width - 88
278
+ // The mini orbs sit between the words and the close button, and
279
+ // the words give up that room while they show.
280
+ let miniWidth = busyCount > 0 ? MiniOrbsHost.width(busyCount) : 0
281
+ miniOrbs.frame = NSRect(x: bounds.width - 28 - miniWidth, y: (h - 24) / 2, width: miniWidth, height: 24)
282
+ let clipWidth = bounds.width - 88 - (miniWidth > 0 ? miniWidth + 4 : 0)
273
283
  if let clip, clip.frame.width != clipWidth {
274
284
  clip.frame.size.width = clipWidth
275
285
  // Text that scrolled may fit now, and the other way round.
@@ -340,6 +350,7 @@ final class Panel: NSPanel {
340
350
  @MainActor
341
351
  func show() {
342
352
  orb.setOnScreen(true)
353
+ miniOrbs.setOnScreen(!miniOrbs.isHidden)
343
354
  if !isVisible { orderFrontRegardless() }
344
355
  }
345
356
 
@@ -347,6 +358,7 @@ final class Panel: NSPanel {
347
358
  private func hide() {
348
359
  closeButton.isHidden = true
349
360
  orb.setOnScreen(false)
361
+ miniOrbs.setOnScreen(false)
350
362
  if isVisible { orderOut(nil) }
351
363
  }
352
364
 
@@ -414,12 +426,29 @@ final class Panel: NSPanel {
414
426
  setText(named(state))
415
427
  }
416
428
 
417
- // Grown into an answer, it stays until it collapses, idle or not.
418
- if dismissed || (state.isMeta && !alwaysVisible && !expanded) { hide() } else { show() }
429
+ // Grown into an answer, it stays until it collapses, idle or not;
430
+ // so does a pill with agents still busy in its mini orbs.
431
+ if dismissed || (state.isMeta && !alwaysVisible && !expanded && busyCount == 0) { hide() } else { show() }
419
432
  armCollapse()
420
433
  onRender?(state)
421
434
  }
422
435
 
436
+ /// The mini orbs: one per busy agent, or none to hide the row. The
437
+ /// pill stays on screen while the row shows, even when idle.
438
+ @MainActor
439
+ func showBusy(_ orbs: [MiniOrbsModel.Orb], more: Int) {
440
+ let count = orbs.isEmpty ? 0 : orbs.count + more
441
+ miniOrbs.show(orbs, more: more)
442
+ miniOrbs.isHidden = count == 0
443
+ if count != busyCount {
444
+ busyCount = count
445
+ if let content = contentView { layoutContent(content.bounds) }
446
+ render(current)
447
+ } else {
448
+ miniOrbs.setOnScreen(isVisible && count > 0)
449
+ }
450
+ }
451
+
423
452
  /// "Nadia · Listening": who is listening or answering. Errors are the
424
453
  /// widget's own, so they carry no name.
425
454
  @MainActor
@@ -0,0 +1,126 @@
1
+ import CoreGraphics
2
+ import Foundation
3
+
4
+ /// Who is busy right now, for the row of mini orbs in the pill (#266).
5
+ ///
6
+ /// Asking two agents back to back used to look like one: the pill has one
7
+ /// orb for one agent. While more than one agent is busy, a small orb per
8
+ /// agent sits in the pill beside the words, in its colour, with a cue for
9
+ /// what it is doing. The main orb keeps following whoever is listening or
10
+ /// speaking.
11
+ ///
12
+ /// Kept free of AppKit and SwiftUI so the tests can check it without a
13
+ /// window.
14
+ enum PillBusy {
15
+ /// What one agent is doing, most visible first.
16
+ enum Activity: Equatable {
17
+ /// Its answer is being spoken now.
18
+ case speaking
19
+ /// Its answer is back and waits in the speaking queue.
20
+ case answering
21
+ /// A question to it is in flight.
22
+ case thinking
23
+ /// Only questions waiting for it: nothing asked yet.
24
+ case queued
25
+
26
+ var word: String {
27
+ switch self {
28
+ case .speaking: return "speaking"
29
+ case .answering: return "answer waiting to be spoken"
30
+ case .thinking: return "thinking"
31
+ case .queued: return "queued"
32
+ }
33
+ }
34
+ }
35
+
36
+ struct Item: Equatable {
37
+ let agentID: String
38
+ let activity: Activity
39
+ /// Questions waiting for it behind the one in flight.
40
+ let queued: Int
41
+ }
42
+
43
+ /// What the app knows about each agent's work.
44
+ struct Snapshot {
45
+ /// Questions this widget has in flight, per agent.
46
+ var inFlight: [String: Int] = [:]
47
+ /// Questions waiting for an agent that already has one in flight.
48
+ var waiting: [String: Int] = [:]
49
+ /// The agent the daemon is speaking for now.
50
+ var playing: String?
51
+ /// Agents with answers waiting in the daemon's speaking queue.
52
+ var toSpeak: [String] = []
53
+ }
54
+
55
+ /// One item per busy agent. Agents already in `order` keep their
56
+ /// place, so an orb never jumps when another's state changes; new
57
+ /// ones join at the end.
58
+ static func items(_ s: Snapshot, order: [String]) -> [Item] {
59
+ var ids = Set(s.inFlight.filter { $0.value > 0 }.keys)
60
+ ids.formUnion(s.waiting.filter { $0.value > 0 }.keys)
61
+ if let p = s.playing, !p.isEmpty { ids.insert(p) }
62
+ ids.formUnion(s.toSpeak.filter { !$0.isEmpty })
63
+
64
+ let kept = order.filter(ids.contains)
65
+ let fresh = ids.subtracting(kept).sorted()
66
+ return (kept + fresh).map { id in
67
+ let activity: Activity
68
+ if s.playing == id { activity = .speaking }
69
+ else if s.toSpeak.contains(id) { activity = .answering }
70
+ else if s.inFlight[id, default: 0] > 0 { activity = .thinking }
71
+ else { activity = .queued }
72
+ // In flight beyond the first also waits (a turn plus an aside).
73
+ let extra = max(0, s.inFlight[id, default: 0] - 1)
74
+ return Item(agentID: id, activity: activity, queued: s.waiting[id, default: 0] + extra)
75
+ }
76
+ }
77
+
78
+ /// The row shows when more than one agent is busy, or when the one
79
+ /// busy agent still has a question out that the main orb is not
80
+ /// showing: the pill listens for the next while it thinks. One line
81
+ /// being spoken on its own is the main orb's, or a notification's.
82
+ static func showsRow(_ items: [Item], mainAgent: String, mainActive: Bool) -> Bool {
83
+ if items.count >= 2 { return true }
84
+ guard let only = items.first, only.activity == .thinking || only.activity == .queued else { return false }
85
+ return !mainActive || only.agentID != mainAgent
86
+ }
87
+
88
+ // MARK: Layout
89
+
90
+ /// A mini orb's diameter, the gap between two, and the most shown
91
+ /// before a "+n" stands for the rest.
92
+ static let diameter: CGFloat = 15
93
+ static let gap: CGFloat = 6
94
+ static let maxShown = 4
95
+ /// Width of the "+n" after the last orb shown.
96
+ static let overflowWidth: CGFloat = 18
97
+
98
+ /// How many orbs are drawn for `count` busy agents, and how many more
99
+ /// the "+n" stands for.
100
+ static func shown(_ count: Int) -> (orbs: Int, more: Int) {
101
+ count <= maxShown ? (count, 0) : (maxShown - 1, count - (maxShown - 1))
102
+ }
103
+
104
+ /// The row's width for `count` busy agents; 0 for none.
105
+ static func rowWidth(_ count: Int) -> CGFloat {
106
+ let (orbs, more) = shown(count)
107
+ guard orbs > 0 else { return 0 }
108
+ let base = CGFloat(orbs) * diameter + CGFloat(orbs - 1) * gap
109
+ return more > 0 ? base + gap + overflowWidth : base
110
+ }
111
+
112
+ /// Left edge of each orb, from the row's left edge.
113
+ static func orbOffsets(_ count: Int) -> [CGFloat] {
114
+ (0..<shown(count).orbs).map { CGFloat($0) * (diameter + gap) }
115
+ }
116
+
117
+ /// The hover text: the agent's name, its node when it is not this
118
+ /// Mac's, and what it is doing.
119
+ static func tooltip(name: String, node: String?, item: Item) -> String {
120
+ var s = name
121
+ if let node, !node.isEmpty { s += " on \(node)" }
122
+ s += " · \(item.activity.word)"
123
+ if item.queued > 0 { s += ", \(item.queued) more queued" }
124
+ return s
125
+ }
126
+ }
@@ -21,14 +21,20 @@ final class StatusMenu: NSObject, NSMenuDelegate {
21
21
  /// Reopen the menu once the retry's answer is in.
22
22
  private var reopenAfterRefresh = false
23
23
  /// The daemon's speaking queue, polled while anything is in flight.
24
- private var queue: AgentClient.QueueState?
24
+ private(set) var queue: AgentClient.QueueState?
25
+ /// Agents on other mesh nodes this widget has asked or been told
26
+ /// about by /voice/address: /agents lists only this node's.
27
+ private var remote: [String: AgentClient.Addressed] = [:]
28
+ /// Told when the thinking counts or the speaking queue change, so the
29
+ /// pill's mini orbs follow.
30
+ var onActivity: (() -> Void)?
25
31
  private var queuePoll: Timer?
26
32
  /// The last three voice exchanges, for a quick replay.
27
33
  private let recent = RecentExchanges()
28
34
 
29
35
  /// Questions this widget has in flight, per agent. Set by the app.
30
36
  var thinking: [String: Int] = [:] {
31
- didSet { rebuild(); watchQueue(force: true) }
37
+ didSet { rebuild(); watchQueue(force: true); onActivity?() }
32
38
  }
33
39
 
34
40
  /// Set by the app.
@@ -54,17 +60,32 @@ final class StatusMenu: NSObject, NSMenuDelegate {
54
60
  rebuild()
55
61
  }
56
62
 
63
+ /// What /voice/address said about an agent. Only agents on other
64
+ /// nodes are kept; this node's come from /agents.
65
+ func learn(_ agent: AgentClient.Addressed) {
66
+ guard agent.isRemote else { return }
67
+ remote[agent.agentID] = agent
68
+ }
69
+
70
+ private func local(_ id: String) -> AgentClient.AgentInfo? {
71
+ guard case .loaded(let agents) = roster else { return nil }
72
+ return agents.first { $0.id == id }
73
+ }
74
+
75
+ /// The node an agent on another node lives on; nil for this node's.
76
+ func node(of id: String) -> String? {
77
+ local(id) == nil ? remote[id]?.node : nil
78
+ }
79
+
57
80
  /// The agent's display name, or its id until /agents has answered.
58
81
  func name(of id: String) -> String {
59
- guard case .loaded(let agents) = roster else { return id }
60
- return agents.first { $0.id == id }?.label ?? id
82
+ local(id)?.label ?? remote[id]?.name ?? id
61
83
  }
62
84
 
63
85
  /// The agent's colour for the orb: what the daemon sent, else the
64
86
  /// one derived from its id, which is the same colour.
65
87
  func color(of id: String) -> NSColor {
66
- var configured: String?
67
- if case .loaded(let agents) = roster { configured = agents.first { $0.id == id }?.color }
88
+ let configured = local(id)?.color ?? remote[id]?.color
68
89
  let hex = OrbMath.colorHex(agentID: id, configured: configured)
69
90
  guard let c = OrbMath.parseHex(hex) else { return Brand.accent }
70
91
  return NSColor(srgbRed: c.r, green: c.g, blue: c.b, alpha: 1)
@@ -73,8 +94,7 @@ final class StatusMenu: NSObject, NSMenuDelegate {
73
94
  /// The agent's orb palette, five colours deep to light, or nil when
74
95
  /// the daemon sent none.
75
96
  func palette(of id: String) -> [NSColor]? {
76
- guard case .loaded(let agents) = roster,
77
- let hexes = agents.first(where: { $0.id == id })?.palette?.colors, hexes.count == 5 else { return nil }
97
+ guard let hexes = local(id)?.palette?.colors ?? remote[id]?.palette?.colors, hexes.count == 5 else { return nil }
78
98
  let colors = hexes.compactMap(OrbMath.parseHex).map { NSColor(srgbRed: $0.r, green: $0.g, blue: $0.b, alpha: 1) }
79
99
  return colors.count == 5 ? colors : nil
80
100
  }
@@ -131,6 +151,7 @@ final class StatusMenu: NSObject, NSMenuDelegate {
131
151
  self.queue = await AgentClient.queueState()
132
152
  self.rebuild()
133
153
  self.showBadge()
154
+ self.onActivity?()
134
155
  self.watchQueue()
135
156
  }
136
157
  }
@@ -138,16 +159,20 @@ final class StatusMenu: NSObject, NSMenuDelegate {
138
159
 
139
160
  /// "thinking", "speaking", "queued 2", or both, for one agent's row.
140
161
  private func state(of agent: AgentClient.AgentInfo) -> String {
162
+ state(of: agent.id, active: agent.active ?? 0)
163
+ }
164
+
165
+ private func state(of id: String, active: Int) -> String {
141
166
  var parts: [String] = []
142
167
  // Speaking one answer and thinking on the next are both true at once.
143
- if queue?.playing?.agentId == agent.id { parts.append("speaking") }
144
- let asked = thinking[agent.id, default: 0]
168
+ if queue?.playing?.agentId == id { parts.append("speaking") }
169
+ let asked = thinking[id, default: 0]
145
170
  if asked > 0 {
146
171
  // More than one: the rest wait for the answer in flight.
147
172
  parts.append(asked > 1 ? "thinking (+\(asked - 1) asked)" : "thinking")
148
173
  }
149
- if parts.isEmpty && (agent.active ?? 0) > 0 { parts.append("working") }
150
- let queued = queue?.waiting.filter { $0.agentId == agent.id }.count ?? 0
174
+ if parts.isEmpty && active > 0 { parts.append("working") }
175
+ let queued = queue?.waiting.filter { $0.agentId == id }.count ?? 0
151
176
  if queued > 0 { parts.append("queued \(queued)") }
152
177
  return parts.isEmpty ? "idle" : parts.joined(separator: " · ")
153
178
  }
@@ -186,6 +211,22 @@ final class StatusMenu: NSObject, NSMenuDelegate {
186
211
  row.isEnabled = !pinned
187
212
  menu.addItem(row)
188
213
  }
214
+ // Agents on other nodes, while asked, answering or the target:
215
+ // "Planner (server) · thinking". /agents lists only this node's.
216
+ let localIDs = Set(agents.map(\.id))
217
+ var others = Set(thinking.keys)
218
+ if let p = queue?.playing?.agentId { others.insert(p) }
219
+ for item in queue?.waiting ?? [] { if let id = item.agentId { others.insert(id) } }
220
+ if !target.isEmpty { others.insert(target) }
221
+ for id in others.subtracting(localIDs).filter({ remote[$0] != nil || thinking[$0] != nil }).sorted() {
222
+ let node = remote[id]?.node ?? "mesh"
223
+ let row = action("\(name(of: id)) (\(node)) · " + state(of: id, active: 0), #selector(pick(_:)),
224
+ key: "", modifiers: [])
225
+ row.representedObject = id
226
+ row.state = id == target ? .on : .off
227
+ row.isEnabled = !pinned
228
+ menu.addItem(row)
229
+ }
189
230
  }
190
231
  recent.addItems(to: menu, name: name(of:))
191
232