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.
@@ -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>?
@@ -204,6 +208,7 @@ final class App: NSObject, NSApplicationDelegate {
204
208
  let asCharacter = saved.general.look == "character"
205
209
  panel.setShowsOrb(!asCharacter)
206
210
  character.setShown(asCharacter)
211
+ if !asCharacter { panel.detach() }
207
212
  if settingsWindow.model.recording == nil { registerHotkeys() }
208
213
  // Colours may have changed.
209
214
  statusMenu.refresh()
@@ -228,15 +233,16 @@ final class App: NSObject, NSApplicationDelegate {
228
233
  NSWorkspace.shared.open(url)
229
234
  }
230
235
 
231
- /// Close, Esc or "Hide pill": the pill goes and the voice stops, the
232
- /// same stop as ⌘⌥. An open microphone closes without sending. The
233
- /// next talk key brings the pill back.
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.
234
239
  private func dismissPill() {
235
240
  Log.info("pill: dismissed")
236
241
  // Closing the pill declines a ringing call and ends one in progress.
237
242
  if ringingCall != nil { endRinging { await CallClient.decline($0) } }
238
243
  if activeCall != nil { hangUp() }
239
244
  panel.dismiss()
245
+ character.setHidden(true)
240
246
  if recorder.isRecording {
241
247
  stopPolling()
242
248
  _ = recorder.stop()
@@ -246,6 +252,13 @@ final class App: NSObject, NSApplicationDelegate {
246
252
  stopSpeaking()
247
253
  }
248
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
+
249
262
  /// ⌘⌥V. Everything except the hotkey lives in `agentx paste`.
250
263
  private func smartPaste() {
251
264
  guard !busy else { return }
@@ -314,9 +327,17 @@ final class App: NSObject, NSApplicationDelegate {
314
327
  panel.orb.levelSource = { [weak self] in self?.recorder.level ?? 0 }
315
328
  character.setAnimated(Config.animatedOrb)
316
329
  character.levelSource = { [weak self] in self?.recorder.level ?? 0 }
330
+ // The pill is the character's speech bubble: it goes where the
331
+ // character goes.
332
+ character.bubble = { [weak self] head, visible in
333
+ guard let panel = self?.panel else { return nil }
334
+ panel.attach(head: head, visible: visible)
335
+ return panel.isVisible ? panel.frame : nil
336
+ }
317
337
  panel.onLook = { [weak self] state, tint, colors in
318
338
  self?.character.show(state.activity, tint: tint, colors: colors)
319
339
  }
340
+ panel.onShown = { [weak self] in self?.character.redraw() }
320
341
  panel.onDismiss = { [weak self] in self?.dismissPill() }
321
342
  panel.agentPalette = { [weak self] in
322
343
  guard let self else { return nil }
@@ -325,7 +346,13 @@ final class App: NSObject, NSApplicationDelegate {
325
346
  panel.answer.onOpenChat = { [weak self] in self?.openChat() }
326
347
  statusMenu.pillVisible = { [weak self] in self?.panel.isVisible ?? false }
327
348
  statusMenu.onHidePill = { [weak self] in self?.dismissPill() }
328
- statusMenu.onResetPosition = { [weak self] in self?.panel.resetPosition() }
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
+ }
329
356
  statusMenu.onAnimatedOrbChanged = { [weak self] on in
330
357
  self?.panel.orb.setAnimated(on)
331
358
  self?.panel.miniOrbs.setAnimated(on)
@@ -336,7 +363,7 @@ final class App: NSObject, NSApplicationDelegate {
336
363
  guard let self else { return }
337
364
  self.panel.alwaysVisible = on
338
365
  // Asking for the pill brings back one that was dismissed.
339
- if on { self.panel.summon() }
366
+ if on { self.summonPill() }
340
367
  if !self.busy && !self.recorder.isRecording { self.panel.render(.idle) }
341
368
  }
342
369
  statusMenu.onTargetChanged = { [weak self] id in
@@ -357,8 +384,17 @@ final class App: NSObject, NSApplicationDelegate {
357
384
  guard let self else { return }
358
385
  if recording { self.unregisterHotkeys() } else { self.registerHotkeys() }
359
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.
360
390
  Task { @MainActor in
361
- if let saved = await AgentClient.settings() { apply(saved) }
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
+ }
362
398
  }
363
399
 
364
400
  // Turning the hold OFF is the moment anything that piled up
@@ -436,6 +472,7 @@ final class App: NSObject, NSApplicationDelegate {
436
472
  panel.render(.error(error.localizedDescription))
437
473
  return
438
474
  }
475
+ if !followUp && lessonOn { talkCheck = Task { await AgentClient.hush() } }
439
476
  openedAt = Date()
440
477
  patience = followUp ? (activeCall == nil ? followUpPatience : callPatience) : clickPatience
441
478
  silentClose = followUp
@@ -467,6 +504,11 @@ final class App: NSObject, NSApplicationDelegate {
467
504
  if Date().timeIntervalSince(openedAt) >= patience {
468
505
  stopPolling()
469
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
+ }
470
512
  panel.render(silentClose ? rest : .error("Didn't catch that"))
471
513
  if !silentClose { resetSoon() }
472
514
  }
@@ -507,7 +549,7 @@ final class App: NSObject, NSApplicationDelegate {
507
549
  stopPolling()
508
550
  guard !recorder.isRecording else { return }
509
551
  // A dismissed pill comes back with the talk key.
510
- panel.summon()
552
+ summonPill()
511
553
  // Option-Space is the door to everything spoken, always: our own
512
554
  // answer or step line stops now, and the daemon hushes any talk,
513
555
  // lesson or narration, remembering which it was. Even mid-turn —
@@ -574,6 +616,7 @@ final class App: NSObject, NSApplicationDelegate {
574
616
  return
575
617
  }
576
618
  let hushed = await door.value
619
+ lessonOn = hushed.kind == "lesson"
577
620
  // Always through the door, even with nothing hushed: the
578
621
  // listener's turn is over, so the daemon's queue plays on.
579
622
  if await AgentClient.door(heard) {
@@ -752,7 +795,7 @@ final class App: NSObject, NSApplicationDelegate {
752
795
  busy = false
753
796
  // A live lesson now runs on screen and speaks for itself; an
754
797
  // open mic would hear the agent. Option-Space is the door.
755
- if ["teach", "watch", "act"].contains(answer.presenceMode ?? "") { return }
798
+ if ["teach", "watch", "act"].contains(answer.presenceMode ?? "") { lessonOn = true; return }
756
799
  // Another agent's answer is still to come; an open mic would
757
800
  // take it for the listener's words.
758
801
  if openAsides > 0 { return }
@@ -870,7 +913,7 @@ final class App: NSObject, NSApplicationDelegate {
870
913
  case .ring(let call):
871
914
  Log.info("call: \(call.agentId) is calling: \(call.reason)")
872
915
  ringingCall = call
873
- panel.summon()
916
+ summonPill()
874
917
  panel.showCall(.ringing)
875
918
  panel.render(.ringing(CallModel.ringingText(name: statusMenu.name(of: call.agentId), reason: call.reason)))
876
919
  ringer.start(sound: state.ringSound)