agentix-cli 0.93.0 → 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 +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/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 +43 -9
- package/dist/{chunk-UQMJSMET.js → chunk-VPCW4477.js} +168 -168
- package/dist/{chunk-UQMJSMET.js.map → chunk-VPCW4477.js.map} +1 -1
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/{program-RO5BWG6I.js → program-BRC2SSDI.js} +4 -4
- package/package.json +1 -1
- /package/dist/{program-RO5BWG6I.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
|
|
@@ -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 = ""
|
|
@@ -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>?
|
|
@@ -229,15 +233,16 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
229
233
|
NSWorkspace.shared.open(url)
|
|
230
234
|
}
|
|
231
235
|
|
|
232
|
-
/// Close, Esc or "Hide pill": the pill goes
|
|
233
|
-
/// same stop as ⌘⌥. An open microphone closes
|
|
234
|
-
/// next talk key brings
|
|
236
|
+
/// Close, Esc or "Hide pill": the pill goes, the character with it,
|
|
237
|
+
/// and the voice stops, the same stop as ⌘⌥. An open microphone closes
|
|
238
|
+
/// without sending. The next talk key brings them back.
|
|
235
239
|
private func dismissPill() {
|
|
236
240
|
Log.info("pill: dismissed")
|
|
237
241
|
// Closing the pill declines a ringing call and ends one in progress.
|
|
238
242
|
if ringingCall != nil { endRinging { await CallClient.decline($0) } }
|
|
239
243
|
if activeCall != nil { hangUp() }
|
|
240
244
|
panel.dismiss()
|
|
245
|
+
character.setHidden(true)
|
|
241
246
|
if recorder.isRecording {
|
|
242
247
|
stopPolling()
|
|
243
248
|
_ = recorder.stop()
|
|
@@ -247,6 +252,13 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
247
252
|
stopSpeaking()
|
|
248
253
|
}
|
|
249
254
|
|
|
255
|
+
/// The talk key, a call or the menu: a hidden pill may show again,
|
|
256
|
+
/// and a hidden character is back.
|
|
257
|
+
private func summonPill() {
|
|
258
|
+
character.setHidden(false)
|
|
259
|
+
panel.summon()
|
|
260
|
+
}
|
|
261
|
+
|
|
250
262
|
/// ⌘⌥V. Everything except the hotkey lives in `agentx paste`.
|
|
251
263
|
private func smartPaste() {
|
|
252
264
|
guard !busy else { return }
|
|
@@ -334,7 +346,13 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
334
346
|
panel.answer.onOpenChat = { [weak self] in self?.openChat() }
|
|
335
347
|
statusMenu.pillVisible = { [weak self] in self?.panel.isVisible ?? false }
|
|
336
348
|
statusMenu.onHidePill = { [weak self] in self?.dismissPill() }
|
|
337
|
-
statusMenu.
|
|
349
|
+
statusMenu.characterVisible = { [weak self] in self?.character.onScreen }
|
|
350
|
+
statusMenu.onShowCharacter = { [weak self] in self?.summonPill() }
|
|
351
|
+
// Each does nothing with the other's look.
|
|
352
|
+
statusMenu.onResetPosition = { [weak self] in
|
|
353
|
+
self?.panel.resetPosition()
|
|
354
|
+
self?.character.resetPosition()
|
|
355
|
+
}
|
|
338
356
|
statusMenu.onAnimatedOrbChanged = { [weak self] on in
|
|
339
357
|
self?.panel.orb.setAnimated(on)
|
|
340
358
|
self?.panel.miniOrbs.setAnimated(on)
|
|
@@ -345,7 +363,7 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
345
363
|
guard let self else { return }
|
|
346
364
|
self.panel.alwaysVisible = on
|
|
347
365
|
// Asking for the pill brings back one that was dismissed.
|
|
348
|
-
if on { self.
|
|
366
|
+
if on { self.summonPill() }
|
|
349
367
|
if !self.busy && !self.recorder.isRecording { self.panel.render(.idle) }
|
|
350
368
|
}
|
|
351
369
|
statusMenu.onTargetChanged = { [weak self] id in
|
|
@@ -366,8 +384,17 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
366
384
|
guard let self else { return }
|
|
367
385
|
if recording { self.unregisterHotkeys() } else { self.registerHotkeys() }
|
|
368
386
|
}
|
|
387
|
+
// At login the app can start before the daemon answers. Keep
|
|
388
|
+
// asking: one missed read left the orb and the built-in
|
|
389
|
+
// shortcuts until the app was restarted.
|
|
369
390
|
Task { @MainActor in
|
|
370
|
-
|
|
391
|
+
var failures = 0
|
|
392
|
+
while true {
|
|
393
|
+
if let saved = await AgentClient.settings() { apply(saved); break }
|
|
394
|
+
failures += 1
|
|
395
|
+
if failures == 1 { Log.warn("settings: the daemon did not answer, asking again") }
|
|
396
|
+
try? await Task.sleep(for: .seconds(VoiceSettings.retryDelay(after: failures)))
|
|
397
|
+
}
|
|
371
398
|
}
|
|
372
399
|
|
|
373
400
|
// Turning the hold OFF is the moment anything that piled up
|
|
@@ -445,6 +472,7 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
445
472
|
panel.render(.error(error.localizedDescription))
|
|
446
473
|
return
|
|
447
474
|
}
|
|
475
|
+
if !followUp && lessonOn { talkCheck = Task { await AgentClient.hush() } }
|
|
448
476
|
openedAt = Date()
|
|
449
477
|
patience = followUp ? (activeCall == nil ? followUpPatience : callPatience) : clickPatience
|
|
450
478
|
silentClose = followUp
|
|
@@ -476,6 +504,11 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
476
504
|
if Date().timeIntervalSince(openedAt) >= patience {
|
|
477
505
|
stopPolling()
|
|
478
506
|
_ = recorder.stop()
|
|
507
|
+
// Nothing said after an early hush: let the queue play on.
|
|
508
|
+
if let door = talkCheck {
|
|
509
|
+
talkCheck = nil
|
|
510
|
+
Task { _ = await door.value; await AgentClient.resume() }
|
|
511
|
+
}
|
|
479
512
|
panel.render(silentClose ? rest : .error("Didn't catch that"))
|
|
480
513
|
if !silentClose { resetSoon() }
|
|
481
514
|
}
|
|
@@ -516,7 +549,7 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
516
549
|
stopPolling()
|
|
517
550
|
guard !recorder.isRecording else { return }
|
|
518
551
|
// A dismissed pill comes back with the talk key.
|
|
519
|
-
|
|
552
|
+
summonPill()
|
|
520
553
|
// Option-Space is the door to everything spoken, always: our own
|
|
521
554
|
// answer or step line stops now, and the daemon hushes any talk,
|
|
522
555
|
// lesson or narration, remembering which it was. Even mid-turn —
|
|
@@ -583,6 +616,7 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
583
616
|
return
|
|
584
617
|
}
|
|
585
618
|
let hushed = await door.value
|
|
619
|
+
lessonOn = hushed.kind == "lesson"
|
|
586
620
|
// Always through the door, even with nothing hushed: the
|
|
587
621
|
// listener's turn is over, so the daemon's queue plays on.
|
|
588
622
|
if await AgentClient.door(heard) {
|
|
@@ -761,7 +795,7 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
761
795
|
busy = false
|
|
762
796
|
// A live lesson now runs on screen and speaks for itself; an
|
|
763
797
|
// open mic would hear the agent. Option-Space is the door.
|
|
764
|
-
if ["teach", "watch", "act"].contains(answer.presenceMode ?? "") { return }
|
|
798
|
+
if ["teach", "watch", "act"].contains(answer.presenceMode ?? "") { lessonOn = true; return }
|
|
765
799
|
// Another agent's answer is still to come; an open mic would
|
|
766
800
|
// take it for the listener's words.
|
|
767
801
|
if openAsides > 0 { return }
|
|
@@ -879,7 +913,7 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
879
913
|
case .ring(let call):
|
|
880
914
|
Log.info("call: \(call.agentId) is calling: \(call.reason)")
|
|
881
915
|
ringingCall = call
|
|
882
|
-
|
|
916
|
+
summonPill()
|
|
883
917
|
panel.showCall(.ringing)
|
|
884
918
|
panel.render(.ringing(CallModel.ringingText(name: statusMenu.name(of: call.agentId), reason: call.reason)))
|
|
885
919
|
ringer.start(sound: state.ringSound)
|