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.
@@ -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, shows what the assistant is doing, and
6
- /// gets out of the pointer's way. Chosen with `voice.look`; the orb stays
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 clicks and no
10
- /// keys, and never comes forward by itself. With Reduce Motion on, or
11
- /// "Animated orb" off in the menu, it is a still picture that changes
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 && timer == nil { tick() }
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
- guard animated, !reduceMotion else { return }
107
- timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30, repeats: true) { [weak self] _ in
108
- MainActor.assumeIsolated { self?.tick() }
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
- if let timer { RunLoop.main.add(timer, forMode: .common) }
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
- // The screen with the menu bar, less the menu bar and the Dock.
116
- guard let screen = NSScreen.screens.first else { return }
117
- let visible = screen.visibleFrame
118
- // It rests under the right end of its bubble, the bubble in the
119
- // pill's own corner, and goes no further right.
120
- let left = Double(visible.minX + 44)
121
- let home = max(Double(visible.maxX - PillPlacement.inset - PillPlacement.bubbleReach), left)
122
- let range = left...home
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 timer == nil {
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 mouse = pointerSource()
134
- let pointer = screen.frame.contains(mouse) ? (x: Double(mouse.x), y: Double(mouse.y - visible.minY)) : nil
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
- let held = bubbleFrame?.insetBy(dx: 0, dy: -PillPlacement.tail).contains(mouse) ?? false
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: 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: visible.minY - Self.ground)
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: visible.minY + Self.head), visible)
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 hitTest(_ point: NSPoint) -> NSView? { nil }
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 IGNORES events for any other.
15
- /// That is not decoration: every installed handler is called for EVERY
16
- /// registered hotkey, so a second binding without this filter makes both
17
- /// actions fire on either key.
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
- if status == noErr, fired.id != me.id { return noErr }
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
- let hide = action("Hide pill", #selector(hidePill), key: "")
247
- hide.isEnabled = pillVisible()
248
- menu.addItem(hide)
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 !key.register(spec) { Log.warn("shortcut \(spec.text ?? "?") for \(what) is taken by another app") }
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 and the voice stops, the
233
- /// same stop as ⌘⌥. An open microphone closes without sending. The
234
- /// next talk key brings the pill back.
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.onResetPosition = { [weak self] in self?.panel.resetPosition() }
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.panel.summon() }
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
- if let saved = await AgentClient.settings() { apply(saved) }
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
- panel.summon()
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
- panel.summon()
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)