agentix-cli 0.56.0 → 0.58.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 +43 -0
  2. package/apps/mac-voice/Sources/AgentXVoice/Config.swift +19 -5
  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 +131 -169
  6. package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +174 -35
  7. package/apps/mac-voice/Sources/AgentXVoice/PillPlacement.swift +70 -0
  8. package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +26 -22
  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 +22 -6
  12. package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +101 -0
  13. package/apps/mac-voice/Sources/AgentXVoice/main.swift +132 -79
  14. package/dist/{App-4EM7IPB3.js → App-Z5VOAUXB.js} +2 -2
  15. package/dist/{ChatApp-FYTEEGSU.js → ChatApp-BE7ONQKS.js} +2 -2
  16. package/dist/{agent-RZ46ZB4W.js → agent-Q74EISKP.js} +2 -2
  17. package/dist/{board-dashboard-NRRYCDZW.js → board-dashboard-A7PQOI6C.js} +4 -4
  18. package/dist/{chunk-W4YMMWJL.js → chunk-2MIOMYT2.js} +2 -2
  19. package/dist/{chunk-EZN2YXRY.js → chunk-547G3HYQ.js} +2 -2
  20. package/dist/{chunk-LKJSPO3S.js → chunk-5LLGIX5M.js} +2 -2
  21. package/dist/{chunk-WOMQ2VGA.js → chunk-5W7NJHCS.js} +2 -2
  22. package/dist/{chunk-4HEVSWO3.js → chunk-6OABTCRA.js} +2 -2
  23. package/dist/{chunk-UIVHSYCN.js → chunk-6QNPCVRN.js} +3 -3
  24. package/dist/chunk-6QNPCVRN.js.map +1 -0
  25. package/dist/{chunk-CELYD5QW.js → chunk-CQUVUVXR.js} +2 -2
  26. package/dist/chunk-ERL6AS3O.js +15 -0
  27. package/dist/chunk-ERL6AS3O.js.map +1 -0
  28. package/dist/{chunk-EJ5ISSWW.js → chunk-I2LYULR4.js} +5 -5
  29. package/dist/{chunk-76AHNHWC.js → chunk-KKGFZEXS.js} +2 -2
  30. package/dist/{chunk-6AK6WKE5.js → chunk-KW7TIOS3.js} +2 -2
  31. package/dist/{chunk-SA23V7WX.js → chunk-NKMFHEGV.js} +2 -2
  32. package/dist/{chunk-WKMTT2UY.js → chunk-OZZPJ2YG.js} +197 -188
  33. package/dist/chunk-OZZPJ2YG.js.map +1 -0
  34. package/dist/{chunk-IL2XLW4H.js → chunk-POL7RAJQ.js} +13 -13
  35. package/dist/{chunk-D3LSPFHK.js → chunk-Q6SMLSRO.js} +2 -2
  36. package/dist/{chunk-723K4SAW.js → chunk-RXEAACVH.js} +2 -2
  37. package/dist/{chunk-O3XG2NVR.js → chunk-U5YBQJ5Q.js} +2 -2
  38. package/dist/{chunk-XEOAKD5C.js → chunk-V7ZK6ZIO.js} +2 -2
  39. package/dist/{chunk-M4AG2YNX.js → chunk-VI7GBDKV.js} +2 -2
  40. package/dist/{chunk-GSOL2LW6.js → chunk-VVBGPT6W.js} +2 -2
  41. package/dist/{chunk-C3VRBEJ7.js → chunk-XTD3SZIP.js} +2 -2
  42. package/dist/cli.js +1 -1
  43. package/dist/client-EFP2PKA3.js +2 -0
  44. package/dist/config-P6YXC6XZ.js +2 -0
  45. package/dist/config-mutator-3GKXLTHH.js +2 -0
  46. package/dist/doctor-JEWMHXYH.js +2 -0
  47. package/dist/index.d.ts +109 -3
  48. package/dist/index.js +1 -1
  49. package/dist/program-7DUQJDSQ.js +2 -0
  50. package/dist/{promote-T4UJROEH.js → promote-KVPLDSKT.js} +2 -2
  51. package/dist/query-AGOMCYTT.js +2 -0
  52. package/dist/{registry-DX2RI7I7.js → registry-AUOI4TW2.js} +2 -2
  53. package/dist/{request-planner-B7DE7VJR.js → request-planner-LMZ5ZAXC.js} +2 -2
  54. package/dist/{review-candidates-4YJHIBAF.js → review-candidates-WCBXLLDS.js} +2 -2
  55. package/dist/{routing-MZG3UODH.js → routing-FBASYKUL.js} +2 -2
  56. package/dist/schedule-tool-RJRJ63SS.js +2 -0
  57. package/dist/scheduler-ATA7Z44S.js +2 -0
  58. package/dist/{seat-2PEI7JRX.js → seat-DBFW3Z5P.js} +2 -2
  59. package/dist/{select-4KIUR27O.js → select-6CNVVFA5.js} +2 -2
  60. package/dist/{set-enabled-SE7HQKPF.js → set-enabled-AYZH3KSB.js} +2 -2
  61. package/dist/{tool-5VMD7IE4.js → tool-PKS4NBZ6.js} +2 -2
  62. package/dist/wiki-QW6BDNEQ.js +2 -0
  63. package/package.json +1 -1
  64. package/dist/chunk-U4H35SJS.js +0 -15
  65. package/dist/chunk-U4H35SJS.js.map +0 -1
  66. package/dist/chunk-UIVHSYCN.js.map +0 -1
  67. package/dist/chunk-WKMTT2UY.js.map +0 -1
  68. package/dist/client-IA7ZPE56.js +0 -2
  69. package/dist/config-HPN2F7LH.js +0 -2
  70. package/dist/config-mutator-7GPTMTVB.js +0 -2
  71. package/dist/doctor-IBWQVQV4.js +0 -2
  72. package/dist/program-G6DIFJUF.js +0 -2
  73. package/dist/query-VUJC4Y5O.js +0 -2
  74. package/dist/schedule-tool-A4OFQ4YZ.js +0 -2
  75. package/dist/scheduler-RHJW2V4M.js +0 -2
  76. package/dist/wiki-2QFLAH4U.js +0 -2
  77. /package/dist/{App-4EM7IPB3.js.map → App-Z5VOAUXB.js.map} +0 -0
  78. /package/dist/{ChatApp-FYTEEGSU.js.map → ChatApp-BE7ONQKS.js.map} +0 -0
  79. /package/dist/{agent-RZ46ZB4W.js.map → agent-Q74EISKP.js.map} +0 -0
  80. /package/dist/{board-dashboard-NRRYCDZW.js.map → board-dashboard-A7PQOI6C.js.map} +0 -0
  81. /package/dist/{chunk-W4YMMWJL.js.map → chunk-2MIOMYT2.js.map} +0 -0
  82. /package/dist/{chunk-EZN2YXRY.js.map → chunk-547G3HYQ.js.map} +0 -0
  83. /package/dist/{chunk-LKJSPO3S.js.map → chunk-5LLGIX5M.js.map} +0 -0
  84. /package/dist/{chunk-WOMQ2VGA.js.map → chunk-5W7NJHCS.js.map} +0 -0
  85. /package/dist/{chunk-4HEVSWO3.js.map → chunk-6OABTCRA.js.map} +0 -0
  86. /package/dist/{chunk-CELYD5QW.js.map → chunk-CQUVUVXR.js.map} +0 -0
  87. /package/dist/{chunk-EJ5ISSWW.js.map → chunk-I2LYULR4.js.map} +0 -0
  88. /package/dist/{chunk-76AHNHWC.js.map → chunk-KKGFZEXS.js.map} +0 -0
  89. /package/dist/{chunk-6AK6WKE5.js.map → chunk-KW7TIOS3.js.map} +0 -0
  90. /package/dist/{chunk-SA23V7WX.js.map → chunk-NKMFHEGV.js.map} +0 -0
  91. /package/dist/{chunk-IL2XLW4H.js.map → chunk-POL7RAJQ.js.map} +0 -0
  92. /package/dist/{chunk-D3LSPFHK.js.map → chunk-Q6SMLSRO.js.map} +0 -0
  93. /package/dist/{chunk-723K4SAW.js.map → chunk-RXEAACVH.js.map} +0 -0
  94. /package/dist/{chunk-O3XG2NVR.js.map → chunk-U5YBQJ5Q.js.map} +0 -0
  95. /package/dist/{chunk-XEOAKD5C.js.map → chunk-V7ZK6ZIO.js.map} +0 -0
  96. /package/dist/{chunk-M4AG2YNX.js.map → chunk-VI7GBDKV.js.map} +0 -0
  97. /package/dist/{chunk-GSOL2LW6.js.map → chunk-VVBGPT6W.js.map} +0 -0
  98. /package/dist/{chunk-C3VRBEJ7.js.map → chunk-XTD3SZIP.js.map} +0 -0
  99. /package/dist/{client-IA7ZPE56.js.map → client-EFP2PKA3.js.map} +0 -0
  100. /package/dist/{config-HPN2F7LH.js.map → config-P6YXC6XZ.js.map} +0 -0
  101. /package/dist/{config-mutator-7GPTMTVB.js.map → config-mutator-3GKXLTHH.js.map} +0 -0
  102. /package/dist/{doctor-IBWQVQV4.js.map → doctor-JEWMHXYH.js.map} +0 -0
  103. /package/dist/{program-G6DIFJUF.js.map → program-7DUQJDSQ.js.map} +0 -0
  104. /package/dist/{promote-T4UJROEH.js.map → promote-KVPLDSKT.js.map} +0 -0
  105. /package/dist/{query-VUJC4Y5O.js.map → query-AGOMCYTT.js.map} +0 -0
  106. /package/dist/{registry-DX2RI7I7.js.map → registry-AUOI4TW2.js.map} +0 -0
  107. /package/dist/{request-planner-B7DE7VJR.js.map → request-planner-LMZ5ZAXC.js.map} +0 -0
  108. /package/dist/{review-candidates-4YJHIBAF.js.map → review-candidates-WCBXLLDS.js.map} +0 -0
  109. /package/dist/{routing-MZG3UODH.js.map → routing-FBASYKUL.js.map} +0 -0
  110. /package/dist/{schedule-tool-A4OFQ4YZ.js.map → schedule-tool-RJRJ63SS.js.map} +0 -0
  111. /package/dist/{scheduler-RHJW2V4M.js.map → scheduler-ATA7Z44S.js.map} +0 -0
  112. /package/dist/{seat-2PEI7JRX.js.map → seat-DBFW3Z5P.js.map} +0 -0
  113. /package/dist/{select-4KIUR27O.js.map → select-6CNVVFA5.js.map} +0 -0
  114. /package/dist/{set-enabled-SE7HQKPF.js.map → set-enabled-AYZH3KSB.js.map} +0 -0
  115. /package/dist/{tool-5VMD7IE4.js.map → tool-PKS4NBZ6.js.map} +0 -0
  116. /package/dist/{wiki-2QFLAH4U.js.map → wiki-QW6BDNEQ.js.map} +0 -0
@@ -161,6 +161,49 @@ 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
@@ -28,11 +28,25 @@ 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") }
31
+ /// The pill's orb moves with the voice. On by default; off keeps it
32
+ /// still in every state, as Reduce Motion does.
33
+ static var animatedOrb: Bool {
34
+ get { UserDefaults.standard.object(forKey: "animatedOrb") as? Bool ?? true }
35
+ set { UserDefaults.standard.set(newValue, forKey: "animatedOrb") }
36
+ }
37
+
38
+ /// Where the pill was last dragged to (its bottom-left corner, in
39
+ /// screen points). Nil: the default corner.
40
+ static var pillOrigin: CGPoint? {
41
+ get {
42
+ guard let xy = UserDefaults.standard.array(forKey: "pillOrigin") as? [Double], xy.count == 2
43
+ else { return nil }
44
+ return CGPoint(x: xy[0], y: xy[1])
45
+ }
46
+ set {
47
+ if let p = newValue { UserDefaults.standard.set([Double(p.x), Double(p.y)], forKey: "pillOrigin") }
48
+ else { UserDefaults.standard.removeObject(forKey: "pillOrigin") }
49
+ }
36
50
  }
37
51
 
38
52
  /// The agent actually answering: `agentID`, else `chosenAgentID`, else
@@ -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
+ }
@@ -1,90 +1,61 @@
1
1
  import AppKit
2
2
  import SwiftUI
3
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.
4
+ /// The Siri-style orb at the head of the pill: the agent's colour, and it
5
+ /// listens, thinks and speaks.
6
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.
7
+ /// Built from SwiftUI's own gradients, no animation library. It only moves
8
+ /// while it has something to show and is on screen: idle or hidden, its
9
+ /// timeline is paused and the microphone level is no longer read, so an
10
+ /// idle assistant costs nothing. With Reduce Motion on, or "Animated orb"
11
+ /// off in the menu, it is a still picture that changes between states,
12
+ /// never a loop.
12
13
  @MainActor
13
14
  final class OrbModel: ObservableObject {
14
- enum Phase: Equatable { case hidden, listening, thinking, speaking }
15
+ enum Phase: Equatable { case idle, listening, thinking, speaking }
15
16
 
16
- @Published var phase: Phase = .hidden
17
+ @Published var phase: Phase = .idle
17
18
  /// 0…1: the microphone while listening.
18
19
  @Published var level: Double = 0
19
20
  @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 = ""
21
+ /// The pill is on screen. Off, nothing moves.
22
+ @Published var onScreen = false
25
23
  @Published var reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
24
+ /// "Animated orb" in the menu. Off: a still orb, as with Reduce Motion.
25
+ @Published var animated = true
26
+
27
+ var still: Bool { reduceMotion || !animated }
28
+ /// The timeline runs only while there is motion to draw.
29
+ var paused: Bool { still || phase == .idle || !onScreen }
26
30
  }
27
31
 
28
- struct OrbView: View {
32
+ /// The orb as the pill draws it: `OrbBody` at its design size, scaled down
33
+ /// to `diameter`, in a frame with room around it for the glow.
34
+ struct PillOrbView: View {
29
35
  @ObservedObject var model: OrbModel
30
- @Environment(\.colorScheme) private var scheme
36
+ let diameter: CGFloat
37
+ let frameSize: CGFloat
31
38
 
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
+ /// The size OrbBody's blur, ring and gradients were drawn for.
40
+ private static let designSize: CGFloat = 96
39
41
 
40
42
  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
43
+ let paused = model.paused
44
+ TimelineView(.animation(minimumInterval: 1.0 / 30, paused: paused)) { context in
79
45
  OrbBody(t: paused ? 0 : context.date.timeIntervalSinceReferenceDate,
80
- phase: model.phase, level: model.level, tint: model.tint, still: still)
46
+ phase: model.phase, level: model.level, tint: model.tint, still: model.still,
47
+ ringWidth: 5)
48
+ .frame(width: Self.designSize, height: Self.designSize)
49
+ .scaleEffect(diameter / Self.designSize)
81
50
  }
51
+ .frame(width: frameSize, height: frameSize)
52
+ .accessibilityElement()
82
53
  .accessibilityLabel(Text(accessibilityText))
83
54
  }
84
55
 
85
56
  private var accessibilityText: String {
86
57
  switch model.phase {
87
- case .hidden: return ""
58
+ case .idle: return "Idle"
88
59
  case .listening: return "Listening"
89
60
  case .thinking: return "Thinking"
90
61
  case .speaking: return "Speaking"
@@ -92,6 +63,97 @@ struct OrbView: View {
92
63
  }
93
64
  }
94
65
 
66
+ /// The orb's view in the pill. Clicks go through it to the pill, so
67
+ /// pressing on the orb drags the pill like anywhere else on it.
68
+ @MainActor
69
+ final class PillOrb: NSHostingView<PillOrbView> {
70
+ private let driver: OrbDriver
71
+ var model: OrbModel { driver.model }
72
+
73
+ /// The microphone's RMS level, read while listening. Set by the app.
74
+ var levelSource: (() -> Float)? {
75
+ get { driver.levelSource }
76
+ set { driver.levelSource = newValue; driver.watchLevel() }
77
+ }
78
+
79
+ init(diameter: CGFloat, frameSize: CGFloat) {
80
+ driver = OrbDriver()
81
+ super.init(rootView: PillOrbView(model: driver.model, diameter: diameter, frameSize: frameSize))
82
+ }
83
+
84
+ @available(*, unavailable)
85
+ required init(rootView: PillOrbView) { fatalError("use init(diameter:frameSize:)") }
86
+
87
+ @available(*, unavailable)
88
+ required init?(coder: NSCoder) { fatalError("not from a nib") }
89
+
90
+ override func hitTest(_ point: NSPoint) -> NSView? { nil }
91
+
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
+ let model = driver.model
96
+ if model.tint != tint { model.tint = tint }
97
+ if model.phase != phase {
98
+ model.phase = phase
99
+ if phase != .listening { model.level = 0 }
100
+ }
101
+ driver.watchLevel()
102
+ }
103
+
104
+ /// The pill came on screen or went off it.
105
+ func setOnScreen(_ on: Bool) {
106
+ let model = driver.model
107
+ guard model.onScreen != on else { return }
108
+ model.onScreen = on
109
+ if !on { model.level = 0 }
110
+ driver.watchLevel()
111
+ }
112
+
113
+ /// "Animated orb" in the menu.
114
+ func setAnimated(_ on: Bool) {
115
+ guard driver.model.animated != on else { return }
116
+ driver.model.animated = on
117
+ driver.watchLevel()
118
+ }
119
+ }
120
+
121
+ /// Reads the microphone into the model while listening, and follows the
122
+ /// Reduce Motion setting.
123
+ @MainActor
124
+ private final class OrbDriver {
125
+ let model = OrbModel()
126
+ var levelSource: (() -> Float)?
127
+ private var levelTimer: Timer?
128
+ private var motionObserver: NSObjectProtocol?
129
+
130
+ init() {
131
+ motionObserver = NSWorkspace.shared.notificationCenter.addObserver(
132
+ forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification,
133
+ object: nil, queue: .main) { [weak self] _ in
134
+ MainActor.assumeIsolated {
135
+ guard let self else { return }
136
+ self.model.reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
137
+ self.watchLevel()
138
+ }
139
+ }
140
+ }
141
+
142
+ /// The microphone is read only while listening, on screen, with
143
+ /// motion allowed.
144
+ func watchLevel() {
145
+ let wanted = model.phase == .listening && model.onScreen && !model.still && levelSource != nil
146
+ if !wanted { levelTimer?.invalidate(); levelTimer = nil; return }
147
+ guard levelTimer == nil else { return }
148
+ levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 20, repeats: true) { [weak self] _ in
149
+ MainActor.assumeIsolated {
150
+ guard let self, let read = self.levelSource else { return }
151
+ self.model.level = OrbMath.smooth(self.model.level, toward: OrbMath.level(fromRMS: read()))
152
+ }
153
+ }
154
+ }
155
+ }
156
+
95
157
  /// One frame of the orb at time `t`.
96
158
  struct OrbBody: View {
97
159
  let t: Double
@@ -100,11 +162,14 @@ struct OrbBody: View {
100
162
  let tint: NSColor
101
163
  /// Reduce Motion: a still orb, sized and shaded by state alone.
102
164
  let still: Bool
165
+ /// The thinking ring's stroke at the 96-point design size. The pill
166
+ /// draws the orb small, so it passes a thicker one that still reads.
167
+ var ringWidth: CGFloat = 2.2
103
168
 
104
169
  /// How much the orb swells and glows, 0…1.
105
170
  private var energy: Double {
106
171
  switch phase {
107
- case .hidden: return 0
172
+ case .idle: return 0
108
173
  case .listening: return still ? 0.45 : 0.12 + 0.88 * level
109
174
  case .thinking: return still ? 0.2 : 0.22 + 0.08 * sin(t * 2.2)
110
175
  case .speaking: return still ? 0.7 : OrbMath.speakingEnvelope(at: t)
@@ -114,7 +179,7 @@ struct OrbBody: View {
114
179
  /// How fast the colours drift.
115
180
  private var speed: Double {
116
181
  switch phase {
117
- case .hidden: return 0
182
+ case .idle: return 0
118
183
  case .listening: return 0.5 + level * 1.5
119
184
  case .thinking: return 1.3
120
185
  case .speaking: return 0.9
@@ -136,7 +201,7 @@ struct OrbBody: View {
136
201
  if phase == .thinking {
137
202
  Circle()
138
203
  .trim(from: 0, to: 0.28)
139
- .stroke(Color.white.opacity(0.75), style: StrokeStyle(lineWidth: 2.2, lineCap: .round))
204
+ .stroke(Color.white.opacity(0.75), style: StrokeStyle(lineWidth: ringWidth, lineCap: .round))
140
205
  .rotationEffect(.radians(still ? -.pi / 2 : t * 3.1))
141
206
  .scaleEffect(scale * 0.86)
142
207
  } else {
@@ -206,106 +271,3 @@ struct OrbBody: View {
206
271
  ]
207
272
  }
208
273
  }
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
- }