agentix-cli 0.101.2 → 0.102.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.
@@ -69,6 +69,11 @@ final class CharacterHost {
69
69
  private var carried: (from: NSPoint, rest: CGPoint)?
70
70
  /// What the answering agent sent it to show (#482), and its mark.
71
71
  private let guide = CharacterGuide()
72
+ /// Sent somewhere, and what its bubble says there (#562).
73
+ var sent: Bool { guide.showing != nil }
74
+ var caption: String? { guide.caption }
75
+ /// Told when it is sent somewhere, to its next stop or back.
76
+ var onGuide: (() -> Void)?
72
77
  /// A still picture: Reduce Motion, or "Animated orb" off.
73
78
  private var still: Bool { !animated || reduceMotion }
74
79
  /// The look is the character: whether it is on screen. Nil with the orb.
@@ -147,12 +152,13 @@ final class CharacterHost {
147
152
  }
148
153
 
149
154
  /// Go beside `rect` and mark it, or with nil go back to where it rests.
150
- func guide(to rect: NSRect?, mark kind: GuideMath.Mark = .none) {
155
+ func guide(to rect: NSRect?, mark kind: GuideMath.Mark = .none, caption: String? = nil) {
151
156
  if shown, !hidden, carried == nil, let rect {
152
- guide.show(rect, kind, color: view.stops[2], animated: !still)
157
+ guide.show(rect, kind, caption: caption, color: view.stops[2], animated: !still)
153
158
  } else {
154
159
  guide.end()
155
160
  }
161
+ onGuide?()
156
162
  redraw()
157
163
  }
158
164
 
@@ -192,7 +198,7 @@ final class CharacterHost {
192
198
  switch phase {
193
199
  case .began:
194
200
  let mouse = pointerSource()
195
- if body.contains(mouse) { guide.end(glide: false); carried = (mouse, CGPoint(x: body.midX, y: rest.y)) }
201
+ if body.contains(mouse) { guide.end(glide: false); onGuide?(); carried = (mouse, CGPoint(x: body.midX, y: rest.y)) }
196
202
  case .moved:
197
203
  if carried != nil { tick() }
198
204
  case .ended:
@@ -271,7 +277,8 @@ final class CharacterHost {
271
277
  let head = min(max(top + 2 - PillPlacement.tail, Self.head), room)
272
278
  let moving = carried != nil || abs(sim.speed) > 8 || rest.y != spot.place.y || head > Self.head
273
279
  if still { motion = BubbleMotion() }
274
- let small = still ? 0 : motion.step(now: now, moving: moving, holds: bubbleShows?() ?? false)
280
+ // A caption is to be read from the moment it is sent (#566).
281
+ let small = still ? 0 : motion.step(now: now, moving: moving, holds: guide.caption != nil || bubbleShows?() ?? false)
275
282
  let at = NSPoint(x: origin.x + Self.size.width / 2, y: origin.y + Self.ground + head)
276
283
  bubbleFrame = bubble?(at, visible, CGFloat(small))
277
284
  // Its tail, unless the bubble had to go beside it.
@@ -10,6 +10,9 @@ enum GuideClient {
10
10
  /// Nil: back to where it rests.
11
11
  let rect: Rect?
12
12
  let mark: String
13
+ /// What its bubble says at the stop (#562). Nil: no bubble there,
14
+ /// and a daemon too old to send one.
15
+ let text: String?
13
16
  }
14
17
 
15
18
  /// GET /voice/guide: the command after `seq`, as soon as there is
@@ -34,8 +37,8 @@ final class GuideWatcher {
34
37
  private var task: Task<Void, Never>?
35
38
  /// The character is on screen.
36
39
  var wanted: (() -> Bool)?
37
- /// Show this, in AppKit coordinates, or go home.
38
- var onCommand: ((NSRect?, GuideMath.Mark) -> Void)?
40
+ /// Show this, in AppKit coordinates, with this caption, or go home.
41
+ var onCommand: ((NSRect?, GuideMath.Mark, String?) -> Void)?
39
42
 
40
43
  /// (Re)start the wait. Called again when the character comes on screen
41
44
  /// or leaves it, so a wait already open is ended: cancelling it closes
@@ -53,7 +56,7 @@ final class GuideWatcher {
53
56
  private func turn() async {
54
57
  guard wanted?() == true, let command = await GuideClient.next(after: seq) else {
55
58
  // Hidden, or the daemon is gone: nothing is left on screen.
56
- if away { away = false; onCommand?(nil, .none) }
59
+ if away { away = false; onCommand?(nil, .none, nil) }
57
60
  try? await Task.sleep(nanoseconds: 2_000_000_000)
58
61
  return
59
62
  }
@@ -65,7 +68,7 @@ final class GuideWatcher {
65
68
  }
66
69
  away = rect != nil
67
70
  Log.info("guide: \(rect.map { "to \(Int($0.minX)),\(Int($0.minY)) \(Int($0.width))x\(Int($0.height)) \(command.mark)" } ?? "home")")
68
- onCommand?(rect, GuideMath.Mark(rawValue: command.mark) ?? .box)
71
+ onCommand?(rect, GuideMath.Mark(rawValue: command.mark) ?? .box, rect == nil ? nil : command.text)
69
72
  }
70
73
  }
71
74
 
@@ -74,13 +77,16 @@ final class GuideWatcher {
74
77
  @MainActor
75
78
  final class CharacterGuide {
76
79
  private(set) var showing: NSRect?
80
+ /// What its bubble says there (#562).
81
+ private(set) var caption: String?
77
82
  private let mark = GuideMark()
78
83
  /// On its way up or down, to what it shows or back; and the last frame.
79
84
  private var gliding = false
80
85
  private var ticked = 0.0
81
86
 
82
- func show(_ rect: NSRect, _ kind: GuideMath.Mark, color: NSColor, animated: Bool) {
87
+ func show(_ rect: NSRect, _ kind: GuideMath.Mark, caption text: String?, color: NSColor, animated: Bool) {
83
88
  showing = rect
89
+ caption = text
84
90
  gliding = true
85
91
  mark.show(kind, around: rect, color: color, animated: animated)
86
92
  }
@@ -89,6 +95,7 @@ final class CharacterGuide {
89
95
  func end(glide: Bool = true) {
90
96
  gliding = glide && showing != nil
91
97
  showing = nil
98
+ caption = nil
92
99
  mark.hide()
93
100
  }
94
101
 
@@ -40,6 +40,24 @@ enum GuideMath {
40
40
  }
41
41
  }
42
42
 
43
+ /// What the bubble says while the assistant has nothing to say (#562).
44
+ enum Idle: Equatable { case hint, caption(String), nothing }
45
+
46
+ /// At a stop the bubble says the command's caption, and with none
47
+ /// there is no bubble; the idle hint is for where it rests, not for
48
+ /// a stop, the way there or a play. `away`: sent somewhere, or playing.
49
+ static func idle(caption: String?, away: Bool) -> Idle {
50
+ if let caption, !caption.isEmpty { return .caption(caption) }
51
+ return away ? .nothing : .hint
52
+ }
53
+
54
+ /// How wide the bubble is around a caption `words` wide (#566): its
55
+ /// words and the room at both ends of the row, no wider; never less
56
+ /// than holds its tail, nor more than the pill, where a long one scrolls.
57
+ static func bubbleWidth(words: CGFloat, full: CGFloat) -> CGFloat {
58
+ min(max(words.rounded(.up) + 52, 96), full)
59
+ }
60
+
43
61
  /// One frame of its way up or down to where it is sent: most of the
44
62
  /// way in a third of a second, and there once it is within a point.
45
63
  static func glide(_ y: CGFloat, toward goal: CGFloat, dt: Double) -> CGFloat {
@@ -130,6 +130,10 @@ final class Panel: NSPanel {
130
130
  var small: CGFloat = 0
131
131
  let dots = BubbleDots()
132
132
 
133
+ /// What the bubble says while the assistant is idle (#562): the hint,
134
+ /// the caption of what the character shows, or nothing while it is away.
135
+ private(set) var idle = GuideMath.Idle.hint
136
+
133
137
  /// Told of every state rendered, so the menu-bar icon can follow.
134
138
  var onRender: ((State) -> Void)?
135
139
 
@@ -358,7 +362,7 @@ final class Panel: NSPanel {
358
362
  clip.frame.size.width = clipWidth
359
363
  // Text that scrolled may fit now, and the other way round.
360
364
  marqueeText = ""
361
- if !current.isMeta { setText(label.stringValue) }
365
+ if !current.isMeta || caption != nil { setText(label.stringValue) }
362
366
  }
363
367
  answer.frame = NSRect(x: 0, y: above ? h : 0, width: Self.expandedWidth, height: answerHeight)
364
368
  separator.frame = NSRect(x: 0, y: above ? h - 1 : answerHeight, width: bounds.width, height: 1)
@@ -424,7 +428,7 @@ final class Panel: NSPanel {
424
428
  // Growing or collapsing: it catches up on the next frame.
425
429
  guard placing == 0 else { return }
426
430
  shrink(expanded ? 0 : amount)
427
- let shape = PillPlacement.shrunk(Self.size, small: small)
431
+ let shape = PillPlacement.shrunk(bubbleSize, small: small)
428
432
  let size = expanded ? frame.size : shape.size
429
433
  let to = NSRect(origin: PillPlacement.bubble(size: size, head: head, visible: visible, reach: shape.reach), size: size)
430
434
  guard to != frame else { return }
@@ -569,7 +573,13 @@ final class Panel: NSPanel {
569
573
  }
570
574
  orb.show(state.orbPhase, tint: tint, colors: colors)
571
575
 
572
- if state.isMeta {
576
+ label.font = Brand.body(size: 12)
577
+ if let words = caption {
578
+ // In the answer's text size, to be read in a small video.
579
+ label.font = Brand.body()
580
+ label.textColor = .labelColor
581
+ setText(words)
582
+ } else if state.isMeta {
573
583
  stopMarquee()
574
584
  label.attributedStringValue = Brand.metaString(state.text, color: state.color)
575
585
  label.frame.origin.x = 0
@@ -583,7 +593,9 @@ final class Panel: NSPanel {
583
593
  // Grown into an answer, it stays until it collapses, idle or not;
584
594
  // so does a pill with agents still busy in its mini orbs.
585
595
  // Reduced, the orb is the assistant's place on screen: it stays, idle or not.
586
- if dismissed || (state.isMeta && !alwaysVisible && !expanded && busyCount == 0 && !reduced) { hide() } else { show() }
596
+ // A caption shows like any words; away with none, the hint does not.
597
+ let quiet = state.isMeta && !expanded && busyCount == 0
598
+ if dismissed || (quiet && idle == .nothing) || (quiet && idle == .hint && !alwaysVisible && !reduced) { hide() } else { show() }
587
599
  // After showing or hiding: a still character draws its bubble's
588
600
  // tail only while the bubble is on screen.
589
601
  onLook?(state, tint, colors)
@@ -591,6 +603,14 @@ final class Panel: NSPanel {
591
603
  onRender?(state)
592
604
  }
593
605
 
606
+ /// The character was sent somewhere or back, or a play began or ended.
607
+ @MainActor
608
+ func setIdle(_ words: GuideMath.Idle) {
609
+ guard words != idle else { return }
610
+ idle = words
611
+ render(current)
612
+ }
613
+
594
614
  /// On screen with something to use: an answer, an error or the call
595
615
  /// buttons. The character does not play with the pointer then (#537).
596
616
  @MainActor
@@ -15,12 +15,31 @@ extension Panel {
15
15
  small = amount
16
16
  row.alphaValue = max(1 - 2 * amount, 0)
17
17
  dots.alphaValue = max(2 * amount - 1, 0)
18
- (contentView as? Surface)?.shape(size: amount > 0 ? PillPlacement.shrunk(Self.size, small: amount).size : nil)
18
+ (contentView as? Surface)?.shape(size: amount > 0 ? PillPlacement.shrunk(bubbleSize, small: amount).size : nil)
19
19
  invalidateShadow()
20
20
  // The row was not laid out while reduced, and the frame may already
21
21
  // be the full pill's: nothing else would lay it out again.
22
22
  if amount == 0, let content = contentView { layoutContent(content.bounds) }
23
23
  }
24
+
25
+ /// The caption the bubble says now (#566): at a stop it shows whatever
26
+ /// the assistant is doing; only an error or a call takes its place.
27
+ @MainActor
28
+ var caption: String? {
29
+ guard case .caption(let words) = idle else { return nil }
30
+ switch current {
31
+ case .error, .ringing, .onCall: return nil
32
+ default: return words
33
+ }
34
+ }
35
+
36
+ /// The bubble's size: with a caption, as wide as its words, no wider.
37
+ @MainActor
38
+ var bubbleSize: NSSize {
39
+ guard let caption else { return Self.size }
40
+ let words = (caption as NSString).size(withAttributes: [.font: Brand.body()]).width
41
+ return NSSize(width: GuideMath.bubbleWidth(words: words, full: Self.size.width), height: Self.size.height)
42
+ }
24
43
  }
25
44
 
26
45
  /// Three dots, in the middle of the bubble.
@@ -86,6 +86,8 @@ final class App: NSObject, NSApplicationDelegate {
86
86
  // An agent ringing the owner (#321).
87
87
  private let callWatcher = CallWatcher()
88
88
  private let guideWatcher = GuideWatcher()
89
+ /// A play runs, or its picture is being taken (#562).
90
+ private var playing = false
89
91
  private let ringer = Ringer()
90
92
  /// The call ringing on the pill now.
91
93
  private var ringingCall: IncomingCall?
@@ -236,6 +238,12 @@ final class App: NSObject, NSApplicationDelegate {
236
238
  && !busy && !recorder.isRecording && !play.running
237
239
  }
238
240
 
241
+ /// What the idle bubble says (#562): no hint at a guide stop, on the
242
+ /// way there or during a play; the stop's caption if it has one.
243
+ private func idleWords() {
244
+ panel.setIdle(GuideMath.idle(caption: character.caption, away: character.sent || playing))
245
+ }
246
+
239
247
  /// "Play on this page" (#505): one picture of the character's screen, the
240
248
  /// lines of text read on it, and the character plays on the picture,
241
249
  /// differently each time. The page itself is never touched.
@@ -252,12 +260,17 @@ final class App: NSObject, NSApplicationDelegate {
252
260
  }
253
261
  let stops = character.stops, size = screen.frame.size
254
262
  let start = PlayMath.Point(x: foot.x - screen.frame.minX, y: screen.frame.maxY - foot.y)
255
- // Off its edge, so it is not in the picture twice.
263
+ // Off its edge, so it is not in the picture twice; its idle
264
+ // bubble too, which would stay in the picture all the play long.
256
265
  character.setShown(false)
266
+ playing = true
267
+ idleWords()
257
268
  Task { @MainActor in
258
269
  try? await Task.sleep(nanoseconds: 150_000_000)
259
270
  guard let picture = PlayRead.picture(of: screen), !busy, !recorder.isRecording else {
271
+ playing = false
260
272
  character.setShown(settings?.general.look == "character")
273
+ idleWords()
261
274
  return
262
275
  }
263
276
  play.start(picture: picture, on: screen, stops: stops, at: start)
@@ -403,7 +416,9 @@ final class App: NSObject, NSApplicationDelegate {
403
416
  statusMenu.onPlay = { [weak self] in self?.startPlay() }
404
417
  play.onEnd = { [weak self] in
405
418
  guard let self else { return }
419
+ self.playing = false
406
420
  self.character.setShown(self.settings?.general.look == "character")
421
+ self.idleWords()
407
422
  }
408
423
  panel.onShown = { [weak self] in self?.character.redraw() }
409
424
  panel.onDismiss = { [weak self] in self?.dismissPill() }
@@ -511,7 +526,8 @@ final class App: NSObject, NSApplicationDelegate {
511
526
  callWatcher.start()
512
527
  // The answering agent sends the character to something (#482).
513
528
  guideWatcher.wanted = { [weak self] in self?.character.onScreen == true }
514
- guideWatcher.onCommand = { [weak self] rect, mark in self?.character.guide(to: rect, mark: mark) }
529
+ guideWatcher.onCommand = { [weak self] rect, mark, caption in self?.character.guide(to: rect, mark: mark, caption: caption) }
530
+ character.onGuide = { [weak self] in self?.idleWords() }
515
531
  guideWatcher.start()
516
532
  character.onScreenChanged = { [weak self] in self?.guideWatcher.start() }
517
533