agentix-cli 0.58.0 → 0.60.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 (130) hide show
  1. package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +4 -0
  2. package/apps/mac-voice/Sources/AgentXVoice/AnswerView.swift +228 -0
  3. package/apps/mac-voice/Sources/AgentXVoice/HistoryClient.swift +55 -0
  4. package/apps/mac-voice/Sources/AgentXVoice/HistoryModel.swift +153 -0
  5. package/apps/mac-voice/Sources/AgentXVoice/HistoryWindow.swift +301 -0
  6. package/apps/mac-voice/Sources/AgentXVoice/LocalSTT.swift +79 -0
  7. package/apps/mac-voice/Sources/AgentXVoice/ModelStore.swift +184 -0
  8. package/apps/mac-voice/Sources/AgentXVoice/Orb.swift +36 -9
  9. package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +126 -55
  10. package/apps/mac-voice/Sources/AgentXVoice/PanelAnswer.swift +113 -0
  11. package/apps/mac-voice/Sources/AgentXVoice/Parakeet.swift +264 -0
  12. package/apps/mac-voice/Sources/AgentXVoice/PillPlacement.swift +28 -17
  13. package/apps/mac-voice/Sources/AgentXVoice/Recorder.swift +81 -3
  14. package/apps/mac-voice/Sources/AgentXVoice/SettingsLook.swift +90 -0
  15. package/apps/mac-voice/Sources/AgentXVoice/SettingsWindow.swift +20 -5
  16. package/apps/mac-voice/Sources/AgentXVoice/SileroVAD.swift +92 -0
  17. package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +7 -5
  18. package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +14 -1
  19. package/apps/mac-voice/Sources/AgentXVoice/Surface.swift +105 -0
  20. package/apps/mac-voice/Sources/AgentXVoice/TurnEnd.swift +110 -0
  21. package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +54 -0
  22. package/apps/mac-voice/Sources/AgentXVoice/main.swift +35 -36
  23. package/apps/mac-voice/build.sh +10 -1
  24. package/dist/{App-Z5VOAUXB.js → App-LQXWKBVY.js} +2 -2
  25. package/dist/{ChatApp-BE7ONQKS.js → ChatApp-75TL4LKW.js} +2 -2
  26. package/dist/{agent-Q74EISKP.js → agent-DH4FMMMY.js} +2 -2
  27. package/dist/{board-dashboard-A7PQOI6C.js → board-dashboard-BVRRDYAG.js} +223 -138
  28. package/dist/board-dashboard-BVRRDYAG.js.map +1 -0
  29. package/dist/{chunk-NKMFHEGV.js → chunk-27P5NLTC.js} +2 -2
  30. package/dist/{chunk-5LLGIX5M.js → chunk-2VQIPJH5.js} +2 -2
  31. package/dist/{chunk-V7ZK6ZIO.js → chunk-3XVTFNYM.js} +2 -2
  32. package/dist/{chunk-KW7TIOS3.js → chunk-B3DCYBP4.js} +2 -2
  33. package/dist/{chunk-547G3HYQ.js → chunk-CRSXKQLZ.js} +2 -2
  34. package/dist/{chunk-5W7NJHCS.js → chunk-DTEQNXK3.js} +2 -2
  35. package/dist/{chunk-XTD3SZIP.js → chunk-E2BJVHBR.js} +2 -2
  36. package/dist/{chunk-RXEAACVH.js → chunk-E2PWCU2N.js} +2 -2
  37. package/dist/{chunk-I2LYULR4.js → chunk-JO3PSLWL.js} +5 -5
  38. package/dist/{chunk-U5YBQJ5Q.js → chunk-JORXRF4E.js} +2 -2
  39. package/dist/{chunk-2MIOMYT2.js → chunk-LXUG2X26.js} +2 -2
  40. package/dist/{chunk-Q6SMLSRO.js → chunk-N5KD3TVN.js} +2 -2
  41. package/dist/{chunk-VI7GBDKV.js → chunk-NBGNPKAF.js} +2 -2
  42. package/dist/{chunk-6QNPCVRN.js → chunk-PAOV37KQ.js} +3 -3
  43. package/dist/chunk-PAOV37KQ.js.map +1 -0
  44. package/dist/{chunk-OZZPJ2YG.js → chunk-RYFUFX6F.js} +194 -181
  45. package/dist/chunk-RYFUFX6F.js.map +1 -0
  46. package/dist/chunk-S7TCVIET.js +15 -0
  47. package/dist/chunk-S7TCVIET.js.map +1 -0
  48. package/dist/{chunk-POL7RAJQ.js → chunk-TM762AXU.js} +159 -153
  49. package/dist/chunk-TM762AXU.js.map +1 -0
  50. package/dist/{chunk-6OABTCRA.js → chunk-WBGC7MWX.js} +2 -2
  51. package/dist/{chunk-CQUVUVXR.js → chunk-WIBMNQKJ.js} +2 -2
  52. package/dist/{chunk-KKGFZEXS.js → chunk-WOPFHKBF.js} +37 -36
  53. package/dist/chunk-WOPFHKBF.js.map +1 -0
  54. package/dist/{chunk-VVBGPT6W.js → chunk-YQCOX4R3.js} +2 -2
  55. package/dist/cli.js +1 -1
  56. package/dist/client-QVZIZFV4.js +2 -0
  57. package/dist/config-3XC7EYB7.js +2 -0
  58. package/dist/config-mutator-HKU63FJM.js +2 -0
  59. package/dist/doctor-YFA4ZSRH.js +2 -0
  60. package/dist/index.d.ts +66 -7
  61. package/dist/index.js +1 -1
  62. package/dist/program-BOCLU6ES.js +2 -0
  63. package/dist/{promote-KVPLDSKT.js → promote-UR2GSHKC.js} +2 -2
  64. package/dist/query-XUP3BMVS.js +2 -0
  65. package/dist/{registry-AUOI4TW2.js → registry-PGALUW5L.js} +2 -2
  66. package/dist/{request-planner-LMZ5ZAXC.js → request-planner-NHE3IRJN.js} +2 -2
  67. package/dist/{review-candidates-WCBXLLDS.js → review-candidates-6GKRSRBQ.js} +2 -2
  68. package/dist/{routing-FBASYKUL.js → routing-AQKHAU3Z.js} +2 -2
  69. package/dist/schedule-tool-6NMCLTIH.js +2 -0
  70. package/dist/scheduler-RAYGNDWT.js +2 -0
  71. package/dist/{seat-DBFW3Z5P.js → seat-6FMW2QI4.js} +2 -2
  72. package/dist/{select-6CNVVFA5.js → select-5VONJMLH.js} +2 -2
  73. package/dist/{set-enabled-AYZH3KSB.js → set-enabled-YHD3IJJZ.js} +2 -2
  74. package/dist/{tool-PKS4NBZ6.js → tool-MXJGLJZ5.js} +2 -2
  75. package/dist/wiki-ZQLEYY24.js +2 -0
  76. package/package.json +1 -1
  77. package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +0 -198
  78. package/dist/board-dashboard-A7PQOI6C.js.map +0 -1
  79. package/dist/chunk-6QNPCVRN.js.map +0 -1
  80. package/dist/chunk-ERL6AS3O.js +0 -15
  81. package/dist/chunk-ERL6AS3O.js.map +0 -1
  82. package/dist/chunk-KKGFZEXS.js.map +0 -1
  83. package/dist/chunk-OZZPJ2YG.js.map +0 -1
  84. package/dist/chunk-POL7RAJQ.js.map +0 -1
  85. package/dist/client-EFP2PKA3.js +0 -2
  86. package/dist/config-P6YXC6XZ.js +0 -2
  87. package/dist/config-mutator-3GKXLTHH.js +0 -2
  88. package/dist/doctor-JEWMHXYH.js +0 -2
  89. package/dist/program-7DUQJDSQ.js +0 -2
  90. package/dist/query-AGOMCYTT.js +0 -2
  91. package/dist/schedule-tool-RJRJ63SS.js +0 -2
  92. package/dist/scheduler-ATA7Z44S.js +0 -2
  93. package/dist/wiki-QW6BDNEQ.js +0 -2
  94. /package/dist/{App-Z5VOAUXB.js.map → App-LQXWKBVY.js.map} +0 -0
  95. /package/dist/{ChatApp-BE7ONQKS.js.map → ChatApp-75TL4LKW.js.map} +0 -0
  96. /package/dist/{agent-Q74EISKP.js.map → agent-DH4FMMMY.js.map} +0 -0
  97. /package/dist/{chunk-NKMFHEGV.js.map → chunk-27P5NLTC.js.map} +0 -0
  98. /package/dist/{chunk-5LLGIX5M.js.map → chunk-2VQIPJH5.js.map} +0 -0
  99. /package/dist/{chunk-V7ZK6ZIO.js.map → chunk-3XVTFNYM.js.map} +0 -0
  100. /package/dist/{chunk-KW7TIOS3.js.map → chunk-B3DCYBP4.js.map} +0 -0
  101. /package/dist/{chunk-547G3HYQ.js.map → chunk-CRSXKQLZ.js.map} +0 -0
  102. /package/dist/{chunk-5W7NJHCS.js.map → chunk-DTEQNXK3.js.map} +0 -0
  103. /package/dist/{chunk-XTD3SZIP.js.map → chunk-E2BJVHBR.js.map} +0 -0
  104. /package/dist/{chunk-RXEAACVH.js.map → chunk-E2PWCU2N.js.map} +0 -0
  105. /package/dist/{chunk-I2LYULR4.js.map → chunk-JO3PSLWL.js.map} +0 -0
  106. /package/dist/{chunk-U5YBQJ5Q.js.map → chunk-JORXRF4E.js.map} +0 -0
  107. /package/dist/{chunk-2MIOMYT2.js.map → chunk-LXUG2X26.js.map} +0 -0
  108. /package/dist/{chunk-Q6SMLSRO.js.map → chunk-N5KD3TVN.js.map} +0 -0
  109. /package/dist/{chunk-VI7GBDKV.js.map → chunk-NBGNPKAF.js.map} +0 -0
  110. /package/dist/{chunk-6OABTCRA.js.map → chunk-WBGC7MWX.js.map} +0 -0
  111. /package/dist/{chunk-CQUVUVXR.js.map → chunk-WIBMNQKJ.js.map} +0 -0
  112. /package/dist/{chunk-VVBGPT6W.js.map → chunk-YQCOX4R3.js.map} +0 -0
  113. /package/dist/{client-EFP2PKA3.js.map → client-QVZIZFV4.js.map} +0 -0
  114. /package/dist/{config-P6YXC6XZ.js.map → config-3XC7EYB7.js.map} +0 -0
  115. /package/dist/{config-mutator-3GKXLTHH.js.map → config-mutator-HKU63FJM.js.map} +0 -0
  116. /package/dist/{doctor-JEWMHXYH.js.map → doctor-YFA4ZSRH.js.map} +0 -0
  117. /package/dist/{program-7DUQJDSQ.js.map → program-BOCLU6ES.js.map} +0 -0
  118. /package/dist/{promote-KVPLDSKT.js.map → promote-UR2GSHKC.js.map} +0 -0
  119. /package/dist/{query-AGOMCYTT.js.map → query-XUP3BMVS.js.map} +0 -0
  120. /package/dist/{registry-AUOI4TW2.js.map → registry-PGALUW5L.js.map} +0 -0
  121. /package/dist/{request-planner-LMZ5ZAXC.js.map → request-planner-NHE3IRJN.js.map} +0 -0
  122. /package/dist/{review-candidates-WCBXLLDS.js.map → review-candidates-6GKRSRBQ.js.map} +0 -0
  123. /package/dist/{routing-FBASYKUL.js.map → routing-AQKHAU3Z.js.map} +0 -0
  124. /package/dist/{schedule-tool-RJRJ63SS.js.map → schedule-tool-6NMCLTIH.js.map} +0 -0
  125. /package/dist/{scheduler-ATA7Z44S.js.map → scheduler-RAYGNDWT.js.map} +0 -0
  126. /package/dist/{seat-DBFW3Z5P.js.map → seat-6FMW2QI4.js.map} +0 -0
  127. /package/dist/{select-6CNVVFA5.js.map → select-5VONJMLH.js.map} +0 -0
  128. /package/dist/{set-enabled-AYZH3KSB.js.map → set-enabled-YHD3IJJZ.js.map} +0 -0
  129. /package/dist/{tool-PKS4NBZ6.js.map → tool-MXJGLJZ5.js.map} +0 -0
  130. /package/dist/{wiki-QW6BDNEQ.js.map → wiki-ZQLEYY24.js.map} +0 -0
@@ -8,8 +8,10 @@ import AppKit
8
8
  /// normal windows, joins every Space, and is deliberately small.
9
9
  ///
10
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
11
+ /// in the answering agent's palette, the state or the words beside it, and
12
+ /// a close button that shows on hover. When an answer has more to read
13
+ /// than was spoken, the pill grows into it (PanelAnswer.swift) and
14
+ /// collapses again after a while. Drag it anywhere; the place is
13
15
  /// remembered across launches.
14
16
  final class Panel: NSPanel {
15
17
  static let size = NSSize(width: 284, height: 54)
@@ -20,10 +22,35 @@ final class Panel: NSPanel {
20
22
  private let label = NSTextField(labelWithString: "")
21
23
  let orb = PillOrb(diameter: Panel.orbDiameter, frameSize: Panel.orbFrame)
22
24
  private let closeButton = NSButton()
25
+ /// The orb, the words and the close button: the pill itself, which
26
+ /// stays where it is while the widget grows into an answer.
27
+ let row = RowView(frame: NSRect(origin: .zero, size: Panel.size))
28
+ /// The answer, below or above the row once the pill has grown.
29
+ let answer = AnswerView(frame: .zero)
30
+ let separator = NSBox()
23
31
  /// 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
32
+ var dismissed = false
33
+ /// Above zero while the app moves or resizes the pill, so only a drag
34
+ /// is remembered.
35
+ var placing = 0
36
+
37
+ // MARK: The answer (PanelAnswer.swift)
38
+
39
+ /// Width of the pill grown into an answer.
40
+ static let expandedWidth: CGFloat = 360
41
+ /// Tallest the answer grows, in points, before it scrolls. From
42
+ /// voice.card.maxHeight.
43
+ var cardMaxHeight: CGFloat = 320
44
+ /// Seconds the answer stays open once spoken; 0 until closed. From
45
+ /// voice.card.timeout.
46
+ var cardTimeout: TimeInterval = 30
47
+ /// Grown into an answer.
48
+ var expanded = false
49
+ /// Which way it grew, so it collapses back to the same place.
50
+ var growth = (above: true, alignRight: true)
51
+ var collapseTimer: Timer?
52
+ /// The pointer is over the widget: the answer stays open.
53
+ var hovering = false
27
54
 
28
55
  /// Scrolls text too long for the pill instead of truncating it.
29
56
  ///
@@ -66,6 +93,9 @@ final class Panel: NSPanel {
66
93
  /// The colour of the agent shown, for the orb.
67
94
  var agentTint: () -> NSColor = { Brand.accent }
68
95
 
96
+ /// The palette of the agent shown, for the orb; nil for shades of its tint.
97
+ var agentPalette: () -> [NSColor]? = { nil }
98
+
69
99
  /// Stay on screen when idle. Off: the pill shows only while active.
70
100
  var alwaysVisible = false
71
101
 
@@ -75,18 +105,18 @@ final class Panel: NSPanel {
75
105
  /// Close button or Esc. The app stops speech and calls `dismiss()`.
76
106
  var onDismiss: (() -> Void)?
77
107
 
78
- /// The pill moved, by a drag or a reset. The answer card follows.
79
- var onMove: (() -> Void)?
80
-
81
108
  /// The last state rendered, to draw again after a dismiss or a move.
82
- private var current = State.idle
109
+ var current = State.idle
83
110
 
111
+ /// A click on the pill's row talks. A click in the answer does not:
112
+ /// that is for reading and selecting.
84
113
  override func mouseUp(with event: NSEvent) {
85
114
  defer { pressedAt = nil }
86
115
  if let start = pressedAt {
87
116
  let now = NSEvent.mouseLocation
88
117
  let moved = hypot(now.x - start.x, now.y - start.y)
89
- if moved < 4 { onClick?() }
118
+ let onRow = convertToScreen(row.convert(row.bounds, to: nil)).contains(start)
119
+ if moved < 4 && onRow { onClick?() }
90
120
  }
91
121
  super.mouseUp(with: event)
92
122
  }
@@ -168,40 +198,21 @@ final class Panel: NSPanel {
168
198
  isMovableByWindowBackground = true
169
199
  hidesOnDeactivate = false
170
200
 
171
- let blur = NSVisualEffectView(frame: NSRect(origin: .zero, size: Self.size))
172
- blur.material = .hudWindow
173
- blur.blendingMode = .behindWindow
174
- blur.state = .active
175
- blur.wantsLayer = true
176
- blur.layer?.cornerRadius = Brand.Radius.lg
177
- blur.layer?.masksToBounds = true
178
- blur.autoresizingMask = [.width, .height]
179
- contentView = blur
180
-
181
- // An opaque tint over the blur, and it is not a style preference.
182
- //
183
- // `blendingMode = .behindWindow` composites whatever is behind the
184
- // pill, so the editor's own panel dividers were coming through it
185
- // as a faint rectangle around the widget — it read as a border the
186
- // widget was drawing, when it was the window behind. Translucency
187
- // is pleasant over wallpaper and actively confusing over ruled UI.
188
- //
189
- // This keeps enough blur to feel native while making the pill read
190
- // as one solid object wherever it is parked.
191
- let tint = NSView(frame: blur.bounds)
192
- tint.wantsLayer = true
193
- tint.layer?.backgroundColor = NSColor.windowBackgroundColor
194
- .withAlphaComponent(0.72).cgColor
195
- tint.layer?.cornerRadius = Brand.Radius.lg
196
- tint.autoresizingMask = [.width, .height]
197
- blur.addSubview(tint)
201
+ let surface = Surface(frame: NSRect(origin: .zero, size: Self.size))
202
+ contentView = surface
203
+ surface.addSubview(row)
204
+ separator.boxType = .separator
205
+ separator.isHidden = true
206
+ surface.addSubview(separator)
207
+ surface.addSubview(answer)
208
+ answer.onHeight = { [weak self] height in self?.grow(answerHeight: height) }
198
209
 
199
210
  // The orb, centred 28 points from the left edge; its square is
200
211
  // larger than the orb so the glow is not cut off.
201
212
  let h = Self.size.height
202
213
  orb.frame = NSRect(x: 28 - Self.orbFrame / 2, y: (h - Self.orbFrame) / 2,
203
214
  width: Self.orbFrame, height: Self.orbFrame)
204
- blur.addSubview(orb)
215
+ row.addSubview(orb)
205
216
 
206
217
  // A clipping window the label slides behind. Clicks pass through
207
218
  // it, so pressing on the words drags the pill too.
@@ -213,7 +224,7 @@ final class Panel: NSPanel {
213
224
  label.lineBreakMode = .byClipping
214
225
  label.wantsLayer = true
215
226
  clipView.addSubview(label)
216
- blur.addSubview(clipView)
227
+ row.addSubview(clipView)
217
228
  clip = clipView
218
229
 
219
230
  // Close, shown while the pointer is over the pill. Its space is
@@ -229,8 +240,10 @@ final class Panel: NSPanel {
229
240
  closeButton.target = self
230
241
  closeButton.action = #selector(closeClicked)
231
242
  closeButton.isHidden = true
232
- blur.addSubview(closeButton)
233
- blur.addTrackingArea(NSTrackingArea(rect: .zero,
243
+ row.addSubview(closeButton)
244
+ surface.layoutContent = { [weak self] bounds in self?.layoutContent(bounds) }
245
+ MainActor.assumeIsolated { layoutContent(surface.bounds) }
246
+ surface.addTrackingArea(NSTrackingArea(rect: .zero,
234
247
  options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
235
248
  owner: self, userInfo: nil))
236
249
 
@@ -246,10 +259,32 @@ final class Panel: NSPanel {
246
259
 
247
260
  // MARK: Place
248
261
 
262
+ /// The row on the edge it grew from, full width; the answer in the
263
+ /// rest. The answer is always laid out at its full width, so its text
264
+ /// does not reflow while the pill grows around it.
265
+ @MainActor
266
+ func layoutContent(_ bounds: NSRect) {
267
+ let h = Self.size.height
268
+ let answerHeight = max(0, bounds.height - h)
269
+ let above = growth.above
270
+ row.frame = NSRect(x: 0, y: above ? 0 : answerHeight, width: bounds.width, height: h)
271
+ closeButton.frame.origin.x = bounds.width - 28
272
+ let clipWidth = bounds.width - 88
273
+ if let clip, clip.frame.width != clipWidth {
274
+ clip.frame.size.width = clipWidth
275
+ // Text that scrolled may fit now, and the other way round.
276
+ marqueeText = ""
277
+ if !current.isMeta { setText(label.stringValue) }
278
+ }
279
+ answer.frame = NSRect(x: 0, y: above ? h : 0, width: Self.expandedWidth, height: answerHeight)
280
+ separator.frame = NSRect(x: 0, y: above ? h - 1 : answerHeight, width: bounds.width, height: 1)
281
+ }
282
+
249
283
  /// Where it was left, kept on a screen that still exists; the
250
284
  /// bottom-right corner the first time.
251
285
  @MainActor
252
286
  func restorePosition() {
287
+ collapse(animated: false)
253
288
  let screens = NSScreen.screens.map(\.visibleFrame)
254
289
  let fallback = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame ?? .zero
255
290
  place(PillPlacement.clamp(saved: Config.pillOrigin, size: frame.size, screens: screens, fallback: fallback))
@@ -264,19 +299,18 @@ final class Panel: NSPanel {
264
299
 
265
300
  @MainActor
266
301
  private func place(_ origin: NSPoint) {
267
- placing = true
302
+ placing += 1
268
303
  setFrameOrigin(origin)
269
- placing = false
270
- onMove?()
304
+ placing -= 1
271
305
  }
272
306
 
273
307
  /// 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.
308
+ /// reset or a clamp never overwrites where the user put it. Dragged
309
+ /// while grown, the pill is remembered where it will collapse to.
275
310
  @objc private func moved() {
276
311
  MainActor.assumeIsolated {
277
- guard !placing else { return }
278
- Config.pillOrigin = frame.origin
279
- onMove?()
312
+ guard placing == 0 else { return }
313
+ Config.pillOrigin = expanded ? collapsedFrame().origin : frame.origin
280
314
  }
281
315
  }
282
316
 
@@ -291,6 +325,7 @@ final class Panel: NSPanel {
291
325
  @MainActor
292
326
  func dismiss() {
293
327
  dismissed = true
328
+ collapse(animated: false)
294
329
  hide()
295
330
  }
296
331
 
@@ -303,7 +338,7 @@ final class Panel: NSPanel {
303
338
  }
304
339
 
305
340
  @MainActor
306
- private func show() {
341
+ func show() {
307
342
  orb.setOnScreen(true)
308
343
  if !isVisible { orderFrontRegardless() }
309
344
  }
@@ -317,8 +352,16 @@ final class Panel: NSPanel {
317
352
 
318
353
  @objc private func closeClicked() { onDismiss?() }
319
354
 
320
- override func mouseEntered(with event: NSEvent) { closeButton.isHidden = false }
321
- override func mouseExited(with event: NSEvent) { closeButton.isHidden = true }
355
+ override func mouseEntered(with event: NSEvent) {
356
+ closeButton.isHidden = false
357
+ hovering = true
358
+ MainActor.assumeIsolated { armCollapse() }
359
+ }
360
+ override func mouseExited(with event: NSEvent) {
361
+ closeButton.isHidden = true
362
+ hovering = false
363
+ MainActor.assumeIsolated { armCollapse() }
364
+ }
322
365
 
323
366
  /// Esc, once the pill has been clicked and so holds the keyboard.
324
367
  override func cancelOperation(_ sender: Any?) { onDismiss?() }
@@ -327,6 +370,17 @@ final class Panel: NSPanel {
327
370
  if event.keyCode == 53 { onDismiss?() } else { super.keyDown(with: event) }
328
371
  }
329
372
 
373
+ /// ⌘C copies what is selected in the answer. The app has no Edit menu
374
+ /// to route the shortcut there.
375
+ override func performKeyEquivalent(with event: NSEvent) -> Bool {
376
+ if expanded, event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command,
377
+ event.charactersIgnoringModifiers == "c" {
378
+ answer.copySelection()
379
+ return true
380
+ }
381
+ return super.performKeyEquivalent(with: event)
382
+ }
383
+
330
384
  /// AppKit is not thread-safe. Marking this explicitly means a stray
331
385
  /// call from a delegate queue is a compile error rather than heap
332
386
  /// corruption that traps somewhere unrelated an hour later.
@@ -341,7 +395,13 @@ final class Panel: NSPanel {
341
395
  case .idle where Hold.isOn: tint = Brand.warn
342
396
  default: tint = agentTint()
343
397
  }
344
- orb.show(state.orbPhase, tint: tint)
398
+ let colors: [NSColor]?
399
+ switch state {
400
+ case .error: colors = nil
401
+ case .idle where Hold.isOn: colors = nil
402
+ default: colors = agentPalette()
403
+ }
404
+ orb.show(state.orbPhase, tint: tint, colors: colors)
345
405
 
346
406
  if state.isMeta {
347
407
  stopMarquee()
@@ -354,7 +414,9 @@ final class Panel: NSPanel {
354
414
  setText(named(state))
355
415
  }
356
416
 
357
- if dismissed || (state.isMeta && !alwaysVisible) { hide() } else { show() }
417
+ // Grown into an answer, it stays until it collapses, idle or not.
418
+ if dismissed || (state.isMeta && !alwaysVisible && !expanded) { hide() } else { show() }
419
+ armCollapse()
358
420
  onRender?(state)
359
421
  }
360
422
 
@@ -371,7 +433,7 @@ final class Panel: NSPanel {
371
433
  /// Fits, or scrolls. Identical text is left alone so a per-second tick
372
434
  /// cannot restart the animation and make it stutter in place.
373
435
  @MainActor
374
- private func setText(_ text: String) {
436
+ func setText(_ text: String) {
375
437
  guard let clipView = clip else { label.stringValue = text; return }
376
438
  let font = label.font ?? NSFont.systemFont(ofSize: 12)
377
439
  let width = (text as NSString).size(withAttributes: [.font: font]).width
@@ -411,7 +473,7 @@ final class Panel: NSPanel {
411
473
  }
412
474
 
413
475
  @MainActor
414
- private func stopMarquee() {
476
+ func stopMarquee() {
415
477
  label.layer?.removeAnimation(forKey: "marquee")
416
478
  marqueeText = ""
417
479
  }
@@ -426,3 +488,12 @@ final class Panel: NSPanel {
426
488
  private final class PassThroughView: NSView {
427
489
  override func hitTest(_ point: NSPoint) -> NSView? { nil }
428
490
  }
491
+
492
+ /// The pill's row: its background passes clicks through, so pressing
493
+ /// anywhere on it drags the pill, while its close button still clicks.
494
+ final class RowView: NSView {
495
+ override func hitTest(_ point: NSPoint) -> NSView? {
496
+ let hit = super.hitTest(point)
497
+ return hit === self ? nil : hit
498
+ }
499
+ }
@@ -0,0 +1,113 @@
1
+ import AppKit
2
+
3
+ /// The pill growing into its answer, and collapsing back.
4
+ ///
5
+ /// One surface, not a second window: the row with the orb stays where it
6
+ /// is and the widget grows up or down from it, whichever has more room.
7
+ /// It collapses on close or Esc, or `cardTimeout` seconds after the answer
8
+ /// has been spoken, and never while the pointer is over it.
9
+ extension Panel {
10
+ /// A gentle spring: a little past the target, then back. Reduce Motion
11
+ /// skips the animation entirely.
12
+ private static let spring = CAMediaTimingFunction(controlPoints: 0.3, 1.12, 0.5, 1)
13
+
14
+ /// Show an answer. The pill grows once it has been measured. A pill
15
+ /// the person dismissed stays dismissed.
16
+ @MainActor
17
+ func showAnswer(spoken: String, written: String?, buttons: [(String, String)], imageURL: String?) {
18
+ guard !dismissed else { return }
19
+ answer.show(spoken: spoken, written: written, buttons: buttons, imageURL: imageURL)
20
+ }
21
+
22
+ /// Grow, or resize while grown, to fit an answer `answerHeight` tall.
23
+ @MainActor
24
+ func grow(answerHeight: CGFloat) {
25
+ guard !dismissed else { return }
26
+ let pill = collapsedFrame()
27
+ guard let visible = visibleFrame(for: pill) else { return }
28
+ let height = min(Self.size.height + min(answerHeight, cardMaxHeight), visible.height)
29
+ let spot = PillPlacement.expanded(size: CGSize(width: Self.expandedWidth, height: height),
30
+ pill: pill, visible: visible)
31
+ if !expanded {
32
+ growth = (spot.above, spot.alignRight)
33
+ expanded = true
34
+ // Lay the row on its edge before the first frame, so it does
35
+ // not jump when the growth starts.
36
+ if let content = contentView { layoutContent(content.bounds) }
37
+ }
38
+ separator.isHidden = false
39
+ show()
40
+ animate(to: spot.frame)
41
+ armCollapse()
42
+ }
43
+
44
+ /// Back to the pill alone, where it was.
45
+ @MainActor
46
+ func collapse(animated: Bool = true) {
47
+ collapseTimer?.invalidate()
48
+ collapseTimer = nil
49
+ guard expanded else { return }
50
+ var pill = collapsedFrame()
51
+ if let visible = visibleFrame(for: pill) { pill = PillPlacement.inside(pill, visible) }
52
+ expanded = false
53
+ animate(to: pill, animated: animated) { [weak self] in
54
+ guard let self, !self.expanded else { return }
55
+ self.separator.isHidden = true
56
+ self.answer.clear()
57
+ // Idle and not kept on screen: it goes, now the answer has.
58
+ self.render(self.current)
59
+ }
60
+ }
61
+
62
+ /// Where the pill is, or will be once collapsed.
63
+ @MainActor
64
+ func collapsedFrame() -> NSRect {
65
+ guard expanded else { return frame }
66
+ return PillPlacement.collapsed(from: frame, size: Self.size, above: growth.above, alignRight: growth.alignRight)
67
+ }
68
+
69
+ /// Start the countdown to collapse, when the answer has been spoken
70
+ /// and nobody is reading it. Anything else cancels it.
71
+ @MainActor
72
+ func armCollapse() {
73
+ collapseTimer?.invalidate()
74
+ collapseTimer = nil
75
+ guard expanded, cardTimeout > 0, !hovering, current.orbPhase == .idle else { return }
76
+ collapseTimer = Timer.scheduledTimer(withTimeInterval: cardTimeout, repeats: false) { [weak self] _ in
77
+ MainActor.assumeIsolated { self?.collapse() }
78
+ }
79
+ }
80
+
81
+ /// The visible frame of the screen `rect` overlaps most.
82
+ @MainActor
83
+ private func visibleFrame(for rect: NSRect) -> NSRect? {
84
+ let best = NSScreen.screens.max { a, b in
85
+ let x = a.frame.intersection(rect), y = b.frame.intersection(rect)
86
+ return (x.isNull ? 0 : x.width * x.height) < (y.isNull ? 0 : y.width * y.height)
87
+ }
88
+ return (best ?? NSScreen.main)?.visibleFrame
89
+ }
90
+
91
+ @MainActor
92
+ private func animate(to target: NSRect, animated: Bool = true, then done: (() -> Void)? = nil) {
93
+ placing += 1
94
+ let finish = { [weak self] in
95
+ guard let self else { return }
96
+ self.placing -= 1
97
+ self.invalidateShadow()
98
+ done?()
99
+ }
100
+ if !animated || !isVisible || NSWorkspace.shared.accessibilityDisplayShouldReduceMotion {
101
+ setFrame(target, display: true)
102
+ finish()
103
+ return
104
+ }
105
+ NSAnimationContext.runAnimationGroup({ ctx in
106
+ ctx.duration = 0.34
107
+ ctx.timingFunction = Self.spring
108
+ animator().setFrame(target, display: true)
109
+ }, completionHandler: {
110
+ MainActor.assumeIsolated { finish() }
111
+ })
112
+ }
113
+ }