agentix-cli 0.59.0 → 0.60.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.
Files changed (116) hide show
  1. package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +4 -0
  2. package/apps/mac-voice/Sources/AgentXVoice/AnswerView.swift +228 -0
  3. package/apps/mac-voice/Sources/AgentXVoice/Orb.swift +36 -9
  4. package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +126 -55
  5. package/apps/mac-voice/Sources/AgentXVoice/PanelAnswer.swift +113 -0
  6. package/apps/mac-voice/Sources/AgentXVoice/PillPlacement.swift +28 -17
  7. package/apps/mac-voice/Sources/AgentXVoice/SettingsLook.swift +90 -0
  8. package/apps/mac-voice/Sources/AgentXVoice/SettingsWindow.swift +10 -3
  9. package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +9 -0
  10. package/apps/mac-voice/Sources/AgentXVoice/Surface.swift +105 -0
  11. package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +32 -1
  12. package/apps/mac-voice/Sources/AgentXVoice/main.swift +23 -10
  13. package/dist/{App-QEJGXZTY.js → App-LQXWKBVY.js} +2 -2
  14. package/dist/{ChatApp-VKWQN5GM.js → ChatApp-75TL4LKW.js} +2 -2
  15. package/dist/{agent-MJKLI2L5.js → agent-DH4FMMMY.js} +2 -2
  16. package/dist/{board-dashboard-2KVT72J7.js → board-dashboard-BVRRDYAG.js} +6 -4
  17. package/dist/{board-dashboard-2KVT72J7.js.map → board-dashboard-BVRRDYAG.js.map} +1 -1
  18. package/dist/{chunk-WFPRVZBA.js → chunk-27P5NLTC.js} +2 -2
  19. package/dist/{chunk-3EVCHRJF.js → chunk-2VQIPJH5.js} +2 -2
  20. package/dist/{chunk-YRB75GMN.js → chunk-3XVTFNYM.js} +2 -2
  21. package/dist/{chunk-K62U5EJJ.js → chunk-B3DCYBP4.js} +2 -2
  22. package/dist/{chunk-EJQOKSKP.js → chunk-CRSXKQLZ.js} +2 -2
  23. package/dist/{chunk-YCIZ2WHX.js → chunk-DTEQNXK3.js} +2 -2
  24. package/dist/{chunk-N5GOBLUI.js → chunk-E2BJVHBR.js} +2 -2
  25. package/dist/{chunk-6DUTIPTY.js → chunk-E2PWCU2N.js} +2 -2
  26. package/dist/{chunk-2XZGHIVP.js → chunk-JO3PSLWL.js} +5 -5
  27. package/dist/{chunk-YQU74AOL.js → chunk-JORXRF4E.js} +2 -2
  28. package/dist/{chunk-DAAWUFO4.js → chunk-LXUG2X26.js} +2 -2
  29. package/dist/{chunk-JUY77L7C.js → chunk-N5KD3TVN.js} +2 -2
  30. package/dist/{chunk-5OF3EGCZ.js → chunk-NBGNPKAF.js} +2 -2
  31. package/dist/{chunk-4ZTV6JV7.js → chunk-PAOV37KQ.js} +2 -2
  32. package/dist/{chunk-SDBU6747.js → chunk-RYFUFX6F.js} +160 -160
  33. package/dist/chunk-RYFUFX6F.js.map +1 -0
  34. package/dist/chunk-S7TCVIET.js +15 -0
  35. package/dist/chunk-S7TCVIET.js.map +1 -0
  36. package/dist/{chunk-ILXW3ERG.js → chunk-TM762AXU.js} +159 -153
  37. package/dist/chunk-TM762AXU.js.map +1 -0
  38. package/dist/{chunk-WI5R64WO.js → chunk-WBGC7MWX.js} +2 -2
  39. package/dist/{chunk-6ZV2QFXE.js → chunk-WIBMNQKJ.js} +2 -2
  40. package/dist/{chunk-L5OCF33B.js → chunk-WOPFHKBF.js} +2 -2
  41. package/dist/{chunk-OIDMPPV5.js → chunk-YQCOX4R3.js} +2 -2
  42. package/dist/cli.js +1 -1
  43. package/dist/client-QVZIZFV4.js +2 -0
  44. package/dist/config-3XC7EYB7.js +2 -0
  45. package/dist/config-mutator-HKU63FJM.js +2 -0
  46. package/dist/doctor-YFA4ZSRH.js +2 -0
  47. package/dist/index.d.ts +49 -7
  48. package/dist/index.js +1 -1
  49. package/dist/program-BOCLU6ES.js +2 -0
  50. package/dist/{promote-GBR6YSQW.js → promote-UR2GSHKC.js} +2 -2
  51. package/dist/query-XUP3BMVS.js +2 -0
  52. package/dist/{registry-P535FNLB.js → registry-PGALUW5L.js} +2 -2
  53. package/dist/{request-planner-KHTCHF7N.js → request-planner-NHE3IRJN.js} +2 -2
  54. package/dist/{review-candidates-Y5KCRUNU.js → review-candidates-6GKRSRBQ.js} +2 -2
  55. package/dist/{routing-RWCCDJ4D.js → routing-AQKHAU3Z.js} +2 -2
  56. package/dist/schedule-tool-6NMCLTIH.js +2 -0
  57. package/dist/scheduler-RAYGNDWT.js +2 -0
  58. package/dist/{seat-PJPKLH6L.js → seat-6FMW2QI4.js} +2 -2
  59. package/dist/{select-VMTJAX5X.js → select-5VONJMLH.js} +2 -2
  60. package/dist/{set-enabled-LPXH3ZQP.js → set-enabled-YHD3IJJZ.js} +2 -2
  61. package/dist/{tool-MWXG44OT.js → tool-MXJGLJZ5.js} +2 -2
  62. package/dist/wiki-ZQLEYY24.js +2 -0
  63. package/package.json +1 -1
  64. package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +0 -198
  65. package/dist/chunk-ILXW3ERG.js.map +0 -1
  66. package/dist/chunk-IW4XIZAZ.js +0 -15
  67. package/dist/chunk-IW4XIZAZ.js.map +0 -1
  68. package/dist/chunk-SDBU6747.js.map +0 -1
  69. package/dist/client-X2PTYKVX.js +0 -2
  70. package/dist/config-XB2ORKWS.js +0 -2
  71. package/dist/config-mutator-KMXR3XDG.js +0 -2
  72. package/dist/doctor-6BIMNEGU.js +0 -2
  73. package/dist/program-DHDC6XYB.js +0 -2
  74. package/dist/query-672RGMXO.js +0 -2
  75. package/dist/schedule-tool-H46AK53H.js +0 -2
  76. package/dist/scheduler-3XLWBCX7.js +0 -2
  77. package/dist/wiki-QHMHVFWU.js +0 -2
  78. /package/dist/{App-QEJGXZTY.js.map → App-LQXWKBVY.js.map} +0 -0
  79. /package/dist/{ChatApp-VKWQN5GM.js.map → ChatApp-75TL4LKW.js.map} +0 -0
  80. /package/dist/{agent-MJKLI2L5.js.map → agent-DH4FMMMY.js.map} +0 -0
  81. /package/dist/{chunk-WFPRVZBA.js.map → chunk-27P5NLTC.js.map} +0 -0
  82. /package/dist/{chunk-3EVCHRJF.js.map → chunk-2VQIPJH5.js.map} +0 -0
  83. /package/dist/{chunk-YRB75GMN.js.map → chunk-3XVTFNYM.js.map} +0 -0
  84. /package/dist/{chunk-K62U5EJJ.js.map → chunk-B3DCYBP4.js.map} +0 -0
  85. /package/dist/{chunk-EJQOKSKP.js.map → chunk-CRSXKQLZ.js.map} +0 -0
  86. /package/dist/{chunk-YCIZ2WHX.js.map → chunk-DTEQNXK3.js.map} +0 -0
  87. /package/dist/{chunk-N5GOBLUI.js.map → chunk-E2BJVHBR.js.map} +0 -0
  88. /package/dist/{chunk-6DUTIPTY.js.map → chunk-E2PWCU2N.js.map} +0 -0
  89. /package/dist/{chunk-2XZGHIVP.js.map → chunk-JO3PSLWL.js.map} +0 -0
  90. /package/dist/{chunk-YQU74AOL.js.map → chunk-JORXRF4E.js.map} +0 -0
  91. /package/dist/{chunk-DAAWUFO4.js.map → chunk-LXUG2X26.js.map} +0 -0
  92. /package/dist/{chunk-JUY77L7C.js.map → chunk-N5KD3TVN.js.map} +0 -0
  93. /package/dist/{chunk-5OF3EGCZ.js.map → chunk-NBGNPKAF.js.map} +0 -0
  94. /package/dist/{chunk-4ZTV6JV7.js.map → chunk-PAOV37KQ.js.map} +0 -0
  95. /package/dist/{chunk-WI5R64WO.js.map → chunk-WBGC7MWX.js.map} +0 -0
  96. /package/dist/{chunk-6ZV2QFXE.js.map → chunk-WIBMNQKJ.js.map} +0 -0
  97. /package/dist/{chunk-L5OCF33B.js.map → chunk-WOPFHKBF.js.map} +0 -0
  98. /package/dist/{chunk-OIDMPPV5.js.map → chunk-YQCOX4R3.js.map} +0 -0
  99. /package/dist/{client-X2PTYKVX.js.map → client-QVZIZFV4.js.map} +0 -0
  100. /package/dist/{config-XB2ORKWS.js.map → config-3XC7EYB7.js.map} +0 -0
  101. /package/dist/{config-mutator-KMXR3XDG.js.map → config-mutator-HKU63FJM.js.map} +0 -0
  102. /package/dist/{doctor-6BIMNEGU.js.map → doctor-YFA4ZSRH.js.map} +0 -0
  103. /package/dist/{program-DHDC6XYB.js.map → program-BOCLU6ES.js.map} +0 -0
  104. /package/dist/{promote-GBR6YSQW.js.map → promote-UR2GSHKC.js.map} +0 -0
  105. /package/dist/{query-672RGMXO.js.map → query-XUP3BMVS.js.map} +0 -0
  106. /package/dist/{registry-P535FNLB.js.map → registry-PGALUW5L.js.map} +0 -0
  107. /package/dist/{request-planner-KHTCHF7N.js.map → request-planner-NHE3IRJN.js.map} +0 -0
  108. /package/dist/{review-candidates-Y5KCRUNU.js.map → review-candidates-6GKRSRBQ.js.map} +0 -0
  109. /package/dist/{routing-RWCCDJ4D.js.map → routing-AQKHAU3Z.js.map} +0 -0
  110. /package/dist/{schedule-tool-H46AK53H.js.map → schedule-tool-6NMCLTIH.js.map} +0 -0
  111. /package/dist/{scheduler-3XLWBCX7.js.map → scheduler-RAYGNDWT.js.map} +0 -0
  112. /package/dist/{seat-PJPKLH6L.js.map → seat-6FMW2QI4.js.map} +0 -0
  113. /package/dist/{select-VMTJAX5X.js.map → select-5VONJMLH.js.map} +0 -0
  114. /package/dist/{set-enabled-LPXH3ZQP.js.map → set-enabled-YHD3IJJZ.js.map} +0 -0
  115. /package/dist/{tool-MWXG44OT.js.map → tool-MXJGLJZ5.js.map} +0 -0
  116. /package/dist/{wiki-QHMHVFWU.js.map → wiki-ZQLEYY24.js.map} +0 -0
@@ -213,7 +213,11 @@ enum AgentClient {
213
213
  /// "#RRGGBB": presence.color, else derived from the id. Older
214
214
  /// daemons send none, and the app derives it the same way.
215
215
  let color: String?
216
+ /// The orb's nature palette. Older daemons send none, and the orb
217
+ /// uses shades of `color`.
218
+ let palette: Palette?
216
219
  var label: String { name ?? id }
220
+ struct Palette: Decodable { let id: String; let colors: [String] }
217
221
  }
218
222
 
219
223
  /// The daemon's agents, or nil when the daemon cannot be reached.
@@ -0,0 +1,228 @@
1
+ import AppKit
2
+ import WebKit
3
+
4
+ /// What the agent said, in a form you can read, select and copy, inside
5
+ /// the pill.
6
+ ///
7
+ /// Speech is lossy in one specific way that matters: a URL cannot be
8
+ /// spoken usefully, an image cannot be spoken at all, and a name you have
9
+ /// not seen written is a name you cannot act on. The spoken answer is
10
+ /// deliberately short and stripped of exactly those things, so without a
11
+ /// visual surface everything worth keeping from a turn is the part that
12
+ /// gets thrown away.
13
+ ///
14
+ /// Rich content arrives through the same `agentx:ui` directive Telegram
15
+ /// and WhatsApp already use, parsed server-side, so an agent has one way to
16
+ /// attach a link or a picture regardless of where it is speaking.
17
+ final class AnswerView: NSView {
18
+ // A web view, because agents write markdown: headings, bullets, fenced
19
+ // code, tables. An NSTextView shows that as a mess of asterisks and
20
+ // hashes, which defeats the answer's only purpose: being MORE readable
21
+ // than the spoken answer, not less. Text in it is selectable, and it
22
+ // scrolls once the answer is taller than the pill lets it grow.
23
+ private let web = WKWebView()
24
+ private let links = NSStackView()
25
+ private let thumb = NSImageView()
26
+ private let actions = NSStackView()
27
+ private var source = ""
28
+
29
+ /// The answer's natural height, measured after it renders. The pill
30
+ /// grows to it, up to its maximum.
31
+ var onHeight: ((CGFloat) -> Void)?
32
+ /// "Open in chat". Set by the app.
33
+ var onOpenChat: (() -> Void)?
34
+
35
+ private static let linkRow: CGFloat = 34
36
+ private static let imageHeight: CGFloat = 120
37
+
38
+ override init(frame: NSRect) {
39
+ super.init(frame: frame)
40
+ web.setValue(false, forKey: "drawsBackground")
41
+ web.navigationDelegate = self
42
+ addSubview(web)
43
+
44
+ thumb.imageScaling = .scaleProportionallyUpOrDown
45
+ thumb.isHidden = true
46
+ addSubview(thumb)
47
+
48
+ links.orientation = .horizontal
49
+ links.spacing = 8
50
+ links.edgeInsets = NSEdgeInsets(top: 4, left: 12, bottom: 8, right: 12)
51
+ addSubview(links)
52
+
53
+ // Copy and Open in chat, shown while the pointer is over the answer.
54
+ actions.orientation = .horizontal
55
+ actions.spacing = 2
56
+ actions.addArrangedSubview(actionButton("doc.on.doc", "Copy the answer", #selector(copyAnswer)))
57
+ actions.addArrangedSubview(actionButton("bubble.left.and.bubble.right", "Open in chat", #selector(openChat)))
58
+ actions.isHidden = true
59
+ actions.edgeInsets = NSEdgeInsets(top: 1, left: 3, bottom: 1, right: 3)
60
+ // A backing of its own, so the buttons read over the text below.
61
+ actions.wantsLayer = true
62
+ actions.layer?.cornerRadius = 7
63
+ actions.layer?.cornerCurve = .continuous
64
+ addSubview(actions)
65
+
66
+ addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
67
+ owner: self, userInfo: nil))
68
+ }
69
+
70
+ @available(*, unavailable)
71
+ required init?(coder: NSCoder) { fatalError("not from a nib") }
72
+
73
+ private func actionButton(_ symbol: String, _ label: String, _ action: Selector) -> NSButton {
74
+ let b = NSButton()
75
+ b.isBordered = false
76
+ b.bezelStyle = .regularSquare
77
+ b.imagePosition = .imageOnly
78
+ b.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label)
79
+ b.contentTintColor = .secondaryLabelColor
80
+ b.toolTip = label
81
+ b.setAccessibilityLabel(label)
82
+ b.target = self
83
+ b.action = action
84
+ b.widthAnchor.constraint(equalToConstant: 24).isActive = true
85
+ b.heightAnchor.constraint(equalToConstant: 22).isActive = true
86
+ return b
87
+ }
88
+
89
+ override func resizeSubviews(withOldSize oldSize: NSSize) {
90
+ let w = bounds.width, h = bounds.height
91
+ let linkH: CGFloat = links.arrangedSubviews.isEmpty ? 0 : Self.linkRow
92
+ let imgH: CGFloat = thumb.isHidden ? 0 : Self.imageHeight
93
+ thumb.frame = NSRect(x: 12, y: h - imgH - 10, width: w - 24, height: imgH)
94
+ web.frame = NSRect(x: 0, y: linkH, width: w, height: max(0, h - linkH - imgH - (imgH > 0 ? 16 : 0)))
95
+ links.frame = NSRect(x: 0, y: 0, width: w, height: linkH)
96
+ actions.frame = NSRect(x: w - 64, y: h - 32, width: 56, height: 26)
97
+ }
98
+
99
+ /// Does this turn have anything the spoken answer did not already
100
+ /// deliver?
101
+ ///
102
+ /// Opening on every turn, including "Ok.", shows one word you have
103
+ /// already heard. A surface that adds nothing trains you to ignore it,
104
+ /// so the turn where it DOES carry a link gets ignored too.
105
+ ///
106
+ /// Worth showing when there is rich content, a URL you could click, or
107
+ /// materially more written than was spoken. Otherwise the speech was
108
+ /// the whole answer.
109
+ static func isWorthShowing(spoken: String, written: String?, buttons: [(String, String)], imageURL: String?) -> Bool {
110
+ if !buttons.isEmpty || imageURL != nil { return true }
111
+ let text = (written?.isEmpty == false ? written! : spoken)
112
+ if text.range(of: #"https?://"#, options: .regularExpression) != nil { return true }
113
+ // Speech drops formatting, so written is often a little longer for
114
+ // the same content. Only a real difference counts.
115
+ let spokenLen = spoken.trimmingCharacters(in: .whitespacesAndNewlines).count
116
+ let writtenLen = text.trimmingCharacters(in: .whitespacesAndNewlines).count
117
+ if writtenLen > spokenLen + 80 { return true }
118
+ // Long enough that re-reading it beats re-hearing it.
119
+ return writtenLen > 280
120
+ }
121
+
122
+ /// `spoken` is what was said; `written` is the fuller answer with the
123
+ /// URLs and formatting the spoken form had to drop. `onHeight` follows
124
+ /// once it has rendered.
125
+ @MainActor
126
+ func show(spoken: String, written: String?, buttons: [(String, String)], imageURL: String?) {
127
+ source = (written?.isEmpty == false ? written! : spoken)
128
+ web.loadHTMLString(Markdown.page(Markdown.toHTML(source), css: Brand.cardCSS), baseURL: nil)
129
+
130
+ links.arrangedSubviews.forEach { links.removeArrangedSubview($0); $0.removeFromSuperview() }
131
+ for (label, url) in buttons.prefix(4) {
132
+ let b = NSButton(title: label, target: self, action: #selector(openLink(_:)))
133
+ b.bezelStyle = .push
134
+ b.controlSize = .small
135
+ b.toolTip = url
136
+ b.identifier = NSUserInterfaceItemIdentifier(url)
137
+ links.addArrangedSubview(b)
138
+ }
139
+
140
+ thumb.isHidden = true
141
+ thumb.image = nil
142
+ if let s = imageURL, let u = URL(string: s) {
143
+ // Off the main thread: a slow image must never stall the UI,
144
+ // and the answer is useful without it.
145
+ URLSession.shared.dataTask(with: u) { [weak self] data, _, _ in
146
+ guard let data, let img = NSImage(data: data) else { return }
147
+ Task { @MainActor in
148
+ guard let self, self.source.isEmpty == false else { return }
149
+ self.thumb.image = img
150
+ self.thumb.isHidden = false
151
+ self.measure()
152
+ }
153
+ }.resume()
154
+ }
155
+ resizeSubviews(withOldSize: bounds.size)
156
+ }
157
+
158
+ /// Empty again, once the pill has collapsed.
159
+ @MainActor
160
+ func clear() {
161
+ source = ""
162
+ web.loadHTMLString("", baseURL: nil)
163
+ thumb.isHidden = true
164
+ links.arrangedSubviews.forEach { links.removeArrangedSubview($0); $0.removeFromSuperview() }
165
+ }
166
+
167
+ /// The rendered text's height plus the image and links around it.
168
+ @MainActor
169
+ private func measure() {
170
+ let js = "(() => { const b = document.body, s = getComputedStyle(b);"
171
+ + " return b.scrollHeight + parseFloat(s.marginTop) + parseFloat(s.marginBottom) })()"
172
+ web.evaluateJavaScript(js) { [weak self] value, _ in
173
+ MainActor.assumeIsolated {
174
+ guard let self, !self.source.isEmpty else { return }
175
+ let text = CGFloat((value as? NSNumber)?.doubleValue ?? 120)
176
+ let extra = (self.links.arrangedSubviews.isEmpty ? 0 : Self.linkRow)
177
+ + (self.thumb.isHidden ? 0 : Self.imageHeight + 16)
178
+ self.resizeSubviews(withOldSize: self.bounds.size)
179
+ self.onHeight?(ceil(text + extra))
180
+ }
181
+ }
182
+ }
183
+
184
+ override func mouseEntered(with event: NSEvent) {
185
+ effectiveAppearance.performAsCurrentDrawingAppearance {
186
+ actions.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
187
+ actions.layer?.borderColor = NSColor.separatorColor.cgColor
188
+ actions.layer?.borderWidth = 1 / (window?.backingScaleFactor ?? 2)
189
+ }
190
+ actions.isHidden = source.isEmpty
191
+ }
192
+ override func mouseExited(with event: NSEvent) { actions.isHidden = true }
193
+
194
+ @objc private func copyAnswer() {
195
+ NSPasteboard.general.clearContents()
196
+ NSPasteboard.general.setString(source, forType: .string)
197
+ }
198
+
199
+ @objc private func openChat() { onOpenChat?() }
200
+
201
+ /// Select-and-⌘C: the pill has no Edit menu to route the shortcut.
202
+ func copySelection() { NSApp.sendAction(#selector(NSText.copy(_:)), to: web, from: nil) }
203
+
204
+ @objc private func openLink(_ sender: NSButton) {
205
+ guard let s = sender.identifier?.rawValue, let u = URL(string: s) else { return }
206
+ NSWorkspace.shared.open(u)
207
+ }
208
+ }
209
+
210
+ extension AnswerView: WKNavigationDelegate {
211
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
212
+ MainActor.assumeIsolated { if !source.isEmpty { measure() } }
213
+ }
214
+
215
+ /// Links open in the real browser. A pill is not a place to read a web
216
+ /// page, and navigating away would replace the answer it exists to hold.
217
+ func webView(_ webView: WKWebView,
218
+ decidePolicyFor navigationAction: WKNavigationAction,
219
+ decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
220
+ if navigationAction.navigationType == .linkActivated,
221
+ let url = navigationAction.request.url {
222
+ NSWorkspace.shared.open(url)
223
+ decisionHandler(.cancel)
224
+ return
225
+ }
226
+ decisionHandler(.allow)
227
+ }
228
+ }
@@ -1,8 +1,8 @@
1
1
  import AppKit
2
2
  import SwiftUI
3
3
 
4
- /// The Siri-style orb at the head of the pill: the agent's colour, and it
5
- /// listens, thinks and speaks.
4
+ /// The Siri-style orb at the head of the pill: the agent's nature palette
5
+ /// (lagoon, forest, dusk…), and it listens, thinks and speaks.
6
6
  ///
7
7
  /// Built from SwiftUI's own gradients, no animation library. It only moves
8
8
  /// while it has something to show and is on screen: idle or hidden, its
@@ -18,6 +18,9 @@ final class OrbModel: ObservableObject {
18
18
  /// 0…1: the microphone while listening.
19
19
  @Published var level: Double = 0
20
20
  @Published var tint = NSColor(srgbRed: 0.078, green: 0.722, blue: 0.651, alpha: 1)
21
+ /// The agent's palette, five stops deep to light, from the daemon.
22
+ /// Nil: shades of `tint` (an error, held notifications, an older daemon).
23
+ @Published var colors: [NSColor]?
21
24
  /// The pill is on screen. Off, nothing moves.
22
25
  @Published var onScreen = false
23
26
  @Published var reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
@@ -43,8 +46,8 @@ struct PillOrbView: View {
43
46
  let paused = model.paused
44
47
  TimelineView(.animation(minimumInterval: 1.0 / 30, paused: paused)) { context in
45
48
  OrbBody(t: paused ? 0 : context.date.timeIntervalSinceReferenceDate,
46
- phase: model.phase, level: model.level, tint: model.tint, still: model.still,
47
- ringWidth: 5)
49
+ phase: model.phase, level: model.level, tint: model.tint, colors: model.colors,
50
+ still: model.still, ringWidth: 5)
48
51
  .frame(width: Self.designSize, height: Self.designSize)
49
52
  .scaleEffect(diameter / Self.designSize)
50
53
  }
@@ -89,11 +92,12 @@ final class PillOrb: NSHostingView<PillOrbView> {
89
92
 
90
93
  override func hitTest(_ point: NSPoint) -> NSView? { nil }
91
94
 
92
- /// Show `phase` in `tint`. Cheap to call on every render: SwiftUI only
93
- /// redraws what changed.
94
- func show(_ phase: OrbModel.Phase, tint: NSColor) {
95
+ /// Show `phase` in `colors`, or in shades of `tint` without them.
96
+ /// Cheap to call on every render: SwiftUI only redraws what changed.
97
+ func show(_ phase: OrbModel.Phase, tint: NSColor, colors: [NSColor]?) {
95
98
  let model = driver.model
96
99
  if model.tint != tint { model.tint = tint }
100
+ if model.colors != colors { model.colors = colors }
97
101
  if model.phase != phase {
98
102
  model.phase = phase
99
103
  if phase != .listening { model.level = 0 }
@@ -160,6 +164,8 @@ struct OrbBody: View {
160
164
  let phase: OrbModel.Phase
161
165
  let level: Double
162
166
  let tint: NSColor
167
+ /// The agent's palette, deep to light; nil for shades of `tint`.
168
+ var colors: [NSColor]? = nil
163
169
  /// Reduce Motion: a still orb, sized and shaded by state alone.
164
170
  let still: Bool
165
171
  /// The thinking ring's stroke at the 96-point design size. The pill
@@ -190,12 +196,17 @@ struct OrbBody: View {
190
196
  let scale = 0.78 + 0.22 * energy
191
197
  ZStack {
192
198
  Circle()
193
- .fill(Color(nsColor: tint).opacity(0.28 + 0.32 * energy))
199
+ .fill(glow.opacity(0.28 + 0.32 * energy))
194
200
  .blur(radius: 10 + 12 * energy)
195
201
  .scaleEffect(scale * 1.08)
196
202
  fill
197
203
  .clipShape(Circle())
198
204
  .scaleEffect(scale)
205
+ // Glass: a soft light from the top left, as on a marble.
206
+ Circle()
207
+ .fill(RadialGradient(colors: [.white.opacity(0.42), .white.opacity(0)],
208
+ center: UnitPoint(x: 0.34, y: 0.26), startRadius: 0, endRadius: 34))
209
+ .scaleEffect(scale)
199
210
  // Thinking reads as a slow ring going round; the other states
200
211
  // as a soft rim.
201
212
  if phase == .thinking {
@@ -247,9 +258,25 @@ struct OrbBody: View {
247
258
  ]
248
259
  }
249
260
 
261
+ /// The halo's colour: the palette's middle stop, or the tint.
262
+ private var glow: Color {
263
+ if let colors, colors.count == 5 { return Color(nsColor: colors[2]) }
264
+ return Color(nsColor: tint)
265
+ }
266
+
267
+ /// Five colours for the mesh: the agent's palette when it has one,
268
+ /// with its light stop brightening into a core as the voice grows.
269
+ private var palette: [Color] {
270
+ guard let colors, colors.count == 5 else { return shades }
271
+ let core = CGFloat(0.1 + 0.35 * energy)
272
+ let light = colors[4].blended(withFraction: core, of: .white) ?? colors[4]
273
+ // Spread so neighbouring mesh points differ: deep beside light.
274
+ return [colors[1], colors[3], colors[0], colors[2], light].map { Color(nsColor: $0) }
275
+ }
276
+
250
277
  /// Five shades around the agent's colour: lighter, deeper and two
251
278
  /// neighbouring hues, and a bright core that grows with the voice.
252
- private var palette: [Color] {
279
+ private var shades: [Color] {
253
280
  let base = tint.usingColorSpace(.sRGB) ?? tint
254
281
  var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
255
282
  base.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
@@ -8,8 +8,10 @@ import AppKit
8
8
  /// normal windows, joins every Space, and is deliberately small.
9
9
  ///
10
10
  /// It is the one floating widget: a small live orb at its head (Orb.swift)
11
- /// in the answering agent's colour, the state or the words beside it, and
12
- /// a close button that shows on hover. Drag it anywhere; the place is
11
+ /// in the answering agent's palette, the state or the words beside it, and
12
+ /// a close button that shows on hover. When an answer has more to read
13
+ /// than was spoken, the pill grows into it (PanelAnswer.swift) and
14
+ /// collapses again after a while. Drag it anywhere; the place is
13
15
  /// remembered across launches.
14
16
  final class Panel: NSPanel {
15
17
  static let size = NSSize(width: 284, height: 54)
@@ -20,10 +22,35 @@ final class Panel: NSPanel {
20
22
  private let label = NSTextField(labelWithString: "")
21
23
  let orb = PillOrb(diameter: Panel.orbDiameter, frameSize: Panel.orbFrame)
22
24
  private let closeButton = NSButton()
25
+ /// The orb, the words and the close button: the pill itself, which
26
+ /// stays where it is while the widget grows into an answer.
27
+ let row = RowView(frame: NSRect(origin: .zero, size: Panel.size))
28
+ /// The answer, below or above the row once the pill has grown.
29
+ let answer = AnswerView(frame: .zero)
30
+ let separator = NSBox()
23
31
  /// Hidden by close, Esc or the menu until the next talk key.
24
- private(set) var dismissed = false
25
- /// Set while the app moves the pill, so only a drag is remembered.
26
- private var placing = false
32
+ var dismissed = false
33
+ /// Above zero while the app moves or resizes the pill, so only a drag
34
+ /// is remembered.
35
+ var placing = 0
36
+
37
+ // MARK: The answer (PanelAnswer.swift)
38
+
39
+ /// Width of the pill grown into an answer.
40
+ static let expandedWidth: CGFloat = 360
41
+ /// Tallest the answer grows, in points, before it scrolls. From
42
+ /// voice.card.maxHeight.
43
+ var cardMaxHeight: CGFloat = 320
44
+ /// Seconds the answer stays open once spoken; 0 until closed. From
45
+ /// voice.card.timeout.
46
+ var cardTimeout: TimeInterval = 30
47
+ /// Grown into an answer.
48
+ var expanded = false
49
+ /// Which way it grew, so it collapses back to the same place.
50
+ var growth = (above: true, alignRight: true)
51
+ var collapseTimer: Timer?
52
+ /// The pointer is over the widget: the answer stays open.
53
+ var hovering = false
27
54
 
28
55
  /// Scrolls text too long for the pill instead of truncating it.
29
56
  ///
@@ -66,6 +93,9 @@ final class Panel: NSPanel {
66
93
  /// The colour of the agent shown, for the orb.
67
94
  var agentTint: () -> NSColor = { Brand.accent }
68
95
 
96
+ /// The palette of the agent shown, for the orb; nil for shades of its tint.
97
+ var agentPalette: () -> [NSColor]? = { nil }
98
+
69
99
  /// Stay on screen when idle. Off: the pill shows only while active.
70
100
  var alwaysVisible = false
71
101
 
@@ -75,18 +105,18 @@ final class Panel: NSPanel {
75
105
  /// Close button or Esc. The app stops speech and calls `dismiss()`.
76
106
  var onDismiss: (() -> Void)?
77
107
 
78
- /// The pill moved, by a drag or a reset. The answer card follows.
79
- var onMove: (() -> Void)?
80
-
81
108
  /// The last state rendered, to draw again after a dismiss or a move.
82
- private var current = State.idle
109
+ var current = State.idle
83
110
 
111
+ /// A click on the pill's row talks. A click in the answer does not:
112
+ /// that is for reading and selecting.
84
113
  override func mouseUp(with event: NSEvent) {
85
114
  defer { pressedAt = nil }
86
115
  if let start = pressedAt {
87
116
  let now = NSEvent.mouseLocation
88
117
  let moved = hypot(now.x - start.x, now.y - start.y)
89
- if moved < 4 { onClick?() }
118
+ let onRow = convertToScreen(row.convert(row.bounds, to: nil)).contains(start)
119
+ if moved < 4 && onRow { onClick?() }
90
120
  }
91
121
  super.mouseUp(with: event)
92
122
  }
@@ -168,40 +198,21 @@ final class Panel: NSPanel {
168
198
  isMovableByWindowBackground = true
169
199
  hidesOnDeactivate = false
170
200
 
171
- let blur = NSVisualEffectView(frame: NSRect(origin: .zero, size: Self.size))
172
- blur.material = .hudWindow
173
- blur.blendingMode = .behindWindow
174
- blur.state = .active
175
- blur.wantsLayer = true
176
- blur.layer?.cornerRadius = Brand.Radius.lg
177
- blur.layer?.masksToBounds = true
178
- blur.autoresizingMask = [.width, .height]
179
- contentView = blur
180
-
181
- // An opaque tint over the blur, and it is not a style preference.
182
- //
183
- // `blendingMode = .behindWindow` composites whatever is behind the
184
- // pill, so the editor's own panel dividers were coming through it
185
- // as a faint rectangle around the widget — it read as a border the
186
- // widget was drawing, when it was the window behind. Translucency
187
- // is pleasant over wallpaper and actively confusing over ruled UI.
188
- //
189
- // This keeps enough blur to feel native while making the pill read
190
- // as one solid object wherever it is parked.
191
- let tint = NSView(frame: blur.bounds)
192
- tint.wantsLayer = true
193
- tint.layer?.backgroundColor = NSColor.windowBackgroundColor
194
- .withAlphaComponent(0.72).cgColor
195
- tint.layer?.cornerRadius = Brand.Radius.lg
196
- tint.autoresizingMask = [.width, .height]
197
- blur.addSubview(tint)
201
+ let surface = Surface(frame: NSRect(origin: .zero, size: Self.size))
202
+ contentView = surface
203
+ surface.addSubview(row)
204
+ separator.boxType = .separator
205
+ separator.isHidden = true
206
+ surface.addSubview(separator)
207
+ surface.addSubview(answer)
208
+ answer.onHeight = { [weak self] height in self?.grow(answerHeight: height) }
198
209
 
199
210
  // The orb, centred 28 points from the left edge; its square is
200
211
  // larger than the orb so the glow is not cut off.
201
212
  let h = Self.size.height
202
213
  orb.frame = NSRect(x: 28 - Self.orbFrame / 2, y: (h - Self.orbFrame) / 2,
203
214
  width: Self.orbFrame, height: Self.orbFrame)
204
- blur.addSubview(orb)
215
+ row.addSubview(orb)
205
216
 
206
217
  // A clipping window the label slides behind. Clicks pass through
207
218
  // it, so pressing on the words drags the pill too.
@@ -213,7 +224,7 @@ final class Panel: NSPanel {
213
224
  label.lineBreakMode = .byClipping
214
225
  label.wantsLayer = true
215
226
  clipView.addSubview(label)
216
- blur.addSubview(clipView)
227
+ row.addSubview(clipView)
217
228
  clip = clipView
218
229
 
219
230
  // Close, shown while the pointer is over the pill. Its space is
@@ -229,8 +240,10 @@ final class Panel: NSPanel {
229
240
  closeButton.target = self
230
241
  closeButton.action = #selector(closeClicked)
231
242
  closeButton.isHidden = true
232
- blur.addSubview(closeButton)
233
- blur.addTrackingArea(NSTrackingArea(rect: .zero,
243
+ row.addSubview(closeButton)
244
+ surface.layoutContent = { [weak self] bounds in self?.layoutContent(bounds) }
245
+ MainActor.assumeIsolated { layoutContent(surface.bounds) }
246
+ surface.addTrackingArea(NSTrackingArea(rect: .zero,
234
247
  options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
235
248
  owner: self, userInfo: nil))
236
249
 
@@ -246,10 +259,32 @@ final class Panel: NSPanel {
246
259
 
247
260
  // MARK: Place
248
261
 
262
+ /// The row on the edge it grew from, full width; the answer in the
263
+ /// rest. The answer is always laid out at its full width, so its text
264
+ /// does not reflow while the pill grows around it.
265
+ @MainActor
266
+ func layoutContent(_ bounds: NSRect) {
267
+ let h = Self.size.height
268
+ let answerHeight = max(0, bounds.height - h)
269
+ let above = growth.above
270
+ row.frame = NSRect(x: 0, y: above ? 0 : answerHeight, width: bounds.width, height: h)
271
+ closeButton.frame.origin.x = bounds.width - 28
272
+ let clipWidth = bounds.width - 88
273
+ if let clip, clip.frame.width != clipWidth {
274
+ clip.frame.size.width = clipWidth
275
+ // Text that scrolled may fit now, and the other way round.
276
+ marqueeText = ""
277
+ if !current.isMeta { setText(label.stringValue) }
278
+ }
279
+ answer.frame = NSRect(x: 0, y: above ? h : 0, width: Self.expandedWidth, height: answerHeight)
280
+ separator.frame = NSRect(x: 0, y: above ? h - 1 : answerHeight, width: bounds.width, height: 1)
281
+ }
282
+
249
283
  /// Where it was left, kept on a screen that still exists; the
250
284
  /// bottom-right corner the first time.
251
285
  @MainActor
252
286
  func restorePosition() {
287
+ collapse(animated: false)
253
288
  let screens = NSScreen.screens.map(\.visibleFrame)
254
289
  let fallback = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame ?? .zero
255
290
  place(PillPlacement.clamp(saved: Config.pillOrigin, size: frame.size, screens: screens, fallback: fallback))
@@ -264,19 +299,18 @@ final class Panel: NSPanel {
264
299
 
265
300
  @MainActor
266
301
  private func place(_ origin: NSPoint) {
267
- placing = true
302
+ placing += 1
268
303
  setFrameOrigin(origin)
269
- placing = false
270
- onMove?()
304
+ placing -= 1
271
305
  }
272
306
 
273
307
  /// Remember a drag. Moves the app makes itself are not saved, so a
274
- /// reset or a clamp never overwrites where the user put it.
308
+ /// reset or a clamp never overwrites where the user put it. Dragged
309
+ /// while grown, the pill is remembered where it will collapse to.
275
310
  @objc private func moved() {
276
311
  MainActor.assumeIsolated {
277
- guard !placing else { return }
278
- Config.pillOrigin = frame.origin
279
- onMove?()
312
+ guard placing == 0 else { return }
313
+ Config.pillOrigin = expanded ? collapsedFrame().origin : frame.origin
280
314
  }
281
315
  }
282
316
 
@@ -291,6 +325,7 @@ final class Panel: NSPanel {
291
325
  @MainActor
292
326
  func dismiss() {
293
327
  dismissed = true
328
+ collapse(animated: false)
294
329
  hide()
295
330
  }
296
331
 
@@ -303,7 +338,7 @@ final class Panel: NSPanel {
303
338
  }
304
339
 
305
340
  @MainActor
306
- private func show() {
341
+ func show() {
307
342
  orb.setOnScreen(true)
308
343
  if !isVisible { orderFrontRegardless() }
309
344
  }
@@ -317,8 +352,16 @@ final class Panel: NSPanel {
317
352
 
318
353
  @objc private func closeClicked() { onDismiss?() }
319
354
 
320
- override func mouseEntered(with event: NSEvent) { closeButton.isHidden = false }
321
- override func mouseExited(with event: NSEvent) { closeButton.isHidden = true }
355
+ override func mouseEntered(with event: NSEvent) {
356
+ closeButton.isHidden = false
357
+ hovering = true
358
+ MainActor.assumeIsolated { armCollapse() }
359
+ }
360
+ override func mouseExited(with event: NSEvent) {
361
+ closeButton.isHidden = true
362
+ hovering = false
363
+ MainActor.assumeIsolated { armCollapse() }
364
+ }
322
365
 
323
366
  /// Esc, once the pill has been clicked and so holds the keyboard.
324
367
  override func cancelOperation(_ sender: Any?) { onDismiss?() }
@@ -327,6 +370,17 @@ final class Panel: NSPanel {
327
370
  if event.keyCode == 53 { onDismiss?() } else { super.keyDown(with: event) }
328
371
  }
329
372
 
373
+ /// ⌘C copies what is selected in the answer. The app has no Edit menu
374
+ /// to route the shortcut there.
375
+ override func performKeyEquivalent(with event: NSEvent) -> Bool {
376
+ if expanded, event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command,
377
+ event.charactersIgnoringModifiers == "c" {
378
+ answer.copySelection()
379
+ return true
380
+ }
381
+ return super.performKeyEquivalent(with: event)
382
+ }
383
+
330
384
  /// AppKit is not thread-safe. Marking this explicitly means a stray
331
385
  /// call from a delegate queue is a compile error rather than heap
332
386
  /// corruption that traps somewhere unrelated an hour later.
@@ -341,7 +395,13 @@ final class Panel: NSPanel {
341
395
  case .idle where Hold.isOn: tint = Brand.warn
342
396
  default: tint = agentTint()
343
397
  }
344
- orb.show(state.orbPhase, tint: tint)
398
+ let colors: [NSColor]?
399
+ switch state {
400
+ case .error: colors = nil
401
+ case .idle where Hold.isOn: colors = nil
402
+ default: colors = agentPalette()
403
+ }
404
+ orb.show(state.orbPhase, tint: tint, colors: colors)
345
405
 
346
406
  if state.isMeta {
347
407
  stopMarquee()
@@ -354,7 +414,9 @@ final class Panel: NSPanel {
354
414
  setText(named(state))
355
415
  }
356
416
 
357
- if dismissed || (state.isMeta && !alwaysVisible) { hide() } else { show() }
417
+ // Grown into an answer, it stays until it collapses, idle or not.
418
+ if dismissed || (state.isMeta && !alwaysVisible && !expanded) { hide() } else { show() }
419
+ armCollapse()
358
420
  onRender?(state)
359
421
  }
360
422
 
@@ -371,7 +433,7 @@ final class Panel: NSPanel {
371
433
  /// Fits, or scrolls. Identical text is left alone so a per-second tick
372
434
  /// cannot restart the animation and make it stutter in place.
373
435
  @MainActor
374
- private func setText(_ text: String) {
436
+ func setText(_ text: String) {
375
437
  guard let clipView = clip else { label.stringValue = text; return }
376
438
  let font = label.font ?? NSFont.systemFont(ofSize: 12)
377
439
  let width = (text as NSString).size(withAttributes: [.font: font]).width
@@ -411,7 +473,7 @@ final class Panel: NSPanel {
411
473
  }
412
474
 
413
475
  @MainActor
414
- private func stopMarquee() {
476
+ func stopMarquee() {
415
477
  label.layer?.removeAnimation(forKey: "marquee")
416
478
  marqueeText = ""
417
479
  }
@@ -426,3 +488,12 @@ final class Panel: NSPanel {
426
488
  private final class PassThroughView: NSView {
427
489
  override func hitTest(_ point: NSPoint) -> NSView? { nil }
428
490
  }
491
+
492
+ /// The pill's row: its background passes clicks through, so pressing
493
+ /// anywhere on it drags the pill, while its close button still clicks.
494
+ final class RowView: NSView {
495
+ override func hitTest(_ point: NSPoint) -> NSView? {
496
+ let hit = super.hitTest(point)
497
+ return hit === self ? nil : hit
498
+ }
499
+ }