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.
- package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +3 -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 +19 -0
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +63 -3
- package/apps/mac-voice/build.sh +1 -1
- package/dist/{chunk-JC5Q5MPI.js → chunk-IL2XLW4H.js} +8 -8
- package/dist/{chunk-K4RDE6YU.js → chunk-WKMTT2UY.js} +82 -82
- package/dist/chunk-WKMTT2UY.js.map +1 -0
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/{program-D5TAPSIH.js → program-G6DIFJUF.js} +2 -2
- package/package.json +1 -1
- package/dist/chunk-K4RDE6YU.js.map +0 -1
- /package/dist/{chunk-JC5Q5MPI.js.map → chunk-IL2XLW4H.js.map} +0 -0
- /package/dist/{program-D5TAPSIH.js.map → program-G6DIFJUF.js.map} +0 -0
|
@@ -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
|
-
|
|
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 }
|
|
@@ -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
|
|
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
|
-
|
|
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)
|
package/apps/mac-voice/build.sh
CHANGED
|
@@ -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"
|