agentix-cli 0.56.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.
Files changed (116) hide show
  1. package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +43 -0
  2. package/apps/mac-voice/Sources/AgentXVoice/Config.swift +19 -5
  3. package/apps/mac-voice/Sources/AgentXVoice/Hotkey.swift +8 -2
  4. package/apps/mac-voice/Sources/AgentXVoice/HotkeySpec.swift +80 -0
  5. package/apps/mac-voice/Sources/AgentXVoice/Orb.swift +131 -169
  6. package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +174 -35
  7. package/apps/mac-voice/Sources/AgentXVoice/PillPlacement.swift +70 -0
  8. package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +26 -22
  9. package/apps/mac-voice/Sources/AgentXVoice/SettingsWindow.swift +518 -0
  10. package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +8 -2
  11. package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +22 -6
  12. package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +101 -0
  13. package/apps/mac-voice/Sources/AgentXVoice/main.swift +132 -79
  14. package/dist/{App-4EM7IPB3.js → App-Z5VOAUXB.js} +2 -2
  15. package/dist/{ChatApp-FYTEEGSU.js → ChatApp-BE7ONQKS.js} +2 -2
  16. package/dist/{agent-RZ46ZB4W.js → agent-Q74EISKP.js} +2 -2
  17. package/dist/{board-dashboard-NRRYCDZW.js → board-dashboard-A7PQOI6C.js} +4 -4
  18. package/dist/{chunk-W4YMMWJL.js → chunk-2MIOMYT2.js} +2 -2
  19. package/dist/{chunk-EZN2YXRY.js → chunk-547G3HYQ.js} +2 -2
  20. package/dist/{chunk-LKJSPO3S.js → chunk-5LLGIX5M.js} +2 -2
  21. package/dist/{chunk-WOMQ2VGA.js → chunk-5W7NJHCS.js} +2 -2
  22. package/dist/{chunk-4HEVSWO3.js → chunk-6OABTCRA.js} +2 -2
  23. package/dist/{chunk-UIVHSYCN.js → chunk-6QNPCVRN.js} +3 -3
  24. package/dist/chunk-6QNPCVRN.js.map +1 -0
  25. package/dist/{chunk-CELYD5QW.js → chunk-CQUVUVXR.js} +2 -2
  26. package/dist/chunk-ERL6AS3O.js +15 -0
  27. package/dist/chunk-ERL6AS3O.js.map +1 -0
  28. package/dist/{chunk-EJ5ISSWW.js → chunk-I2LYULR4.js} +5 -5
  29. package/dist/{chunk-76AHNHWC.js → chunk-KKGFZEXS.js} +2 -2
  30. package/dist/{chunk-6AK6WKE5.js → chunk-KW7TIOS3.js} +2 -2
  31. package/dist/{chunk-SA23V7WX.js → chunk-NKMFHEGV.js} +2 -2
  32. package/dist/{chunk-WKMTT2UY.js → chunk-OZZPJ2YG.js} +197 -188
  33. package/dist/chunk-OZZPJ2YG.js.map +1 -0
  34. package/dist/{chunk-IL2XLW4H.js → chunk-POL7RAJQ.js} +13 -13
  35. package/dist/{chunk-D3LSPFHK.js → chunk-Q6SMLSRO.js} +2 -2
  36. package/dist/{chunk-723K4SAW.js → chunk-RXEAACVH.js} +2 -2
  37. package/dist/{chunk-O3XG2NVR.js → chunk-U5YBQJ5Q.js} +2 -2
  38. package/dist/{chunk-XEOAKD5C.js → chunk-V7ZK6ZIO.js} +2 -2
  39. package/dist/{chunk-M4AG2YNX.js → chunk-VI7GBDKV.js} +2 -2
  40. package/dist/{chunk-GSOL2LW6.js → chunk-VVBGPT6W.js} +2 -2
  41. package/dist/{chunk-C3VRBEJ7.js → chunk-XTD3SZIP.js} +2 -2
  42. package/dist/cli.js +1 -1
  43. package/dist/client-EFP2PKA3.js +2 -0
  44. package/dist/config-P6YXC6XZ.js +2 -0
  45. package/dist/config-mutator-3GKXLTHH.js +2 -0
  46. package/dist/doctor-JEWMHXYH.js +2 -0
  47. package/dist/index.d.ts +109 -3
  48. package/dist/index.js +1 -1
  49. package/dist/program-7DUQJDSQ.js +2 -0
  50. package/dist/{promote-T4UJROEH.js → promote-KVPLDSKT.js} +2 -2
  51. package/dist/query-AGOMCYTT.js +2 -0
  52. package/dist/{registry-DX2RI7I7.js → registry-AUOI4TW2.js} +2 -2
  53. package/dist/{request-planner-B7DE7VJR.js → request-planner-LMZ5ZAXC.js} +2 -2
  54. package/dist/{review-candidates-4YJHIBAF.js → review-candidates-WCBXLLDS.js} +2 -2
  55. package/dist/{routing-MZG3UODH.js → routing-FBASYKUL.js} +2 -2
  56. package/dist/schedule-tool-RJRJ63SS.js +2 -0
  57. package/dist/scheduler-ATA7Z44S.js +2 -0
  58. package/dist/{seat-2PEI7JRX.js → seat-DBFW3Z5P.js} +2 -2
  59. package/dist/{select-4KIUR27O.js → select-6CNVVFA5.js} +2 -2
  60. package/dist/{set-enabled-SE7HQKPF.js → set-enabled-AYZH3KSB.js} +2 -2
  61. package/dist/{tool-5VMD7IE4.js → tool-PKS4NBZ6.js} +2 -2
  62. package/dist/wiki-QW6BDNEQ.js +2 -0
  63. package/package.json +1 -1
  64. package/dist/chunk-U4H35SJS.js +0 -15
  65. package/dist/chunk-U4H35SJS.js.map +0 -1
  66. package/dist/chunk-UIVHSYCN.js.map +0 -1
  67. package/dist/chunk-WKMTT2UY.js.map +0 -1
  68. package/dist/client-IA7ZPE56.js +0 -2
  69. package/dist/config-HPN2F7LH.js +0 -2
  70. package/dist/config-mutator-7GPTMTVB.js +0 -2
  71. package/dist/doctor-IBWQVQV4.js +0 -2
  72. package/dist/program-G6DIFJUF.js +0 -2
  73. package/dist/query-VUJC4Y5O.js +0 -2
  74. package/dist/schedule-tool-A4OFQ4YZ.js +0 -2
  75. package/dist/scheduler-RHJW2V4M.js +0 -2
  76. package/dist/wiki-2QFLAH4U.js +0 -2
  77. /package/dist/{App-4EM7IPB3.js.map → App-Z5VOAUXB.js.map} +0 -0
  78. /package/dist/{ChatApp-FYTEEGSU.js.map → ChatApp-BE7ONQKS.js.map} +0 -0
  79. /package/dist/{agent-RZ46ZB4W.js.map → agent-Q74EISKP.js.map} +0 -0
  80. /package/dist/{board-dashboard-NRRYCDZW.js.map → board-dashboard-A7PQOI6C.js.map} +0 -0
  81. /package/dist/{chunk-W4YMMWJL.js.map → chunk-2MIOMYT2.js.map} +0 -0
  82. /package/dist/{chunk-EZN2YXRY.js.map → chunk-547G3HYQ.js.map} +0 -0
  83. /package/dist/{chunk-LKJSPO3S.js.map → chunk-5LLGIX5M.js.map} +0 -0
  84. /package/dist/{chunk-WOMQ2VGA.js.map → chunk-5W7NJHCS.js.map} +0 -0
  85. /package/dist/{chunk-4HEVSWO3.js.map → chunk-6OABTCRA.js.map} +0 -0
  86. /package/dist/{chunk-CELYD5QW.js.map → chunk-CQUVUVXR.js.map} +0 -0
  87. /package/dist/{chunk-EJ5ISSWW.js.map → chunk-I2LYULR4.js.map} +0 -0
  88. /package/dist/{chunk-76AHNHWC.js.map → chunk-KKGFZEXS.js.map} +0 -0
  89. /package/dist/{chunk-6AK6WKE5.js.map → chunk-KW7TIOS3.js.map} +0 -0
  90. /package/dist/{chunk-SA23V7WX.js.map → chunk-NKMFHEGV.js.map} +0 -0
  91. /package/dist/{chunk-IL2XLW4H.js.map → chunk-POL7RAJQ.js.map} +0 -0
  92. /package/dist/{chunk-D3LSPFHK.js.map → chunk-Q6SMLSRO.js.map} +0 -0
  93. /package/dist/{chunk-723K4SAW.js.map → chunk-RXEAACVH.js.map} +0 -0
  94. /package/dist/{chunk-O3XG2NVR.js.map → chunk-U5YBQJ5Q.js.map} +0 -0
  95. /package/dist/{chunk-XEOAKD5C.js.map → chunk-V7ZK6ZIO.js.map} +0 -0
  96. /package/dist/{chunk-M4AG2YNX.js.map → chunk-VI7GBDKV.js.map} +0 -0
  97. /package/dist/{chunk-GSOL2LW6.js.map → chunk-VVBGPT6W.js.map} +0 -0
  98. /package/dist/{chunk-C3VRBEJ7.js.map → chunk-XTD3SZIP.js.map} +0 -0
  99. /package/dist/{client-IA7ZPE56.js.map → client-EFP2PKA3.js.map} +0 -0
  100. /package/dist/{config-HPN2F7LH.js.map → config-P6YXC6XZ.js.map} +0 -0
  101. /package/dist/{config-mutator-7GPTMTVB.js.map → config-mutator-3GKXLTHH.js.map} +0 -0
  102. /package/dist/{doctor-IBWQVQV4.js.map → doctor-JEWMHXYH.js.map} +0 -0
  103. /package/dist/{program-G6DIFJUF.js.map → program-7DUQJDSQ.js.map} +0 -0
  104. /package/dist/{promote-T4UJROEH.js.map → promote-KVPLDSKT.js.map} +0 -0
  105. /package/dist/{query-VUJC4Y5O.js.map → query-AGOMCYTT.js.map} +0 -0
  106. /package/dist/{registry-DX2RI7I7.js.map → registry-AUOI4TW2.js.map} +0 -0
  107. /package/dist/{request-planner-B7DE7VJR.js.map → request-planner-LMZ5ZAXC.js.map} +0 -0
  108. /package/dist/{review-candidates-4YJHIBAF.js.map → review-candidates-WCBXLLDS.js.map} +0 -0
  109. /package/dist/{routing-MZG3UODH.js.map → routing-FBASYKUL.js.map} +0 -0
  110. /package/dist/{schedule-tool-A4OFQ4YZ.js.map → schedule-tool-RJRJ63SS.js.map} +0 -0
  111. /package/dist/{scheduler-RHJW2V4M.js.map → scheduler-ATA7Z44S.js.map} +0 -0
  112. /package/dist/{seat-2PEI7JRX.js.map → seat-DBFW3Z5P.js.map} +0 -0
  113. /package/dist/{select-4KIUR27O.js.map → select-6CNVVFA5.js.map} +0 -0
  114. /package/dist/{set-enabled-SE7HQKPF.js.map → set-enabled-AYZH3KSB.js.map} +0 -0
  115. /package/dist/{tool-5VMD7IE4.js.map → tool-PKS4NBZ6.js.map} +0 -0
  116. /package/dist/{wiki-2QFLAH4U.js.map → wiki-QW6BDNEQ.js.map} +0 -0
@@ -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