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.
- package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +4 -0
- package/apps/mac-voice/Sources/AgentXVoice/AnswerView.swift +228 -0
- package/apps/mac-voice/Sources/AgentXVoice/HistoryClient.swift +55 -0
- package/apps/mac-voice/Sources/AgentXVoice/HistoryModel.swift +153 -0
- package/apps/mac-voice/Sources/AgentXVoice/HistoryWindow.swift +301 -0
- package/apps/mac-voice/Sources/AgentXVoice/LocalSTT.swift +79 -0
- package/apps/mac-voice/Sources/AgentXVoice/ModelStore.swift +184 -0
- package/apps/mac-voice/Sources/AgentXVoice/Orb.swift +36 -9
- package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +126 -55
- package/apps/mac-voice/Sources/AgentXVoice/PanelAnswer.swift +113 -0
- package/apps/mac-voice/Sources/AgentXVoice/Parakeet.swift +264 -0
- package/apps/mac-voice/Sources/AgentXVoice/PillPlacement.swift +28 -17
- package/apps/mac-voice/Sources/AgentXVoice/Recorder.swift +81 -3
- package/apps/mac-voice/Sources/AgentXVoice/SettingsLook.swift +90 -0
- package/apps/mac-voice/Sources/AgentXVoice/SettingsWindow.swift +20 -5
- package/apps/mac-voice/Sources/AgentXVoice/SileroVAD.swift +92 -0
- package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +7 -5
- package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +14 -1
- package/apps/mac-voice/Sources/AgentXVoice/Surface.swift +105 -0
- package/apps/mac-voice/Sources/AgentXVoice/TurnEnd.swift +110 -0
- package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +54 -0
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +35 -36
- package/apps/mac-voice/build.sh +10 -1
- package/dist/{App-Z5VOAUXB.js → App-LQXWKBVY.js} +2 -2
- package/dist/{ChatApp-BE7ONQKS.js → ChatApp-75TL4LKW.js} +2 -2
- package/dist/{agent-Q74EISKP.js → agent-DH4FMMMY.js} +2 -2
- package/dist/{board-dashboard-A7PQOI6C.js → board-dashboard-BVRRDYAG.js} +223 -138
- package/dist/board-dashboard-BVRRDYAG.js.map +1 -0
- package/dist/{chunk-NKMFHEGV.js → chunk-27P5NLTC.js} +2 -2
- package/dist/{chunk-5LLGIX5M.js → chunk-2VQIPJH5.js} +2 -2
- package/dist/{chunk-V7ZK6ZIO.js → chunk-3XVTFNYM.js} +2 -2
- package/dist/{chunk-KW7TIOS3.js → chunk-B3DCYBP4.js} +2 -2
- package/dist/{chunk-547G3HYQ.js → chunk-CRSXKQLZ.js} +2 -2
- package/dist/{chunk-5W7NJHCS.js → chunk-DTEQNXK3.js} +2 -2
- package/dist/{chunk-XTD3SZIP.js → chunk-E2BJVHBR.js} +2 -2
- package/dist/{chunk-RXEAACVH.js → chunk-E2PWCU2N.js} +2 -2
- package/dist/{chunk-I2LYULR4.js → chunk-JO3PSLWL.js} +5 -5
- package/dist/{chunk-U5YBQJ5Q.js → chunk-JORXRF4E.js} +2 -2
- package/dist/{chunk-2MIOMYT2.js → chunk-LXUG2X26.js} +2 -2
- package/dist/{chunk-Q6SMLSRO.js → chunk-N5KD3TVN.js} +2 -2
- package/dist/{chunk-VI7GBDKV.js → chunk-NBGNPKAF.js} +2 -2
- package/dist/{chunk-6QNPCVRN.js → chunk-PAOV37KQ.js} +3 -3
- package/dist/chunk-PAOV37KQ.js.map +1 -0
- package/dist/{chunk-OZZPJ2YG.js → chunk-RYFUFX6F.js} +194 -181
- package/dist/chunk-RYFUFX6F.js.map +1 -0
- package/dist/chunk-S7TCVIET.js +15 -0
- package/dist/chunk-S7TCVIET.js.map +1 -0
- package/dist/{chunk-POL7RAJQ.js → chunk-TM762AXU.js} +159 -153
- package/dist/chunk-TM762AXU.js.map +1 -0
- package/dist/{chunk-6OABTCRA.js → chunk-WBGC7MWX.js} +2 -2
- package/dist/{chunk-CQUVUVXR.js → chunk-WIBMNQKJ.js} +2 -2
- package/dist/{chunk-KKGFZEXS.js → chunk-WOPFHKBF.js} +37 -36
- package/dist/chunk-WOPFHKBF.js.map +1 -0
- package/dist/{chunk-VVBGPT6W.js → chunk-YQCOX4R3.js} +2 -2
- package/dist/cli.js +1 -1
- package/dist/client-QVZIZFV4.js +2 -0
- package/dist/config-3XC7EYB7.js +2 -0
- package/dist/config-mutator-HKU63FJM.js +2 -0
- package/dist/doctor-YFA4ZSRH.js +2 -0
- package/dist/index.d.ts +66 -7
- package/dist/index.js +1 -1
- package/dist/program-BOCLU6ES.js +2 -0
- package/dist/{promote-KVPLDSKT.js → promote-UR2GSHKC.js} +2 -2
- package/dist/query-XUP3BMVS.js +2 -0
- package/dist/{registry-AUOI4TW2.js → registry-PGALUW5L.js} +2 -2
- package/dist/{request-planner-LMZ5ZAXC.js → request-planner-NHE3IRJN.js} +2 -2
- package/dist/{review-candidates-WCBXLLDS.js → review-candidates-6GKRSRBQ.js} +2 -2
- package/dist/{routing-FBASYKUL.js → routing-AQKHAU3Z.js} +2 -2
- package/dist/schedule-tool-6NMCLTIH.js +2 -0
- package/dist/scheduler-RAYGNDWT.js +2 -0
- package/dist/{seat-DBFW3Z5P.js → seat-6FMW2QI4.js} +2 -2
- package/dist/{select-6CNVVFA5.js → select-5VONJMLH.js} +2 -2
- package/dist/{set-enabled-AYZH3KSB.js → set-enabled-YHD3IJJZ.js} +2 -2
- package/dist/{tool-PKS4NBZ6.js → tool-MXJGLJZ5.js} +2 -2
- package/dist/wiki-ZQLEYY24.js +2 -0
- package/package.json +1 -1
- package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +0 -198
- package/dist/board-dashboard-A7PQOI6C.js.map +0 -1
- package/dist/chunk-6QNPCVRN.js.map +0 -1
- package/dist/chunk-ERL6AS3O.js +0 -15
- package/dist/chunk-ERL6AS3O.js.map +0 -1
- package/dist/chunk-KKGFZEXS.js.map +0 -1
- package/dist/chunk-OZZPJ2YG.js.map +0 -1
- package/dist/chunk-POL7RAJQ.js.map +0 -1
- package/dist/client-EFP2PKA3.js +0 -2
- package/dist/config-P6YXC6XZ.js +0 -2
- package/dist/config-mutator-3GKXLTHH.js +0 -2
- package/dist/doctor-JEWMHXYH.js +0 -2
- package/dist/program-7DUQJDSQ.js +0 -2
- package/dist/query-AGOMCYTT.js +0 -2
- package/dist/schedule-tool-RJRJ63SS.js +0 -2
- package/dist/scheduler-ATA7Z44S.js +0 -2
- package/dist/wiki-QW6BDNEQ.js +0 -2
- /package/dist/{App-Z5VOAUXB.js.map → App-LQXWKBVY.js.map} +0 -0
- /package/dist/{ChatApp-BE7ONQKS.js.map → ChatApp-75TL4LKW.js.map} +0 -0
- /package/dist/{agent-Q74EISKP.js.map → agent-DH4FMMMY.js.map} +0 -0
- /package/dist/{chunk-NKMFHEGV.js.map → chunk-27P5NLTC.js.map} +0 -0
- /package/dist/{chunk-5LLGIX5M.js.map → chunk-2VQIPJH5.js.map} +0 -0
- /package/dist/{chunk-V7ZK6ZIO.js.map → chunk-3XVTFNYM.js.map} +0 -0
- /package/dist/{chunk-KW7TIOS3.js.map → chunk-B3DCYBP4.js.map} +0 -0
- /package/dist/{chunk-547G3HYQ.js.map → chunk-CRSXKQLZ.js.map} +0 -0
- /package/dist/{chunk-5W7NJHCS.js.map → chunk-DTEQNXK3.js.map} +0 -0
- /package/dist/{chunk-XTD3SZIP.js.map → chunk-E2BJVHBR.js.map} +0 -0
- /package/dist/{chunk-RXEAACVH.js.map → chunk-E2PWCU2N.js.map} +0 -0
- /package/dist/{chunk-I2LYULR4.js.map → chunk-JO3PSLWL.js.map} +0 -0
- /package/dist/{chunk-U5YBQJ5Q.js.map → chunk-JORXRF4E.js.map} +0 -0
- /package/dist/{chunk-2MIOMYT2.js.map → chunk-LXUG2X26.js.map} +0 -0
- /package/dist/{chunk-Q6SMLSRO.js.map → chunk-N5KD3TVN.js.map} +0 -0
- /package/dist/{chunk-VI7GBDKV.js.map → chunk-NBGNPKAF.js.map} +0 -0
- /package/dist/{chunk-6OABTCRA.js.map → chunk-WBGC7MWX.js.map} +0 -0
- /package/dist/{chunk-CQUVUVXR.js.map → chunk-WIBMNQKJ.js.map} +0 -0
- /package/dist/{chunk-VVBGPT6W.js.map → chunk-YQCOX4R3.js.map} +0 -0
- /package/dist/{client-EFP2PKA3.js.map → client-QVZIZFV4.js.map} +0 -0
- /package/dist/{config-P6YXC6XZ.js.map → config-3XC7EYB7.js.map} +0 -0
- /package/dist/{config-mutator-3GKXLTHH.js.map → config-mutator-HKU63FJM.js.map} +0 -0
- /package/dist/{doctor-JEWMHXYH.js.map → doctor-YFA4ZSRH.js.map} +0 -0
- /package/dist/{program-7DUQJDSQ.js.map → program-BOCLU6ES.js.map} +0 -0
- /package/dist/{promote-KVPLDSKT.js.map → promote-UR2GSHKC.js.map} +0 -0
- /package/dist/{query-AGOMCYTT.js.map → query-XUP3BMVS.js.map} +0 -0
- /package/dist/{registry-AUOI4TW2.js.map → registry-PGALUW5L.js.map} +0 -0
- /package/dist/{request-planner-LMZ5ZAXC.js.map → request-planner-NHE3IRJN.js.map} +0 -0
- /package/dist/{review-candidates-WCBXLLDS.js.map → review-candidates-6GKRSRBQ.js.map} +0 -0
- /package/dist/{routing-FBASYKUL.js.map → routing-AQKHAU3Z.js.map} +0 -0
- /package/dist/{schedule-tool-RJRJ63SS.js.map → schedule-tool-6NMCLTIH.js.map} +0 -0
- /package/dist/{scheduler-ATA7Z44S.js.map → scheduler-RAYGNDWT.js.map} +0 -0
- /package/dist/{seat-DBFW3Z5P.js.map → seat-6FMW2QI4.js.map} +0 -0
- /package/dist/{select-6CNVVFA5.js.map → select-5VONJMLH.js.map} +0 -0
- /package/dist/{set-enabled-AYZH3KSB.js.map → set-enabled-YHD3IJJZ.js.map} +0 -0
- /package/dist/{tool-PKS4NBZ6.js.map → tool-MXJGLJZ5.js.map} +0 -0
- /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
|
|
12
|
-
/// a close button that shows on hover.
|
|
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
|
-
|
|
25
|
-
///
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
|
302
|
+
placing += 1
|
|
268
303
|
setFrameOrigin(origin)
|
|
269
|
-
placing
|
|
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
|
|
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
|
-
|
|
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) {
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|