agentix-cli 0.92.4 → 0.94.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/Character.swift +161 -27
- package/apps/mac-voice/Sources/AgentXVoice/CharacterSim.swift +14 -3
- package/apps/mac-voice/Sources/AgentXVoice/Config.swift +15 -0
- package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +44 -3
- package/apps/mac-voice/Sources/AgentXVoice/PillPlacement.swift +49 -0
- package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +15 -3
- package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +6 -0
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +52 -9
- package/dist/{chunk-GWOJJ7FE.js → chunk-VPCW4477.js} +168 -168
- package/dist/{chunk-GWOJJ7FE.js.map → chunk-VPCW4477.js.map} +1 -1
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/{program-IAOEFLZP.js → program-BRC2SSDI.js} +4 -4
- package/package.json +1 -1
- /package/dist/{program-IAOEFLZP.js.map → program-BRC2SSDI.js.map} +0 -0
|
@@ -2,14 +2,14 @@ import AppKit
|
|
|
2
2
|
|
|
3
3
|
/// The character (#458): the orb grown into a small creature, in the
|
|
4
4
|
/// agent's palette, with two eyes, no mouth and no legs. It hovers above
|
|
5
|
-
/// the bottom edge of the screen,
|
|
6
|
-
/// gets out of the pointer's way.
|
|
7
|
-
/// the default.
|
|
5
|
+
/// the bottom edge of the screen, or where it was dragged to (#502),
|
|
6
|
+
/// shows what the assistant is doing, and gets out of the pointer's way.
|
|
7
|
+
/// Chosen with `voice.look`; the orb stays the default.
|
|
8
8
|
///
|
|
9
|
-
/// It reacts and never interrupts: its window takes no
|
|
10
|
-
///
|
|
11
|
-
/// "Animated orb" off in the menu, it is a
|
|
12
|
-
/// between states and stays where it rests.
|
|
9
|
+
/// It reacts and never interrupts: its window takes no keys, no clicks
|
|
10
|
+
/// but a drag of its body with ⌘ held, and never comes forward by itself.
|
|
11
|
+
/// With Reduce Motion on, or "Animated orb" off in the menu, it is a
|
|
12
|
+
/// still picture that changes between states and stays where it rests.
|
|
13
13
|
@MainActor
|
|
14
14
|
final class CharacterHost {
|
|
15
15
|
/// The body's width in points, and the room around it for its marks
|
|
@@ -18,6 +18,9 @@ final class CharacterHost {
|
|
|
18
18
|
private static let size = NSSize(width: 440, height: 150)
|
|
19
19
|
/// The edge it hovers above, from the bottom of its window.
|
|
20
20
|
private static let ground: CGFloat = 8
|
|
21
|
+
/// Above the edge, clear of its body at the top of a hop: where its
|
|
22
|
+
/// speech bubble's tail ends.
|
|
23
|
+
private static let head: CGFloat = 78
|
|
21
24
|
|
|
22
25
|
private let window: NSPanel
|
|
23
26
|
private let view = CharacterView(frame: NSRect(origin: .zero, size: CharacterHost.size))
|
|
@@ -26,6 +29,8 @@ final class CharacterHost {
|
|
|
26
29
|
private var motionObserver: NSObjectProtocol?
|
|
27
30
|
|
|
28
31
|
private var shown = false
|
|
32
|
+
/// Hidden with its bubble, until the talk key or the menu (#502).
|
|
33
|
+
private var hidden = false
|
|
29
34
|
private var animated = true
|
|
30
35
|
private var reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
|
|
31
36
|
private var activity = CharacterMath.Activity.idle
|
|
@@ -34,6 +39,25 @@ final class CharacterHost {
|
|
|
34
39
|
var levelSource: (() -> Float)?
|
|
35
40
|
/// Where the pointer is, in screen coordinates.
|
|
36
41
|
var pointerSource: () -> NSPoint = { NSEvent.mouseLocation }
|
|
42
|
+
/// Its speech bubble (#491), the pill: told every frame where the
|
|
43
|
+
/// character's head is and on which screen, it answers with where the
|
|
44
|
+
/// bubble is, or nil while it is hidden. Set by the app.
|
|
45
|
+
var bubble: ((NSPoint, NSRect) -> NSRect?)?
|
|
46
|
+
private var bubbleFrame: NSRect?
|
|
47
|
+
|
|
48
|
+
/// Where it was dragged to: the point under it. Nil: its corner.
|
|
49
|
+
private var place = Config.characterPlace
|
|
50
|
+
/// Where it rests now, `place` made safe for the screens there are.
|
|
51
|
+
private var rest = CGPoint.zero
|
|
52
|
+
/// Its body on screen: where it can be taken hold of.
|
|
53
|
+
private var body = NSRect.zero
|
|
54
|
+
/// Carried by the pointer: where the pointer took hold, and where it
|
|
55
|
+
/// stood then, which is not where it rests once it has stepped aside.
|
|
56
|
+
private var carried: (from: NSPoint, rest: CGPoint)?
|
|
57
|
+
/// A still picture: Reduce Motion, or "Animated orb" off.
|
|
58
|
+
private var still: Bool { !animated || reduceMotion }
|
|
59
|
+
/// The look is the character: whether it is on screen. Nil with the orb.
|
|
60
|
+
var onScreen: Bool? { shown ? !hidden : nil }
|
|
37
61
|
|
|
38
62
|
init() {
|
|
39
63
|
window = NSPanel(contentRect: NSRect(origin: .zero, size: Self.size),
|
|
@@ -47,6 +71,7 @@ final class CharacterHost {
|
|
|
47
71
|
window.hidesOnDeactivate = false
|
|
48
72
|
window.ignoresMouseEvents = true
|
|
49
73
|
window.contentView = view
|
|
74
|
+
view.onDrag = { [weak self] phase in self?.drag(phase) }
|
|
50
75
|
view.unit = Self.diameter / 100
|
|
51
76
|
view.edge = CGPoint(x: Self.size.width / 2, y: Self.size.height - Self.ground)
|
|
52
77
|
motionObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
|
@@ -68,6 +93,22 @@ final class CharacterHost {
|
|
|
68
93
|
run()
|
|
69
94
|
}
|
|
70
95
|
|
|
96
|
+
/// Hide it, as its bubble is hidden: gone until it is asked back.
|
|
97
|
+
func setHidden(_ on: Bool) {
|
|
98
|
+
guard hidden != on else { return }
|
|
99
|
+
hidden = on
|
|
100
|
+
run()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// "Reset position" in the menu: back to its corner, forgetting where
|
|
104
|
+
/// it was dragged. Not with the orb look, whose place is the pill's.
|
|
105
|
+
func resetPosition() {
|
|
106
|
+
guard shown else { return }
|
|
107
|
+
place = nil
|
|
108
|
+
Config.characterPlace = nil
|
|
109
|
+
redraw()
|
|
110
|
+
}
|
|
111
|
+
|
|
71
112
|
/// "Animated orb" in the menu.
|
|
72
113
|
func setAnimated(_ on: Bool) {
|
|
73
114
|
guard animated != on else { return }
|
|
@@ -79,36 +120,83 @@ final class CharacterHost {
|
|
|
79
120
|
func show(_ activity: CharacterMath.Activity, tint: NSColor, colors: [NSColor]?) {
|
|
80
121
|
self.activity = activity
|
|
81
122
|
view.stops = CharacterDraw.stops(tint: tint, colors: colors)
|
|
82
|
-
|
|
123
|
+
redraw()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Standing still, it is drawn again when something changes: its
|
|
127
|
+
/// state, or its bubble showing or hiding.
|
|
128
|
+
func redraw() {
|
|
129
|
+
if shown && !hidden && still { tick() }
|
|
83
130
|
}
|
|
84
131
|
|
|
85
132
|
/// The frames run only while it is on screen and may move.
|
|
86
133
|
private func run() {
|
|
87
134
|
timer?.invalidate()
|
|
88
135
|
timer = nil
|
|
89
|
-
guard shown else { window.orderOut(nil); return }
|
|
136
|
+
guard shown, !hidden else { carried = nil; window.orderOut(nil); return }
|
|
90
137
|
tick()
|
|
91
138
|
window.orderFrontRegardless()
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
139
|
+
// Still, it only watches for the hand that moves it.
|
|
140
|
+
let still = still
|
|
141
|
+
let timer = Timer.scheduledTimer(withTimeInterval: still ? 0.1 : 1.0 / 30, repeats: true) { [weak self] _ in
|
|
142
|
+
MainActor.assumeIsolated { if still { self?.aim() } else { self?.tick() } }
|
|
95
143
|
}
|
|
96
144
|
// Keeps moving while a menu is open or the pill is dragged.
|
|
97
|
-
|
|
145
|
+
RunLoop.main.add(timer, forMode: .common)
|
|
146
|
+
self.timer = timer
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/// It takes the pointer only to be moved: on its body, with ⌘ held.
|
|
150
|
+
/// Any other click goes through it to the window behind.
|
|
151
|
+
private func aim() {
|
|
152
|
+
// The button came up where no event told of it.
|
|
153
|
+
if carried != nil && NSEvent.pressedMouseButtons & 1 == 0 { drop() }
|
|
154
|
+
let take = carried != nil || (NSEvent.modifierFlags.contains(.command) && body.contains(pointerSource()))
|
|
155
|
+
if window.ignoresMouseEvents == take { window.ignoresMouseEvents = !take }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private func drag(_ phase: CharacterView.Drag) {
|
|
159
|
+
switch phase {
|
|
160
|
+
case .began:
|
|
161
|
+
let mouse = pointerSource()
|
|
162
|
+
if body.contains(mouse) { carried = (mouse, CGPoint(x: body.midX, y: rest.y)) }
|
|
163
|
+
case .moved:
|
|
164
|
+
if carried != nil { tick() }
|
|
165
|
+
case .ended:
|
|
166
|
+
if carried != nil { tick() }
|
|
167
|
+
drop()
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// Let go: where it was carried to is kept. A click alone moves nothing.
|
|
172
|
+
private func drop() {
|
|
173
|
+
guard let was = carried else { return }
|
|
174
|
+
carried = nil
|
|
175
|
+
guard pointerSource() != was.from else { return }
|
|
176
|
+
place = rest
|
|
177
|
+
Config.characterPlace = rest
|
|
98
178
|
}
|
|
99
179
|
|
|
100
180
|
private func tick() {
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
let
|
|
107
|
-
|
|
108
|
-
let
|
|
181
|
+
// Where nobody put it: the screen with the menu bar, less the
|
|
182
|
+
// menu bar and the Dock; under the right end of its bubble, the
|
|
183
|
+
// bubble in the pill's own corner. It goes no further right.
|
|
184
|
+
let screens = NSScreen.screens
|
|
185
|
+
guard let first = screens.first else { return }
|
|
186
|
+
let mouse = pointerSource()
|
|
187
|
+
var wanted = place
|
|
188
|
+
if let carried {
|
|
189
|
+
wanted = CGPoint(x: carried.rest.x + mouse.x - carried.from.x, y: carried.rest.y + mouse.y - carried.from.y)
|
|
190
|
+
}
|
|
191
|
+
let spot = PillPlacement.character(saved: wanted, room: Self.head + PillPlacement.tail + Panel.size.height,
|
|
192
|
+
screens: screens.map(\.visibleFrame), fallback: first.visibleFrame)
|
|
193
|
+
let visible = spot.visible
|
|
194
|
+
rest = spot.place
|
|
195
|
+
let home = Double(rest.x)
|
|
196
|
+
if carried != nil { sim.carry(to: home) }
|
|
109
197
|
|
|
110
198
|
let frame: CharacterSim.Frame
|
|
111
|
-
if
|
|
199
|
+
if still {
|
|
112
200
|
frame = CharacterSim.still(activity, home: home)
|
|
113
201
|
} else {
|
|
114
202
|
if activity == .listening, let read = levelSource {
|
|
@@ -116,16 +204,28 @@ final class CharacterHost {
|
|
|
116
204
|
} else {
|
|
117
205
|
level = 0
|
|
118
206
|
}
|
|
119
|
-
let
|
|
120
|
-
let pointer =
|
|
207
|
+
let near = screens.first { $0.visibleFrame == visible }?.frame.contains(mouse) ?? false
|
|
208
|
+
let pointer = near ? (x: Double(mouse.x), y: Double(mouse.y - rest.y)) : nil
|
|
209
|
+
// The gap its tail fills counts as the bubble, so a pointer a
|
|
210
|
+
// little under a button does not send both away. With ⌘ held
|
|
211
|
+
// it waits too, to be taken hold of.
|
|
212
|
+
let held = carried != nil
|
|
213
|
+
|| (bubbleFrame?.insetBy(dx: 0, dy: -PillPlacement.tail).contains(mouse) ?? false)
|
|
214
|
+
|| (NSEvent.modifierFlags.contains(.command) && window.frame.contains(mouse))
|
|
121
215
|
frame = sim.step(to: ProcessInfo.processInfo.systemUptime,
|
|
122
|
-
CharacterSim.Input(activity: activity, level: level, pointer: pointer,
|
|
216
|
+
CharacterSim.Input(activity: activity, level: level, pointer: pointer, held: held,
|
|
217
|
+
home: home, range: Double(spot.ends.lowerBound)...Double(spot.ends.upperBound)))
|
|
123
218
|
}
|
|
124
|
-
let origin = NSPoint(x: (CGFloat(frame.x) - Self.size.width / 2).rounded(), y:
|
|
219
|
+
let origin = NSPoint(x: (CGFloat(frame.x) - Self.size.width / 2).rounded(), y: (rest.y - Self.ground).rounded())
|
|
125
220
|
if window.frame.origin != origin { window.setFrameOrigin(origin) }
|
|
221
|
+
body = NSRect(x: CGFloat(frame.x) - Self.diameter / 2, y: rest.y, width: Self.diameter, height: Self.head)
|
|
222
|
+
// The bubble goes with the window, so the two move as one.
|
|
223
|
+
bubbleFrame = bubble?(NSPoint(x: origin.x + Self.size.width / 2, y: origin.y + Self.ground + Self.head), visible)
|
|
224
|
+
view.bubble = bubbleFrame?.offsetBy(dx: -origin.x, dy: -origin.y)
|
|
126
225
|
view.origin = origin.x
|
|
127
226
|
view.shown = frame
|
|
128
227
|
view.needsDisplay = true
|
|
228
|
+
aim()
|
|
129
229
|
}
|
|
130
230
|
}
|
|
131
231
|
|
|
@@ -138,9 +238,18 @@ final class CharacterView: NSView {
|
|
|
138
238
|
/// Under the character, in this view; and the view's left edge on screen.
|
|
139
239
|
var edge = CGPoint.zero
|
|
140
240
|
var origin: CGFloat = 0
|
|
241
|
+
/// Its speech bubble in this window, origin bottom-left; nil while hidden.
|
|
242
|
+
var bubble: NSRect?
|
|
243
|
+
|
|
244
|
+
/// A drag of its body. Its window takes the mouse for nothing else.
|
|
245
|
+
enum Drag { case began, moved, ended }
|
|
246
|
+
var onDrag: ((Drag) -> Void)?
|
|
141
247
|
|
|
142
248
|
override var isFlipped: Bool { true }
|
|
143
|
-
override func
|
|
249
|
+
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
|
|
250
|
+
override func mouseDown(with event: NSEvent) { onDrag?(.began) }
|
|
251
|
+
override func mouseDragged(with event: NSEvent) { onDrag?(.moved) }
|
|
252
|
+
override func mouseUp(with event: NSEvent) { onDrag?(.ended) }
|
|
144
253
|
|
|
145
254
|
override func draw(_ dirtyRect: NSRect) {
|
|
146
255
|
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
|
|
@@ -148,5 +257,30 @@ final class CharacterView: NSView {
|
|
|
148
257
|
// to the nearest point; the rest of the way is drawn here.
|
|
149
258
|
let at = CGPoint(x: CGFloat(shown.x) - origin, y: edge.y)
|
|
150
259
|
CharacterDraw.draw(shown, in: ctx, edge: at, origin: origin, unit: unit, stops: stops)
|
|
260
|
+
if let bubble { drawTail(in: ctx, from: bubble) }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/// The bubble's tail: a small point from its bottom edge down to the
|
|
264
|
+
/// character, in the bubble's own colours.
|
|
265
|
+
private func drawTail(in ctx: CGContext, from bubble: NSRect) {
|
|
266
|
+
let half: CGFloat = 7, corner = Brand.Radius.lg + half
|
|
267
|
+
let x = min(max(bounds.midX, bubble.minX + corner), bubble.maxX - corner)
|
|
268
|
+
// One point up into the bubble, so no gap shows between the two.
|
|
269
|
+
let top = bounds.height - bubble.minY - 1
|
|
270
|
+
let tip = CGPoint(x: x, y: top + 1 + PillPlacement.tail)
|
|
271
|
+
effectiveAppearance.performAsCurrentDrawingAppearance {
|
|
272
|
+
ctx.move(to: CGPoint(x: x - half, y: top))
|
|
273
|
+
ctx.addLine(to: tip)
|
|
274
|
+
ctx.addLine(to: CGPoint(x: x + half, y: top))
|
|
275
|
+
ctx.closePath()
|
|
276
|
+
ctx.setFillColor(NSColor.windowBackgroundColor.withAlphaComponent(0.92).cgColor)
|
|
277
|
+
ctx.fillPath()
|
|
278
|
+
ctx.move(to: CGPoint(x: x - half, y: top + 1))
|
|
279
|
+
ctx.addLine(to: tip)
|
|
280
|
+
ctx.addLine(to: CGPoint(x: x + half, y: top + 1))
|
|
281
|
+
ctx.setStrokeColor(NSColor.separatorColor.cgColor)
|
|
282
|
+
ctx.setLineWidth(1)
|
|
283
|
+
ctx.strokePath()
|
|
284
|
+
}
|
|
151
285
|
}
|
|
152
286
|
}
|
|
@@ -17,6 +17,9 @@ struct CharacterSim {
|
|
|
17
17
|
/// The pointer: along the edge, and its height above it. Nil when
|
|
18
18
|
/// it is on another screen.
|
|
19
19
|
var pointer: (x: Double, y: Double)?
|
|
20
|
+
/// The pointer is on its speech bubble: it stays where it is, so
|
|
21
|
+
/// the bubble is not pulled from under the pointer.
|
|
22
|
+
var held = false
|
|
20
23
|
/// Where it rests, and how far it may go.
|
|
21
24
|
var home = 0.0
|
|
22
25
|
var range: ClosedRange<Double> = 0...0
|
|
@@ -98,14 +101,17 @@ struct CharacterSim {
|
|
|
98
101
|
|
|
99
102
|
// The pointer comes close: out of its way. Left alone: back home,
|
|
100
103
|
// unless the pointer is resting there.
|
|
101
|
-
let near = input.pointer.map { abs($0.x - x) < reach && $0.y < tall } ?? false
|
|
102
|
-
if
|
|
104
|
+
let near = input.pointer.map { abs($0.x - x) < reach && abs($0.y) < tall } ?? false
|
|
105
|
+
if input.held {
|
|
106
|
+
target = x
|
|
107
|
+
awayUntil = max(awayUntil, now + Self.awayFor)
|
|
108
|
+
} else if near {
|
|
103
109
|
target = M.aside(x: x, pointer: input.pointer!.x, clear: Self.clear, range: input.range)
|
|
104
110
|
awayUntil = now + Self.awayFor
|
|
105
111
|
restSince = now
|
|
106
112
|
steppedAside = true
|
|
107
113
|
} else if now >= awayUntil {
|
|
108
|
-
let taken = input.pointer.map { abs($0.x - input.home) < Self.clear && $0.y < tall } ?? false
|
|
114
|
+
let taken = input.pointer.map { abs($0.x - input.home) < Self.clear && abs($0.y) < tall } ?? false
|
|
109
115
|
if !taken {
|
|
110
116
|
if target != input.home { steppedAside = false }
|
|
111
117
|
target = min(max(input.home, input.range.lowerBound), input.range.upperBound)
|
|
@@ -191,6 +197,11 @@ struct CharacterSim {
|
|
|
191
197
|
dots: dots.compactMap { dot(at: now, $0) }, stars: bursts.flatMap { stars(at: now, $0) })
|
|
192
198
|
}
|
|
193
199
|
|
|
200
|
+
/// Carried by the pointer (#502): it is where it is put, at once.
|
|
201
|
+
mutating func carry(to place: Double) {
|
|
202
|
+
x = place; target = place; speed = 0
|
|
203
|
+
}
|
|
204
|
+
|
|
194
205
|
/// The state alone, where it rests: for Reduce Motion, which shows a
|
|
195
206
|
/// still picture that changes between states and never a loop.
|
|
196
207
|
static func still(_ activity: M.Activity, home: Double) -> Frame {
|
|
@@ -49,6 +49,21 @@ enum Config {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/// Where the character was last dragged to (the point under it, in
|
|
53
|
+
/// screen points). Its own key, so the pill's place is kept for the
|
|
54
|
+
/// orb look. Nil: under its bubble's default corner.
|
|
55
|
+
static var characterPlace: CGPoint? {
|
|
56
|
+
get {
|
|
57
|
+
guard let xy = UserDefaults.standard.array(forKey: "characterPlace") as? [Double], xy.count == 2
|
|
58
|
+
else { return nil }
|
|
59
|
+
return CGPoint(x: xy[0], y: xy[1])
|
|
60
|
+
}
|
|
61
|
+
set {
|
|
62
|
+
if let p = newValue { UserDefaults.standard.set([Double(p.x), Double(p.y)], forKey: "characterPlace") }
|
|
63
|
+
else { UserDefaults.standard.removeObject(forKey: "characterPlace") }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
52
67
|
/// The agent actually answering: `agentID`, else `chosenAgentID`, else
|
|
53
68
|
/// the daemon's default (AgentClient.resolveAgent). Progress follows it.
|
|
54
69
|
@MainActor static var effectiveAgentID: String = ""
|
|
@@ -107,11 +107,18 @@ final class Panel: NSPanel {
|
|
|
107
107
|
|
|
108
108
|
/// Told what the orb is shown, so the character can show the same.
|
|
109
109
|
var onLook: ((State, NSColor, [NSColor]?) -> Void)?
|
|
110
|
+
/// Told when it comes on screen or leaves it, so a still character
|
|
111
|
+
/// draws its bubble's tail again.
|
|
112
|
+
var onShown: (() -> Void)?
|
|
110
113
|
|
|
111
114
|
/// Off while the character stands in for the orb (Character.swift):
|
|
112
115
|
/// the words start where the orb was.
|
|
113
116
|
private(set) var showsOrb = true
|
|
114
117
|
|
|
118
|
+
/// Set while the pill is the character's speech bubble: where the
|
|
119
|
+
/// character's head is, and the screen it is on.
|
|
120
|
+
private(set) var bubble: (head: NSPoint, visible: NSRect)?
|
|
121
|
+
|
|
115
122
|
/// Told of every state rendered, so the menu-bar icon can follow.
|
|
116
123
|
var onRender: ((State) -> Void)?
|
|
117
124
|
|
|
@@ -332,18 +339,50 @@ final class Panel: NSPanel {
|
|
|
332
339
|
@MainActor
|
|
333
340
|
func restorePosition() {
|
|
334
341
|
collapse(animated: false)
|
|
342
|
+
if let bubble {
|
|
343
|
+
place(PillPlacement.bubble(size: frame.size, head: bubble.head, visible: bubble.visible))
|
|
344
|
+
return
|
|
345
|
+
}
|
|
335
346
|
let screens = NSScreen.screens.map(\.visibleFrame)
|
|
336
347
|
let fallback = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame ?? .zero
|
|
337
348
|
place(PillPlacement.clamp(saved: Config.pillOrigin, size: frame.size, screens: screens, fallback: fallback))
|
|
338
349
|
}
|
|
339
350
|
|
|
340
351
|
/// Back to the bottom-right corner, forgetting where it was dragged.
|
|
352
|
+
/// Not while it is the character's bubble: nothing would move, and
|
|
353
|
+
/// the place kept for the orb look would be lost.
|
|
341
354
|
@MainActor
|
|
342
355
|
func resetPosition() {
|
|
356
|
+
guard bubble == nil else { return }
|
|
343
357
|
Config.pillOrigin = nil
|
|
344
358
|
restorePosition()
|
|
345
359
|
}
|
|
346
360
|
|
|
361
|
+
/// The character's speech bubble (#491): the pill sits above `head`,
|
|
362
|
+
/// the top of the character, and goes where it goes, grown or not. It
|
|
363
|
+
/// is not dragged by itself, and the place it was dragged to as a
|
|
364
|
+
/// pill is kept for the orb look.
|
|
365
|
+
@MainActor
|
|
366
|
+
func attach(head: NSPoint, visible: NSRect) {
|
|
367
|
+
bubble = (head, visible)
|
|
368
|
+
isMovableByWindowBackground = false
|
|
369
|
+
// Growing or collapsing: it catches up on the next frame.
|
|
370
|
+
guard placing == 0 else { return }
|
|
371
|
+
let to = PillPlacement.bubble(size: Self.size, head: head, visible: visible)
|
|
372
|
+
let from = collapsedFrame().origin
|
|
373
|
+
let moved = PillPlacement.inside(frame.offsetBy(dx: to.x - from.x, dy: to.y - from.y), visible)
|
|
374
|
+
if moved.origin != frame.origin { place(moved.origin) }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/// The orb look again: a pill of its own, back where it was left.
|
|
378
|
+
@MainActor
|
|
379
|
+
func detach() {
|
|
380
|
+
guard bubble != nil else { return }
|
|
381
|
+
bubble = nil
|
|
382
|
+
isMovableByWindowBackground = true
|
|
383
|
+
restorePosition()
|
|
384
|
+
}
|
|
385
|
+
|
|
347
386
|
@MainActor
|
|
348
387
|
private func place(_ origin: NSPoint) {
|
|
349
388
|
placing += 1
|
|
@@ -398,7 +437,7 @@ final class Panel: NSPanel {
|
|
|
398
437
|
func show() {
|
|
399
438
|
orb.setOnScreen(showsOrb)
|
|
400
439
|
miniOrbs.setOnScreen(!miniOrbs.isHidden)
|
|
401
|
-
if !isVisible { orderFrontRegardless() }
|
|
440
|
+
if !isVisible { orderFrontRegardless(); onShown?() }
|
|
402
441
|
}
|
|
403
442
|
|
|
404
443
|
@MainActor
|
|
@@ -406,7 +445,7 @@ final class Panel: NSPanel {
|
|
|
406
445
|
closeButton.isHidden = true
|
|
407
446
|
orb.setOnScreen(false)
|
|
408
447
|
miniOrbs.setOnScreen(false)
|
|
409
|
-
if isVisible { orderOut(nil) }
|
|
448
|
+
if isVisible { orderOut(nil); onShown?() }
|
|
410
449
|
}
|
|
411
450
|
|
|
412
451
|
@objc private func closeClicked() { onDismiss?() }
|
|
@@ -461,7 +500,6 @@ final class Panel: NSPanel {
|
|
|
461
500
|
default: colors = agentPalette()
|
|
462
501
|
}
|
|
463
502
|
orb.show(state.orbPhase, tint: tint, colors: colors)
|
|
464
|
-
onLook?(state, tint, colors)
|
|
465
503
|
|
|
466
504
|
if state.isMeta {
|
|
467
505
|
stopMarquee()
|
|
@@ -477,6 +515,9 @@ final class Panel: NSPanel {
|
|
|
477
515
|
// Grown into an answer, it stays until it collapses, idle or not;
|
|
478
516
|
// so does a pill with agents still busy in its mini orbs.
|
|
479
517
|
if dismissed || (state.isMeta && !alwaysVisible && !expanded && busyCount == 0) { hide() } else { show() }
|
|
518
|
+
// After showing or hiding: a still character draws its bubble's
|
|
519
|
+
// tail only while the bubble is on screen.
|
|
520
|
+
onLook?(state, tint, colors)
|
|
480
521
|
armCollapse()
|
|
481
522
|
onRender?(state)
|
|
482
523
|
}
|
|
@@ -68,6 +68,55 @@ enum PillPlacement {
|
|
|
68
68
|
width: size.width, height: size.height)
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/// How far the bubble reaches to the right of the character's middle,
|
|
72
|
+
/// and the gap above its head that the bubble's tail fills.
|
|
73
|
+
static let bubbleReach: CGFloat = 64
|
|
74
|
+
static let tail: CGFloat = 10
|
|
75
|
+
|
|
76
|
+
/// The pill as the character's speech bubble (#491): above `head`,
|
|
77
|
+
/// the top of the character, most of it to the left, kept on screen.
|
|
78
|
+
static func bubble(size: CGSize, head: CGPoint, visible: CGRect) -> CGPoint {
|
|
79
|
+
inside(CGRect(x: head.x + bubbleReach - size.width, y: head.y + tail,
|
|
80
|
+
width: size.width, height: size.height), visible).origin
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// How close the middle of the character comes to the left edge of
|
|
84
|
+
/// its screen.
|
|
85
|
+
static let characterInset: CGFloat = 44
|
|
86
|
+
|
|
87
|
+
/// Where the character rests: the point under it, the screen it is
|
|
88
|
+
/// on, and the ends it may step aside to.
|
|
89
|
+
struct Spot: Equatable {
|
|
90
|
+
let place: CGPoint
|
|
91
|
+
let visible: CGRect
|
|
92
|
+
let ends: ClosedRange<CGFloat>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// A place the character was dragged to (#502) made safe: on the
|
|
96
|
+
/// screen nearest to it, between the ends it may step aside to, with
|
|
97
|
+
/// `room` above it for its body and its bubble. Further than `room`
|
|
98
|
+
/// from every screen (its monitor was unplugged, or the saved value
|
|
99
|
+
/// is nonsense) or nil: under the right end of its bubble, the bubble
|
|
100
|
+
/// in the pill's default corner of `fallback`.
|
|
101
|
+
static func character(saved: CGPoint?, room: CGFloat, screens: [CGRect], fallback: CGRect) -> Spot {
|
|
102
|
+
var visible = fallback
|
|
103
|
+
var p = CGPoint(x: fallback.maxX, y: fallback.minY)
|
|
104
|
+
if let saved, saved.x.isFinite, saved.y.isFinite {
|
|
105
|
+
func far(_ r: CGRect) -> CGFloat {
|
|
106
|
+
hypot(max(r.minX - saved.x, 0, saved.x - r.maxX), max(r.minY - saved.y, 0, saved.y - r.maxY))
|
|
107
|
+
}
|
|
108
|
+
if let near = screens.min(by: { far($0) < far($1) }), far(near) <= room {
|
|
109
|
+
visible = near
|
|
110
|
+
p = saved
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
let left = visible.minX + characterInset
|
|
114
|
+
let right = max(visible.maxX - inset - bubbleReach, left)
|
|
115
|
+
return Spot(place: CGPoint(x: min(max(p.x, left), right),
|
|
116
|
+
y: min(max(p.y, visible.minY), max(visible.maxY - room, visible.minY))),
|
|
117
|
+
visible: visible, ends: left...right)
|
|
118
|
+
}
|
|
119
|
+
|
|
71
120
|
/// `frame` moved the least distance that puts it inside `bounds`. A
|
|
72
121
|
/// frame larger than `bounds` keeps its top-left corner in view.
|
|
73
122
|
static func inside(_ frame: CGRect, _ bounds: CGRect) -> CGRect {
|
|
@@ -49,6 +49,11 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
49
49
|
var onResetPosition: (() -> Void)?
|
|
50
50
|
/// Whether the pill is on screen, for "Hide pill" and "Show floating pill".
|
|
51
51
|
var pillVisible: () -> Bool = { false }
|
|
52
|
+
/// The character look: whether the character is on screen. Nil with
|
|
53
|
+
/// the orb look.
|
|
54
|
+
var characterVisible: () -> Bool? = { nil }
|
|
55
|
+
/// Bring a hidden character back, and let its bubble show again.
|
|
56
|
+
var onShowCharacter: (() -> Void)?
|
|
52
57
|
var onSettings: (() -> Void)?
|
|
53
58
|
|
|
54
59
|
override init() {
|
|
@@ -243,9 +248,15 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
243
248
|
let orb = action("Animated orb", #selector(toggleOrb), key: "")
|
|
244
249
|
orb.state = Config.animatedOrb ? .on : .off
|
|
245
250
|
menu.addItem(orb)
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
menu.addItem(
|
|
251
|
+
// The character hides with its bubble, and has no bubble at rest.
|
|
252
|
+
switch characterVisible() {
|
|
253
|
+
case true?: menu.addItem(action("Hide character", #selector(hidePill), key: ""))
|
|
254
|
+
case false?: menu.addItem(action("Show character", #selector(showCharacter), key: ""))
|
|
255
|
+
case nil:
|
|
256
|
+
let hide = action("Hide pill", #selector(hidePill), key: "")
|
|
257
|
+
hide.isEnabled = pillVisible()
|
|
258
|
+
menu.addItem(hide)
|
|
259
|
+
}
|
|
249
260
|
menu.addItem(action("Reset position", #selector(resetPosition), key: ""))
|
|
250
261
|
|
|
251
262
|
menu.addItem(.separator())
|
|
@@ -303,6 +314,7 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
303
314
|
}
|
|
304
315
|
|
|
305
316
|
@objc private func hidePill() { onHidePill?() }
|
|
317
|
+
@objc private func showCharacter() { onShowCharacter?() }
|
|
306
318
|
@objc private func resetPosition() { onResetPosition?() }
|
|
307
319
|
|
|
308
320
|
@objc private func openSettings() { onSettings?() }
|
|
@@ -145,6 +145,12 @@ struct VoiceSettings: Codable, Equatable {
|
|
|
145
145
|
return out
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/// Seconds to wait before asking the daemon again after a read at
|
|
149
|
+
/// start that failed: 2, 4, 8, 16, then every 30.
|
|
150
|
+
static func retryDelay(after failures: Int) -> Double {
|
|
151
|
+
min(30, pow(2, Double(max(1, failures))))
|
|
152
|
+
}
|
|
153
|
+
|
|
148
154
|
/// The fields a preview speaks with: the draft's voice for one agent.
|
|
149
155
|
func previewVoice(for id: String) -> [String: Any] {
|
|
150
156
|
guard let v = agents.first(where: { $0.id == id })?.voice else { return [:] }
|