agentix-cli 0.55.0 → 0.57.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 (117) hide show
  1. package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +46 -0
  2. package/apps/mac-voice/Sources/AgentXVoice/Config.swift +7 -0
  3. package/apps/mac-voice/Sources/AgentXVoice/Hotkey.swift +8 -2
  4. package/apps/mac-voice/Sources/AgentXVoice/HotkeySpec.swift +80 -0
  5. package/apps/mac-voice/Sources/AgentXVoice/Orb.swift +311 -0
  6. package/apps/mac-voice/Sources/AgentXVoice/OrbMath.swift +57 -0
  7. package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +14 -1
  8. package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +33 -1
  9. package/apps/mac-voice/Sources/AgentXVoice/SettingsWindow.swift +518 -0
  10. package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +8 -2
  11. package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +23 -1
  12. package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +101 -0
  13. package/apps/mac-voice/Sources/AgentXVoice/main.swift +161 -39
  14. package/apps/mac-voice/build.sh +1 -1
  15. package/dist/{App-4EM7IPB3.js → App-Z5VOAUXB.js} +2 -2
  16. package/dist/{ChatApp-FYTEEGSU.js → ChatApp-BE7ONQKS.js} +2 -2
  17. package/dist/{agent-RZ46ZB4W.js → agent-Q74EISKP.js} +2 -2
  18. package/dist/{board-dashboard-NRRYCDZW.js → board-dashboard-A7PQOI6C.js} +4 -4
  19. package/dist/{chunk-W4YMMWJL.js → chunk-2MIOMYT2.js} +2 -2
  20. package/dist/{chunk-EZN2YXRY.js → chunk-547G3HYQ.js} +2 -2
  21. package/dist/{chunk-LKJSPO3S.js → chunk-5LLGIX5M.js} +2 -2
  22. package/dist/{chunk-WOMQ2VGA.js → chunk-5W7NJHCS.js} +2 -2
  23. package/dist/{chunk-4HEVSWO3.js → chunk-6OABTCRA.js} +2 -2
  24. package/dist/{chunk-UIVHSYCN.js → chunk-6QNPCVRN.js} +3 -3
  25. package/dist/chunk-6QNPCVRN.js.map +1 -0
  26. package/dist/{chunk-CELYD5QW.js → chunk-CQUVUVXR.js} +2 -2
  27. package/dist/chunk-ERL6AS3O.js +15 -0
  28. package/dist/chunk-ERL6AS3O.js.map +1 -0
  29. package/dist/{chunk-EJ5ISSWW.js → chunk-I2LYULR4.js} +5 -5
  30. package/dist/{chunk-76AHNHWC.js → chunk-KKGFZEXS.js} +2 -2
  31. package/dist/{chunk-6AK6WKE5.js → chunk-KW7TIOS3.js} +2 -2
  32. package/dist/{chunk-SA23V7WX.js → chunk-NKMFHEGV.js} +2 -2
  33. package/dist/{chunk-K4RDE6YU.js → chunk-OZZPJ2YG.js} +197 -188
  34. package/dist/chunk-OZZPJ2YG.js.map +1 -0
  35. package/dist/{chunk-JC5Q5MPI.js → chunk-POL7RAJQ.js} +17 -17
  36. package/dist/{chunk-D3LSPFHK.js → chunk-Q6SMLSRO.js} +2 -2
  37. package/dist/{chunk-723K4SAW.js → chunk-RXEAACVH.js} +2 -2
  38. package/dist/{chunk-O3XG2NVR.js → chunk-U5YBQJ5Q.js} +2 -2
  39. package/dist/{chunk-XEOAKD5C.js → chunk-V7ZK6ZIO.js} +2 -2
  40. package/dist/{chunk-M4AG2YNX.js → chunk-VI7GBDKV.js} +2 -2
  41. package/dist/{chunk-GSOL2LW6.js → chunk-VVBGPT6W.js} +2 -2
  42. package/dist/{chunk-C3VRBEJ7.js → chunk-XTD3SZIP.js} +2 -2
  43. package/dist/cli.js +1 -1
  44. package/dist/client-EFP2PKA3.js +2 -0
  45. package/dist/config-P6YXC6XZ.js +2 -0
  46. package/dist/config-mutator-3GKXLTHH.js +2 -0
  47. package/dist/doctor-JEWMHXYH.js +2 -0
  48. package/dist/index.d.ts +109 -3
  49. package/dist/index.js +1 -1
  50. package/dist/program-7DUQJDSQ.js +2 -0
  51. package/dist/{promote-T4UJROEH.js → promote-KVPLDSKT.js} +2 -2
  52. package/dist/query-AGOMCYTT.js +2 -0
  53. package/dist/{registry-DX2RI7I7.js → registry-AUOI4TW2.js} +2 -2
  54. package/dist/{request-planner-B7DE7VJR.js → request-planner-LMZ5ZAXC.js} +2 -2
  55. package/dist/{review-candidates-4YJHIBAF.js → review-candidates-WCBXLLDS.js} +2 -2
  56. package/dist/{routing-MZG3UODH.js → routing-FBASYKUL.js} +2 -2
  57. package/dist/schedule-tool-RJRJ63SS.js +2 -0
  58. package/dist/scheduler-ATA7Z44S.js +2 -0
  59. package/dist/{seat-2PEI7JRX.js → seat-DBFW3Z5P.js} +2 -2
  60. package/dist/{select-4KIUR27O.js → select-6CNVVFA5.js} +2 -2
  61. package/dist/{set-enabled-SE7HQKPF.js → set-enabled-AYZH3KSB.js} +2 -2
  62. package/dist/{tool-5VMD7IE4.js → tool-PKS4NBZ6.js} +2 -2
  63. package/dist/wiki-QW6BDNEQ.js +2 -0
  64. package/package.json +1 -1
  65. package/dist/chunk-K4RDE6YU.js.map +0 -1
  66. package/dist/chunk-U4H35SJS.js +0 -15
  67. package/dist/chunk-U4H35SJS.js.map +0 -1
  68. package/dist/chunk-UIVHSYCN.js.map +0 -1
  69. package/dist/client-IA7ZPE56.js +0 -2
  70. package/dist/config-HPN2F7LH.js +0 -2
  71. package/dist/config-mutator-7GPTMTVB.js +0 -2
  72. package/dist/doctor-IBWQVQV4.js +0 -2
  73. package/dist/program-D5TAPSIH.js +0 -2
  74. package/dist/query-VUJC4Y5O.js +0 -2
  75. package/dist/schedule-tool-A4OFQ4YZ.js +0 -2
  76. package/dist/scheduler-RHJW2V4M.js +0 -2
  77. package/dist/wiki-2QFLAH4U.js +0 -2
  78. /package/dist/{App-4EM7IPB3.js.map → App-Z5VOAUXB.js.map} +0 -0
  79. /package/dist/{ChatApp-FYTEEGSU.js.map → ChatApp-BE7ONQKS.js.map} +0 -0
  80. /package/dist/{agent-RZ46ZB4W.js.map → agent-Q74EISKP.js.map} +0 -0
  81. /package/dist/{board-dashboard-NRRYCDZW.js.map → board-dashboard-A7PQOI6C.js.map} +0 -0
  82. /package/dist/{chunk-W4YMMWJL.js.map → chunk-2MIOMYT2.js.map} +0 -0
  83. /package/dist/{chunk-EZN2YXRY.js.map → chunk-547G3HYQ.js.map} +0 -0
  84. /package/dist/{chunk-LKJSPO3S.js.map → chunk-5LLGIX5M.js.map} +0 -0
  85. /package/dist/{chunk-WOMQ2VGA.js.map → chunk-5W7NJHCS.js.map} +0 -0
  86. /package/dist/{chunk-4HEVSWO3.js.map → chunk-6OABTCRA.js.map} +0 -0
  87. /package/dist/{chunk-CELYD5QW.js.map → chunk-CQUVUVXR.js.map} +0 -0
  88. /package/dist/{chunk-EJ5ISSWW.js.map → chunk-I2LYULR4.js.map} +0 -0
  89. /package/dist/{chunk-76AHNHWC.js.map → chunk-KKGFZEXS.js.map} +0 -0
  90. /package/dist/{chunk-6AK6WKE5.js.map → chunk-KW7TIOS3.js.map} +0 -0
  91. /package/dist/{chunk-SA23V7WX.js.map → chunk-NKMFHEGV.js.map} +0 -0
  92. /package/dist/{chunk-JC5Q5MPI.js.map → chunk-POL7RAJQ.js.map} +0 -0
  93. /package/dist/{chunk-D3LSPFHK.js.map → chunk-Q6SMLSRO.js.map} +0 -0
  94. /package/dist/{chunk-723K4SAW.js.map → chunk-RXEAACVH.js.map} +0 -0
  95. /package/dist/{chunk-O3XG2NVR.js.map → chunk-U5YBQJ5Q.js.map} +0 -0
  96. /package/dist/{chunk-XEOAKD5C.js.map → chunk-V7ZK6ZIO.js.map} +0 -0
  97. /package/dist/{chunk-M4AG2YNX.js.map → chunk-VI7GBDKV.js.map} +0 -0
  98. /package/dist/{chunk-GSOL2LW6.js.map → chunk-VVBGPT6W.js.map} +0 -0
  99. /package/dist/{chunk-C3VRBEJ7.js.map → chunk-XTD3SZIP.js.map} +0 -0
  100. /package/dist/{client-IA7ZPE56.js.map → client-EFP2PKA3.js.map} +0 -0
  101. /package/dist/{config-HPN2F7LH.js.map → config-P6YXC6XZ.js.map} +0 -0
  102. /package/dist/{config-mutator-7GPTMTVB.js.map → config-mutator-3GKXLTHH.js.map} +0 -0
  103. /package/dist/{doctor-IBWQVQV4.js.map → doctor-JEWMHXYH.js.map} +0 -0
  104. /package/dist/{program-D5TAPSIH.js.map → program-7DUQJDSQ.js.map} +0 -0
  105. /package/dist/{promote-T4UJROEH.js.map → promote-KVPLDSKT.js.map} +0 -0
  106. /package/dist/{query-VUJC4Y5O.js.map → query-AGOMCYTT.js.map} +0 -0
  107. /package/dist/{registry-DX2RI7I7.js.map → registry-AUOI4TW2.js.map} +0 -0
  108. /package/dist/{request-planner-B7DE7VJR.js.map → request-planner-LMZ5ZAXC.js.map} +0 -0
  109. /package/dist/{review-candidates-4YJHIBAF.js.map → review-candidates-WCBXLLDS.js.map} +0 -0
  110. /package/dist/{routing-MZG3UODH.js.map → routing-FBASYKUL.js.map} +0 -0
  111. /package/dist/{schedule-tool-A4OFQ4YZ.js.map → schedule-tool-RJRJ63SS.js.map} +0 -0
  112. /package/dist/{scheduler-RHJW2V4M.js.map → scheduler-ATA7Z44S.js.map} +0 -0
  113. /package/dist/{seat-2PEI7JRX.js.map → seat-DBFW3Z5P.js.map} +0 -0
  114. /package/dist/{select-4KIUR27O.js.map → select-6CNVVFA5.js.map} +0 -0
  115. /package/dist/{set-enabled-SE7HQKPF.js.map → set-enabled-AYZH3KSB.js.map} +0 -0
  116. /package/dist/{tool-5VMD7IE4.js.map → tool-PKS4NBZ6.js.map} +0 -0
  117. /package/dist/{wiki-2QFLAH4U.js.map → wiki-QW6BDNEQ.js.map} +0 -0
@@ -161,12 +161,58 @@ enum AgentClient {
161
161
  return try await URLSession.shared.data(for: req)
162
162
  }
163
163
 
164
+ // MARK: Settings
165
+ //
166
+ // The settings window reads and saves through the daemon, which checks
167
+ // every value and writes agentx.json. The app keeps no copy of its own.
168
+ // See src/daemon/voice-settings-api.ts.
169
+
170
+ /// GET /voice/settings, or nil when the daemon cannot be reached.
171
+ static func settings() async -> VoiceSettings? {
172
+ guard let url = URL(string: "\(Config.daemonURL)/voice/settings") else { return nil }
173
+ var req = URLRequest(url: url)
174
+ // The first call lists the Mac's voices, which can take a moment.
175
+ req.timeoutInterval = 15
176
+ guard let (data, response) = try? await URLSession.shared.data(for: req),
177
+ (response as? HTTPURLResponse)?.statusCode == 200 else { return nil }
178
+ return try? JSONDecoder().decode(VoiceSettings.self, from: data)
179
+ }
180
+
181
+ /// Save a change. The saved settings, or the daemon's reason in words.
182
+ static func saveSettings(_ patch: [String: Any]) async -> Result<VoiceSettings, SettingsFailure> {
183
+ struct Reply: Decodable { let settings: VoiceSettings?; let error: String? }
184
+ do {
185
+ let (data, response) = try await post("/voice/settings", patch, timeout: 20)
186
+ let reply = try? JSONDecoder().decode(Reply.self, from: data)
187
+ if let s = reply?.settings, (response as? HTTPURLResponse)?.statusCode == 200 { return .success(s) }
188
+ return .failure(SettingsFailure(message: reply?.error ?? "The daemon did not save the settings."))
189
+ } catch {
190
+ return .failure(SettingsFailure(message: "The AgentX daemon isn't reachable: \(error.localizedDescription)"))
191
+ }
192
+ }
193
+
194
+ /// Say a sample line in an agent's voice with unsaved changes. Nil on
195
+ /// success, else why not.
196
+ static func preview(agentID: String, voice: [String: Any]) async -> String? {
197
+ struct Reply: Decodable { let error: String? }
198
+ guard let (data, response) = try? await post("/voice/preview", ["agentId": agentID, "voice": voice], timeout: 10) else {
199
+ return "The AgentX daemon isn't reachable."
200
+ }
201
+ if (response as? HTTPURLResponse)?.statusCode == 202 { return nil }
202
+ return (try? JSONDecoder().decode(Reply.self, from: data))?.error ?? "The preview didn't play."
203
+ }
204
+
205
+ struct SettingsFailure: Error { let message: String }
206
+
164
207
  /// One row of GET /agents: who can answer, and whether they are busy.
165
208
  struct AgentInfo: Decodable {
166
209
  let id: String
167
210
  let name: String?
168
211
  /// Tasks running now.
169
212
  let active: Int?
213
+ /// "#RRGGBB": presence.color, else derived from the id. Older
214
+ /// daemons send none, and the app derives it the same way.
215
+ let color: String?
170
216
  var label: String { name ?? id }
171
217
  }
172
218
 
@@ -28,6 +28,13 @@ enum Config {
28
28
  set { UserDefaults.standard.set(newValue, forKey: "showPill") }
29
29
  }
30
30
 
31
+ /// The orb overlay while listening, thinking and speaking. On by
32
+ /// default; off brings back the pill for those states.
33
+ static var showOrb: Bool {
34
+ get { UserDefaults.standard.object(forKey: "showOrb") as? Bool ?? true }
35
+ set { UserDefaults.standard.set(newValue, forKey: "showOrb") }
36
+ }
37
+
31
38
  /// The agent actually answering: `agentID`, else `chosenAgentID`, else
32
39
  /// the daemon's default (AgentClient.resolveAgent). Progress follows it.
33
40
  @MainActor static var effectiveAgentID: String = ""
@@ -28,7 +28,13 @@ final class Hotkey {
28
28
  self.onRelease = onRelease
29
29
  }
30
30
 
31
- func register(keyCode: UInt32 = UInt32(kVK_Space), modifiers: UInt32 = UInt32(optionKey)) {
31
+ /// Register one shortcut; false when macOS refused it (another app
32
+ /// already holds it).
33
+ @discardableResult
34
+ func register(_ spec: HotkeySpec) -> Bool { register(keyCode: spec.keyCode, modifiers: spec.modifiers) }
35
+
36
+ @discardableResult
37
+ func register(keyCode: UInt32 = UInt32(kVK_Space), modifiers: UInt32 = UInt32(optionKey)) -> Bool {
32
38
  var spec = [
33
39
  EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
34
40
  EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased)),
@@ -66,7 +72,7 @@ final class Hotkey {
66
72
  }, spec.count, &spec, context, &handler)
67
73
 
68
74
  let hotKeyID = EventHotKeyID(signature: OSType(0x41475856), id: id) // 'AGXV'
69
- RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &ref)
75
+ return RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &ref) == noErr
70
76
  }
71
77
 
72
78
  deinit {
@@ -0,0 +1,80 @@
1
+ import Carbon.HIToolbox
2
+
3
+ /// A shortcut as agentx.json stores it: "opt+space", "cmd+opt+period",
4
+ /// "ctrl+opt+1". The daemon checks and normalises the same form
5
+ /// (src/voice/hotkey.ts); this turns it into what Carbon registers, and a
6
+ /// pressed key back into it.
7
+ struct HotkeySpec: Equatable {
8
+ let keyCode: UInt32
9
+ /// Carbon modifier flags (cmdKey, optionKey, …).
10
+ let modifiers: UInt32
11
+
12
+ static let keys: [String: Int] = {
13
+ var k: [String: Int] = [
14
+ "a": kVK_ANSI_A, "b": kVK_ANSI_B, "c": kVK_ANSI_C, "d": kVK_ANSI_D, "e": kVK_ANSI_E,
15
+ "f": kVK_ANSI_F, "g": kVK_ANSI_G, "h": kVK_ANSI_H, "i": kVK_ANSI_I, "j": kVK_ANSI_J,
16
+ "k": kVK_ANSI_K, "l": kVK_ANSI_L, "m": kVK_ANSI_M, "n": kVK_ANSI_N, "o": kVK_ANSI_O,
17
+ "p": kVK_ANSI_P, "q": kVK_ANSI_Q, "r": kVK_ANSI_R, "s": kVK_ANSI_S, "t": kVK_ANSI_T,
18
+ "u": kVK_ANSI_U, "v": kVK_ANSI_V, "w": kVK_ANSI_W, "x": kVK_ANSI_X, "y": kVK_ANSI_Y,
19
+ "z": kVK_ANSI_Z,
20
+ "0": kVK_ANSI_0, "1": kVK_ANSI_1, "2": kVK_ANSI_2, "3": kVK_ANSI_3, "4": kVK_ANSI_4,
21
+ "5": kVK_ANSI_5, "6": kVK_ANSI_6, "7": kVK_ANSI_7, "8": kVK_ANSI_8, "9": kVK_ANSI_9,
22
+ "space": kVK_Space, "period": kVK_ANSI_Period, "comma": kVK_ANSI_Comma,
23
+ "slash": kVK_ANSI_Slash, "semicolon": kVK_ANSI_Semicolon, "quote": kVK_ANSI_Quote,
24
+ "minus": kVK_ANSI_Minus, "equal": kVK_ANSI_Equal,
25
+ "leftbracket": kVK_ANSI_LeftBracket, "rightbracket": kVK_ANSI_RightBracket,
26
+ "backslash": kVK_ANSI_Backslash, "grave": kVK_ANSI_Grave,
27
+ "return": kVK_Return, "tab": kVK_Tab,
28
+ ]
29
+ let f = [kVK_F1, kVK_F2, kVK_F3, kVK_F4, kVK_F5, kVK_F6, kVK_F7, kVK_F8, kVK_F9, kVK_F10,
30
+ kVK_F11, kVK_F12, kVK_F13, kVK_F14, kVK_F15, kVK_F16, kVK_F17, kVK_F18, kVK_F19, kVK_F20]
31
+ for (i, code) in f.enumerated() { k["f\(i + 1)"] = code }
32
+ return k
33
+ }()
34
+
35
+ private static let modifierFlags: [(String, UInt32)] = [
36
+ ("ctrl", UInt32(controlKey)), ("opt", UInt32(optionKey)),
37
+ ("shift", UInt32(shiftKey)), ("cmd", UInt32(cmdKey)),
38
+ ]
39
+ private static let aliases = ["control": "ctrl", "option": "opt", "alt": "opt", "command": "cmd"]
40
+
41
+ /// Nil for anything the daemon would refuse.
42
+ init?(_ text: String) {
43
+ let parts = text.lowercased().split(separator: "+", omittingEmptySubsequences: false)
44
+ .map { $0.trimmingCharacters(in: .whitespaces) }
45
+ guard let key = parts.last, let code = Self.keys[key] else { return nil }
46
+ var mods: UInt32 = 0
47
+ for p in parts.dropLast() {
48
+ let name = Self.aliases[p] ?? p
49
+ guard let flag = Self.modifierFlags.first(where: { $0.0 == name })?.1, mods & flag == 0 else { return nil }
50
+ mods |= flag
51
+ }
52
+ let isF = key.hasPrefix("f") && key.count > 1
53
+ guard isF || mods & UInt32(controlKey | optionKey | cmdKey) != 0 else { return nil }
54
+ keyCode = UInt32(code)
55
+ modifiers = mods
56
+ }
57
+
58
+ init(keyCode: UInt32, modifiers: UInt32) {
59
+ self.keyCode = keyCode
60
+ self.modifiers = modifiers
61
+ }
62
+
63
+ /// The stored form, modifiers in the Mac's order.
64
+ var text: String? {
65
+ guard let key = Self.keys.first(where: { UInt32($0.value) == keyCode })?.key else { return nil }
66
+ let mods = Self.modifierFlags.filter { modifiers & $0.1 != 0 }.map(\.0)
67
+ return (mods + [key]).joined(separator: "+")
68
+ }
69
+
70
+ /// "⌃⌥1", as menus show shortcuts.
71
+ static func display(_ text: String) -> String {
72
+ let symbols = ["ctrl": "⌃", "opt": "⌥", "shift": "⇧", "cmd": "⌘"]
73
+ let names = ["space": "Space", "period": ".", "comma": ",", "slash": "/", "semicolon": ";",
74
+ "quote": "'", "minus": "-", "equal": "=", "leftbracket": "[", "rightbracket": "]",
75
+ "backslash": "\\", "grave": "`", "return": "Return", "tab": "Tab"]
76
+ var parts = text.lowercased().split(separator: "+").map(String.init)
77
+ guard let key = parts.popLast() else { return text }
78
+ return parts.map { symbols[Self.aliases[$0] ?? $0] ?? $0 }.joined() + (names[key] ?? key.uppercased())
79
+ }
80
+ }
@@ -0,0 +1,311 @@
1
+ import AppKit
2
+ import SwiftUI
3
+
4
+ /// The Siri-style overlay: an orb in the agent's colour that listens,
5
+ /// thinks and speaks, with a line of words under it.
6
+ ///
7
+ /// Built from SwiftUI's own gradients, no animation library. The orb only
8
+ /// moves while it is on screen: hidden, its timeline is paused and the
9
+ /// microphone level is no longer read, so an idle assistant costs nothing.
10
+ /// With Reduce Motion on it is a still picture that changes between
11
+ /// states, never a loop.
12
+ @MainActor
13
+ final class OrbModel: ObservableObject {
14
+ enum Phase: Equatable { case hidden, listening, thinking, speaking }
15
+
16
+ @Published var phase: Phase = .hidden
17
+ /// 0…1: the microphone while listening.
18
+ @Published var level: Double = 0
19
+ @Published var tint = NSColor(srgbRed: 0.078, green: 0.722, blue: 0.651, alpha: 1)
20
+ @Published var name = ""
21
+ /// What was heard, or what is being said.
22
+ @Published var line = ""
23
+ /// The step the agent is on, and for how long.
24
+ @Published var status = ""
25
+ @Published var reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
26
+ }
27
+
28
+ struct OrbView: View {
29
+ @ObservedObject var model: OrbModel
30
+ @Environment(\.colorScheme) private var scheme
31
+
32
+ /// The agent's colour, lifted in dark mode so the name stays readable.
33
+ private var nameColor: Color {
34
+ guard scheme == .dark, let c = model.tint.usingColorSpace(.sRGB) else { return Color(nsColor: model.tint) }
35
+ var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
36
+ c.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
37
+ return Color(nsColor: NSColor(hue: h, saturation: s * 0.55, brightness: min(1, b + 0.35), alpha: 1))
38
+ }
39
+
40
+ var body: some View {
41
+ VStack(spacing: 8) {
42
+ orb.frame(width: 96, height: 96).padding(.top, 4)
43
+ if !model.name.isEmpty {
44
+ Text(model.name.uppercased())
45
+ .font(.system(size: 10.5, weight: .semibold, design: .monospaced))
46
+ .tracking(0.8)
47
+ .foregroundStyle(nameColor)
48
+ }
49
+ if !model.line.isEmpty {
50
+ Text(model.line)
51
+ .font(.system(size: 13, weight: .medium))
52
+ .foregroundStyle(.primary)
53
+ .multilineTextAlignment(.center)
54
+ .lineLimit(3)
55
+ .truncationMode(.head)
56
+ .fixedSize(horizontal: false, vertical: true)
57
+ }
58
+ if !model.status.isEmpty {
59
+ Text(model.status)
60
+ .font(.system(size: 11))
61
+ .foregroundStyle(.secondary)
62
+ .lineLimit(1)
63
+ .truncationMode(.middle)
64
+ }
65
+ }
66
+ .padding(.horizontal, 18)
67
+ .padding(.vertical, 14)
68
+ .frame(width: OrbOverlay.width)
69
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
70
+ .overlay(RoundedRectangle(cornerRadius: 22, style: .continuous)
71
+ .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1))
72
+ .frame(maxHeight: .infinity, alignment: .top)
73
+ }
74
+
75
+ private var orb: some View {
76
+ let still = model.reduceMotion
77
+ let paused = still || model.phase == .hidden
78
+ return TimelineView(.animation(minimumInterval: 1.0 / 30, paused: paused)) { context in
79
+ OrbBody(t: paused ? 0 : context.date.timeIntervalSinceReferenceDate,
80
+ phase: model.phase, level: model.level, tint: model.tint, still: still)
81
+ }
82
+ .accessibilityLabel(Text(accessibilityText))
83
+ }
84
+
85
+ private var accessibilityText: String {
86
+ switch model.phase {
87
+ case .hidden: return ""
88
+ case .listening: return "Listening"
89
+ case .thinking: return "Thinking"
90
+ case .speaking: return "Speaking"
91
+ }
92
+ }
93
+ }
94
+
95
+ /// One frame of the orb at time `t`.
96
+ struct OrbBody: View {
97
+ let t: Double
98
+ let phase: OrbModel.Phase
99
+ let level: Double
100
+ let tint: NSColor
101
+ /// Reduce Motion: a still orb, sized and shaded by state alone.
102
+ let still: Bool
103
+
104
+ /// How much the orb swells and glows, 0…1.
105
+ private var energy: Double {
106
+ switch phase {
107
+ case .hidden: return 0
108
+ case .listening: return still ? 0.45 : 0.12 + 0.88 * level
109
+ case .thinking: return still ? 0.2 : 0.22 + 0.08 * sin(t * 2.2)
110
+ case .speaking: return still ? 0.7 : OrbMath.speakingEnvelope(at: t)
111
+ }
112
+ }
113
+
114
+ /// How fast the colours drift.
115
+ private var speed: Double {
116
+ switch phase {
117
+ case .hidden: return 0
118
+ case .listening: return 0.5 + level * 1.5
119
+ case .thinking: return 1.3
120
+ case .speaking: return 0.9
121
+ }
122
+ }
123
+
124
+ var body: some View {
125
+ let scale = 0.78 + 0.22 * energy
126
+ ZStack {
127
+ Circle()
128
+ .fill(Color(nsColor: tint).opacity(0.28 + 0.32 * energy))
129
+ .blur(radius: 10 + 12 * energy)
130
+ .scaleEffect(scale * 1.08)
131
+ fill
132
+ .clipShape(Circle())
133
+ .scaleEffect(scale)
134
+ // Thinking reads as a slow ring going round; the other states
135
+ // as a soft rim.
136
+ if phase == .thinking {
137
+ Circle()
138
+ .trim(from: 0, to: 0.28)
139
+ .stroke(Color.white.opacity(0.75), style: StrokeStyle(lineWidth: 2.2, lineCap: .round))
140
+ .rotationEffect(.radians(still ? -.pi / 2 : t * 3.1))
141
+ .scaleEffect(scale * 0.86)
142
+ } else {
143
+ Circle()
144
+ .strokeBorder(Color.white.opacity(0.3 + 0.25 * energy), lineWidth: 1)
145
+ .scaleEffect(scale)
146
+ }
147
+ }
148
+ }
149
+
150
+ @ViewBuilder private var fill: some View {
151
+ let shades = palette
152
+ if #available(macOS 15, *) {
153
+ MeshGradient(width: 3, height: 3, points: meshPoints, colors: [
154
+ shades[0], shades[1], shades[2],
155
+ shades[3], shades[4], shades[1],
156
+ shades[2], shades[3], shades[0],
157
+ ])
158
+ } else {
159
+ // macOS 14: two gradients stand in for the mesh.
160
+ ZStack {
161
+ AngularGradient(colors: [shades[0], shades[2], shades[3], shades[1], shades[0]],
162
+ center: .center, angle: .radians(t * speed))
163
+ RadialGradient(colors: [shades[4], shades[4].opacity(0)],
164
+ center: UnitPoint(x: 0.5 + 0.12 * cos(t * speed), y: 0.5 + 0.12 * sin(t * speed)),
165
+ startRadius: 2, endRadius: 44)
166
+ }
167
+ }
168
+ }
169
+
170
+ /// Corners fixed, edges sway, the centre wanders further when there
171
+ /// is more energy. `still` keeps the resting grid.
172
+ private var meshPoints: [SIMD2<Float>] {
173
+ let s = still ? 0 : t * speed
174
+ let reach = Float(0.08 + 0.14 * energy)
175
+ func sway(_ phase: Double) -> Float { Float(sin(s + phase)) * reach * 0.6 }
176
+ return [
177
+ [0, 0], [0.5 + sway(0.3), 0], [1, 0],
178
+ [0, 0.5 + sway(1.1)],
179
+ [0.5 + Float(cos(s * 1.3)) * reach, 0.5 + Float(sin(s * 1.7)) * reach],
180
+ [1, 0.5 + sway(2.3)],
181
+ [0, 1], [0.5 + sway(3.7), 1], [1, 1],
182
+ ]
183
+ }
184
+
185
+ /// Five shades around the agent's colour: lighter, deeper and two
186
+ /// neighbouring hues, and a bright core that grows with the voice.
187
+ private var palette: [Color] {
188
+ let base = tint.usingColorSpace(.sRGB) ?? tint
189
+ var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
190
+ base.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
191
+ // Thinking turns the hues slowly; the others hold them.
192
+ let drift = phase == .thinking && !still ? CGFloat(sin(t * 0.8)) * 0.04 : 0
193
+ func shade(_ dh: CGFloat, _ ds: CGFloat, _ db: CGFloat) -> Color {
194
+ var hue = (h + dh + drift).truncatingRemainder(dividingBy: 1)
195
+ if hue < 0 { hue += 1 }
196
+ return Color(nsColor: NSColor(hue: hue, saturation: min(1, max(0, s + ds)),
197
+ brightness: min(1, max(0, b + db)), alpha: 1))
198
+ }
199
+ let core = CGFloat(0.12 + 0.3 * energy)
200
+ return [
201
+ shade(0, 0, 0),
202
+ shade(0.07, -0.05, 0.08),
203
+ shade(-0.06, 0.05, -0.12),
204
+ shade(0.13, -0.1, 0.02),
205
+ shade(0.02, -0.35 * core * 2, core),
206
+ ]
207
+ }
208
+ }
209
+
210
+ /// The window the orb lives in: floating, on every Space, click-through
211
+ /// and never key, so the app in front keeps the keyboard.
212
+ private final class OrbPanel: NSPanel {
213
+ override var canBecomeKey: Bool { false }
214
+ override var canBecomeMain: Bool { false }
215
+ }
216
+
217
+ @MainActor
218
+ final class OrbOverlay {
219
+ static let width: CGFloat = 300
220
+ private static let height: CGFloat = 250
221
+
222
+ let model = OrbModel()
223
+ private let panel: NSPanel
224
+ private let host: NSHostingView<OrbView>
225
+ private var levelTimer: Timer?
226
+ private var motionObserver: NSObjectProtocol?
227
+
228
+ /// The microphone's RMS level, read while listening. Set by the app.
229
+ var levelSource: (() -> Float)?
230
+
231
+ init() {
232
+ panel = OrbPanel(contentRect: NSRect(x: 0, y: 0, width: Self.width, height: Self.height),
233
+ styleMask: [.borderless, .nonactivatingPanel],
234
+ backing: .buffered, defer: true)
235
+ panel.isFloatingPanel = true
236
+ panel.level = .floating
237
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]
238
+ panel.isOpaque = false
239
+ panel.backgroundColor = .clear
240
+ panel.hasShadow = false
241
+ panel.ignoresMouseEvents = true
242
+ panel.hidesOnDeactivate = false
243
+ host = NSHostingView(rootView: OrbView(model: model))
244
+ host.frame = NSRect(x: 0, y: 0, width: Self.width, height: Self.height)
245
+ panel.contentView = host
246
+
247
+ let model = self.model
248
+ motionObserver = NSWorkspace.shared.notificationCenter.addObserver(
249
+ forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification,
250
+ object: nil, queue: .main) { _ in
251
+ MainActor.assumeIsolated {
252
+ model.reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
253
+ }
254
+ }
255
+ }
256
+
257
+ var isVisible: Bool { panel.isVisible }
258
+
259
+ /// Where the orb's card ends on screen, for the answer card to sit
260
+ /// under. Nil while hidden.
261
+ var contentFrame: NSRect? {
262
+ guard panel.isVisible else { return nil }
263
+ let h = min(Self.height, host.fittingSize.height)
264
+ return NSRect(x: panel.frame.minX, y: panel.frame.maxY - h, width: Self.width, height: h)
265
+ }
266
+
267
+ func show(_ phase: OrbModel.Phase, name: String, tint: NSColor, line: String, status: String) {
268
+ guard phase != .hidden else { hide(); return }
269
+ model.name = name
270
+ model.tint = tint
271
+ model.line = line
272
+ model.status = status
273
+ if model.phase != phase {
274
+ model.phase = phase
275
+ if phase != .listening { model.level = 0 }
276
+ }
277
+ watchLevel()
278
+ if !panel.isVisible {
279
+ position()
280
+ panel.orderFrontRegardless()
281
+ }
282
+ }
283
+
284
+ func hide() {
285
+ model.phase = .hidden
286
+ model.level = 0
287
+ watchLevel()
288
+ panel.orderOut(nil)
289
+ }
290
+
291
+ /// Top right, under the menu bar, where macOS puts Siri.
292
+ private func position() {
293
+ guard let screen = NSScreen.main else { return }
294
+ let v = screen.visibleFrame
295
+ panel.setFrameOrigin(NSPoint(x: v.maxX - Self.width - 16, y: v.maxY - Self.height - 10))
296
+ }
297
+
298
+ /// The microphone is read only while listening, on screen, with
299
+ /// motion allowed.
300
+ private func watchLevel() {
301
+ let wanted = model.phase == .listening && !model.reduceMotion && levelSource != nil
302
+ if !wanted { levelTimer?.invalidate(); levelTimer = nil; return }
303
+ guard levelTimer == nil else { return }
304
+ levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 20, repeats: true) { [weak self] _ in
305
+ MainActor.assumeIsolated {
306
+ guard let self, let read = self.levelSource else { return }
307
+ self.model.level = OrbMath.smooth(self.model.level, toward: OrbMath.level(fromRMS: read()))
308
+ }
309
+ }
310
+ }
311
+ }
@@ -0,0 +1,57 @@
1
+ import Foundation
2
+
3
+ /// The numbers behind the orb, kept free of AppKit and SwiftUI so the
4
+ /// tests can check them without a window.
5
+ enum OrbMath {
6
+ /// The palette the daemon derives an agent's colour from when
7
+ /// `presence.color` is unset. Same list, same order and same hash as
8
+ /// `presenceLook` in src/voice/presence.ts, so the orb matches the
9
+ /// agent's on-screen cursor even when /agents sends no colour.
10
+ static let palette = ["#7C3AED", "#DB2777", "#EA580C", "#0D9488",
11
+ "#2563EB", "#65A30D", "#C026D3", "#0891B2"]
12
+
13
+ /// The colour for an agent: the daemon's when it sent one, else the
14
+ /// hash of the id.
15
+ static func colorHex(agentID: String, configured: String?) -> String {
16
+ if let c = configured, parseHex(c) != nil { return c }
17
+ var h: UInt32 = 0
18
+ // JS iterates code points and takes charCodeAt(0) of each: the
19
+ // first UTF-16 unit. Identical for every ASCII id.
20
+ for scalar in agentID.unicodeScalars {
21
+ let unit = UInt32(String(scalar).utf16.first ?? 0)
22
+ h = h &* 31 &+ unit
23
+ }
24
+ return palette[Int(h % UInt32(palette.count))]
25
+ }
26
+
27
+ /// "#RRGGBB" as 0…1 components, or nil when it is not that.
28
+ static func parseHex(_ s: String) -> (r: Double, g: Double, b: Double)? {
29
+ guard s.count == 7, s.hasPrefix("#"), let v = UInt32(s.dropFirst(), radix: 16) else { return nil }
30
+ return (Double((v >> 16) & 0xFF) / 255, Double((v >> 8) & 0xFF) / 255, Double(v & 0xFF) / 255)
31
+ }
32
+
33
+ /// Microphone RMS (about 0.005 in a quiet room, 0.02 for a voice,
34
+ /// 0.2 when shouting) to 0…1. Square root, because loudness is heard
35
+ /// roughly that way: linear made normal speech barely move the orb.
36
+ static func level(fromRMS rms: Float) -> Double {
37
+ guard rms.isFinite, rms > 0.004 else { return 0 }
38
+ return min(1, Double((rms - 0.004).squareRoot()) * 3.2)
39
+ }
40
+
41
+ /// A speech-like rise and fall at time `t`, 0.15…1: syllables at about
42
+ /// five a second under a slower phrase swell. The daemon plays the
43
+ /// answer, so the app cannot meter it; this keeps the orb talking in
44
+ /// rhythm without pretending to be a level meter.
45
+ static func speakingEnvelope(at t: Double) -> Double {
46
+ let syllable = abs(sin(t * 15.7))
47
+ let phrase = 0.55 + 0.45 * sin(t * 1.9 + 0.7)
48
+ let jitter = 0.5 + 0.5 * sin(t * 37.3 + sin(t * 3.1))
49
+ return 0.15 + 0.85 * syllable * phrase * (0.7 + 0.3 * jitter)
50
+ }
51
+
52
+ /// Smooth toward `target`: fast attack, slower release, as the
53
+ /// recorder does, so the orb swells with a word and settles after it.
54
+ static func smooth(_ current: Double, toward target: Double) -> Double {
55
+ target > current ? current + (target - current) * 0.6 : current + (target - current) * 0.2
56
+ }
57
+ }
@@ -51,6 +51,10 @@ final class Panel: NSPanel {
51
51
  /// Stay on screen when idle. Off: the pill shows only while active.
52
52
  var alwaysVisible = false
53
53
 
54
+ /// The orb overlay shows listening, thinking and speaking, so the
55
+ /// pill stays out of the way then; it still shows idle and errors.
56
+ var yieldsActiveStates = false
57
+
54
58
  /// Told of every state rendered, so the menu-bar icon can follow.
55
59
  var onRender: ((State) -> Void)?
56
60
 
@@ -107,6 +111,14 @@ final class Panel: NSPanel {
107
111
  /// Idle is an invitation and belongs in meta type; everything else
108
112
  /// is a running commentary and belongs in body type.
109
113
  var isMeta: Bool { if case .idle = self { return true }; return false }
114
+
115
+ /// Listening, thinking or speaking: what the orb shows.
116
+ var isActive: Bool {
117
+ switch self {
118
+ case .idle, .error: return false
119
+ default: return true
120
+ }
121
+ }
110
122
  }
111
123
 
112
124
  /// Called when the pill is clicked. Set by the app.
@@ -208,7 +220,8 @@ final class Panel: NSPanel {
208
220
  setText(named(state))
209
221
  }
210
222
 
211
- if state.isMeta && !alwaysVisible { orderOut(nil) } else { orderFrontRegardless() }
223
+ let yield = yieldsActiveStates && state.isActive
224
+ if (state.isMeta && !alwaysVisible) || yield { orderOut(nil) } else { orderFrontRegardless() }
212
225
  onRender?(state)
213
226
  }
214
227
 
@@ -22,6 +22,10 @@ final class ResultCard: NSPanel {
22
22
  private let links = NSStackView()
23
23
  private let thumb = NSImageView()
24
24
 
25
+ /// The orb's frame on screen while it shows, for the card to slide in
26
+ /// under it. Nil: the card sits above the pill. Set by the app.
27
+ var anchor: (() -> NSRect?)?
28
+
25
29
  init() {
26
30
  super.init(contentRect: NSRect(x: 0, y: 0, width: 380, height: 260),
27
31
  styleMask: [.titled, .closable, .resizable, .utilityWindow, .nonactivatingPanel],
@@ -125,8 +129,35 @@ final class ResultCard: NSPanel {
125
129
  }
126
130
 
127
131
  if let c = contentView { layout(c) }
128
- positionAboveWidget()
132
+ if let orb = anchor?() {
133
+ slideIn(under: orb)
134
+ } else {
135
+ positionAboveWidget()
136
+ orderFrontRegardless()
137
+ }
138
+ }
139
+
140
+ /// Under the orb, right edges aligned, sliding down into place. With
141
+ /// Reduce Motion it simply appears there.
142
+ @MainActor
143
+ private func slideIn(under orb: NSRect) {
144
+ let target = NSPoint(x: orb.maxX - frame.width, y: orb.minY - frame.height - 8)
145
+ let wasVisible = isVisible
146
+ guard !wasVisible, !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else {
147
+ setFrameOrigin(target)
148
+ alphaValue = 1
149
+ orderFrontRegardless()
150
+ return
151
+ }
152
+ setFrameOrigin(NSPoint(x: target.x, y: target.y + 18))
153
+ alphaValue = 0
129
154
  orderFrontRegardless()
155
+ NSAnimationContext.runAnimationGroup { ctx in
156
+ ctx.duration = 0.28
157
+ ctx.timingFunction = Brand.ease
158
+ animator().setFrameOrigin(target)
159
+ animator().alphaValue = 1
160
+ }
130
161
  }
131
162
 
132
163
  @objc private func openLink(_ sender: NSButton) {
@@ -139,6 +170,7 @@ final class ResultCard: NSPanel {
139
170
  guard let screen = NSScreen.main else { return }
140
171
  let v = screen.visibleFrame
141
172
  setFrameOrigin(NSPoint(x: v.maxX - frame.width - 24, y: v.minY + 24 + 54 + 10))
173
+ alphaValue = 1
142
174
  }
143
175
 
144
176
  override var canBecomeKey: Bool { true }