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.
Files changed (126) hide show
  1. package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +29 -0
  2. package/apps/mac-voice/Sources/AgentXVoice/Config.swift +7 -0
  3. package/apps/mac-voice/Sources/AgentXVoice/Orb.swift +311 -0
  4. package/apps/mac-voice/Sources/AgentXVoice/OrbMath.swift +57 -0
  5. package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +14 -1
  6. package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +33 -1
  7. package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +74 -3
  8. package/apps/mac-voice/Sources/AgentXVoice/main.swift +197 -14
  9. package/apps/mac-voice/build.sh +1 -1
  10. package/dist/{App-VRTLAPRF.js → App-4EM7IPB3.js} +2 -2
  11. package/dist/{ChatApp-YENWPZT4.js → ChatApp-FYTEEGSU.js} +2 -2
  12. package/dist/{agent-HVUGHYBF.js → agent-RZ46ZB4W.js} +2 -2
  13. package/dist/{board-dashboard-3XJVYDB3.js → board-dashboard-NRRYCDZW.js} +826 -180
  14. package/dist/board-dashboard-NRRYCDZW.js.map +1 -0
  15. package/dist/{chunk-M67AN4J7.js → chunk-4HEVSWO3.js} +2 -2
  16. package/dist/{chunk-KLDGJSR5.js → chunk-6AK6WKE5.js} +2 -2
  17. package/dist/{chunk-ENRJ2QEH.js → chunk-723K4SAW.js} +2 -2
  18. package/dist/{chunk-UYMK3XNB.js → chunk-76AHNHWC.js} +2 -2
  19. package/dist/{chunk-XWU7O3S5.js → chunk-C3VRBEJ7.js} +2 -2
  20. package/dist/{chunk-HQMISQ7L.js → chunk-CELYD5QW.js} +2 -2
  21. package/dist/{chunk-33QV5O6P.js → chunk-D3LSPFHK.js} +2 -2
  22. package/dist/chunk-EJ5ISSWW.js +151 -0
  23. package/dist/chunk-EJ5ISSWW.js.map +1 -0
  24. package/dist/{chunk-4HNHHKJF.js → chunk-EZN2YXRY.js} +2 -2
  25. package/dist/{chunk-BKDUCPZ3.js → chunk-GSOL2LW6.js} +2 -2
  26. package/dist/{chunk-TLFJVLVQ.js → chunk-IL2XLW4H.js} +152 -151
  27. package/dist/chunk-IL2XLW4H.js.map +1 -0
  28. package/dist/{chunk-WLRHL2P7.js → chunk-LKJSPO3S.js} +2 -2
  29. package/dist/{chunk-37EABIOJ.js → chunk-M4AG2YNX.js} +2 -2
  30. package/dist/{chunk-ZOJUHPD5.js → chunk-O3XG2NVR.js} +18 -18
  31. package/dist/{chunk-ZOJUHPD5.js.map → chunk-O3XG2NVR.js.map} +1 -1
  32. package/dist/{chunk-QYHABRLM.js → chunk-SA23V7WX.js} +2 -2
  33. package/dist/chunk-U4H35SJS.js +15 -0
  34. package/dist/chunk-U4H35SJS.js.map +1 -0
  35. package/dist/{chunk-3RCFUIYR.js → chunk-UIVHSYCN.js} +9 -9
  36. package/dist/chunk-UIVHSYCN.js.map +1 -0
  37. package/dist/{chunk-3W2KMI5O.js → chunk-W4YMMWJL.js} +2 -2
  38. package/dist/{chunk-6JHA3ZE6.js → chunk-WKMTT2UY.js} +211 -197
  39. package/dist/chunk-WKMTT2UY.js.map +1 -0
  40. package/dist/chunk-WOMQ2VGA.js +5 -0
  41. package/dist/{chunk-QX4R5WY7.js.map → chunk-WOMQ2VGA.js.map} +1 -1
  42. package/dist/chunk-X5C5W3E4.js +3 -0
  43. package/dist/chunk-X5C5W3E4.js.map +1 -0
  44. package/dist/{chunk-2TJYZF4E.js → chunk-XEOAKD5C.js} +2 -2
  45. package/dist/cli.js +1 -1
  46. package/dist/cli.js.map +1 -1
  47. package/dist/client-IA7ZPE56.js +2 -0
  48. package/dist/config-HPN2F7LH.js +2 -0
  49. package/dist/config-mutator-7GPTMTVB.js +2 -0
  50. package/dist/doctor-IBWQVQV4.js +2 -0
  51. package/dist/index.d.ts +185 -19
  52. package/dist/index.js +1 -1
  53. package/dist/index.js.map +1 -1
  54. package/dist/program-G6DIFJUF.js +2 -0
  55. package/dist/{promote-H6ZHJXTY.js → promote-T4UJROEH.js} +2 -2
  56. package/dist/query-VUJC4Y5O.js +2 -0
  57. package/dist/registry-DX2RI7I7.js +2 -0
  58. package/dist/{request-planner-ABD4WIGM.js → request-planner-B7DE7VJR.js} +2 -2
  59. package/dist/{review-candidates-B6574GOJ.js → review-candidates-4YJHIBAF.js} +2 -2
  60. package/dist/routing-MZG3UODH.js +2 -0
  61. package/dist/schedule-tool-A4OFQ4YZ.js +2 -0
  62. package/dist/scheduler-RHJW2V4M.js +2 -0
  63. package/dist/seat-2PEI7JRX.js +2 -0
  64. package/dist/{select-Y7SBUCGJ.js → select-4KIUR27O.js} +2 -2
  65. package/dist/{set-enabled-ARKMDLXN.js → set-enabled-SE7HQKPF.js} +2 -2
  66. package/dist/subscriptions-RYVPHVIV.js +2 -0
  67. package/dist/{tool-5EVYUN2G.js → tool-5VMD7IE4.js} +2 -2
  68. package/dist/wiki-2QFLAH4U.js +2 -0
  69. package/dist/wiki-2QFLAH4U.js.map +1 -0
  70. package/package.json +1 -1
  71. package/dist/board-dashboard-3XJVYDB3.js.map +0 -1
  72. package/dist/chunk-3RCFUIYR.js.map +0 -1
  73. package/dist/chunk-4FFWNPFX.js +0 -15
  74. package/dist/chunk-4FFWNPFX.js.map +0 -1
  75. package/dist/chunk-6JHA3ZE6.js.map +0 -1
  76. package/dist/chunk-BMSPRUEJ.js +0 -150
  77. package/dist/chunk-BMSPRUEJ.js.map +0 -1
  78. package/dist/chunk-QX4R5WY7.js +0 -5
  79. package/dist/chunk-TLFJVLVQ.js.map +0 -1
  80. package/dist/client-N4XQEAVL.js +0 -2
  81. package/dist/config-KHKIY6LI.js +0 -2
  82. package/dist/config-mutator-ZZKFQ7HJ.js +0 -2
  83. package/dist/doctor-QICAXIQB.js +0 -2
  84. package/dist/program-ZWNGK3RB.js +0 -2
  85. package/dist/query-FTE4FLYK.js +0 -2
  86. package/dist/registry-HDOFVNRK.js +0 -2
  87. package/dist/routing-BGKZOHOA.js +0 -2
  88. package/dist/schedule-tool-UDHXTKWV.js +0 -2
  89. package/dist/scheduler-NQM3HVUY.js +0 -2
  90. package/dist/seat-VGST5AQB.js +0 -2
  91. package/dist/wiki-GREI2TSP.js +0 -2
  92. /package/dist/{App-VRTLAPRF.js.map → App-4EM7IPB3.js.map} +0 -0
  93. /package/dist/{ChatApp-YENWPZT4.js.map → ChatApp-FYTEEGSU.js.map} +0 -0
  94. /package/dist/{agent-HVUGHYBF.js.map → agent-RZ46ZB4W.js.map} +0 -0
  95. /package/dist/{chunk-M67AN4J7.js.map → chunk-4HEVSWO3.js.map} +0 -0
  96. /package/dist/{chunk-KLDGJSR5.js.map → chunk-6AK6WKE5.js.map} +0 -0
  97. /package/dist/{chunk-ENRJ2QEH.js.map → chunk-723K4SAW.js.map} +0 -0
  98. /package/dist/{chunk-UYMK3XNB.js.map → chunk-76AHNHWC.js.map} +0 -0
  99. /package/dist/{chunk-XWU7O3S5.js.map → chunk-C3VRBEJ7.js.map} +0 -0
  100. /package/dist/{chunk-HQMISQ7L.js.map → chunk-CELYD5QW.js.map} +0 -0
  101. /package/dist/{chunk-33QV5O6P.js.map → chunk-D3LSPFHK.js.map} +0 -0
  102. /package/dist/{chunk-4HNHHKJF.js.map → chunk-EZN2YXRY.js.map} +0 -0
  103. /package/dist/{chunk-BKDUCPZ3.js.map → chunk-GSOL2LW6.js.map} +0 -0
  104. /package/dist/{chunk-WLRHL2P7.js.map → chunk-LKJSPO3S.js.map} +0 -0
  105. /package/dist/{chunk-37EABIOJ.js.map → chunk-M4AG2YNX.js.map} +0 -0
  106. /package/dist/{chunk-QYHABRLM.js.map → chunk-SA23V7WX.js.map} +0 -0
  107. /package/dist/{chunk-3W2KMI5O.js.map → chunk-W4YMMWJL.js.map} +0 -0
  108. /package/dist/{chunk-2TJYZF4E.js.map → chunk-XEOAKD5C.js.map} +0 -0
  109. /package/dist/{client-N4XQEAVL.js.map → client-IA7ZPE56.js.map} +0 -0
  110. /package/dist/{config-KHKIY6LI.js.map → config-HPN2F7LH.js.map} +0 -0
  111. /package/dist/{config-mutator-ZZKFQ7HJ.js.map → config-mutator-7GPTMTVB.js.map} +0 -0
  112. /package/dist/{doctor-QICAXIQB.js.map → doctor-IBWQVQV4.js.map} +0 -0
  113. /package/dist/{program-ZWNGK3RB.js.map → program-G6DIFJUF.js.map} +0 -0
  114. /package/dist/{promote-H6ZHJXTY.js.map → promote-T4UJROEH.js.map} +0 -0
  115. /package/dist/{query-FTE4FLYK.js.map → query-VUJC4Y5O.js.map} +0 -0
  116. /package/dist/{registry-HDOFVNRK.js.map → registry-DX2RI7I7.js.map} +0 -0
  117. /package/dist/{request-planner-ABD4WIGM.js.map → request-planner-B7DE7VJR.js.map} +0 -0
  118. /package/dist/{review-candidates-B6574GOJ.js.map → review-candidates-4YJHIBAF.js.map} +0 -0
  119. /package/dist/{routing-BGKZOHOA.js.map → routing-MZG3UODH.js.map} +0 -0
  120. /package/dist/{schedule-tool-UDHXTKWV.js.map → schedule-tool-A4OFQ4YZ.js.map} +0 -0
  121. /package/dist/{scheduler-NQM3HVUY.js.map → scheduler-RHJW2V4M.js.map} +0 -0
  122. /package/dist/{seat-VGST5AQB.js.map → seat-2PEI7JRX.js.map} +0 -0
  123. /package/dist/{select-Y7SBUCGJ.js.map → select-4KIUR27O.js.map} +0 -0
  124. /package/dist/{set-enabled-ARKMDLXN.js.map → set-enabled-SE7HQKPF.js.map} +0 -0
  125. /package/dist/{wiki-GREI2TSP.js.map → subscriptions-RYVPHVIV.js.map} +0 -0
  126. /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
- if state.isMeta && !alwaysVisible { orderOut(nil) } else { orderFrontRegardless() }
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
- positionAboveWidget()
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
- private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
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 + (busy ? " · working" : " · idle"), #selector(pick(_:)),
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