agentix-cli 0.93.0 → 0.94.1
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 +116 -30
- package/apps/mac-voice/Sources/AgentXVoice/CharacterSim.swift +7 -2
- package/apps/mac-voice/Sources/AgentXVoice/Config.swift +15 -0
- package/apps/mac-voice/Sources/AgentXVoice/Hotkey.swift +8 -5
- package/apps/mac-voice/Sources/AgentXVoice/PillPlacement.swift +37 -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 +45 -10
- package/dist/{chunk-UQMJSMET.js → chunk-IBXHC3ZV.js} +168 -168
- package/dist/{chunk-UQMJSMET.js.map → chunk-IBXHC3ZV.js.map} +1 -1
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/{program-RO5BWG6I.js → program-6G2IS3WF.js} +4 -4
- package/package.json +1 -1
- /package/dist/{program-RO5BWG6I.js.map → program-6G2IS3WF.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
|
|
@@ -29,6 +29,8 @@ final class CharacterHost {
|
|
|
29
29
|
private var motionObserver: NSObjectProtocol?
|
|
30
30
|
|
|
31
31
|
private var shown = false
|
|
32
|
+
/// Hidden with its bubble, until the talk key or the menu (#502).
|
|
33
|
+
private var hidden = false
|
|
32
34
|
private var animated = true
|
|
33
35
|
private var reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
|
|
34
36
|
private var activity = CharacterMath.Activity.idle
|
|
@@ -43,6 +45,20 @@ final class CharacterHost {
|
|
|
43
45
|
var bubble: ((NSPoint, NSRect) -> NSRect?)?
|
|
44
46
|
private var bubbleFrame: NSRect?
|
|
45
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 }
|
|
61
|
+
|
|
46
62
|
init() {
|
|
47
63
|
window = NSPanel(contentRect: NSRect(origin: .zero, size: Self.size),
|
|
48
64
|
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
|
|
@@ -55,6 +71,7 @@ final class CharacterHost {
|
|
|
55
71
|
window.hidesOnDeactivate = false
|
|
56
72
|
window.ignoresMouseEvents = true
|
|
57
73
|
window.contentView = view
|
|
74
|
+
view.onDrag = { [weak self] phase in self?.drag(phase) }
|
|
58
75
|
view.unit = Self.diameter / 100
|
|
59
76
|
view.edge = CGPoint(x: Self.size.width / 2, y: Self.size.height - Self.ground)
|
|
60
77
|
motionObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
|
@@ -76,6 +93,22 @@ final class CharacterHost {
|
|
|
76
93
|
run()
|
|
77
94
|
}
|
|
78
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
|
+
|
|
79
112
|
/// "Animated orb" in the menu.
|
|
80
113
|
func setAnimated(_ on: Bool) {
|
|
81
114
|
guard animated != on else { return }
|
|
@@ -93,36 +126,77 @@ final class CharacterHost {
|
|
|
93
126
|
/// Standing still, it is drawn again when something changes: its
|
|
94
127
|
/// state, or its bubble showing or hiding.
|
|
95
128
|
func redraw() {
|
|
96
|
-
if shown &&
|
|
129
|
+
if shown && !hidden && still { tick() }
|
|
97
130
|
}
|
|
98
131
|
|
|
99
132
|
/// The frames run only while it is on screen and may move.
|
|
100
133
|
private func run() {
|
|
101
134
|
timer?.invalidate()
|
|
102
135
|
timer = nil
|
|
103
|
-
guard shown else { window.orderOut(nil); return }
|
|
136
|
+
guard shown, !hidden else { carried = nil; window.orderOut(nil); return }
|
|
104
137
|
tick()
|
|
105
138
|
window.orderFrontRegardless()
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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() } }
|
|
109
143
|
}
|
|
110
144
|
// Keeps moving while a menu is open or the pill is dragged.
|
|
111
|
-
|
|
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
|
|
112
178
|
}
|
|
113
179
|
|
|
114
180
|
private func tick() {
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
let
|
|
121
|
-
|
|
122
|
-
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) }
|
|
123
197
|
|
|
124
198
|
let frame: CharacterSim.Frame
|
|
125
|
-
if
|
|
199
|
+
if still {
|
|
126
200
|
frame = CharacterSim.still(activity, home: home)
|
|
127
201
|
} else {
|
|
128
202
|
if activity == .listening, let read = levelSource {
|
|
@@ -130,23 +204,28 @@ final class CharacterHost {
|
|
|
130
204
|
} else {
|
|
131
205
|
level = 0
|
|
132
206
|
}
|
|
133
|
-
let
|
|
134
|
-
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
|
|
135
209
|
// The gap its tail fills counts as the bubble, so a pointer a
|
|
136
|
-
// little under a button does not send both away.
|
|
137
|
-
|
|
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))
|
|
138
215
|
frame = sim.step(to: ProcessInfo.processInfo.systemUptime,
|
|
139
216
|
CharacterSim.Input(activity: activity, level: level, pointer: pointer, held: held,
|
|
140
|
-
home: home, range:
|
|
217
|
+
home: home, range: Double(spot.ends.lowerBound)...Double(spot.ends.upperBound)))
|
|
141
218
|
}
|
|
142
|
-
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())
|
|
143
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)
|
|
144
222
|
// The bubble goes with the window, so the two move as one.
|
|
145
|
-
bubbleFrame = bubble?(NSPoint(x: origin.x + Self.size.width / 2, y:
|
|
223
|
+
bubbleFrame = bubble?(NSPoint(x: origin.x + Self.size.width / 2, y: origin.y + Self.ground + Self.head), visible)
|
|
146
224
|
view.bubble = bubbleFrame?.offsetBy(dx: -origin.x, dy: -origin.y)
|
|
147
225
|
view.origin = origin.x
|
|
148
226
|
view.shown = frame
|
|
149
227
|
view.needsDisplay = true
|
|
228
|
+
aim()
|
|
150
229
|
}
|
|
151
230
|
}
|
|
152
231
|
|
|
@@ -162,8 +241,15 @@ final class CharacterView: NSView {
|
|
|
162
241
|
/// Its speech bubble in this window, origin bottom-left; nil while hidden.
|
|
163
242
|
var bubble: NSRect?
|
|
164
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)?
|
|
247
|
+
|
|
165
248
|
override var isFlipped: Bool { true }
|
|
166
|
-
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) }
|
|
167
253
|
|
|
168
254
|
override func draw(_ dirtyRect: NSRect) {
|
|
169
255
|
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
|
|
@@ -101,7 +101,7 @@ struct CharacterSim {
|
|
|
101
101
|
|
|
102
102
|
// The pointer comes close: out of its way. Left alone: back home,
|
|
103
103
|
// unless the pointer is resting there.
|
|
104
|
-
let near = input.pointer.map { abs($0.x - x) < reach && $0.y < tall } ?? false
|
|
104
|
+
let near = input.pointer.map { abs($0.x - x) < reach && abs($0.y) < tall } ?? false
|
|
105
105
|
if input.held {
|
|
106
106
|
target = x
|
|
107
107
|
awayUntil = max(awayUntil, now + Self.awayFor)
|
|
@@ -111,7 +111,7 @@ struct CharacterSim {
|
|
|
111
111
|
restSince = now
|
|
112
112
|
steppedAside = true
|
|
113
113
|
} else if now >= awayUntil {
|
|
114
|
-
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
|
|
115
115
|
if !taken {
|
|
116
116
|
if target != input.home { steppedAside = false }
|
|
117
117
|
target = min(max(input.home, input.range.lowerBound), input.range.upperBound)
|
|
@@ -197,6 +197,11 @@ struct CharacterSim {
|
|
|
197
197
|
dots: dots.compactMap { dot(at: now, $0) }, stars: bursts.flatMap { stars(at: now, $0) })
|
|
198
198
|
}
|
|
199
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
|
+
|
|
200
205
|
/// The state alone, where it rests: for Reduce Motion, which shows a
|
|
201
206
|
/// still picture that changes between states and never a loop.
|
|
202
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 = ""
|
|
@@ -11,10 +11,10 @@ import Carbon.HIToolbox
|
|
|
11
11
|
/// ⌥Space and not ⌃Space: control-space is bound to input-source switching
|
|
12
12
|
/// on many Macs, and silently stealing it is a bad first impression.
|
|
13
13
|
///
|
|
14
|
-
/// Each instance carries its own `id` and
|
|
15
|
-
/// That is not decoration:
|
|
16
|
-
///
|
|
17
|
-
///
|
|
14
|
+
/// Each instance carries its own `id` and PASSES ON events for any other.
|
|
15
|
+
/// That is not decoration: a press of any registered hotkey goes to the
|
|
16
|
+
/// handler installed last, and reaches the one before it only when that
|
|
17
|
+
/// handler says the event was not its own.
|
|
18
18
|
final class Hotkey {
|
|
19
19
|
private var ref: EventHotKeyRef?
|
|
20
20
|
private var handler: EventHandlerRef?
|
|
@@ -62,7 +62,10 @@ final class Hotkey {
|
|
|
62
62
|
event, EventParamName(kEventParamDirectObject),
|
|
63
63
|
EventParamType(typeEventHotKeyID), nil,
|
|
64
64
|
MemoryLayout<EventHotKeyID>.size, nil, &fired)
|
|
65
|
-
|
|
65
|
+
// Not ours: say so. `noErr` means "handled" and ends the event
|
|
66
|
+
// there, so the key registered last answered and every key
|
|
67
|
+
// before it, the talk key first, was dead (#511).
|
|
68
|
+
if status == noErr, fired.id != me.id { return OSStatus(eventNotHandledErr) }
|
|
66
69
|
|
|
67
70
|
let kind = GetEventKind(event)
|
|
68
71
|
DispatchQueue.main.async {
|
|
@@ -80,6 +80,43 @@ enum PillPlacement {
|
|
|
80
80
|
width: size.width, height: size.height), visible).origin
|
|
81
81
|
}
|
|
82
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
|
+
|
|
83
120
|
/// `frame` moved the least distance that puts it inside `bounds`. A
|
|
84
121
|
/// frame larger than `bounds` keeps its top-left corner in view.
|
|
85
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 [:] }
|
|
@@ -50,6 +50,10 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
50
50
|
/// Set when the door opens: what was speaking (and is now hushed), so
|
|
51
51
|
/// the words spoken go to that activity rather than to /ask.
|
|
52
52
|
private var talkCheck: Task<AgentClient.Hushed, Never>?
|
|
53
|
+
/// A lesson our last turn started may still be speaking: a click then
|
|
54
|
+
/// opens the door at once, like the key, so the microphone does not
|
|
55
|
+
/// take the lesson's voice for the listener's.
|
|
56
|
+
private var lessonOn = false
|
|
53
57
|
/// Waiting for our answer to be spoken by the daemon's queue. The door
|
|
54
58
|
/// lets go of it: the answer stays queued and plays after the turn.
|
|
55
59
|
private var speaking: Task<Void, Never>?
|
|
@@ -150,7 +154,8 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
150
154
|
text.flatMap(HotkeySpec.init) ?? HotkeySpec(fallback)!
|
|
151
155
|
}
|
|
152
156
|
let register = { (key: Hotkey, spec: HotkeySpec, what: String) in
|
|
153
|
-
if
|
|
157
|
+
if key.register(spec) { Log.info("shortcut \(spec.text ?? "?") for \(what): registered") }
|
|
158
|
+
else { Log.warn("shortcut \(spec.text ?? "?") for \(what) is taken by another app") }
|
|
154
159
|
}
|
|
155
160
|
|
|
156
161
|
// Hold to talk; release sends.
|
|
@@ -229,15 +234,16 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
229
234
|
NSWorkspace.shared.open(url)
|
|
230
235
|
}
|
|
231
236
|
|
|
232
|
-
/// Close, Esc or "Hide pill": the pill goes
|
|
233
|
-
/// same stop as ⌘⌥. An open microphone closes
|
|
234
|
-
/// next talk key brings
|
|
237
|
+
/// Close, Esc or "Hide pill": the pill goes, the character with it,
|
|
238
|
+
/// and the voice stops, the same stop as ⌘⌥. An open microphone closes
|
|
239
|
+
/// without sending. The next talk key brings them back.
|
|
235
240
|
private func dismissPill() {
|
|
236
241
|
Log.info("pill: dismissed")
|
|
237
242
|
// Closing the pill declines a ringing call and ends one in progress.
|
|
238
243
|
if ringingCall != nil { endRinging { await CallClient.decline($0) } }
|
|
239
244
|
if activeCall != nil { hangUp() }
|
|
240
245
|
panel.dismiss()
|
|
246
|
+
character.setHidden(true)
|
|
241
247
|
if recorder.isRecording {
|
|
242
248
|
stopPolling()
|
|
243
249
|
_ = recorder.stop()
|
|
@@ -247,6 +253,13 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
247
253
|
stopSpeaking()
|
|
248
254
|
}
|
|
249
255
|
|
|
256
|
+
/// The talk key, a call or the menu: a hidden pill may show again,
|
|
257
|
+
/// and a hidden character is back.
|
|
258
|
+
private func summonPill() {
|
|
259
|
+
character.setHidden(false)
|
|
260
|
+
panel.summon()
|
|
261
|
+
}
|
|
262
|
+
|
|
250
263
|
/// ⌘⌥V. Everything except the hotkey lives in `agentx paste`.
|
|
251
264
|
private func smartPaste() {
|
|
252
265
|
guard !busy else { return }
|
|
@@ -334,7 +347,13 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
334
347
|
panel.answer.onOpenChat = { [weak self] in self?.openChat() }
|
|
335
348
|
statusMenu.pillVisible = { [weak self] in self?.panel.isVisible ?? false }
|
|
336
349
|
statusMenu.onHidePill = { [weak self] in self?.dismissPill() }
|
|
337
|
-
statusMenu.
|
|
350
|
+
statusMenu.characterVisible = { [weak self] in self?.character.onScreen }
|
|
351
|
+
statusMenu.onShowCharacter = { [weak self] in self?.summonPill() }
|
|
352
|
+
// Each does nothing with the other's look.
|
|
353
|
+
statusMenu.onResetPosition = { [weak self] in
|
|
354
|
+
self?.panel.resetPosition()
|
|
355
|
+
self?.character.resetPosition()
|
|
356
|
+
}
|
|
338
357
|
statusMenu.onAnimatedOrbChanged = { [weak self] on in
|
|
339
358
|
self?.panel.orb.setAnimated(on)
|
|
340
359
|
self?.panel.miniOrbs.setAnimated(on)
|
|
@@ -345,7 +364,7 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
345
364
|
guard let self else { return }
|
|
346
365
|
self.panel.alwaysVisible = on
|
|
347
366
|
// Asking for the pill brings back one that was dismissed.
|
|
348
|
-
if on { self.
|
|
367
|
+
if on { self.summonPill() }
|
|
349
368
|
if !self.busy && !self.recorder.isRecording { self.panel.render(.idle) }
|
|
350
369
|
}
|
|
351
370
|
statusMenu.onTargetChanged = { [weak self] id in
|
|
@@ -366,8 +385,17 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
366
385
|
guard let self else { return }
|
|
367
386
|
if recording { self.unregisterHotkeys() } else { self.registerHotkeys() }
|
|
368
387
|
}
|
|
388
|
+
// At login the app can start before the daemon answers. Keep
|
|
389
|
+
// asking: one missed read left the orb and the built-in
|
|
390
|
+
// shortcuts until the app was restarted.
|
|
369
391
|
Task { @MainActor in
|
|
370
|
-
|
|
392
|
+
var failures = 0
|
|
393
|
+
while true {
|
|
394
|
+
if let saved = await AgentClient.settings() { apply(saved); break }
|
|
395
|
+
failures += 1
|
|
396
|
+
if failures == 1 { Log.warn("settings: the daemon did not answer, asking again") }
|
|
397
|
+
try? await Task.sleep(for: .seconds(VoiceSettings.retryDelay(after: failures)))
|
|
398
|
+
}
|
|
371
399
|
}
|
|
372
400
|
|
|
373
401
|
// Turning the hold OFF is the moment anything that piled up
|
|
@@ -445,6 +473,7 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
445
473
|
panel.render(.error(error.localizedDescription))
|
|
446
474
|
return
|
|
447
475
|
}
|
|
476
|
+
if !followUp && lessonOn { talkCheck = Task { await AgentClient.hush() } }
|
|
448
477
|
openedAt = Date()
|
|
449
478
|
patience = followUp ? (activeCall == nil ? followUpPatience : callPatience) : clickPatience
|
|
450
479
|
silentClose = followUp
|
|
@@ -476,6 +505,11 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
476
505
|
if Date().timeIntervalSince(openedAt) >= patience {
|
|
477
506
|
stopPolling()
|
|
478
507
|
_ = recorder.stop()
|
|
508
|
+
// Nothing said after an early hush: let the queue play on.
|
|
509
|
+
if let door = talkCheck {
|
|
510
|
+
talkCheck = nil
|
|
511
|
+
Task { _ = await door.value; await AgentClient.resume() }
|
|
512
|
+
}
|
|
479
513
|
panel.render(silentClose ? rest : .error("Didn't catch that"))
|
|
480
514
|
if !silentClose { resetSoon() }
|
|
481
515
|
}
|
|
@@ -516,7 +550,7 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
516
550
|
stopPolling()
|
|
517
551
|
guard !recorder.isRecording else { return }
|
|
518
552
|
// A dismissed pill comes back with the talk key.
|
|
519
|
-
|
|
553
|
+
summonPill()
|
|
520
554
|
// Option-Space is the door to everything spoken, always: our own
|
|
521
555
|
// answer or step line stops now, and the daemon hushes any talk,
|
|
522
556
|
// lesson or narration, remembering which it was. Even mid-turn —
|
|
@@ -583,6 +617,7 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
583
617
|
return
|
|
584
618
|
}
|
|
585
619
|
let hushed = await door.value
|
|
620
|
+
lessonOn = hushed.kind == "lesson"
|
|
586
621
|
// Always through the door, even with nothing hushed: the
|
|
587
622
|
// listener's turn is over, so the daemon's queue plays on.
|
|
588
623
|
if await AgentClient.door(heard) {
|
|
@@ -761,7 +796,7 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
761
796
|
busy = false
|
|
762
797
|
// A live lesson now runs on screen and speaks for itself; an
|
|
763
798
|
// open mic would hear the agent. Option-Space is the door.
|
|
764
|
-
if ["teach", "watch", "act"].contains(answer.presenceMode ?? "") { return }
|
|
799
|
+
if ["teach", "watch", "act"].contains(answer.presenceMode ?? "") { lessonOn = true; return }
|
|
765
800
|
// Another agent's answer is still to come; an open mic would
|
|
766
801
|
// take it for the listener's words.
|
|
767
802
|
if openAsides > 0 { return }
|
|
@@ -879,7 +914,7 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
879
914
|
case .ring(let call):
|
|
880
915
|
Log.info("call: \(call.agentId) is calling: \(call.reason)")
|
|
881
916
|
ringingCall = call
|
|
882
|
-
|
|
917
|
+
summonPill()
|
|
883
918
|
panel.showCall(.ringing)
|
|
884
919
|
panel.render(.ringing(CallModel.ringingText(name: statusMenu.name(of: call.agentId), reason: call.reason)))
|
|
885
920
|
ringer.start(sound: state.ringSound)
|