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.
@@ -28,11 +28,25 @@ 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") }
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 overlay: an orb in the agent's colour that listens,
5
- /// thinks and speaks, with a line of words under it.
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. 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.
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 hidden, listening, thinking, speaking }
15
+ enum Phase: Equatable { case idle, listening, thinking, speaking }
15
16
 
16
- @Published var phase: Phase = .hidden
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
- @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 = ""
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
- struct OrbView: View {
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
- @Environment(\.colorScheme) private var scheme
36
+ let diameter: CGFloat
37
+ let frameSize: CGFloat
31
38
 
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
+ /// 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
- 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
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 .hidden: return ""
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 .hidden: return 0
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 .hidden: return 0
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: 2.2, lineCap: .round))
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
- private let orb = NSView()
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
- /// Listening, thinking or speaking: what the orb shows.
116
- var isActive: Bool {
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 false
119
- default: return true
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(x: 0, y: 0, width: 230, height: 54),
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: NSSize(width: 230, height: 54)))
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.wantsLayer = true
174
- orb.layer?.cornerRadius = 4
175
- orb.frame = NSRect(x: 18, y: 22, width: 8, height: 8)
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
- let clipView = NSView(frame: NSRect(x: 38, y: 17, width: 180, height: 20))
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: 180, height: 20)
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
- positionBottomRight()
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
- /// Bottom-right, clear of the Dock — out of the way but visible.
195
- private func positionBottomRight() {
196
- guard let screen = NSScreen.main else { return }
197
- let v = screen.visibleFrame
198
- setFrameOrigin(NSPoint(x: v.maxX - frame.width - 24, y: v.minY + 24))
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
- orb.layer?.backgroundColor = state.color.cgColor
207
- // A soft halo on the dot while active: the same --nq-ring-accent
208
- // idea, and the only ornament on the pill.
209
- orb.layer?.shadowColor = state.color.cgColor
210
- orb.layer?.shadowOpacity = state.isMeta ? 0 : 0.55
211
- orb.layer?.shadowRadius = 5
212
- orb.layer?.shadowOffset = .zero
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
- label.textColor = state.color
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
- let yield = yieldsActiveStates && state.isActive
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; without
287
- // this the widget cannot show a caret or take any future text input.
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 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?)?
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
- if let orb = anchor?() {
133
- slideIn(under: orb)
134
- } else {
135
- positionAboveWidget()
136
- orderFrontRegardless()
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
- /// Under the orb, right edges aligned, sliding down into place. With
141
- /// Reduce Motion it simply appears there.
144
+ /// The pill moved: the open card moves with it, without animation.
142
145
  @MainActor
143
- private func slideIn(under orb: NSRect) {
144
- let target = NSPoint(x: orb.maxX - frame.width, y: orb.minY - frame.height - 8)
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 onOrbChanged: ((Bool) -> Void)?
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("Show orb", #selector(toggleOrb), key: "")
182
- orb.state = Config.showOrb ? .on : .off
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.showOrb.toggle()
236
- onOrbChanged?(Config.showOrb)
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 panel and orb name: the agent a shortcut is asking, a
168
- /// by-name answer being spoken, else the agent our turn is asking,
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
- /// The orb follows the pill's state: listening, thinking or speaking
177
- /// show it; idle and errors hide it.
178
- private func renderOrb(_ state: Panel.State) {
179
- guard Config.showOrb else { orb.hide(); return }
180
- let who = shownAgent
181
- let name = who.isEmpty ? "" : statusMenu.name(of: who)
182
- let tint = statusMenu.color(of: who)
183
- switch state {
184
- case .idle, .error:
185
- orb.hide()
186
- case .listening:
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.alwaysVisible = Config.showPill
257
- panel.yieldsActiveStates = Config.showOrb
258
- orb.levelSource = { [weak self] in self?.recorder.level ?? 0 }
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 orb shows this answer, in
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.57.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": {