agentix-cli 0.55.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.
@@ -167,6 +167,9 @@ enum AgentClient {
167
167
  let name: String?
168
168
  /// Tasks running now.
169
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?
170
173
  var label: String { name ?? id }
171
174
  }
172
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 }
@@ -34,6 +34,7 @@ final class StatusMenu: NSObject, NSMenuDelegate {
34
34
  var onHoldChanged: ((Bool) -> Void)?
35
35
  var onTargetChanged: ((String) -> Void)?
36
36
  var onPillChanged: ((Bool) -> Void)?
37
+ var onOrbChanged: ((Bool) -> Void)?
37
38
 
38
39
  override init() {
39
40
  super.init()
@@ -50,6 +51,16 @@ final class StatusMenu: NSObject, NSMenuDelegate {
50
51
  return agents.first { $0.id == id }?.label ?? id
51
52
  }
52
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
+
53
64
  /// Open the menu from the keyboard.
54
65
  func open() { item.button?.performClick(nil) }
55
66
 
@@ -166,6 +177,9 @@ final class StatusMenu: NSObject, NSMenuDelegate {
166
177
  let pill = action("Show floating pill", #selector(togglePill), key: "")
167
178
  pill.state = Config.showPill ? .on : .off
168
179
  menu.addItem(pill)
180
+ let orb = action("Show orb", #selector(toggleOrb), key: "")
181
+ orb.state = Config.showOrb ? .on : .off
182
+ menu.addItem(orb)
169
183
 
170
184
  menu.addItem(.separator())
171
185
  menu.addItem(action("Settings…", #selector(openSettings), key: ","))
@@ -215,6 +229,11 @@ final class StatusMenu: NSObject, NSMenuDelegate {
215
229
  onPillChanged?(Config.showPill)
216
230
  }
217
231
 
232
+ @objc private func toggleOrb() {
233
+ Config.showOrb.toggle()
234
+ onOrbChanged?(Config.showOrb)
235
+ }
236
+
218
237
  @objc private func openSettings() { openDashboard("/admin") }
219
238
  @objc private func openHistory() { openDashboard("/activity") }
220
239
 
@@ -19,6 +19,11 @@ final class App: NSObject, NSApplicationDelegate {
19
19
  private let panel = Panel()
20
20
  private let statusMenu = StatusMenu()
21
21
  private let card = ResultCard()
22
+ private let orb = OrbOverlay()
23
+ /// What the listener said this turn, shown under the orb.
24
+ private var heardLine = ""
25
+ /// The agent whose by-name answer is on screen now, while no turn runs.
26
+ private var asideSpeaker: String?
22
27
  private let recorder = Recorder()
23
28
  private var hotkey: Hotkey?
24
29
  private var pasteHotkey: Hotkey?
@@ -83,6 +88,37 @@ final class App: NSObject, NSApplicationDelegate {
83
88
  statusMenu.thinking = counts
84
89
  }
85
90
 
91
+ /// Who the panel and orb name: a by-name answer being spoken, else the
92
+ /// agent our turn is asking, else the target.
93
+ private var shownAgent: String {
94
+ if let asideSpeaker { return asideSpeaker }
95
+ return busy && !turnAgent.isEmpty ? turnAgent : Config.effectiveAgentID
96
+ }
97
+
98
+ /// The orb follows the pill's state: listening, thinking or speaking
99
+ /// show it; idle and errors hide it.
100
+ private func renderOrb(_ state: Panel.State) {
101
+ guard Config.showOrb else { orb.hide(); return }
102
+ let who = shownAgent
103
+ let name = who.isEmpty ? "" : statusMenu.name(of: who)
104
+ let tint = statusMenu.color(of: who)
105
+ switch state {
106
+ case .idle, .error:
107
+ orb.hide()
108
+ case .listening:
109
+ heardLine = ""
110
+ orb.show(.listening, name: name, tint: tint, line: "Listening…", status: "")
111
+ case .thinking:
112
+ orb.show(.thinking, name: name, tint: tint, line: heardLine, status: "Thinking…")
113
+ case .working(let step, let secs):
114
+ orb.show(.thinking, name: name, tint: tint, line: heardLine, status: "\(step) · \(secs)s")
115
+ case .speaking:
116
+ orb.show(.speaking, name: name, tint: tint, line: heardLine, status: "")
117
+ case .saying(let text):
118
+ orb.show(.speaking, name: name, tint: tint, line: text, status: "")
119
+ }
120
+ }
121
+
86
122
  /// ⌘⌥V. Everything except the hotkey lives in `agentx paste`.
87
123
  private func smartPaste() {
88
124
  guard !busy else { return }
@@ -129,15 +165,26 @@ final class App: NSObject, NSApplicationDelegate {
129
165
  func applicationDidFinishLaunching(_ note: Notification) {
130
166
  NSApp.setActivationPolicy(.accessory)
131
167
  retireOlderInstances()
132
- panel.onRender = { [weak self] state in self?.statusMenu.show(state) }
168
+ panel.onRender = { [weak self] state in
169
+ self?.statusMenu.show(state)
170
+ self?.renderOrb(state)
171
+ }
133
172
  panel.contextMenu = { [weak self] in self?.statusMenu.menu ?? NSMenu() }
134
173
  panel.agentName = { [weak self] in
135
174
  guard let self else { return "" }
136
- // A question sent by name shows who is answering it, not the target.
137
- let who = self.busy && !self.turnAgent.isEmpty ? self.turnAgent : Config.effectiveAgentID
175
+ let who = self.shownAgent
138
176
  return who.isEmpty ? "" : self.statusMenu.name(of: who)
139
177
  }
140
178
  panel.alwaysVisible = Config.showPill
179
+ panel.yieldsActiveStates = Config.showOrb
180
+ orb.levelSource = { [weak self] in self?.recorder.level ?? 0 }
181
+ card.anchor = { [weak self] in self?.orb.contentFrame }
182
+ statusMenu.onOrbChanged = { [weak self] on in
183
+ guard let self else { return }
184
+ self.panel.yieldsActiveStates = on
185
+ if !on { self.orb.hide() }
186
+ if !self.busy && !self.recorder.isRecording { self.panel.render(.idle) }
187
+ }
141
188
  panel.render(.idle)
142
189
  statusMenu.onPillChanged = { [weak self] on in
143
190
  guard let self else { return }
@@ -259,6 +306,7 @@ final class App: NSObject, NSApplicationDelegate {
259
306
  openedAt = Date()
260
307
  patience = followUp ? followUpPatience : clickPatience
261
308
  silentClose = followUp
309
+ asideSpeaker = nil
262
310
  panel.render(.listening)
263
311
 
264
312
  listenPoll?.invalidate()
@@ -324,6 +372,7 @@ final class App: NSObject, NSApplicationDelegate {
324
372
  stopGeneration += 1
325
373
  waitingAsides.removeAll()
326
374
  publishThinking()
375
+ asideSpeaker = nil
327
376
  lastSpokeAt = Date()
328
377
  Task { await Speech.stopAll() }
329
378
  if !recorder.isRecording { panel.render(.idle) }
@@ -347,6 +396,7 @@ final class App: NSObject, NSApplicationDelegate {
347
396
  Log.info("door: opened\(busy ? " (a turn is running)" : "")")
348
397
  do {
349
398
  try recorder.start()
399
+ asideSpeaker = nil
350
400
  panel.render(.listening)
351
401
  } catch {
352
402
  panel.render(.error(error.localizedDescription))
@@ -386,6 +436,8 @@ final class App: NSObject, NSApplicationDelegate {
386
436
  return
387
437
  }
388
438
  Log.info("heard: \(heard)")
439
+ heardLine = heard
440
+ if orb.isVisible { orb.model.line = heard }
389
441
  let hushed = await door.value
390
442
  // Always through the door, even with nothing hushed: the
391
443
  // listener's turn is over, so the daemon's queue plays on.
@@ -466,7 +518,15 @@ final class App: NSObject, NSApplicationDelegate {
466
518
  card.show(spoken: answer.text, written: answer.written,
467
519
  buttons: answer.buttons, imageURL: answer.imageURL)
468
520
  }
521
+ // Nothing else on screen: the orb shows this answer, in
522
+ // this agent's colour, while it is spoken.
523
+ let onScreen = !busy && !recorder.isRecording
524
+ if onScreen { asideSpeaker = agent; panel.render(.saying(answer.text)) }
469
525
  await Speech.say(answer.text, agentID: answer.agentID ?? agent, kind: "answer", voice: answer.voice)
526
+ if onScreen && asideSpeaker == agent {
527
+ asideSpeaker = nil
528
+ if !busy && !recorder.isRecording { panel.render(.idle) }
529
+ }
470
530
  case .failure(let error):
471
531
  Log.warn("aside failed (\(agent)): \(error.localizedDescription)")
472
532
  await Speech.say("Sorry, \(statusMenu.name(of: agent)) couldn't answer that.", agentID: nil, kind: "line", voice: nil)
@@ -18,7 +18,7 @@ rm -rf build && mkdir -p "$BIN" "$APP/Contents/Resources"
18
18
  swiftc -O \
19
19
  -o "$BIN/AgentXVoice" \
20
20
  Sources/AgentXVoice/*.swift \
21
- -framework AppKit -framework AVFoundation -framework Carbon \
21
+ -framework AppKit -framework AVFoundation -framework Carbon -framework SwiftUI \
22
22
  -target arm64-apple-macosx14.0
23
23
 
24
24
  cp Resources/Info.plist "$APP/Contents/Info.plist"