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.
- package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +29 -8
- package/apps/mac-voice/Sources/AgentXVoice/MiniOrbs.swift +219 -0
- package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +32 -3
- package/apps/mac-voice/Sources/AgentXVoice/PillBusy.swift +126 -0
- package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +53 -12
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +49 -3
- package/dist/{agent-HUKCS3AK.js → agent-BQ5CCARE.js} +2 -2
- package/dist/{board-dashboard-S4V4WR2N.js → board-dashboard-K3P2M7A7.js} +813 -366
- package/dist/board-dashboard-K3P2M7A7.js.map +1 -0
- package/dist/{chunk-WWMFNLKD.js → chunk-2NYP4OQU.js} +3 -3
- package/dist/chunk-2NYP4OQU.js.map +1 -0
- package/dist/{chunk-AVDWFQZ5.js → chunk-6CFAF5CH.js} +2 -2
- package/dist/{chunk-OTTAKMGC.js → chunk-AWKZ3BF2.js} +3 -3
- package/dist/{chunk-HSJRI3NW.js → chunk-BBXBSM4R.js} +148 -148
- package/dist/chunk-BBXBSM4R.js.map +1 -0
- package/dist/{chunk-LEFRNHZF.js → chunk-EPTZ6JMA.js} +4 -4
- package/dist/chunk-JWDJMFU6.js +4 -0
- package/dist/chunk-JWDJMFU6.js.map +1 -0
- package/dist/{chunk-EOKOGQZG.js → chunk-YDHI2CJG.js} +2 -2
- package/dist/{chunk-ECBKTFTL.js → chunk-ZIEUP56T.js} +2 -2
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -1
- package/dist/{program-DZCKFNHD.js → program-4W45H3J2.js} +2 -2
- package/dist/push-announce-PM5FZJRW.js +3 -0
- package/dist/push-announce-PM5FZJRW.js.map +1 -0
- package/dist/push-prefs-YOJNTOJF.js +2 -0
- package/dist/push-store-5VJ4FNI4.js +2 -0
- package/dist/{registry-P7IAAFCX.js → registry-TERSIHOC.js} +2 -2
- package/dist/registry-TERSIHOC.js.map +1 -0
- package/package.json +1 -1
- package/dist/board-dashboard-S4V4WR2N.js.map +0 -1
- package/dist/chunk-HSJRI3NW.js.map +0 -1
- package/dist/chunk-WWMFNLKD.js.map +0 -1
- package/dist/push-store-LXTYULUF.js +0 -2
- /package/dist/{agent-HUKCS3AK.js.map → agent-BQ5CCARE.js.map} +0 -0
- /package/dist/{chunk-AVDWFQZ5.js.map → chunk-6CFAF5CH.js.map} +0 -0
- /package/dist/{chunk-OTTAKMGC.js.map → chunk-AWKZ3BF2.js.map} +0 -0
- /package/dist/{chunk-LEFRNHZF.js.map → chunk-EPTZ6JMA.js.map} +0 -0
- /package/dist/{chunk-EOKOGQZG.js.map → chunk-YDHI2CJG.js.map} +0 -0
- /package/dist/{chunk-ECBKTFTL.js.map → chunk-ZIEUP56T.js.map} +0 -0
- /package/dist/{program-DZCKFNHD.js.map → program-4W45H3J2.js.map} +0 -0
- /package/dist/{push-store-LXTYULUF.js.map → push-prefs-YOJNTOJF.js.map} +0 -0
- /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
|
-
///
|
|
146
|
-
///
|
|
147
|
-
///
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 ==
|
|
144
|
-
let asked = thinking[
|
|
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 &&
|
|
150
|
-
let queued = queue?.waiting.filter { $0.agentId ==
|
|
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
|
|