agentix-cli 0.57.0 → 0.58.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/Config.swift +19 -5
- package/apps/mac-voice/Sources/AgentXVoice/Orb.swift +131 -169
- package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +174 -35
- package/apps/mac-voice/Sources/AgentXVoice/PillPlacement.swift +70 -0
- package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +26 -22
- package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +18 -5
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +35 -44
- package/package.json +1 -1
|
@@ -28,11 +28,25 @@ enum Config {
|
|
|
28
28
|
set { UserDefaults.standard.set(newValue, forKey: "showPill") }
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/// The orb
|
|
32
|
-
///
|
|
33
|
-
static var
|
|
34
|
-
get { UserDefaults.standard.object(forKey: "
|
|
35
|
-
set { UserDefaults.standard.set(newValue, forKey: "
|
|
31
|
+
/// The pill's orb moves with the voice. On by default; off keeps it
|
|
32
|
+
/// still in every state, as Reduce Motion does.
|
|
33
|
+
static var animatedOrb: Bool {
|
|
34
|
+
get { UserDefaults.standard.object(forKey: "animatedOrb") as? Bool ?? true }
|
|
35
|
+
set { UserDefaults.standard.set(newValue, forKey: "animatedOrb") }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Where the pill was last dragged to (its bottom-left corner, in
|
|
39
|
+
/// screen points). Nil: the default corner.
|
|
40
|
+
static var pillOrigin: CGPoint? {
|
|
41
|
+
get {
|
|
42
|
+
guard let xy = UserDefaults.standard.array(forKey: "pillOrigin") as? [Double], xy.count == 2
|
|
43
|
+
else { return nil }
|
|
44
|
+
return CGPoint(x: xy[0], y: xy[1])
|
|
45
|
+
}
|
|
46
|
+
set {
|
|
47
|
+
if let p = newValue { UserDefaults.standard.set([Double(p.x), Double(p.y)], forKey: "pillOrigin") }
|
|
48
|
+
else { UserDefaults.standard.removeObject(forKey: "pillOrigin") }
|
|
49
|
+
}
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
/// The agent actually answering: `agentID`, else `chosenAgentID`, else
|
|
@@ -1,90 +1,61 @@
|
|
|
1
1
|
import AppKit
|
|
2
2
|
import SwiftUI
|
|
3
3
|
|
|
4
|
-
/// The Siri-style
|
|
5
|
-
/// thinks and speaks
|
|
4
|
+
/// The Siri-style orb at the head of the pill: the agent's colour, and it
|
|
5
|
+
/// listens, thinks and speaks.
|
|
6
6
|
///
|
|
7
|
-
/// Built from SwiftUI's own gradients, no animation library.
|
|
8
|
-
///
|
|
9
|
-
/// microphone level is no longer read, so an
|
|
10
|
-
/// With Reduce Motion on
|
|
11
|
-
///
|
|
7
|
+
/// Built from SwiftUI's own gradients, no animation library. It only moves
|
|
8
|
+
/// while it has something to show and is on screen: idle or hidden, its
|
|
9
|
+
/// timeline is paused and the microphone level is no longer read, so an
|
|
10
|
+
/// idle assistant costs nothing. With Reduce Motion on, or "Animated orb"
|
|
11
|
+
/// off in the menu, it is a still picture that changes between states,
|
|
12
|
+
/// never a loop.
|
|
12
13
|
@MainActor
|
|
13
14
|
final class OrbModel: ObservableObject {
|
|
14
|
-
enum Phase: Equatable { case
|
|
15
|
+
enum Phase: Equatable { case idle, listening, thinking, speaking }
|
|
15
16
|
|
|
16
|
-
@Published var phase: Phase = .
|
|
17
|
+
@Published var phase: Phase = .idle
|
|
17
18
|
/// 0…1: the microphone while listening.
|
|
18
19
|
@Published var level: Double = 0
|
|
19
20
|
@Published var tint = NSColor(srgbRed: 0.078, green: 0.722, blue: 0.651, alpha: 1)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@Published var line = ""
|
|
23
|
-
/// The step the agent is on, and for how long.
|
|
24
|
-
@Published var status = ""
|
|
21
|
+
/// The pill is on screen. Off, nothing moves.
|
|
22
|
+
@Published var onScreen = false
|
|
25
23
|
@Published var reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
|
|
24
|
+
/// "Animated orb" in the menu. Off: a still orb, as with Reduce Motion.
|
|
25
|
+
@Published var animated = true
|
|
26
|
+
|
|
27
|
+
var still: Bool { reduceMotion || !animated }
|
|
28
|
+
/// The timeline runs only while there is motion to draw.
|
|
29
|
+
var paused: Bool { still || phase == .idle || !onScreen }
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
|
|
32
|
+
/// The orb as the pill draws it: `OrbBody` at its design size, scaled down
|
|
33
|
+
/// to `diameter`, in a frame with room around it for the glow.
|
|
34
|
+
struct PillOrbView: View {
|
|
29
35
|
@ObservedObject var model: OrbModel
|
|
30
|
-
|
|
36
|
+
let diameter: CGFloat
|
|
37
|
+
let frameSize: CGFloat
|
|
31
38
|
|
|
32
|
-
/// The
|
|
33
|
-
private
|
|
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
|
+
/// The size OrbBody's blur, ring and gradients were drawn for.
|
|
40
|
+
private static let designSize: CGFloat = 96
|
|
39
41
|
|
|
40
42
|
var body: some View {
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
43
|
+
let paused = model.paused
|
|
44
|
+
TimelineView(.animation(minimumInterval: 1.0 / 30, paused: paused)) { context in
|
|
79
45
|
OrbBody(t: paused ? 0 : context.date.timeIntervalSinceReferenceDate,
|
|
80
|
-
phase: model.phase, level: model.level, tint: model.tint, still: still
|
|
46
|
+
phase: model.phase, level: model.level, tint: model.tint, still: model.still,
|
|
47
|
+
ringWidth: 5)
|
|
48
|
+
.frame(width: Self.designSize, height: Self.designSize)
|
|
49
|
+
.scaleEffect(diameter / Self.designSize)
|
|
81
50
|
}
|
|
51
|
+
.frame(width: frameSize, height: frameSize)
|
|
52
|
+
.accessibilityElement()
|
|
82
53
|
.accessibilityLabel(Text(accessibilityText))
|
|
83
54
|
}
|
|
84
55
|
|
|
85
56
|
private var accessibilityText: String {
|
|
86
57
|
switch model.phase {
|
|
87
|
-
case .
|
|
58
|
+
case .idle: return "Idle"
|
|
88
59
|
case .listening: return "Listening"
|
|
89
60
|
case .thinking: return "Thinking"
|
|
90
61
|
case .speaking: return "Speaking"
|
|
@@ -92,6 +63,97 @@ struct OrbView: View {
|
|
|
92
63
|
}
|
|
93
64
|
}
|
|
94
65
|
|
|
66
|
+
/// The orb's view in the pill. Clicks go through it to the pill, so
|
|
67
|
+
/// pressing on the orb drags the pill like anywhere else on it.
|
|
68
|
+
@MainActor
|
|
69
|
+
final class PillOrb: NSHostingView<PillOrbView> {
|
|
70
|
+
private let driver: OrbDriver
|
|
71
|
+
var model: OrbModel { driver.model }
|
|
72
|
+
|
|
73
|
+
/// The microphone's RMS level, read while listening. Set by the app.
|
|
74
|
+
var levelSource: (() -> Float)? {
|
|
75
|
+
get { driver.levelSource }
|
|
76
|
+
set { driver.levelSource = newValue; driver.watchLevel() }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
init(diameter: CGFloat, frameSize: CGFloat) {
|
|
80
|
+
driver = OrbDriver()
|
|
81
|
+
super.init(rootView: PillOrbView(model: driver.model, diameter: diameter, frameSize: frameSize))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@available(*, unavailable)
|
|
85
|
+
required init(rootView: PillOrbView) { fatalError("use init(diameter:frameSize:)") }
|
|
86
|
+
|
|
87
|
+
@available(*, unavailable)
|
|
88
|
+
required init?(coder: NSCoder) { fatalError("not from a nib") }
|
|
89
|
+
|
|
90
|
+
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
|
91
|
+
|
|
92
|
+
/// Show `phase` in `tint`. Cheap to call on every render: SwiftUI only
|
|
93
|
+
/// redraws what changed.
|
|
94
|
+
func show(_ phase: OrbModel.Phase, tint: NSColor) {
|
|
95
|
+
let model = driver.model
|
|
96
|
+
if model.tint != tint { model.tint = tint }
|
|
97
|
+
if model.phase != phase {
|
|
98
|
+
model.phase = phase
|
|
99
|
+
if phase != .listening { model.level = 0 }
|
|
100
|
+
}
|
|
101
|
+
driver.watchLevel()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// The pill came on screen or went off it.
|
|
105
|
+
func setOnScreen(_ on: Bool) {
|
|
106
|
+
let model = driver.model
|
|
107
|
+
guard model.onScreen != on else { return }
|
|
108
|
+
model.onScreen = on
|
|
109
|
+
if !on { model.level = 0 }
|
|
110
|
+
driver.watchLevel()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/// "Animated orb" in the menu.
|
|
114
|
+
func setAnimated(_ on: Bool) {
|
|
115
|
+
guard driver.model.animated != on else { return }
|
|
116
|
+
driver.model.animated = on
|
|
117
|
+
driver.watchLevel()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// Reads the microphone into the model while listening, and follows the
|
|
122
|
+
/// Reduce Motion setting.
|
|
123
|
+
@MainActor
|
|
124
|
+
private final class OrbDriver {
|
|
125
|
+
let model = OrbModel()
|
|
126
|
+
var levelSource: (() -> Float)?
|
|
127
|
+
private var levelTimer: Timer?
|
|
128
|
+
private var motionObserver: NSObjectProtocol?
|
|
129
|
+
|
|
130
|
+
init() {
|
|
131
|
+
motionObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
|
132
|
+
forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification,
|
|
133
|
+
object: nil, queue: .main) { [weak self] _ in
|
|
134
|
+
MainActor.assumeIsolated {
|
|
135
|
+
guard let self else { return }
|
|
136
|
+
self.model.reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
|
|
137
|
+
self.watchLevel()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// The microphone is read only while listening, on screen, with
|
|
143
|
+
/// motion allowed.
|
|
144
|
+
func watchLevel() {
|
|
145
|
+
let wanted = model.phase == .listening && model.onScreen && !model.still && levelSource != nil
|
|
146
|
+
if !wanted { levelTimer?.invalidate(); levelTimer = nil; return }
|
|
147
|
+
guard levelTimer == nil else { return }
|
|
148
|
+
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 20, repeats: true) { [weak self] _ in
|
|
149
|
+
MainActor.assumeIsolated {
|
|
150
|
+
guard let self, let read = self.levelSource else { return }
|
|
151
|
+
self.model.level = OrbMath.smooth(self.model.level, toward: OrbMath.level(fromRMS: read()))
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
95
157
|
/// One frame of the orb at time `t`.
|
|
96
158
|
struct OrbBody: View {
|
|
97
159
|
let t: Double
|
|
@@ -100,11 +162,14 @@ struct OrbBody: View {
|
|
|
100
162
|
let tint: NSColor
|
|
101
163
|
/// Reduce Motion: a still orb, sized and shaded by state alone.
|
|
102
164
|
let still: Bool
|
|
165
|
+
/// The thinking ring's stroke at the 96-point design size. The pill
|
|
166
|
+
/// draws the orb small, so it passes a thicker one that still reads.
|
|
167
|
+
var ringWidth: CGFloat = 2.2
|
|
103
168
|
|
|
104
169
|
/// How much the orb swells and glows, 0…1.
|
|
105
170
|
private var energy: Double {
|
|
106
171
|
switch phase {
|
|
107
|
-
case .
|
|
172
|
+
case .idle: return 0
|
|
108
173
|
case .listening: return still ? 0.45 : 0.12 + 0.88 * level
|
|
109
174
|
case .thinking: return still ? 0.2 : 0.22 + 0.08 * sin(t * 2.2)
|
|
110
175
|
case .speaking: return still ? 0.7 : OrbMath.speakingEnvelope(at: t)
|
|
@@ -114,7 +179,7 @@ struct OrbBody: View {
|
|
|
114
179
|
/// How fast the colours drift.
|
|
115
180
|
private var speed: Double {
|
|
116
181
|
switch phase {
|
|
117
|
-
case .
|
|
182
|
+
case .idle: return 0
|
|
118
183
|
case .listening: return 0.5 + level * 1.5
|
|
119
184
|
case .thinking: return 1.3
|
|
120
185
|
case .speaking: return 0.9
|
|
@@ -136,7 +201,7 @@ struct OrbBody: View {
|
|
|
136
201
|
if phase == .thinking {
|
|
137
202
|
Circle()
|
|
138
203
|
.trim(from: 0, to: 0.28)
|
|
139
|
-
.stroke(Color.white.opacity(0.75), style: StrokeStyle(lineWidth:
|
|
204
|
+
.stroke(Color.white.opacity(0.75), style: StrokeStyle(lineWidth: ringWidth, lineCap: .round))
|
|
140
205
|
.rotationEffect(.radians(still ? -.pi / 2 : t * 3.1))
|
|
141
206
|
.scaleEffect(scale * 0.86)
|
|
142
207
|
} else {
|
|
@@ -206,106 +271,3 @@ struct OrbBody: View {
|
|
|
206
271
|
]
|
|
207
272
|
}
|
|
208
273
|
}
|
|
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
|
-
}
|
|
@@ -6,9 +6,24 @@ import AppKit
|
|
|
6
6
|
/// focus out of whatever you were doing — the entire point of a voice
|
|
7
7
|
/// assistant is that you don't stop working to use it. It floats above
|
|
8
8
|
/// normal windows, joins every Space, and is deliberately small.
|
|
9
|
+
///
|
|
10
|
+
/// It is the one floating widget: a small live orb at its head (Orb.swift)
|
|
11
|
+
/// in the answering agent's colour, the state or the words beside it, and
|
|
12
|
+
/// a close button that shows on hover. Drag it anywhere; the place is
|
|
13
|
+
/// remembered across launches.
|
|
9
14
|
final class Panel: NSPanel {
|
|
15
|
+
static let size = NSSize(width: 284, height: 54)
|
|
16
|
+
/// The orb's diameter, and the square it draws in with room for its glow.
|
|
17
|
+
private static let orbDiameter: CGFloat = 36
|
|
18
|
+
private static let orbFrame: CGFloat = 52
|
|
19
|
+
|
|
10
20
|
private let label = NSTextField(labelWithString: "")
|
|
11
|
-
|
|
21
|
+
let orb = PillOrb(diameter: Panel.orbDiameter, frameSize: Panel.orbFrame)
|
|
22
|
+
private let closeButton = NSButton()
|
|
23
|
+
/// Hidden by close, Esc or the menu until the next talk key.
|
|
24
|
+
private(set) var dismissed = false
|
|
25
|
+
/// Set while the app moves the pill, so only a drag is remembered.
|
|
26
|
+
private var placing = false
|
|
12
27
|
|
|
13
28
|
/// Scrolls text too long for the pill instead of truncating it.
|
|
14
29
|
///
|
|
@@ -48,16 +63,24 @@ final class Panel: NSPanel {
|
|
|
48
63
|
/// The target agent's name, shown while listening or answering.
|
|
49
64
|
var agentName: () -> String = { "" }
|
|
50
65
|
|
|
66
|
+
/// The colour of the agent shown, for the orb.
|
|
67
|
+
var agentTint: () -> NSColor = { Brand.accent }
|
|
68
|
+
|
|
51
69
|
/// Stay on screen when idle. Off: the pill shows only while active.
|
|
52
70
|
var alwaysVisible = false
|
|
53
71
|
|
|
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
|
-
|
|
58
72
|
/// Told of every state rendered, so the menu-bar icon can follow.
|
|
59
73
|
var onRender: ((State) -> Void)?
|
|
60
74
|
|
|
75
|
+
/// Close button or Esc. The app stops speech and calls `dismiss()`.
|
|
76
|
+
var onDismiss: (() -> Void)?
|
|
77
|
+
|
|
78
|
+
/// The pill moved, by a drag or a reset. The answer card follows.
|
|
79
|
+
var onMove: (() -> Void)?
|
|
80
|
+
|
|
81
|
+
/// The last state rendered, to draw again after a dismiss or a move.
|
|
82
|
+
private var current = State.idle
|
|
83
|
+
|
|
61
84
|
override func mouseUp(with event: NSEvent) {
|
|
62
85
|
defer { pressedAt = nil }
|
|
63
86
|
if let start = pressedAt {
|
|
@@ -112,11 +135,14 @@ final class Panel: NSPanel {
|
|
|
112
135
|
/// is a running commentary and belongs in body type.
|
|
113
136
|
var isMeta: Bool { if case .idle = self { return true }; return false }
|
|
114
137
|
|
|
115
|
-
///
|
|
116
|
-
|
|
138
|
+
/// What the orb does: follows the voice, turns a ring, or pulses.
|
|
139
|
+
/// Idle and errors hold it still.
|
|
140
|
+
var orbPhase: OrbModel.Phase {
|
|
117
141
|
switch self {
|
|
118
|
-
case .idle, .error: return
|
|
119
|
-
|
|
142
|
+
case .idle, .error: return .idle
|
|
143
|
+
case .listening: return .listening
|
|
144
|
+
case .thinking, .working: return .thinking
|
|
145
|
+
case .speaking, .saying: return .speaking
|
|
120
146
|
}
|
|
121
147
|
}
|
|
122
148
|
}
|
|
@@ -130,7 +156,7 @@ final class Panel: NSPanel {
|
|
|
130
156
|
var onClick: (() -> Void)?
|
|
131
157
|
|
|
132
158
|
init() {
|
|
133
|
-
super.init(contentRect: NSRect(
|
|
159
|
+
super.init(contentRect: NSRect(origin: .zero, size: Self.size),
|
|
134
160
|
styleMask: [.borderless, .nonactivatingPanel],
|
|
135
161
|
backing: .buffered, defer: false)
|
|
136
162
|
isFloatingPanel = true
|
|
@@ -142,7 +168,7 @@ final class Panel: NSPanel {
|
|
|
142
168
|
isMovableByWindowBackground = true
|
|
143
169
|
hidesOnDeactivate = false
|
|
144
170
|
|
|
145
|
-
let blur = NSVisualEffectView(frame: NSRect(origin: .zero, size:
|
|
171
|
+
let blur = NSVisualEffectView(frame: NSRect(origin: .zero, size: Self.size))
|
|
146
172
|
blur.material = .hudWindow
|
|
147
173
|
blur.blendingMode = .behindWindow
|
|
148
174
|
blur.state = .active
|
|
@@ -170,16 +196,19 @@ final class Panel: NSPanel {
|
|
|
170
196
|
tint.autoresizingMask = [.width, .height]
|
|
171
197
|
blur.addSubview(tint)
|
|
172
198
|
|
|
173
|
-
orb
|
|
174
|
-
orb
|
|
175
|
-
|
|
199
|
+
// The orb, centred 28 points from the left edge; its square is
|
|
200
|
+
// larger than the orb so the glow is not cut off.
|
|
201
|
+
let h = Self.size.height
|
|
202
|
+
orb.frame = NSRect(x: 28 - Self.orbFrame / 2, y: (h - Self.orbFrame) / 2,
|
|
203
|
+
width: Self.orbFrame, height: Self.orbFrame)
|
|
176
204
|
blur.addSubview(orb)
|
|
177
205
|
|
|
178
|
-
// A clipping window the label slides behind.
|
|
179
|
-
|
|
206
|
+
// A clipping window the label slides behind. Clicks pass through
|
|
207
|
+
// it, so pressing on the words drags the pill too.
|
|
208
|
+
let clipView = PassThroughView(frame: NSRect(x: 54, y: 17, width: 196, height: 20))
|
|
180
209
|
clipView.wantsLayer = true
|
|
181
210
|
clipView.layer?.masksToBounds = true
|
|
182
|
-
label.frame = NSRect(x: 0, y: 0, width:
|
|
211
|
+
label.frame = NSRect(x: 0, y: 0, width: 196, height: 20)
|
|
183
212
|
label.font = Brand.body(size: 12)
|
|
184
213
|
label.lineBreakMode = .byClipping
|
|
185
214
|
label.wantsLayer = true
|
|
@@ -187,15 +216,115 @@ final class Panel: NSPanel {
|
|
|
187
216
|
blur.addSubview(clipView)
|
|
188
217
|
clip = clipView
|
|
189
218
|
|
|
219
|
+
// Close, shown while the pointer is over the pill. Its space is
|
|
220
|
+
// kept when hidden, so the words never jump under it.
|
|
221
|
+
closeButton.frame = NSRect(x: Self.size.width - 28, y: (h - 18) / 2, width: 18, height: 18)
|
|
222
|
+
closeButton.isBordered = false
|
|
223
|
+
closeButton.bezelStyle = .regularSquare
|
|
224
|
+
closeButton.imagePosition = .imageOnly
|
|
225
|
+
closeButton.image = NSImage(systemSymbolName: "xmark.circle.fill", accessibilityDescription: "Hide")
|
|
226
|
+
closeButton.contentTintColor = .secondaryLabelColor
|
|
227
|
+
closeButton.toolTip = "Hide (Esc)"
|
|
228
|
+
closeButton.setAccessibilityLabel("Hide the pill")
|
|
229
|
+
closeButton.target = self
|
|
230
|
+
closeButton.action = #selector(closeClicked)
|
|
231
|
+
closeButton.isHidden = true
|
|
232
|
+
blur.addSubview(closeButton)
|
|
233
|
+
blur.addTrackingArea(NSTrackingArea(rect: .zero,
|
|
234
|
+
options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
|
|
235
|
+
owner: self, userInfo: nil))
|
|
236
|
+
|
|
190
237
|
render(.idle)
|
|
191
|
-
|
|
238
|
+
restorePosition()
|
|
239
|
+
|
|
240
|
+
NotificationCenter.default.addObserver(self, selector: #selector(moved),
|
|
241
|
+
name: NSWindow.didMoveNotification, object: self)
|
|
242
|
+
// A monitor unplugged or rearranged: bring the pill back on screen.
|
|
243
|
+
NotificationCenter.default.addObserver(self, selector: #selector(screensChanged),
|
|
244
|
+
name: NSApplication.didChangeScreenParametersNotification, object: nil)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// MARK: Place
|
|
248
|
+
|
|
249
|
+
/// Where it was left, kept on a screen that still exists; the
|
|
250
|
+
/// bottom-right corner the first time.
|
|
251
|
+
@MainActor
|
|
252
|
+
func restorePosition() {
|
|
253
|
+
let screens = NSScreen.screens.map(\.visibleFrame)
|
|
254
|
+
let fallback = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame ?? .zero
|
|
255
|
+
place(PillPlacement.clamp(saved: Config.pillOrigin, size: frame.size, screens: screens, fallback: fallback))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/// Back to the bottom-right corner, forgetting where it was dragged.
|
|
259
|
+
@MainActor
|
|
260
|
+
func resetPosition() {
|
|
261
|
+
Config.pillOrigin = nil
|
|
262
|
+
restorePosition()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
@MainActor
|
|
266
|
+
private func place(_ origin: NSPoint) {
|
|
267
|
+
placing = true
|
|
268
|
+
setFrameOrigin(origin)
|
|
269
|
+
placing = false
|
|
270
|
+
onMove?()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/// Remember a drag. Moves the app makes itself are not saved, so a
|
|
274
|
+
/// reset or a clamp never overwrites where the user put it.
|
|
275
|
+
@objc private func moved() {
|
|
276
|
+
MainActor.assumeIsolated {
|
|
277
|
+
guard !placing else { return }
|
|
278
|
+
Config.pillOrigin = frame.origin
|
|
279
|
+
onMove?()
|
|
280
|
+
}
|
|
192
281
|
}
|
|
193
282
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
283
|
+
@objc private func screensChanged() {
|
|
284
|
+
MainActor.assumeIsolated { restorePosition() }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// MARK: Show and hide
|
|
288
|
+
|
|
289
|
+
/// Close, Esc or the menu: hidden until `summon()`, whatever is
|
|
290
|
+
/// rendered meanwhile.
|
|
291
|
+
@MainActor
|
|
292
|
+
func dismiss() {
|
|
293
|
+
dismissed = true
|
|
294
|
+
hide()
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/// The talk key: allowed on screen again.
|
|
298
|
+
@MainActor
|
|
299
|
+
func summon() {
|
|
300
|
+
guard dismissed else { return }
|
|
301
|
+
dismissed = false
|
|
302
|
+
render(current)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
@MainActor
|
|
306
|
+
private func show() {
|
|
307
|
+
orb.setOnScreen(true)
|
|
308
|
+
if !isVisible { orderFrontRegardless() }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
@MainActor
|
|
312
|
+
private func hide() {
|
|
313
|
+
closeButton.isHidden = true
|
|
314
|
+
orb.setOnScreen(false)
|
|
315
|
+
if isVisible { orderOut(nil) }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
@objc private func closeClicked() { onDismiss?() }
|
|
319
|
+
|
|
320
|
+
override func mouseEntered(with event: NSEvent) { closeButton.isHidden = false }
|
|
321
|
+
override func mouseExited(with event: NSEvent) { closeButton.isHidden = true }
|
|
322
|
+
|
|
323
|
+
/// Esc, once the pill has been clicked and so holds the keyboard.
|
|
324
|
+
override func cancelOperation(_ sender: Any?) { onDismiss?() }
|
|
325
|
+
|
|
326
|
+
override func keyDown(with event: NSEvent) {
|
|
327
|
+
if event.keyCode == 53 { onDismiss?() } else { super.keyDown(with: event) }
|
|
199
328
|
}
|
|
200
329
|
|
|
201
330
|
/// AppKit is not thread-safe. Marking this explicitly means a stray
|
|
@@ -203,25 +332,29 @@ final class Panel: NSPanel {
|
|
|
203
332
|
/// corruption that traps somewhere unrelated an hour later.
|
|
204
333
|
@MainActor
|
|
205
334
|
func render(_ state: State) {
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
335
|
+
current = state
|
|
336
|
+
// The agent's colour, except where the state is the message: an
|
|
337
|
+
// error, or notifications held.
|
|
338
|
+
let tint: NSColor
|
|
339
|
+
switch state {
|
|
340
|
+
case .error: tint = Brand.alert
|
|
341
|
+
case .idle where Hold.isOn: tint = Brand.warn
|
|
342
|
+
default: tint = agentTint()
|
|
343
|
+
}
|
|
344
|
+
orb.show(state.orbPhase, tint: tint)
|
|
213
345
|
|
|
214
346
|
if state.isMeta {
|
|
215
347
|
stopMarquee()
|
|
216
348
|
label.attributedStringValue = Brand.metaString(state.text, color: state.color)
|
|
217
349
|
label.frame.origin.x = 0
|
|
218
350
|
} else {
|
|
219
|
-
|
|
351
|
+
// The orb carries the state now, so the words are plain text
|
|
352
|
+
// that reads in both themes. Errors keep their colour.
|
|
353
|
+
if case .error = state { label.textColor = state.color } else { label.textColor = .labelColor }
|
|
220
354
|
setText(named(state))
|
|
221
355
|
}
|
|
222
356
|
|
|
223
|
-
|
|
224
|
-
if (state.isMeta && !alwaysVisible) || yield { orderOut(nil) } else { orderFrontRegardless() }
|
|
357
|
+
if dismissed || (state.isMeta && !alwaysVisible) { hide() } else { show() }
|
|
225
358
|
onRender?(state)
|
|
226
359
|
}
|
|
227
360
|
|
|
@@ -283,7 +416,13 @@ final class Panel: NSPanel {
|
|
|
283
416
|
marqueeText = ""
|
|
284
417
|
}
|
|
285
418
|
|
|
286
|
-
// Borderless panels refuse key status unless told otherwise
|
|
287
|
-
//
|
|
419
|
+
// Borderless panels refuse key status unless told otherwise. The pill
|
|
420
|
+
// takes it only when clicked (showing it never does: that is
|
|
421
|
+
// orderFrontRegardless, not makeKey), so Esc can reach it then.
|
|
288
422
|
override var canBecomeKey: Bool { true }
|
|
289
423
|
}
|
|
424
|
+
|
|
425
|
+
/// A view clicks go through, to the pill behind it.
|
|
426
|
+
private final class PassThroughView: NSView {
|
|
427
|
+
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
|
428
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import CoreGraphics
|
|
2
|
+
|
|
3
|
+
/// Where the pill and its answer card go on screen, kept free of AppKit so
|
|
4
|
+
/// the tests can check it without a window. Frames are in screen
|
|
5
|
+
/// coordinates, origin bottom-left, as AppKit uses them.
|
|
6
|
+
enum PillPlacement {
|
|
7
|
+
/// Distance from the screen's edges for the default corner.
|
|
8
|
+
static let inset: CGFloat = 24
|
|
9
|
+
/// Space between the pill and the answer card.
|
|
10
|
+
static let cardGap: CGFloat = 8
|
|
11
|
+
|
|
12
|
+
/// Bottom-right of `visible` (a screen's frame minus the menu bar and
|
|
13
|
+
/// Dock), clear of both.
|
|
14
|
+
static func defaultOrigin(size: CGSize, visible: CGRect) -> CGPoint {
|
|
15
|
+
CGPoint(x: visible.maxX - size.width - inset, y: visible.minY + inset)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// A saved position made safe: the pill lands fully on the screen it
|
|
19
|
+
/// overlaps most. When it overlaps none (the monitor it was on has been
|
|
20
|
+
/// unplugged, or the saved value is nonsense) it goes back to the
|
|
21
|
+
/// default corner of `fallback`. Nil `saved`: the default corner.
|
|
22
|
+
static func clamp(saved: CGPoint?, size: CGSize, screens: [CGRect], fallback: CGRect) -> CGPoint {
|
|
23
|
+
guard let saved, saved.x.isFinite, saved.y.isFinite else {
|
|
24
|
+
return defaultOrigin(size: size, visible: fallback)
|
|
25
|
+
}
|
|
26
|
+
let frame = CGRect(origin: saved, size: size)
|
|
27
|
+
var best: CGRect?
|
|
28
|
+
var bestArea: CGFloat = 0
|
|
29
|
+
for screen in screens {
|
|
30
|
+
let overlap = screen.intersection(frame)
|
|
31
|
+
guard !overlap.isNull else { continue }
|
|
32
|
+
let area = overlap.width * overlap.height
|
|
33
|
+
if area > bestArea { bestArea = area; best = screen }
|
|
34
|
+
}
|
|
35
|
+
guard let screen = best, bestArea > 0 else {
|
|
36
|
+
return defaultOrigin(size: size, visible: fallback)
|
|
37
|
+
}
|
|
38
|
+
return inside(frame, screen).origin
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Which side of the pill the answer card opens on, and where.
|
|
42
|
+
struct Card: Equatable {
|
|
43
|
+
let origin: CGPoint
|
|
44
|
+
/// True: above the pill. False: below it.
|
|
45
|
+
let above: Bool
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// Above the pill when there is at least as much room above it as
|
|
49
|
+
/// below, else below. Right edges line up with the pill's, and the
|
|
50
|
+
/// card is kept on the screen.
|
|
51
|
+
static func card(size: CGSize, pill: CGRect, visible: CGRect) -> Card {
|
|
52
|
+
let roomAbove = visible.maxY - pill.maxY
|
|
53
|
+
let roomBelow = pill.minY - visible.minY
|
|
54
|
+
let above = roomAbove >= roomBelow
|
|
55
|
+
let y = above ? pill.maxY + cardGap : pill.minY - cardGap - size.height
|
|
56
|
+
let frame = CGRect(x: pill.maxX - size.width, y: y, width: size.width, height: size.height)
|
|
57
|
+
return Card(origin: inside(frame, visible).origin, above: above)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// `frame` moved the least distance that puts it inside `bounds`. A
|
|
61
|
+
/// frame larger than `bounds` keeps its top-left corner in view.
|
|
62
|
+
static func inside(_ frame: CGRect, _ bounds: CGRect) -> CGRect {
|
|
63
|
+
var f = frame
|
|
64
|
+
if f.maxX > bounds.maxX { f.origin.x = bounds.maxX - f.width }
|
|
65
|
+
if f.minX < bounds.minX { f.origin.x = bounds.minX }
|
|
66
|
+
if f.minY < bounds.minY { f.origin.y = bounds.minY }
|
|
67
|
+
if f.maxY > bounds.maxY { f.origin.y = bounds.maxY - f.height }
|
|
68
|
+
return f
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -22,9 +22,8 @@ final class ResultCard: NSPanel {
|
|
|
22
22
|
private let links = NSStackView()
|
|
23
23
|
private let thumb = NSImageView()
|
|
24
24
|
|
|
25
|
-
/// The
|
|
26
|
-
|
|
27
|
-
var anchor: (() -> NSRect?)?
|
|
25
|
+
/// The pill's frame on screen, which the card attaches to. Set by the app.
|
|
26
|
+
var anchor: (() -> NSRect)?
|
|
28
27
|
|
|
29
28
|
init() {
|
|
30
29
|
super.init(contentRect: NSRect(x: 0, y: 0, width: 380, height: 260),
|
|
@@ -129,19 +128,32 @@ final class ResultCard: NSPanel {
|
|
|
129
128
|
}
|
|
130
129
|
|
|
131
130
|
if let c = contentView { layout(c) }
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
131
|
+
slideIn()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// Where the card goes against the pill: above it when there is at
|
|
135
|
+
/// least as much room above as below, else below; right edges aligned.
|
|
136
|
+
@MainActor
|
|
137
|
+
private func placement() -> PillPlacement.Card? {
|
|
138
|
+
guard let pill = anchor?() else { return nil }
|
|
139
|
+
let screen = NSScreen.screens.first { $0.frame.intersects(pill) } ?? NSScreen.main
|
|
140
|
+
guard let visible = screen?.visibleFrame else { return nil }
|
|
141
|
+
return PillPlacement.card(size: frame.size, pill: pill, visible: visible)
|
|
138
142
|
}
|
|
139
143
|
|
|
140
|
-
///
|
|
141
|
-
/// Reduce Motion it simply appears there.
|
|
144
|
+
/// The pill moved: the open card moves with it, without animation.
|
|
142
145
|
@MainActor
|
|
143
|
-
|
|
144
|
-
let
|
|
146
|
+
func follow() {
|
|
147
|
+
guard isVisible, let spot = placement() else { return }
|
|
148
|
+
setFrameOrigin(spot.origin)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/// Slides out of the pill into place: up when it opens above, down
|
|
152
|
+
/// when below. With Reduce Motion it simply appears there.
|
|
153
|
+
@MainActor
|
|
154
|
+
private func slideIn() {
|
|
155
|
+
guard let spot = placement() else { alphaValue = 1; orderFrontRegardless(); return }
|
|
156
|
+
let target = spot.origin
|
|
145
157
|
let wasVisible = isVisible
|
|
146
158
|
guard !wasVisible, !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else {
|
|
147
159
|
setFrameOrigin(target)
|
|
@@ -149,7 +161,7 @@ final class ResultCard: NSPanel {
|
|
|
149
161
|
orderFrontRegardless()
|
|
150
162
|
return
|
|
151
163
|
}
|
|
152
|
-
setFrameOrigin(NSPoint(x: target.x, y: target.y + 18))
|
|
164
|
+
setFrameOrigin(NSPoint(x: target.x, y: target.y + (spot.above ? -18 : 18)))
|
|
153
165
|
alphaValue = 0
|
|
154
166
|
orderFrontRegardless()
|
|
155
167
|
NSAnimationContext.runAnimationGroup { ctx in
|
|
@@ -165,14 +177,6 @@ final class ResultCard: NSPanel {
|
|
|
165
177
|
NSWorkspace.shared.open(u)
|
|
166
178
|
}
|
|
167
179
|
|
|
168
|
-
/// Sits just above the pill so the two read as one thing.
|
|
169
|
-
private func positionAboveWidget() {
|
|
170
|
-
guard let screen = NSScreen.main else { return }
|
|
171
|
-
let v = screen.visibleFrame
|
|
172
|
-
setFrameOrigin(NSPoint(x: v.maxX - frame.width - 24, y: v.minY + 24 + 54 + 10))
|
|
173
|
-
alphaValue = 1
|
|
174
|
-
}
|
|
175
|
-
|
|
176
180
|
override var canBecomeKey: Bool { true }
|
|
177
181
|
}
|
|
178
182
|
|
|
@@ -34,7 +34,13 @@ 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
|
|
37
|
+
var onAnimatedOrbChanged: ((Bool) -> Void)?
|
|
38
|
+
/// Hide the pill and stop speech, as its close button does.
|
|
39
|
+
var onHidePill: (() -> Void)?
|
|
40
|
+
/// Put the pill back in the bottom-right corner.
|
|
41
|
+
var onResetPosition: (() -> Void)?
|
|
42
|
+
/// Whether the pill is on screen, for "Hide pill".
|
|
43
|
+
var pillVisible: () -> Bool = { false }
|
|
38
44
|
var onSettings: (() -> Void)?
|
|
39
45
|
|
|
40
46
|
override init() {
|
|
@@ -178,9 +184,13 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
178
184
|
let pill = action("Show floating pill", #selector(togglePill), key: "")
|
|
179
185
|
pill.state = Config.showPill ? .on : .off
|
|
180
186
|
menu.addItem(pill)
|
|
181
|
-
let orb = action("
|
|
182
|
-
orb.state = Config.
|
|
187
|
+
let orb = action("Animated orb", #selector(toggleOrb), key: "")
|
|
188
|
+
orb.state = Config.animatedOrb ? .on : .off
|
|
183
189
|
menu.addItem(orb)
|
|
190
|
+
let hide = action("Hide pill", #selector(hidePill), key: "")
|
|
191
|
+
hide.isEnabled = pillVisible()
|
|
192
|
+
menu.addItem(hide)
|
|
193
|
+
menu.addItem(action("Reset position", #selector(resetPosition), key: ""))
|
|
184
194
|
|
|
185
195
|
menu.addItem(.separator())
|
|
186
196
|
menu.addItem(action("Settings…", #selector(openSettings), key: ","))
|
|
@@ -232,10 +242,13 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
232
242
|
}
|
|
233
243
|
|
|
234
244
|
@objc private func toggleOrb() {
|
|
235
|
-
Config.
|
|
236
|
-
|
|
245
|
+
Config.animatedOrb.toggle()
|
|
246
|
+
onAnimatedOrbChanged?(Config.animatedOrb)
|
|
237
247
|
}
|
|
238
248
|
|
|
249
|
+
@objc private func hidePill() { onHidePill?() }
|
|
250
|
+
@objc private func resetPosition() { onResetPosition?() }
|
|
251
|
+
|
|
239
252
|
@objc private func openSettings() { onSettings?() }
|
|
240
253
|
@objc private func openDashboardHome() { openDashboard("/admin") }
|
|
241
254
|
@objc private func openHistory() { openDashboard("/activity") }
|
|
@@ -19,9 +19,6 @@ 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
22
|
/// The agent whose by-name answer is on screen now, while no turn runs.
|
|
26
23
|
private var asideSpeaker: String?
|
|
27
24
|
private let recorder = Recorder()
|
|
@@ -164,37 +161,28 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
164
161
|
Log.info("settings: talk \(saved.general.hotkeys.talk), speech to text \(saved.general.stt)")
|
|
165
162
|
}
|
|
166
163
|
|
|
167
|
-
/// Who the
|
|
168
|
-
/// by-name answer being spoken, else the agent
|
|
169
|
-
/// else the target.
|
|
164
|
+
/// Who the pill names and whose colour its orb wears: the agent a
|
|
165
|
+
/// shortcut is asking, a by-name answer being spoken, else the agent
|
|
166
|
+
/// our turn is asking, else the target.
|
|
170
167
|
private var shownAgent: String {
|
|
171
168
|
if let forcedAgent, recorder.isRecording { return forcedAgent }
|
|
172
169
|
if let asideSpeaker { return asideSpeaker }
|
|
173
170
|
return busy && !turnAgent.isEmpty ? turnAgent : Config.effectiveAgentID
|
|
174
171
|
}
|
|
175
172
|
|
|
176
|
-
///
|
|
177
|
-
///
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
heardLine = ""
|
|
188
|
-
orb.show(.listening, name: name, tint: tint, line: "Listening…", status: "")
|
|
189
|
-
case .thinking:
|
|
190
|
-
orb.show(.thinking, name: name, tint: tint, line: heardLine, status: "Thinking…")
|
|
191
|
-
case .working(let step, let secs):
|
|
192
|
-
orb.show(.thinking, name: name, tint: tint, line: heardLine, status: "\(step) · \(secs)s")
|
|
193
|
-
case .speaking:
|
|
194
|
-
orb.show(.speaking, name: name, tint: tint, line: heardLine, status: "")
|
|
195
|
-
case .saying(let text):
|
|
196
|
-
orb.show(.speaking, name: name, tint: tint, line: text, status: "")
|
|
173
|
+
/// Close, Esc or "Hide pill": the pill goes and the voice stops, the
|
|
174
|
+
/// same stop as ⌘⌥. An open microphone closes without sending. The
|
|
175
|
+
/// next talk key brings the pill back.
|
|
176
|
+
private func dismissPill() {
|
|
177
|
+
Log.info("pill: dismissed")
|
|
178
|
+
panel.dismiss()
|
|
179
|
+
if recorder.isRecording {
|
|
180
|
+
stopPolling()
|
|
181
|
+
_ = recorder.stop()
|
|
182
|
+
forcedAgent = nil
|
|
183
|
+
talkCheck = nil
|
|
197
184
|
}
|
|
185
|
+
stopSpeaking()
|
|
198
186
|
}
|
|
199
187
|
|
|
200
188
|
/// ⌘⌥V. Everything except the hotkey lives in `agentx paste`.
|
|
@@ -243,30 +231,33 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
243
231
|
func applicationDidFinishLaunching(_ note: Notification) {
|
|
244
232
|
NSApp.setActivationPolicy(.accessory)
|
|
245
233
|
retireOlderInstances()
|
|
246
|
-
panel.onRender = { [weak self] state in
|
|
247
|
-
self?.statusMenu.show(state)
|
|
248
|
-
self?.renderOrb(state)
|
|
249
|
-
}
|
|
234
|
+
panel.onRender = { [weak self] state in self?.statusMenu.show(state) }
|
|
250
235
|
panel.contextMenu = { [weak self] in self?.statusMenu.menu ?? NSMenu() }
|
|
251
236
|
panel.agentName = { [weak self] in
|
|
252
237
|
guard let self else { return "" }
|
|
253
238
|
let who = self.shownAgent
|
|
254
239
|
return who.isEmpty ? "" : self.statusMenu.name(of: who)
|
|
255
240
|
}
|
|
256
|
-
panel.
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
card.anchor = { [weak self] in self?.orb.contentFrame }
|
|
260
|
-
statusMenu.onOrbChanged = { [weak self] on in
|
|
261
|
-
guard let self else { return }
|
|
262
|
-
self.panel.yieldsActiveStates = on
|
|
263
|
-
if !on { self.orb.hide() }
|
|
264
|
-
if !self.busy && !self.recorder.isRecording { self.panel.render(.idle) }
|
|
241
|
+
panel.agentTint = { [weak self] in
|
|
242
|
+
guard let self else { return Brand.accent }
|
|
243
|
+
return self.statusMenu.color(of: self.shownAgent)
|
|
265
244
|
}
|
|
245
|
+
panel.alwaysVisible = Config.showPill
|
|
246
|
+
panel.orb.setAnimated(Config.animatedOrb)
|
|
247
|
+
panel.orb.levelSource = { [weak self] in self?.recorder.level ?? 0 }
|
|
248
|
+
panel.onDismiss = { [weak self] in self?.dismissPill() }
|
|
249
|
+
panel.onMove = { [weak self] in self?.card.follow() }
|
|
250
|
+
card.anchor = { [weak self] in self?.panel.frame ?? .zero }
|
|
251
|
+
statusMenu.pillVisible = { [weak self] in self?.panel.isVisible ?? false }
|
|
252
|
+
statusMenu.onHidePill = { [weak self] in self?.dismissPill() }
|
|
253
|
+
statusMenu.onResetPosition = { [weak self] in self?.panel.resetPosition() }
|
|
254
|
+
statusMenu.onAnimatedOrbChanged = { [weak self] on in self?.panel.orb.setAnimated(on) }
|
|
266
255
|
panel.render(.idle)
|
|
267
256
|
statusMenu.onPillChanged = { [weak self] on in
|
|
268
257
|
guard let self else { return }
|
|
269
258
|
self.panel.alwaysVisible = on
|
|
259
|
+
// Asking for the pill brings back one that was dismissed.
|
|
260
|
+
if on { self.panel.summon() }
|
|
270
261
|
if !self.busy && !self.recorder.isRecording { self.panel.render(.idle) }
|
|
271
262
|
}
|
|
272
263
|
statusMenu.onTargetChanged = { [weak self] id in
|
|
@@ -440,6 +431,8 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
440
431
|
// two ways of talking never fight over the microphone.
|
|
441
432
|
stopPolling()
|
|
442
433
|
guard !recorder.isRecording else { return }
|
|
434
|
+
// A dismissed pill comes back with the talk key.
|
|
435
|
+
panel.summon()
|
|
443
436
|
// Option-Space is the door to everything spoken, always: our own
|
|
444
437
|
// answer or step line stops now, and the daemon hushes any talk,
|
|
445
438
|
// lesson or narration, remembering which it was. Even mid-turn —
|
|
@@ -495,8 +488,6 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
495
488
|
return
|
|
496
489
|
}
|
|
497
490
|
Log.info("heard: \(heard)")
|
|
498
|
-
heardLine = heard
|
|
499
|
-
if orb.isVisible { orb.model.line = heard }
|
|
500
491
|
let hushed = await door.value
|
|
501
492
|
// Always through the door, even with nothing hushed: the
|
|
502
493
|
// listener's turn is over, so the daemon's queue plays on.
|
|
@@ -580,8 +571,8 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
580
571
|
card.show(spoken: answer.text, written: answer.written,
|
|
581
572
|
buttons: answer.buttons, imageURL: answer.imageURL)
|
|
582
573
|
}
|
|
583
|
-
// Nothing else on screen: the
|
|
584
|
-
// this agent's colour, while it is spoken.
|
|
574
|
+
// Nothing else on screen: the pill shows this answer, its
|
|
575
|
+
// orb in this agent's colour, while it is spoken.
|
|
585
576
|
let onScreen = !busy && !recorder.isRecording
|
|
586
577
|
if onScreen { asideSpeaker = agent; panel.render(.saying(answer.text)) }
|
|
587
578
|
await Speech.say(answer.text, agentID: answer.agentID ?? agent, kind: "answer", voice: answer.voice)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentix-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.58.0",
|
|
4
4
|
"description": "AI operations layer for small and medium businesses. Routes Telegram, WhatsApp, GitLab, GitHub, crons, webhooks, and mesh tasks to AI agents. Web wizard for operators, CLI for engineers. Self-hosted.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|