agentix-cli 0.54.0 → 0.56.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 -0
- package/apps/mac-voice/Sources/AgentXVoice/Config.swift +7 -0
- package/apps/mac-voice/Sources/AgentXVoice/Orb.swift +311 -0
- package/apps/mac-voice/Sources/AgentXVoice/OrbMath.swift +57 -0
- package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +14 -1
- package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +33 -1
- package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +74 -3
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +197 -14
- package/apps/mac-voice/build.sh +1 -1
- package/dist/{App-VRTLAPRF.js → App-4EM7IPB3.js} +2 -2
- package/dist/{ChatApp-YENWPZT4.js → ChatApp-FYTEEGSU.js} +2 -2
- package/dist/{agent-HVUGHYBF.js → agent-RZ46ZB4W.js} +2 -2
- package/dist/{board-dashboard-3XJVYDB3.js → board-dashboard-NRRYCDZW.js} +826 -180
- package/dist/board-dashboard-NRRYCDZW.js.map +1 -0
- package/dist/{chunk-M67AN4J7.js → chunk-4HEVSWO3.js} +2 -2
- package/dist/{chunk-KLDGJSR5.js → chunk-6AK6WKE5.js} +2 -2
- package/dist/{chunk-ENRJ2QEH.js → chunk-723K4SAW.js} +2 -2
- package/dist/{chunk-UYMK3XNB.js → chunk-76AHNHWC.js} +2 -2
- package/dist/{chunk-XWU7O3S5.js → chunk-C3VRBEJ7.js} +2 -2
- package/dist/{chunk-HQMISQ7L.js → chunk-CELYD5QW.js} +2 -2
- package/dist/{chunk-33QV5O6P.js → chunk-D3LSPFHK.js} +2 -2
- package/dist/chunk-EJ5ISSWW.js +151 -0
- package/dist/chunk-EJ5ISSWW.js.map +1 -0
- package/dist/{chunk-4HNHHKJF.js → chunk-EZN2YXRY.js} +2 -2
- package/dist/{chunk-BKDUCPZ3.js → chunk-GSOL2LW6.js} +2 -2
- package/dist/{chunk-TLFJVLVQ.js → chunk-IL2XLW4H.js} +152 -151
- package/dist/chunk-IL2XLW4H.js.map +1 -0
- package/dist/{chunk-WLRHL2P7.js → chunk-LKJSPO3S.js} +2 -2
- package/dist/{chunk-37EABIOJ.js → chunk-M4AG2YNX.js} +2 -2
- package/dist/{chunk-ZOJUHPD5.js → chunk-O3XG2NVR.js} +18 -18
- package/dist/{chunk-ZOJUHPD5.js.map → chunk-O3XG2NVR.js.map} +1 -1
- package/dist/{chunk-QYHABRLM.js → chunk-SA23V7WX.js} +2 -2
- package/dist/chunk-U4H35SJS.js +15 -0
- package/dist/chunk-U4H35SJS.js.map +1 -0
- package/dist/{chunk-3RCFUIYR.js → chunk-UIVHSYCN.js} +9 -9
- package/dist/chunk-UIVHSYCN.js.map +1 -0
- package/dist/{chunk-3W2KMI5O.js → chunk-W4YMMWJL.js} +2 -2
- package/dist/{chunk-6JHA3ZE6.js → chunk-WKMTT2UY.js} +211 -197
- package/dist/chunk-WKMTT2UY.js.map +1 -0
- package/dist/chunk-WOMQ2VGA.js +5 -0
- package/dist/{chunk-QX4R5WY7.js.map → chunk-WOMQ2VGA.js.map} +1 -1
- package/dist/chunk-X5C5W3E4.js +3 -0
- package/dist/chunk-X5C5W3E4.js.map +1 -0
- package/dist/{chunk-2TJYZF4E.js → chunk-XEOAKD5C.js} +2 -2
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/client-IA7ZPE56.js +2 -0
- package/dist/config-HPN2F7LH.js +2 -0
- package/dist/config-mutator-7GPTMTVB.js +2 -0
- package/dist/doctor-IBWQVQV4.js +2 -0
- package/dist/index.d.ts +185 -19
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/program-G6DIFJUF.js +2 -0
- package/dist/{promote-H6ZHJXTY.js → promote-T4UJROEH.js} +2 -2
- package/dist/query-VUJC4Y5O.js +2 -0
- package/dist/registry-DX2RI7I7.js +2 -0
- package/dist/{request-planner-ABD4WIGM.js → request-planner-B7DE7VJR.js} +2 -2
- package/dist/{review-candidates-B6574GOJ.js → review-candidates-4YJHIBAF.js} +2 -2
- package/dist/routing-MZG3UODH.js +2 -0
- package/dist/schedule-tool-A4OFQ4YZ.js +2 -0
- package/dist/scheduler-RHJW2V4M.js +2 -0
- package/dist/seat-2PEI7JRX.js +2 -0
- package/dist/{select-Y7SBUCGJ.js → select-4KIUR27O.js} +2 -2
- package/dist/{set-enabled-ARKMDLXN.js → set-enabled-SE7HQKPF.js} +2 -2
- package/dist/subscriptions-RYVPHVIV.js +2 -0
- package/dist/{tool-5EVYUN2G.js → tool-5VMD7IE4.js} +2 -2
- package/dist/wiki-2QFLAH4U.js +2 -0
- package/dist/wiki-2QFLAH4U.js.map +1 -0
- package/package.json +1 -1
- package/dist/board-dashboard-3XJVYDB3.js.map +0 -1
- package/dist/chunk-3RCFUIYR.js.map +0 -1
- package/dist/chunk-4FFWNPFX.js +0 -15
- package/dist/chunk-4FFWNPFX.js.map +0 -1
- package/dist/chunk-6JHA3ZE6.js.map +0 -1
- package/dist/chunk-BMSPRUEJ.js +0 -150
- package/dist/chunk-BMSPRUEJ.js.map +0 -1
- package/dist/chunk-QX4R5WY7.js +0 -5
- package/dist/chunk-TLFJVLVQ.js.map +0 -1
- package/dist/client-N4XQEAVL.js +0 -2
- package/dist/config-KHKIY6LI.js +0 -2
- package/dist/config-mutator-ZZKFQ7HJ.js +0 -2
- package/dist/doctor-QICAXIQB.js +0 -2
- package/dist/program-ZWNGK3RB.js +0 -2
- package/dist/query-FTE4FLYK.js +0 -2
- package/dist/registry-HDOFVNRK.js +0 -2
- package/dist/routing-BGKZOHOA.js +0 -2
- package/dist/schedule-tool-UDHXTKWV.js +0 -2
- package/dist/scheduler-NQM3HVUY.js +0 -2
- package/dist/seat-VGST5AQB.js +0 -2
- package/dist/wiki-GREI2TSP.js +0 -2
- /package/dist/{App-VRTLAPRF.js.map → App-4EM7IPB3.js.map} +0 -0
- /package/dist/{ChatApp-YENWPZT4.js.map → ChatApp-FYTEEGSU.js.map} +0 -0
- /package/dist/{agent-HVUGHYBF.js.map → agent-RZ46ZB4W.js.map} +0 -0
- /package/dist/{chunk-M67AN4J7.js.map → chunk-4HEVSWO3.js.map} +0 -0
- /package/dist/{chunk-KLDGJSR5.js.map → chunk-6AK6WKE5.js.map} +0 -0
- /package/dist/{chunk-ENRJ2QEH.js.map → chunk-723K4SAW.js.map} +0 -0
- /package/dist/{chunk-UYMK3XNB.js.map → chunk-76AHNHWC.js.map} +0 -0
- /package/dist/{chunk-XWU7O3S5.js.map → chunk-C3VRBEJ7.js.map} +0 -0
- /package/dist/{chunk-HQMISQ7L.js.map → chunk-CELYD5QW.js.map} +0 -0
- /package/dist/{chunk-33QV5O6P.js.map → chunk-D3LSPFHK.js.map} +0 -0
- /package/dist/{chunk-4HNHHKJF.js.map → chunk-EZN2YXRY.js.map} +0 -0
- /package/dist/{chunk-BKDUCPZ3.js.map → chunk-GSOL2LW6.js.map} +0 -0
- /package/dist/{chunk-WLRHL2P7.js.map → chunk-LKJSPO3S.js.map} +0 -0
- /package/dist/{chunk-37EABIOJ.js.map → chunk-M4AG2YNX.js.map} +0 -0
- /package/dist/{chunk-QYHABRLM.js.map → chunk-SA23V7WX.js.map} +0 -0
- /package/dist/{chunk-3W2KMI5O.js.map → chunk-W4YMMWJL.js.map} +0 -0
- /package/dist/{chunk-2TJYZF4E.js.map → chunk-XEOAKD5C.js.map} +0 -0
- /package/dist/{client-N4XQEAVL.js.map → client-IA7ZPE56.js.map} +0 -0
- /package/dist/{config-KHKIY6LI.js.map → config-HPN2F7LH.js.map} +0 -0
- /package/dist/{config-mutator-ZZKFQ7HJ.js.map → config-mutator-7GPTMTVB.js.map} +0 -0
- /package/dist/{doctor-QICAXIQB.js.map → doctor-IBWQVQV4.js.map} +0 -0
- /package/dist/{program-ZWNGK3RB.js.map → program-G6DIFJUF.js.map} +0 -0
- /package/dist/{promote-H6ZHJXTY.js.map → promote-T4UJROEH.js.map} +0 -0
- /package/dist/{query-FTE4FLYK.js.map → query-VUJC4Y5O.js.map} +0 -0
- /package/dist/{registry-HDOFVNRK.js.map → registry-DX2RI7I7.js.map} +0 -0
- /package/dist/{request-planner-ABD4WIGM.js.map → request-planner-B7DE7VJR.js.map} +0 -0
- /package/dist/{review-candidates-B6574GOJ.js.map → review-candidates-4YJHIBAF.js.map} +0 -0
- /package/dist/{routing-BGKZOHOA.js.map → routing-MZG3UODH.js.map} +0 -0
- /package/dist/{schedule-tool-UDHXTKWV.js.map → schedule-tool-A4OFQ4YZ.js.map} +0 -0
- /package/dist/{scheduler-NQM3HVUY.js.map → scheduler-RHJW2V4M.js.map} +0 -0
- /package/dist/{seat-VGST5AQB.js.map → seat-2PEI7JRX.js.map} +0 -0
- /package/dist/{select-Y7SBUCGJ.js.map → select-4KIUR27O.js.map} +0 -0
- /package/dist/{set-enabled-ARKMDLXN.js.map → set-enabled-SE7HQKPF.js.map} +0 -0
- /package/dist/{wiki-GREI2TSP.js.map → subscriptions-RYVPHVIV.js.map} +0 -0
- /package/dist/{tool-5EVYUN2G.js.map → tool-5VMD7IE4.js.map} +0 -0
|
@@ -126,6 +126,32 @@ enum AgentClient {
|
|
|
126
126
|
return (200..<300).contains(http.statusCode)
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/// Who is speaking and who waits, from the daemon's speaking queue.
|
|
130
|
+
struct QueueState: Decodable {
|
|
131
|
+
struct Item: Decodable { let agentId: String? }
|
|
132
|
+
let playing: Item?
|
|
133
|
+
let waiting: [Item]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// The speaking queue now, or nil when the daemon cannot be reached.
|
|
137
|
+
static func queueState() async -> QueueState? {
|
|
138
|
+
guard let url = URL(string: "\(Config.daemonURL)/voice/queue") else { return nil }
|
|
139
|
+
var req = URLRequest(url: url)
|
|
140
|
+
req.timeoutInterval = 2
|
|
141
|
+
guard let (data, _) = try? await URLSession.shared.data(for: req) else { return nil }
|
|
142
|
+
return try? JSONDecoder().decode(QueueState.self, from: data)
|
|
143
|
+
}
|
|
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
|
|
153
|
+
}
|
|
154
|
+
|
|
129
155
|
private static func post(_ path: String, _ body: [String: Any], timeout: TimeInterval) async throws -> (Data, URLResponse) {
|
|
130
156
|
var req = URLRequest(url: URL(string: "\(Config.daemonURL)\(path)")!)
|
|
131
157
|
req.httpMethod = "POST"
|
|
@@ -141,6 +167,9 @@ enum AgentClient {
|
|
|
141
167
|
let name: String?
|
|
142
168
|
/// Tasks running now.
|
|
143
169
|
let active: Int?
|
|
170
|
+
/// "#RRGGBB": presence.color, else derived from the id. Older
|
|
171
|
+
/// daemons send none, and the app derives it the same way.
|
|
172
|
+
let color: String?
|
|
144
173
|
var label: String { name ?? id }
|
|
145
174
|
}
|
|
146
175
|
|
|
@@ -28,6 +28,13 @@ enum Config {
|
|
|
28
28
|
set { UserDefaults.standard.set(newValue, forKey: "showPill") }
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/// The orb overlay while listening, thinking and speaking. On by
|
|
32
|
+
/// default; off brings back the pill for those states.
|
|
33
|
+
static var showOrb: Bool {
|
|
34
|
+
get { UserDefaults.standard.object(forKey: "showOrb") as? Bool ?? true }
|
|
35
|
+
set { UserDefaults.standard.set(newValue, forKey: "showOrb") }
|
|
36
|
+
}
|
|
37
|
+
|
|
31
38
|
/// The agent actually answering: `agentID`, else `chosenAgentID`, else
|
|
32
39
|
/// the daemon's default (AgentClient.resolveAgent). Progress follows it.
|
|
33
40
|
@MainActor static var effectiveAgentID: String = ""
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import SwiftUI
|
|
3
|
+
|
|
4
|
+
/// The Siri-style overlay: an orb in the agent's colour that listens,
|
|
5
|
+
/// thinks and speaks, with a line of words under it.
|
|
6
|
+
///
|
|
7
|
+
/// Built from SwiftUI's own gradients, no animation library. The orb only
|
|
8
|
+
/// moves while it is on screen: hidden, its timeline is paused and the
|
|
9
|
+
/// microphone level is no longer read, so an idle assistant costs nothing.
|
|
10
|
+
/// With Reduce Motion on it is a still picture that changes between
|
|
11
|
+
/// states, never a loop.
|
|
12
|
+
@MainActor
|
|
13
|
+
final class OrbModel: ObservableObject {
|
|
14
|
+
enum Phase: Equatable { case hidden, listening, thinking, speaking }
|
|
15
|
+
|
|
16
|
+
@Published var phase: Phase = .hidden
|
|
17
|
+
/// 0…1: the microphone while listening.
|
|
18
|
+
@Published var level: Double = 0
|
|
19
|
+
@Published var tint = NSColor(srgbRed: 0.078, green: 0.722, blue: 0.651, alpha: 1)
|
|
20
|
+
@Published var name = ""
|
|
21
|
+
/// What was heard, or what is being said.
|
|
22
|
+
@Published var line = ""
|
|
23
|
+
/// The step the agent is on, and for how long.
|
|
24
|
+
@Published var status = ""
|
|
25
|
+
@Published var reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
struct OrbView: View {
|
|
29
|
+
@ObservedObject var model: OrbModel
|
|
30
|
+
@Environment(\.colorScheme) private var scheme
|
|
31
|
+
|
|
32
|
+
/// The agent's colour, lifted in dark mode so the name stays readable.
|
|
33
|
+
private var nameColor: Color {
|
|
34
|
+
guard scheme == .dark, let c = model.tint.usingColorSpace(.sRGB) else { return Color(nsColor: model.tint) }
|
|
35
|
+
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
|
36
|
+
c.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
|
|
37
|
+
return Color(nsColor: NSColor(hue: h, saturation: s * 0.55, brightness: min(1, b + 0.35), alpha: 1))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
var body: some View {
|
|
41
|
+
VStack(spacing: 8) {
|
|
42
|
+
orb.frame(width: 96, height: 96).padding(.top, 4)
|
|
43
|
+
if !model.name.isEmpty {
|
|
44
|
+
Text(model.name.uppercased())
|
|
45
|
+
.font(.system(size: 10.5, weight: .semibold, design: .monospaced))
|
|
46
|
+
.tracking(0.8)
|
|
47
|
+
.foregroundStyle(nameColor)
|
|
48
|
+
}
|
|
49
|
+
if !model.line.isEmpty {
|
|
50
|
+
Text(model.line)
|
|
51
|
+
.font(.system(size: 13, weight: .medium))
|
|
52
|
+
.foregroundStyle(.primary)
|
|
53
|
+
.multilineTextAlignment(.center)
|
|
54
|
+
.lineLimit(3)
|
|
55
|
+
.truncationMode(.head)
|
|
56
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
57
|
+
}
|
|
58
|
+
if !model.status.isEmpty {
|
|
59
|
+
Text(model.status)
|
|
60
|
+
.font(.system(size: 11))
|
|
61
|
+
.foregroundStyle(.secondary)
|
|
62
|
+
.lineLimit(1)
|
|
63
|
+
.truncationMode(.middle)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
.padding(.horizontal, 18)
|
|
67
|
+
.padding(.vertical, 14)
|
|
68
|
+
.frame(width: OrbOverlay.width)
|
|
69
|
+
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
|
70
|
+
.overlay(RoundedRectangle(cornerRadius: 22, style: .continuous)
|
|
71
|
+
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1))
|
|
72
|
+
.frame(maxHeight: .infinity, alignment: .top)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private var orb: some View {
|
|
76
|
+
let still = model.reduceMotion
|
|
77
|
+
let paused = still || model.phase == .hidden
|
|
78
|
+
return TimelineView(.animation(minimumInterval: 1.0 / 30, paused: paused)) { context in
|
|
79
|
+
OrbBody(t: paused ? 0 : context.date.timeIntervalSinceReferenceDate,
|
|
80
|
+
phase: model.phase, level: model.level, tint: model.tint, still: still)
|
|
81
|
+
}
|
|
82
|
+
.accessibilityLabel(Text(accessibilityText))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private var accessibilityText: String {
|
|
86
|
+
switch model.phase {
|
|
87
|
+
case .hidden: return ""
|
|
88
|
+
case .listening: return "Listening"
|
|
89
|
+
case .thinking: return "Thinking"
|
|
90
|
+
case .speaking: return "Speaking"
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// One frame of the orb at time `t`.
|
|
96
|
+
struct OrbBody: View {
|
|
97
|
+
let t: Double
|
|
98
|
+
let phase: OrbModel.Phase
|
|
99
|
+
let level: Double
|
|
100
|
+
let tint: NSColor
|
|
101
|
+
/// Reduce Motion: a still orb, sized and shaded by state alone.
|
|
102
|
+
let still: Bool
|
|
103
|
+
|
|
104
|
+
/// How much the orb swells and glows, 0…1.
|
|
105
|
+
private var energy: Double {
|
|
106
|
+
switch phase {
|
|
107
|
+
case .hidden: return 0
|
|
108
|
+
case .listening: return still ? 0.45 : 0.12 + 0.88 * level
|
|
109
|
+
case .thinking: return still ? 0.2 : 0.22 + 0.08 * sin(t * 2.2)
|
|
110
|
+
case .speaking: return still ? 0.7 : OrbMath.speakingEnvelope(at: t)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// How fast the colours drift.
|
|
115
|
+
private var speed: Double {
|
|
116
|
+
switch phase {
|
|
117
|
+
case .hidden: return 0
|
|
118
|
+
case .listening: return 0.5 + level * 1.5
|
|
119
|
+
case .thinking: return 1.3
|
|
120
|
+
case .speaking: return 0.9
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
var body: some View {
|
|
125
|
+
let scale = 0.78 + 0.22 * energy
|
|
126
|
+
ZStack {
|
|
127
|
+
Circle()
|
|
128
|
+
.fill(Color(nsColor: tint).opacity(0.28 + 0.32 * energy))
|
|
129
|
+
.blur(radius: 10 + 12 * energy)
|
|
130
|
+
.scaleEffect(scale * 1.08)
|
|
131
|
+
fill
|
|
132
|
+
.clipShape(Circle())
|
|
133
|
+
.scaleEffect(scale)
|
|
134
|
+
// Thinking reads as a slow ring going round; the other states
|
|
135
|
+
// as a soft rim.
|
|
136
|
+
if phase == .thinking {
|
|
137
|
+
Circle()
|
|
138
|
+
.trim(from: 0, to: 0.28)
|
|
139
|
+
.stroke(Color.white.opacity(0.75), style: StrokeStyle(lineWidth: 2.2, lineCap: .round))
|
|
140
|
+
.rotationEffect(.radians(still ? -.pi / 2 : t * 3.1))
|
|
141
|
+
.scaleEffect(scale * 0.86)
|
|
142
|
+
} else {
|
|
143
|
+
Circle()
|
|
144
|
+
.strokeBorder(Color.white.opacity(0.3 + 0.25 * energy), lineWidth: 1)
|
|
145
|
+
.scaleEffect(scale)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@ViewBuilder private var fill: some View {
|
|
151
|
+
let shades = palette
|
|
152
|
+
if #available(macOS 15, *) {
|
|
153
|
+
MeshGradient(width: 3, height: 3, points: meshPoints, colors: [
|
|
154
|
+
shades[0], shades[1], shades[2],
|
|
155
|
+
shades[3], shades[4], shades[1],
|
|
156
|
+
shades[2], shades[3], shades[0],
|
|
157
|
+
])
|
|
158
|
+
} else {
|
|
159
|
+
// macOS 14: two gradients stand in for the mesh.
|
|
160
|
+
ZStack {
|
|
161
|
+
AngularGradient(colors: [shades[0], shades[2], shades[3], shades[1], shades[0]],
|
|
162
|
+
center: .center, angle: .radians(t * speed))
|
|
163
|
+
RadialGradient(colors: [shades[4], shades[4].opacity(0)],
|
|
164
|
+
center: UnitPoint(x: 0.5 + 0.12 * cos(t * speed), y: 0.5 + 0.12 * sin(t * speed)),
|
|
165
|
+
startRadius: 2, endRadius: 44)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// Corners fixed, edges sway, the centre wanders further when there
|
|
171
|
+
/// is more energy. `still` keeps the resting grid.
|
|
172
|
+
private var meshPoints: [SIMD2<Float>] {
|
|
173
|
+
let s = still ? 0 : t * speed
|
|
174
|
+
let reach = Float(0.08 + 0.14 * energy)
|
|
175
|
+
func sway(_ phase: Double) -> Float { Float(sin(s + phase)) * reach * 0.6 }
|
|
176
|
+
return [
|
|
177
|
+
[0, 0], [0.5 + sway(0.3), 0], [1, 0],
|
|
178
|
+
[0, 0.5 + sway(1.1)],
|
|
179
|
+
[0.5 + Float(cos(s * 1.3)) * reach, 0.5 + Float(sin(s * 1.7)) * reach],
|
|
180
|
+
[1, 0.5 + sway(2.3)],
|
|
181
|
+
[0, 1], [0.5 + sway(3.7), 1], [1, 1],
|
|
182
|
+
]
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/// Five shades around the agent's colour: lighter, deeper and two
|
|
186
|
+
/// neighbouring hues, and a bright core that grows with the voice.
|
|
187
|
+
private var palette: [Color] {
|
|
188
|
+
let base = tint.usingColorSpace(.sRGB) ?? tint
|
|
189
|
+
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
|
190
|
+
base.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
|
|
191
|
+
// Thinking turns the hues slowly; the others hold them.
|
|
192
|
+
let drift = phase == .thinking && !still ? CGFloat(sin(t * 0.8)) * 0.04 : 0
|
|
193
|
+
func shade(_ dh: CGFloat, _ ds: CGFloat, _ db: CGFloat) -> Color {
|
|
194
|
+
var hue = (h + dh + drift).truncatingRemainder(dividingBy: 1)
|
|
195
|
+
if hue < 0 { hue += 1 }
|
|
196
|
+
return Color(nsColor: NSColor(hue: hue, saturation: min(1, max(0, s + ds)),
|
|
197
|
+
brightness: min(1, max(0, b + db)), alpha: 1))
|
|
198
|
+
}
|
|
199
|
+
let core = CGFloat(0.12 + 0.3 * energy)
|
|
200
|
+
return [
|
|
201
|
+
shade(0, 0, 0),
|
|
202
|
+
shade(0.07, -0.05, 0.08),
|
|
203
|
+
shade(-0.06, 0.05, -0.12),
|
|
204
|
+
shade(0.13, -0.1, 0.02),
|
|
205
|
+
shade(0.02, -0.35 * core * 2, core),
|
|
206
|
+
]
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/// The window the orb lives in: floating, on every Space, click-through
|
|
211
|
+
/// and never key, so the app in front keeps the keyboard.
|
|
212
|
+
private final class OrbPanel: NSPanel {
|
|
213
|
+
override var canBecomeKey: Bool { false }
|
|
214
|
+
override var canBecomeMain: Bool { false }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
@MainActor
|
|
218
|
+
final class OrbOverlay {
|
|
219
|
+
static let width: CGFloat = 300
|
|
220
|
+
private static let height: CGFloat = 250
|
|
221
|
+
|
|
222
|
+
let model = OrbModel()
|
|
223
|
+
private let panel: NSPanel
|
|
224
|
+
private let host: NSHostingView<OrbView>
|
|
225
|
+
private var levelTimer: Timer?
|
|
226
|
+
private var motionObserver: NSObjectProtocol?
|
|
227
|
+
|
|
228
|
+
/// The microphone's RMS level, read while listening. Set by the app.
|
|
229
|
+
var levelSource: (() -> Float)?
|
|
230
|
+
|
|
231
|
+
init() {
|
|
232
|
+
panel = OrbPanel(contentRect: NSRect(x: 0, y: 0, width: Self.width, height: Self.height),
|
|
233
|
+
styleMask: [.borderless, .nonactivatingPanel],
|
|
234
|
+
backing: .buffered, defer: true)
|
|
235
|
+
panel.isFloatingPanel = true
|
|
236
|
+
panel.level = .floating
|
|
237
|
+
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]
|
|
238
|
+
panel.isOpaque = false
|
|
239
|
+
panel.backgroundColor = .clear
|
|
240
|
+
panel.hasShadow = false
|
|
241
|
+
panel.ignoresMouseEvents = true
|
|
242
|
+
panel.hidesOnDeactivate = false
|
|
243
|
+
host = NSHostingView(rootView: OrbView(model: model))
|
|
244
|
+
host.frame = NSRect(x: 0, y: 0, width: Self.width, height: Self.height)
|
|
245
|
+
panel.contentView = host
|
|
246
|
+
|
|
247
|
+
let model = self.model
|
|
248
|
+
motionObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
|
249
|
+
forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification,
|
|
250
|
+
object: nil, queue: .main) { _ in
|
|
251
|
+
MainActor.assumeIsolated {
|
|
252
|
+
model.reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
var isVisible: Bool { panel.isVisible }
|
|
258
|
+
|
|
259
|
+
/// Where the orb's card ends on screen, for the answer card to sit
|
|
260
|
+
/// under. Nil while hidden.
|
|
261
|
+
var contentFrame: NSRect? {
|
|
262
|
+
guard panel.isVisible else { return nil }
|
|
263
|
+
let h = min(Self.height, host.fittingSize.height)
|
|
264
|
+
return NSRect(x: panel.frame.minX, y: panel.frame.maxY - h, width: Self.width, height: h)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
func show(_ phase: OrbModel.Phase, name: String, tint: NSColor, line: String, status: String) {
|
|
268
|
+
guard phase != .hidden else { hide(); return }
|
|
269
|
+
model.name = name
|
|
270
|
+
model.tint = tint
|
|
271
|
+
model.line = line
|
|
272
|
+
model.status = status
|
|
273
|
+
if model.phase != phase {
|
|
274
|
+
model.phase = phase
|
|
275
|
+
if phase != .listening { model.level = 0 }
|
|
276
|
+
}
|
|
277
|
+
watchLevel()
|
|
278
|
+
if !panel.isVisible {
|
|
279
|
+
position()
|
|
280
|
+
panel.orderFrontRegardless()
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
func hide() {
|
|
285
|
+
model.phase = .hidden
|
|
286
|
+
model.level = 0
|
|
287
|
+
watchLevel()
|
|
288
|
+
panel.orderOut(nil)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/// Top right, under the menu bar, where macOS puts Siri.
|
|
292
|
+
private func position() {
|
|
293
|
+
guard let screen = NSScreen.main else { return }
|
|
294
|
+
let v = screen.visibleFrame
|
|
295
|
+
panel.setFrameOrigin(NSPoint(x: v.maxX - Self.width - 16, y: v.maxY - Self.height - 10))
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/// The microphone is read only while listening, on screen, with
|
|
299
|
+
/// motion allowed.
|
|
300
|
+
private func watchLevel() {
|
|
301
|
+
let wanted = model.phase == .listening && !model.reduceMotion && levelSource != nil
|
|
302
|
+
if !wanted { levelTimer?.invalidate(); levelTimer = nil; return }
|
|
303
|
+
guard levelTimer == nil else { return }
|
|
304
|
+
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 20, repeats: true) { [weak self] _ in
|
|
305
|
+
MainActor.assumeIsolated {
|
|
306
|
+
guard let self, let read = self.levelSource else { return }
|
|
307
|
+
self.model.level = OrbMath.smooth(self.model.level, toward: OrbMath.level(fromRMS: read()))
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// The numbers behind the orb, kept free of AppKit and SwiftUI so the
|
|
4
|
+
/// tests can check them without a window.
|
|
5
|
+
enum OrbMath {
|
|
6
|
+
/// The palette the daemon derives an agent's colour from when
|
|
7
|
+
/// `presence.color` is unset. Same list, same order and same hash as
|
|
8
|
+
/// `presenceLook` in src/voice/presence.ts, so the orb matches the
|
|
9
|
+
/// agent's on-screen cursor even when /agents sends no colour.
|
|
10
|
+
static let palette = ["#7C3AED", "#DB2777", "#EA580C", "#0D9488",
|
|
11
|
+
"#2563EB", "#65A30D", "#C026D3", "#0891B2"]
|
|
12
|
+
|
|
13
|
+
/// The colour for an agent: the daemon's when it sent one, else the
|
|
14
|
+
/// hash of the id.
|
|
15
|
+
static func colorHex(agentID: String, configured: String?) -> String {
|
|
16
|
+
if let c = configured, parseHex(c) != nil { return c }
|
|
17
|
+
var h: UInt32 = 0
|
|
18
|
+
// JS iterates code points and takes charCodeAt(0) of each: the
|
|
19
|
+
// first UTF-16 unit. Identical for every ASCII id.
|
|
20
|
+
for scalar in agentID.unicodeScalars {
|
|
21
|
+
let unit = UInt32(String(scalar).utf16.first ?? 0)
|
|
22
|
+
h = h &* 31 &+ unit
|
|
23
|
+
}
|
|
24
|
+
return palette[Int(h % UInt32(palette.count))]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// "#RRGGBB" as 0…1 components, or nil when it is not that.
|
|
28
|
+
static func parseHex(_ s: String) -> (r: Double, g: Double, b: Double)? {
|
|
29
|
+
guard s.count == 7, s.hasPrefix("#"), let v = UInt32(s.dropFirst(), radix: 16) else { return nil }
|
|
30
|
+
return (Double((v >> 16) & 0xFF) / 255, Double((v >> 8) & 0xFF) / 255, Double(v & 0xFF) / 255)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// Microphone RMS (about 0.005 in a quiet room, 0.02 for a voice,
|
|
34
|
+
/// 0.2 when shouting) to 0…1. Square root, because loudness is heard
|
|
35
|
+
/// roughly that way: linear made normal speech barely move the orb.
|
|
36
|
+
static func level(fromRMS rms: Float) -> Double {
|
|
37
|
+
guard rms.isFinite, rms > 0.004 else { return 0 }
|
|
38
|
+
return min(1, Double((rms - 0.004).squareRoot()) * 3.2)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// A speech-like rise and fall at time `t`, 0.15…1: syllables at about
|
|
42
|
+
/// five a second under a slower phrase swell. The daemon plays the
|
|
43
|
+
/// answer, so the app cannot meter it; this keeps the orb talking in
|
|
44
|
+
/// rhythm without pretending to be a level meter.
|
|
45
|
+
static func speakingEnvelope(at t: Double) -> Double {
|
|
46
|
+
let syllable = abs(sin(t * 15.7))
|
|
47
|
+
let phrase = 0.55 + 0.45 * sin(t * 1.9 + 0.7)
|
|
48
|
+
let jitter = 0.5 + 0.5 * sin(t * 37.3 + sin(t * 3.1))
|
|
49
|
+
return 0.15 + 0.85 * syllable * phrase * (0.7 + 0.3 * jitter)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Smooth toward `target`: fast attack, slower release, as the
|
|
53
|
+
/// recorder does, so the orb swells with a word and settles after it.
|
|
54
|
+
static func smooth(_ current: Double, toward target: Double) -> Double {
|
|
55
|
+
target > current ? current + (target - current) * 0.6 : current + (target - current) * 0.2
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -51,6 +51,10 @@ final class Panel: NSPanel {
|
|
|
51
51
|
/// Stay on screen when idle. Off: the pill shows only while active.
|
|
52
52
|
var alwaysVisible = false
|
|
53
53
|
|
|
54
|
+
/// The orb overlay shows listening, thinking and speaking, so the
|
|
55
|
+
/// pill stays out of the way then; it still shows idle and errors.
|
|
56
|
+
var yieldsActiveStates = false
|
|
57
|
+
|
|
54
58
|
/// Told of every state rendered, so the menu-bar icon can follow.
|
|
55
59
|
var onRender: ((State) -> Void)?
|
|
56
60
|
|
|
@@ -107,6 +111,14 @@ final class Panel: NSPanel {
|
|
|
107
111
|
/// Idle is an invitation and belongs in meta type; everything else
|
|
108
112
|
/// is a running commentary and belongs in body type.
|
|
109
113
|
var isMeta: Bool { if case .idle = self { return true }; return false }
|
|
114
|
+
|
|
115
|
+
/// Listening, thinking or speaking: what the orb shows.
|
|
116
|
+
var isActive: Bool {
|
|
117
|
+
switch self {
|
|
118
|
+
case .idle, .error: return false
|
|
119
|
+
default: return true
|
|
120
|
+
}
|
|
121
|
+
}
|
|
110
122
|
}
|
|
111
123
|
|
|
112
124
|
/// Called when the pill is clicked. Set by the app.
|
|
@@ -208,7 +220,8 @@ final class Panel: NSPanel {
|
|
|
208
220
|
setText(named(state))
|
|
209
221
|
}
|
|
210
222
|
|
|
211
|
-
|
|
223
|
+
let yield = yieldsActiveStates && state.isActive
|
|
224
|
+
if (state.isMeta && !alwaysVisible) || yield { orderOut(nil) } else { orderFrontRegardless() }
|
|
212
225
|
onRender?(state)
|
|
213
226
|
}
|
|
214
227
|
|
|
@@ -22,6 +22,10 @@ final class ResultCard: NSPanel {
|
|
|
22
22
|
private let links = NSStackView()
|
|
23
23
|
private let thumb = NSImageView()
|
|
24
24
|
|
|
25
|
+
/// The orb's frame on screen while it shows, for the card to slide in
|
|
26
|
+
/// under it. Nil: the card sits above the pill. Set by the app.
|
|
27
|
+
var anchor: (() -> NSRect?)?
|
|
28
|
+
|
|
25
29
|
init() {
|
|
26
30
|
super.init(contentRect: NSRect(x: 0, y: 0, width: 380, height: 260),
|
|
27
31
|
styleMask: [.titled, .closable, .resizable, .utilityWindow, .nonactivatingPanel],
|
|
@@ -125,8 +129,35 @@ final class ResultCard: NSPanel {
|
|
|
125
129
|
}
|
|
126
130
|
|
|
127
131
|
if let c = contentView { layout(c) }
|
|
128
|
-
|
|
132
|
+
if let orb = anchor?() {
|
|
133
|
+
slideIn(under: orb)
|
|
134
|
+
} else {
|
|
135
|
+
positionAboveWidget()
|
|
136
|
+
orderFrontRegardless()
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// Under the orb, right edges aligned, sliding down into place. With
|
|
141
|
+
/// Reduce Motion it simply appears there.
|
|
142
|
+
@MainActor
|
|
143
|
+
private func slideIn(under orb: NSRect) {
|
|
144
|
+
let target = NSPoint(x: orb.maxX - frame.width, y: orb.minY - frame.height - 8)
|
|
145
|
+
let wasVisible = isVisible
|
|
146
|
+
guard !wasVisible, !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else {
|
|
147
|
+
setFrameOrigin(target)
|
|
148
|
+
alphaValue = 1
|
|
149
|
+
orderFrontRegardless()
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
setFrameOrigin(NSPoint(x: target.x, y: target.y + 18))
|
|
153
|
+
alphaValue = 0
|
|
129
154
|
orderFrontRegardless()
|
|
155
|
+
NSAnimationContext.runAnimationGroup { ctx in
|
|
156
|
+
ctx.duration = 0.28
|
|
157
|
+
ctx.timingFunction = Brand.ease
|
|
158
|
+
animator().setFrameOrigin(target)
|
|
159
|
+
animator().alphaValue = 1
|
|
160
|
+
}
|
|
130
161
|
}
|
|
131
162
|
|
|
132
163
|
@objc private func openLink(_ sender: NSButton) {
|
|
@@ -139,6 +170,7 @@ final class ResultCard: NSPanel {
|
|
|
139
170
|
guard let screen = NSScreen.main else { return }
|
|
140
171
|
let v = screen.visibleFrame
|
|
141
172
|
setFrameOrigin(NSPoint(x: v.maxX - frame.width - 24, y: v.minY + 24 + 54 + 10))
|
|
173
|
+
alphaValue = 1
|
|
142
174
|
}
|
|
143
175
|
|
|
144
176
|
override var canBecomeKey: Bool { true }
|
|
@@ -7,7 +7,8 @@ import AppKit
|
|
|
7
7
|
@MainActor
|
|
8
8
|
final class StatusMenu: NSObject, NSMenuDelegate {
|
|
9
9
|
let menu = NSMenu()
|
|
10
|
-
|
|
10
|
+
/// Variable width: the badge with the number of queued lines sits beside the icon.
|
|
11
|
+
private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
11
12
|
|
|
12
13
|
private enum Roster {
|
|
13
14
|
case loading
|
|
@@ -19,12 +20,21 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
19
20
|
private var roster = Roster.loading
|
|
20
21
|
/// Reopen the menu once the retry's answer is in.
|
|
21
22
|
private var reopenAfterRefresh = false
|
|
23
|
+
/// The daemon's speaking queue, polled while anything is in flight.
|
|
24
|
+
private var queue: AgentClient.QueueState?
|
|
25
|
+
private var queuePoll: Timer?
|
|
26
|
+
|
|
27
|
+
/// Questions this widget has in flight, per agent. Set by the app.
|
|
28
|
+
var thinking: [String: Int] = [:] {
|
|
29
|
+
didSet { rebuild(); watchQueue(force: true) }
|
|
30
|
+
}
|
|
22
31
|
|
|
23
32
|
/// Set by the app.
|
|
24
33
|
var onStop: (() -> Void)?
|
|
25
34
|
var onHoldChanged: ((Bool) -> Void)?
|
|
26
35
|
var onTargetChanged: ((String) -> Void)?
|
|
27
36
|
var onPillChanged: ((Bool) -> Void)?
|
|
37
|
+
var onOrbChanged: ((Bool) -> Void)?
|
|
28
38
|
|
|
29
39
|
override init() {
|
|
30
40
|
super.init()
|
|
@@ -41,6 +51,16 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
41
51
|
return agents.first { $0.id == id }?.label ?? id
|
|
42
52
|
}
|
|
43
53
|
|
|
54
|
+
/// The agent's colour for the orb: what the daemon sent, else the
|
|
55
|
+
/// one derived from its id, which is the same colour.
|
|
56
|
+
func color(of id: String) -> NSColor {
|
|
57
|
+
var configured: String?
|
|
58
|
+
if case .loaded(let agents) = roster { configured = agents.first { $0.id == id }?.color }
|
|
59
|
+
let hex = OrbMath.colorHex(agentID: id, configured: configured)
|
|
60
|
+
guard let c = OrbMath.parseHex(hex) else { return Brand.accent }
|
|
61
|
+
return NSColor(srgbRed: c.r, green: c.g, blue: c.b, alpha: 1)
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
/// Open the menu from the keyboard.
|
|
45
65
|
func open() { item.button?.performClick(nil) }
|
|
46
66
|
|
|
@@ -48,7 +68,9 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
48
68
|
Task {
|
|
49
69
|
let agents = await AgentClient.agents()
|
|
50
70
|
roster = agents.map { .loaded($0) } ?? .down
|
|
71
|
+
queue = await AgentClient.queueState()
|
|
51
72
|
rebuild()
|
|
73
|
+
showBadge()
|
|
52
74
|
if reopenAfterRefresh { reopenAfterRefresh = false; open() }
|
|
53
75
|
}
|
|
54
76
|
}
|
|
@@ -69,6 +91,48 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
69
91
|
item.button?.toolTip = "AgentX Voice — \(label)"
|
|
70
92
|
}
|
|
71
93
|
|
|
94
|
+
/// The number of lines waiting to be spoken, beside the icon.
|
|
95
|
+
private func showBadge() {
|
|
96
|
+
let n = queue?.waiting.count ?? 0
|
|
97
|
+
item.button?.imagePosition = .imageLeading
|
|
98
|
+
item.button?.title = n > 0 ? "\(n)" : ""
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// Poll the queue only while there is something to show: a question in
|
|
102
|
+
/// flight, or lines playing or waiting. `force` starts it whatever the
|
|
103
|
+
/// last poll saw: an answer that just came back is about to be queued,
|
|
104
|
+
/// so the poll carries on until it has been seen through.
|
|
105
|
+
private func watchQueue(force: Bool = false) {
|
|
106
|
+
let active = force || !thinking.isEmpty || queue?.playing != nil || !(queue?.waiting.isEmpty ?? true)
|
|
107
|
+
if !active { queuePoll?.invalidate(); queuePoll = nil; return }
|
|
108
|
+
guard queuePoll == nil else { return }
|
|
109
|
+
queuePoll = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
|
|
110
|
+
Task { @MainActor [weak self] in
|
|
111
|
+
guard let self else { return }
|
|
112
|
+
self.queue = await AgentClient.queueState()
|
|
113
|
+
self.rebuild()
|
|
114
|
+
self.showBadge()
|
|
115
|
+
self.watchQueue()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/// "thinking", "speaking", "queued 2", or both, for one agent's row.
|
|
121
|
+
private func state(of agent: AgentClient.AgentInfo) -> String {
|
|
122
|
+
var parts: [String] = []
|
|
123
|
+
// Speaking one answer and thinking on the next are both true at once.
|
|
124
|
+
if queue?.playing?.agentId == agent.id { parts.append("speaking") }
|
|
125
|
+
let asked = thinking[agent.id, default: 0]
|
|
126
|
+
if asked > 0 {
|
|
127
|
+
// More than one: the rest wait for the answer in flight.
|
|
128
|
+
parts.append(asked > 1 ? "thinking (+\(asked - 1) asked)" : "thinking")
|
|
129
|
+
}
|
|
130
|
+
if parts.isEmpty && (agent.active ?? 0) > 0 { parts.append("working") }
|
|
131
|
+
let queued = queue?.waiting.filter { $0.agentId == agent.id }.count ?? 0
|
|
132
|
+
if queued > 0 { parts.append("queued \(queued)") }
|
|
133
|
+
return parts.isEmpty ? "idle" : parts.joined(separator: " · ")
|
|
134
|
+
}
|
|
135
|
+
|
|
72
136
|
// MARK: NSMenuDelegate
|
|
73
137
|
|
|
74
138
|
/// Show what is known now and refresh underneath; the open menu
|
|
@@ -95,9 +159,8 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
95
159
|
menu.addItem(NSMenuItem.sectionHeader(title: pinned ? "Agent (set by AGENTX_VOICE_AGENT)" : "Talk to"))
|
|
96
160
|
if agents.isEmpty { menu.addItem(disabled("No agents configured")) }
|
|
97
161
|
for (i, agent) in agents.enumerated() {
|
|
98
|
-
let busy = (agent.active ?? 0) > 0
|
|
99
162
|
// Digits pick an agent while the menu is open.
|
|
100
|
-
let row = action(agent.label +
|
|
163
|
+
let row = action(agent.label + " · " + state(of: agent), #selector(pick(_:)),
|
|
101
164
|
key: i < 9 ? "\(i + 1)" : "", modifiers: [])
|
|
102
165
|
row.representedObject = agent.id
|
|
103
166
|
row.state = agent.id == target ? .on : .off
|
|
@@ -114,6 +177,9 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
114
177
|
let pill = action("Show floating pill", #selector(togglePill), key: "")
|
|
115
178
|
pill.state = Config.showPill ? .on : .off
|
|
116
179
|
menu.addItem(pill)
|
|
180
|
+
let orb = action("Show orb", #selector(toggleOrb), key: "")
|
|
181
|
+
orb.state = Config.showOrb ? .on : .off
|
|
182
|
+
menu.addItem(orb)
|
|
117
183
|
|
|
118
184
|
menu.addItem(.separator())
|
|
119
185
|
menu.addItem(action("Settings…", #selector(openSettings), key: ","))
|
|
@@ -163,6 +229,11 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
163
229
|
onPillChanged?(Config.showPill)
|
|
164
230
|
}
|
|
165
231
|
|
|
232
|
+
@objc private func toggleOrb() {
|
|
233
|
+
Config.showOrb.toggle()
|
|
234
|
+
onOrbChanged?(Config.showOrb)
|
|
235
|
+
}
|
|
236
|
+
|
|
166
237
|
@objc private func openSettings() { openDashboard("/admin") }
|
|
167
238
|
@objc private func openHistory() { openDashboard("/activity") }
|
|
168
239
|
|