agentix-cli 0.56.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 (111) hide show
  1. package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +43 -0
  2. package/apps/mac-voice/Sources/AgentXVoice/Hotkey.swift +8 -2
  3. package/apps/mac-voice/Sources/AgentXVoice/HotkeySpec.swift +80 -0
  4. package/apps/mac-voice/Sources/AgentXVoice/SettingsWindow.swift +518 -0
  5. package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +8 -2
  6. package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +4 -1
  7. package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +101 -0
  8. package/apps/mac-voice/Sources/AgentXVoice/main.swift +100 -38
  9. package/dist/{App-4EM7IPB3.js → App-Z5VOAUXB.js} +2 -2
  10. package/dist/{ChatApp-FYTEEGSU.js → ChatApp-BE7ONQKS.js} +2 -2
  11. package/dist/{agent-RZ46ZB4W.js → agent-Q74EISKP.js} +2 -2
  12. package/dist/{board-dashboard-NRRYCDZW.js → board-dashboard-A7PQOI6C.js} +4 -4
  13. package/dist/{chunk-W4YMMWJL.js → chunk-2MIOMYT2.js} +2 -2
  14. package/dist/{chunk-EZN2YXRY.js → chunk-547G3HYQ.js} +2 -2
  15. package/dist/{chunk-LKJSPO3S.js → chunk-5LLGIX5M.js} +2 -2
  16. package/dist/{chunk-WOMQ2VGA.js → chunk-5W7NJHCS.js} +2 -2
  17. package/dist/{chunk-4HEVSWO3.js → chunk-6OABTCRA.js} +2 -2
  18. package/dist/{chunk-UIVHSYCN.js → chunk-6QNPCVRN.js} +3 -3
  19. package/dist/chunk-6QNPCVRN.js.map +1 -0
  20. package/dist/{chunk-CELYD5QW.js → chunk-CQUVUVXR.js} +2 -2
  21. package/dist/chunk-ERL6AS3O.js +15 -0
  22. package/dist/chunk-ERL6AS3O.js.map +1 -0
  23. package/dist/{chunk-EJ5ISSWW.js → chunk-I2LYULR4.js} +5 -5
  24. package/dist/{chunk-76AHNHWC.js → chunk-KKGFZEXS.js} +2 -2
  25. package/dist/{chunk-6AK6WKE5.js → chunk-KW7TIOS3.js} +2 -2
  26. package/dist/{chunk-SA23V7WX.js → chunk-NKMFHEGV.js} +2 -2
  27. package/dist/{chunk-WKMTT2UY.js → chunk-OZZPJ2YG.js} +197 -188
  28. package/dist/chunk-OZZPJ2YG.js.map +1 -0
  29. package/dist/{chunk-IL2XLW4H.js → chunk-POL7RAJQ.js} +13 -13
  30. package/dist/{chunk-D3LSPFHK.js → chunk-Q6SMLSRO.js} +2 -2
  31. package/dist/{chunk-723K4SAW.js → chunk-RXEAACVH.js} +2 -2
  32. package/dist/{chunk-O3XG2NVR.js → chunk-U5YBQJ5Q.js} +2 -2
  33. package/dist/{chunk-XEOAKD5C.js → chunk-V7ZK6ZIO.js} +2 -2
  34. package/dist/{chunk-M4AG2YNX.js → chunk-VI7GBDKV.js} +2 -2
  35. package/dist/{chunk-GSOL2LW6.js → chunk-VVBGPT6W.js} +2 -2
  36. package/dist/{chunk-C3VRBEJ7.js → chunk-XTD3SZIP.js} +2 -2
  37. package/dist/cli.js +1 -1
  38. package/dist/client-EFP2PKA3.js +2 -0
  39. package/dist/config-P6YXC6XZ.js +2 -0
  40. package/dist/config-mutator-3GKXLTHH.js +2 -0
  41. package/dist/doctor-JEWMHXYH.js +2 -0
  42. package/dist/index.d.ts +109 -3
  43. package/dist/index.js +1 -1
  44. package/dist/program-7DUQJDSQ.js +2 -0
  45. package/dist/{promote-T4UJROEH.js → promote-KVPLDSKT.js} +2 -2
  46. package/dist/query-AGOMCYTT.js +2 -0
  47. package/dist/{registry-DX2RI7I7.js → registry-AUOI4TW2.js} +2 -2
  48. package/dist/{request-planner-B7DE7VJR.js → request-planner-LMZ5ZAXC.js} +2 -2
  49. package/dist/{review-candidates-4YJHIBAF.js → review-candidates-WCBXLLDS.js} +2 -2
  50. package/dist/{routing-MZG3UODH.js → routing-FBASYKUL.js} +2 -2
  51. package/dist/schedule-tool-RJRJ63SS.js +2 -0
  52. package/dist/scheduler-ATA7Z44S.js +2 -0
  53. package/dist/{seat-2PEI7JRX.js → seat-DBFW3Z5P.js} +2 -2
  54. package/dist/{select-4KIUR27O.js → select-6CNVVFA5.js} +2 -2
  55. package/dist/{set-enabled-SE7HQKPF.js → set-enabled-AYZH3KSB.js} +2 -2
  56. package/dist/{tool-5VMD7IE4.js → tool-PKS4NBZ6.js} +2 -2
  57. package/dist/wiki-QW6BDNEQ.js +2 -0
  58. package/package.json +1 -1
  59. package/dist/chunk-U4H35SJS.js +0 -15
  60. package/dist/chunk-U4H35SJS.js.map +0 -1
  61. package/dist/chunk-UIVHSYCN.js.map +0 -1
  62. package/dist/chunk-WKMTT2UY.js.map +0 -1
  63. package/dist/client-IA7ZPE56.js +0 -2
  64. package/dist/config-HPN2F7LH.js +0 -2
  65. package/dist/config-mutator-7GPTMTVB.js +0 -2
  66. package/dist/doctor-IBWQVQV4.js +0 -2
  67. package/dist/program-G6DIFJUF.js +0 -2
  68. package/dist/query-VUJC4Y5O.js +0 -2
  69. package/dist/schedule-tool-A4OFQ4YZ.js +0 -2
  70. package/dist/scheduler-RHJW2V4M.js +0 -2
  71. package/dist/wiki-2QFLAH4U.js +0 -2
  72. /package/dist/{App-4EM7IPB3.js.map → App-Z5VOAUXB.js.map} +0 -0
  73. /package/dist/{ChatApp-FYTEEGSU.js.map → ChatApp-BE7ONQKS.js.map} +0 -0
  74. /package/dist/{agent-RZ46ZB4W.js.map → agent-Q74EISKP.js.map} +0 -0
  75. /package/dist/{board-dashboard-NRRYCDZW.js.map → board-dashboard-A7PQOI6C.js.map} +0 -0
  76. /package/dist/{chunk-W4YMMWJL.js.map → chunk-2MIOMYT2.js.map} +0 -0
  77. /package/dist/{chunk-EZN2YXRY.js.map → chunk-547G3HYQ.js.map} +0 -0
  78. /package/dist/{chunk-LKJSPO3S.js.map → chunk-5LLGIX5M.js.map} +0 -0
  79. /package/dist/{chunk-WOMQ2VGA.js.map → chunk-5W7NJHCS.js.map} +0 -0
  80. /package/dist/{chunk-4HEVSWO3.js.map → chunk-6OABTCRA.js.map} +0 -0
  81. /package/dist/{chunk-CELYD5QW.js.map → chunk-CQUVUVXR.js.map} +0 -0
  82. /package/dist/{chunk-EJ5ISSWW.js.map → chunk-I2LYULR4.js.map} +0 -0
  83. /package/dist/{chunk-76AHNHWC.js.map → chunk-KKGFZEXS.js.map} +0 -0
  84. /package/dist/{chunk-6AK6WKE5.js.map → chunk-KW7TIOS3.js.map} +0 -0
  85. /package/dist/{chunk-SA23V7WX.js.map → chunk-NKMFHEGV.js.map} +0 -0
  86. /package/dist/{chunk-IL2XLW4H.js.map → chunk-POL7RAJQ.js.map} +0 -0
  87. /package/dist/{chunk-D3LSPFHK.js.map → chunk-Q6SMLSRO.js.map} +0 -0
  88. /package/dist/{chunk-723K4SAW.js.map → chunk-RXEAACVH.js.map} +0 -0
  89. /package/dist/{chunk-O3XG2NVR.js.map → chunk-U5YBQJ5Q.js.map} +0 -0
  90. /package/dist/{chunk-XEOAKD5C.js.map → chunk-V7ZK6ZIO.js.map} +0 -0
  91. /package/dist/{chunk-M4AG2YNX.js.map → chunk-VI7GBDKV.js.map} +0 -0
  92. /package/dist/{chunk-GSOL2LW6.js.map → chunk-VVBGPT6W.js.map} +0 -0
  93. /package/dist/{chunk-C3VRBEJ7.js.map → chunk-XTD3SZIP.js.map} +0 -0
  94. /package/dist/{client-IA7ZPE56.js.map → client-EFP2PKA3.js.map} +0 -0
  95. /package/dist/{config-HPN2F7LH.js.map → config-P6YXC6XZ.js.map} +0 -0
  96. /package/dist/{config-mutator-7GPTMTVB.js.map → config-mutator-3GKXLTHH.js.map} +0 -0
  97. /package/dist/{doctor-IBWQVQV4.js.map → doctor-JEWMHXYH.js.map} +0 -0
  98. /package/dist/{program-G6DIFJUF.js.map → program-7DUQJDSQ.js.map} +0 -0
  99. /package/dist/{promote-T4UJROEH.js.map → promote-KVPLDSKT.js.map} +0 -0
  100. /package/dist/{query-VUJC4Y5O.js.map → query-AGOMCYTT.js.map} +0 -0
  101. /package/dist/{registry-DX2RI7I7.js.map → registry-AUOI4TW2.js.map} +0 -0
  102. /package/dist/{request-planner-B7DE7VJR.js.map → request-planner-LMZ5ZAXC.js.map} +0 -0
  103. /package/dist/{review-candidates-4YJHIBAF.js.map → review-candidates-WCBXLLDS.js.map} +0 -0
  104. /package/dist/{routing-MZG3UODH.js.map → routing-FBASYKUL.js.map} +0 -0
  105. /package/dist/{schedule-tool-A4OFQ4YZ.js.map → schedule-tool-RJRJ63SS.js.map} +0 -0
  106. /package/dist/{scheduler-RHJW2V4M.js.map → scheduler-ATA7Z44S.js.map} +0 -0
  107. /package/dist/{seat-2PEI7JRX.js.map → seat-DBFW3Z5P.js.map} +0 -0
  108. /package/dist/{select-4KIUR27O.js.map → select-6CNVVFA5.js.map} +0 -0
  109. /package/dist/{set-enabled-SE7HQKPF.js.map → set-enabled-AYZH3KSB.js.map} +0 -0
  110. /package/dist/{tool-5VMD7IE4.js.map → tool-PKS4NBZ6.js.map} +0 -0
  111. /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,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,518 @@
1
+ import AppKit
2
+ import Carbon.HIToolbox
3
+ import ServiceManagement
4
+ import SwiftUI
5
+
6
+ /// Settings: per-agent voices and the app's own shortcuts and engines.
7
+ ///
8
+ /// Everything but launch at login lives in agentx.json and goes through
9
+ /// the daemon, which checks each value against the config schema before
10
+ /// writing. The window shows what the daemon has and keeps no copy: it
11
+ /// loads fresh every time it opens and after every save.
12
+ @MainActor
13
+ final class SettingsModel: ObservableObject {
14
+ @Published var saved: VoiceSettings?
15
+ @Published var draft: VoiceSettings?
16
+ @Published var selected: String?
17
+ @Published var loading = false
18
+ @Published var saving = false
19
+ @Published var error = ""
20
+ @Published var notice = ""
21
+ @Published var launchAtLogin = LoginItem.isOn
22
+ /// "agents" or "general".
23
+ @Published var tab = "agents"
24
+ /// A shortcut field is waiting for keys: the app's own shortcuts are
25
+ /// off meanwhile, or they would fire instead of being recorded.
26
+ @Published var recording: String? {
27
+ didSet { if (recording == nil) != (oldValue == nil) { onRecording?(recording != nil) } }
28
+ }
29
+
30
+ var onSaved: ((VoiceSettings) -> Void)?
31
+ var onRecording: ((Bool) -> Void)?
32
+
33
+ var hasChanges: Bool {
34
+ guard let saved, let draft else { return false }
35
+ return !draft.patch(from: saved).isEmpty
36
+ }
37
+
38
+ func load() {
39
+ loading = true
40
+ error = ""
41
+ Task {
42
+ let s = await AgentClient.settings()
43
+ loading = false
44
+ guard let s else {
45
+ error = "The AgentX daemon isn't reachable. Start it, then open Settings again."
46
+ return
47
+ }
48
+ saved = s
49
+ draft = s
50
+ if selected == nil || !s.agents.contains(where: { $0.id == selected }) { selected = s.agents.first?.id }
51
+ }
52
+ }
53
+
54
+ func revert() {
55
+ draft = saved
56
+ error = ""
57
+ notice = ""
58
+ }
59
+
60
+ func save() {
61
+ guard let saved, let draft, hasChanges else { return }
62
+ saving = true
63
+ error = ""
64
+ notice = ""
65
+ Task {
66
+ let result = await AgentClient.saveSettings(draft.patch(from: saved))
67
+ saving = false
68
+ switch result {
69
+ case .success(let s):
70
+ self.saved = s
71
+ self.draft = s
72
+ notice = "Saved. The next line spoken uses it; no restart needed."
73
+ onSaved?(s)
74
+ case .failure(let f):
75
+ error = f.message
76
+ }
77
+ }
78
+ }
79
+
80
+ func preview(_ id: String) {
81
+ guard let draft else { return }
82
+ error = ""
83
+ notice = "Playing a sample…"
84
+ Task {
85
+ if let problem = await AgentClient.preview(agentID: id, voice: draft.previewVoice(for: id)) {
86
+ notice = ""
87
+ error = problem
88
+ } else {
89
+ notice = "Playing a sample. It is not saved until you press Save."
90
+ }
91
+ }
92
+ }
93
+
94
+ func setLaunchAtLogin(_ on: Bool) {
95
+ do {
96
+ try LoginItem.set(on)
97
+ error = ""
98
+ } catch {
99
+ self.error = "macOS didn't change the login item: \(error.localizedDescription)"
100
+ }
101
+ launchAtLogin = LoginItem.isOn
102
+ }
103
+
104
+ /// A binding into the draft for the agent `id`.
105
+ func agent(_ id: String) -> Binding<VoiceSettings.Agent>? {
106
+ guard let i = draft?.agents.firstIndex(where: { $0.id == id }) else { return nil }
107
+ return Binding(get: { self.draft!.agents[i] }, set: { self.draft!.agents[i] = $0 })
108
+ }
109
+ }
110
+
111
+ /// Launch at login is macOS's own setting, never agentx.json.
112
+ enum LoginItem {
113
+ /// The launchd label `agentx desktop install` gives the app.
114
+ static let installerLabel = "tn.acme.agentx.voice"
115
+
116
+ /// Installed with `agentx desktop install`, which starts the app at
117
+ /// login with launchd. A second login item would start a second copy.
118
+ static var managedByInstaller: Bool {
119
+ if ProcessInfo.processInfo.environment["XPC_SERVICE_NAME"] == installerLabel { return true }
120
+ let plist = "\(NSHomeDirectory())/Library/LaunchAgents/\(installerLabel).plist"
121
+ return FileManager.default.fileExists(atPath: plist)
122
+ }
123
+
124
+ static var isOn: Bool { managedByInstaller || SMAppService.mainApp.status == .enabled }
125
+
126
+ static func set(_ on: Bool) throws {
127
+ if on { try SMAppService.mainApp.register() } else { try SMAppService.mainApp.unregister() }
128
+ }
129
+ }
130
+
131
+ struct SettingsView: View {
132
+ @ObservedObject var model: SettingsModel
133
+
134
+ var body: some View {
135
+ VStack(spacing: 0) {
136
+ if let draft = model.draft {
137
+ TabView(selection: $model.tab) {
138
+ AgentsTab(model: model, settings: draft)
139
+ .tabItem { Text("Agents") }
140
+ .tag("agents")
141
+ GeneralTab(model: model)
142
+ .tabItem { Text("General") }
143
+ .tag("general")
144
+ }
145
+ .padding([.horizontal, .top], 12)
146
+ } else {
147
+ VStack(spacing: 12) {
148
+ if model.loading { ProgressView() }
149
+ Text(model.loading ? "Loading settings…" : model.error)
150
+ .foregroundStyle(.secondary)
151
+ if !model.loading { Button("Try again") { model.load() } }
152
+ }
153
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
154
+ }
155
+ if model.draft != nil { footer }
156
+ }
157
+ .frame(width: 660, height: 640)
158
+ }
159
+
160
+ private var footer: some View {
161
+ HStack(alignment: .top, spacing: 12) {
162
+ VStack(alignment: .leading, spacing: 2) {
163
+ if !model.error.isEmpty {
164
+ Text(model.error)
165
+ .foregroundStyle(Color(nsColor: .systemRed))
166
+ .fixedSize(horizontal: false, vertical: true)
167
+ .textSelection(.enabled)
168
+ } else if !model.notice.isEmpty {
169
+ Text(model.notice).foregroundStyle(.secondary)
170
+ }
171
+ }
172
+ .font(.callout)
173
+ .frame(maxWidth: .infinity, alignment: .leading)
174
+ Button("Revert") { model.revert() }
175
+ .disabled(!model.hasChanges || model.saving)
176
+ Button(model.saving ? "Saving…" : "Save") { model.save() }
177
+ .keyboardShortcut("s", modifiers: .command)
178
+ .disabled(!model.hasChanges || model.saving)
179
+ }
180
+ .padding(12)
181
+ }
182
+ }
183
+
184
+ // MARK: Agents
185
+
186
+ private struct AgentsTab: View {
187
+ @ObservedObject var model: SettingsModel
188
+ let settings: VoiceSettings
189
+
190
+ var body: some View {
191
+ HStack(spacing: 0) {
192
+ List(settings.agents, selection: $model.selected) { agent in
193
+ HStack(spacing: 8) {
194
+ Circle().fill(Color(hex: agent.color)).frame(width: 10, height: 10)
195
+ Text(agent.name)
196
+ }
197
+ .tag(agent.id)
198
+ }
199
+ .frame(width: 170)
200
+ Divider()
201
+ if let id = model.selected, let agent = model.agent(id) {
202
+ AgentForm(model: model, agent: agent, settings: settings)
203
+ } else {
204
+ Text("No agents in agentx.json").foregroundStyle(.secondary)
205
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
206
+ }
207
+ }
208
+ }
209
+ }
210
+
211
+ private struct AgentForm: View {
212
+ @ObservedObject var model: SettingsModel
213
+ @Binding var agent: VoiceSettings.Agent
214
+ let settings: VoiceSettings
215
+
216
+ private var provider: String { agent.voice.provider ?? settings.general.provider }
217
+
218
+ var body: some View {
219
+ Form {
220
+ voiceSection
221
+ workSection
222
+ shortcutSection
223
+ }
224
+ .formStyle(.grouped)
225
+ }
226
+
227
+ private var voiceSection: some View {
228
+ Section {
229
+ providerPicker
230
+ systemVoicePicker
231
+ elevenLabsField
232
+ previewRow
233
+ rateRow
234
+ } header: {
235
+ Text("Voice")
236
+ } footer: {
237
+ Text("Voice changes apply to the next line this agent speaks. ElevenLabs speaks at most 1.2× and at least 0.7×.")
238
+ .font(.caption).foregroundStyle(.secondary)
239
+ }
240
+ }
241
+
242
+ private var providerPicker: some View {
243
+ let fallback = settings.general.provider == "elevenlabs" ? "ElevenLabs" : "Mac voices"
244
+ return Picker("Voice provider", selection: $agent.voice.provider) {
245
+ Text("Default (\(fallback))").tag(String?.none)
246
+ Text("Mac voices (free)").tag(String?.some("system"))
247
+ Text("ElevenLabs").tag(String?.some("elevenlabs"))
248
+ }
249
+ }
250
+
251
+ /// A configured voice the Mac doesn't list (a plain name such as
252
+ /// "Daniel"), kept selectable so the picker can show it.
253
+ private var unlistedVoice: String? {
254
+ guard let s = agent.voice.system, s != "system",
255
+ !settings.systemVoices.contains(where: { $0.id == s }) else { return nil }
256
+ return s
257
+ }
258
+
259
+ @ViewBuilder private var systemVoicePicker: some View {
260
+ if agent.voice.systemPerLanguage {
261
+ LabeledContent("Mac voice", value: "One voice per language, set in agentx.json")
262
+ } else {
263
+ Picker("Mac voice", selection: $agent.voice.system) {
264
+ Text("Assigned automatically").tag(String?.none)
265
+ Text("The Mac's default voice").tag(String?.some("system"))
266
+ if let s = unlistedVoice { Text(s).tag(String?.some(s)) }
267
+ ForEach(settings.systemVoices) { v in
268
+ Text(v.label).tag(String?.some(v.id))
269
+ }
270
+ }
271
+ }
272
+ }
273
+
274
+ private var elevenLabsField: some View {
275
+ let text = Binding<String>(
276
+ get: { agent.voice.elevenlabsVoiceId ?? "" },
277
+ set: { agent.voice.elevenlabsVoiceId = $0.isEmpty ? nil : $0 })
278
+ return TextField("ElevenLabs voice ID", text: text, prompt: Text("Default voice"))
279
+ .disabled(provider != "elevenlabs")
280
+ }
281
+
282
+ private var previewRow: some View {
283
+ let now: String = agent.speaks.provider == "elevenlabs" ? "ElevenLabs" : (agent.speaks.systemVoice ?? "the Mac's default voice")
284
+ return HStack {
285
+ Text("Saved voice: \(now)").font(.caption).foregroundStyle(.secondary)
286
+ Spacer()
287
+ Button("Preview") { model.preview(agent.id) }
288
+ }
289
+ }
290
+
291
+ private var rateRow: some View {
292
+ let rate = Binding<Double>(
293
+ get: { agent.voice.rate ?? 1 },
294
+ set: { v in
295
+ let snapped = (v * 20).rounded() / 20
296
+ agent.voice.rate = abs(snapped - 1) < 0.001 ? nil : snapped
297
+ })
298
+ return HStack {
299
+ Slider(value: rate, in: 0.75...1.5, step: 0.05) { Text("Speaking speed") }
300
+ Text(String(format: "%.2f×", agent.voice.rate ?? 1)).monospacedDigit().frame(width: 48)
301
+ Button("Normal") { agent.voice.rate = nil }.disabled(agent.voice.rate == nil)
302
+ }
303
+ }
304
+
305
+ private var workSection: some View {
306
+ // "off" written out and unset mean the same: both show as Off.
307
+ let narrate = Binding<String?>(
308
+ get: { agent.voice.narrate == "off" ? nil : agent.voice.narrate },
309
+ set: { agent.voice.narrate = $0 })
310
+ return Section("While it works") {
311
+ Picker("Narration", selection: narrate) {
312
+ Text("Off").tag(String?.none)
313
+ Text("On, except scheduled jobs").tag(String?.some("on"))
314
+ Text("On, scheduled jobs too").tag(String?.some("all"))
315
+ }
316
+ Picker("Queue priority", selection: $agent.voice.priority) {
317
+ Text("Normal").tag(String?.none)
318
+ Text("High: ahead of waiting lines").tag(String?.some("high"))
319
+ Text("Low: after waiting lines").tag(String?.some("low"))
320
+ }
321
+ }
322
+ }
323
+
324
+ private var shortcutSection: some View {
325
+ let color = Binding<Color>(
326
+ get: { Color(hex: agent.color) },
327
+ set: { agent.color = $0.hex; agent.colorSet = true })
328
+ let footer = agent.colorSet
329
+ ? "Hold the shortcut and speak to ask this agent without changing who is ticked in the menu. The colour also paints this agent's pointer."
330
+ : "Hold the shortcut and speak to ask this agent without changing who is ticked in the menu. The colour comes from the agent's id until you pick one."
331
+ return Section {
332
+ HotkeyField(title: "Ask with shortcut", value: $agent.voice.hotkey, optional: true, model: model, id: "agent.\(agent.id)")
333
+ HStack {
334
+ ColorPicker("Orb colour", selection: color, supportsOpacity: false)
335
+ Button("Use default") { agent.colorSet = false }.disabled(!agent.colorSet)
336
+ }
337
+ } header: {
338
+ Text("Shortcut and colour")
339
+ } footer: {
340
+ Text(footer).font(.caption).foregroundStyle(.secondary)
341
+ }
342
+ }
343
+ }
344
+
345
+ // MARK: General
346
+
347
+ private struct GeneralTab: View {
348
+ @ObservedObject var model: SettingsModel
349
+
350
+ var body: some View {
351
+ Form {
352
+ if let draft = model.draft {
353
+ Section {
354
+ HotkeyField(title: "Talk (hold)", value: nonOptional(\.talk, draft.general.hotkeys.talk), optional: false, model: model, id: "talk")
355
+ HotkeyField(title: "Stop every voice", value: nonOptional(\.stop, draft.general.hotkeys.stop), optional: false, model: model, id: "stop")
356
+ HotkeyField(title: "Smart paste", value: nonOptional(\.paste, draft.general.hotkeys.paste), optional: false, model: model, id: "paste")
357
+ LabeledContent("Open the menu", value: HotkeySpec.display(draft.menuHotkey))
358
+ } header: {
359
+ Text("Shortcuts")
360
+ } footer: {
361
+ Text("Shortcuts change as soon as you save. If one does nothing, another app already uses it.")
362
+ .font(.caption).foregroundStyle(.secondary)
363
+ }
364
+ Section {
365
+ Picker("Speech to text", selection: binding(\.stt)) {
366
+ Text("Automatic").tag("auto")
367
+ Text("ElevenLabs").tag("elevenlabs")
368
+ Text("On this Mac (Whisper)").tag("local")
369
+ }
370
+ Picker("Default voice provider", selection: binding(\.provider)) {
371
+ Text("Mac voices (free)").tag("system")
372
+ Text("ElevenLabs").tag("elevenlabs")
373
+ }
374
+ } header: {
375
+ Text("Speech")
376
+ } footer: {
377
+ Text("Automatic uses ElevenLabs when a key is set, and Whisper on this Mac otherwise. The default provider is for agents that don't choose their own. Both apply to the next question.")
378
+ .font(.caption).foregroundStyle(.secondary)
379
+ }
380
+ Section {
381
+ Toggle("Launch at login", isOn: Binding(get: { model.launchAtLogin }, set: { model.setLaunchAtLogin($0) }))
382
+ .disabled(LoginItem.managedByInstaller)
383
+ } footer: {
384
+ Text(LoginItem.managedByInstaller
385
+ ? "Started at login by `agentx desktop install`. To stop that, run `agentx desktop stop` in Terminal."
386
+ : "Saved by macOS as a login item, not in agentx.json. Changes right away.")
387
+ .font(.caption).foregroundStyle(.secondary)
388
+ }
389
+ }
390
+ }
391
+ .formStyle(.grouped)
392
+ }
393
+
394
+ private func binding(_ path: WritableKeyPath<VoiceSettings.General, String>) -> Binding<String> {
395
+ Binding(get: { model.draft?.general[keyPath: path] ?? "" },
396
+ set: { model.draft?.general[keyPath: path] = $0 })
397
+ }
398
+
399
+ private func nonOptional(_ path: WritableKeyPath<VoiceSettings.Hotkeys, String>, _ now: String) -> Binding<String?> {
400
+ Binding(get: { now }, set: { if let v = $0 { model.draft?.general.hotkeys[keyPath: path] = v } })
401
+ }
402
+ }
403
+
404
+ // MARK: Shortcut field
405
+
406
+ /// Click, then press the keys. Escape cancels; Delete clears an optional one.
407
+ private struct HotkeyField: View {
408
+ let title: String
409
+ @Binding var value: String?
410
+ let optional: Bool
411
+ @ObservedObject var model: SettingsModel
412
+ let id: String
413
+ @State private var monitor: Any?
414
+ @State private var hint = ""
415
+
416
+ private var recording: Bool { model.recording == id }
417
+
418
+ var body: some View {
419
+ LabeledContent(title) {
420
+ HStack(spacing: 6) {
421
+ if !hint.isEmpty { Text(hint).font(.caption).foregroundStyle(.secondary) }
422
+ Button(recording ? "Type the shortcut…" : value.map(HotkeySpec.display) ?? "None") {
423
+ recording ? stop() : start()
424
+ }
425
+ .frame(minWidth: 110)
426
+ if optional && value != nil && !recording {
427
+ Button { value = nil } label: { Image(systemName: "xmark.circle.fill") }
428
+ .buttonStyle(.borderless)
429
+ .accessibilityLabel("Clear \(title)")
430
+ }
431
+ }
432
+ }
433
+ .onDisappear { stop() }
434
+ }
435
+
436
+ private func start() {
437
+ model.recording = id
438
+ hint = "Esc to cancel"
439
+ monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
440
+ let flags = event.modifierFlags.intersection([.control, .option, .shift, .command])
441
+ if event.keyCode == 53 && flags.isEmpty { stop(); return nil } // Escape
442
+ if (event.keyCode == 51 || event.keyCode == 117) && flags.isEmpty && optional { // Delete
443
+ value = nil; stop(); return nil
444
+ }
445
+ var mods: UInt32 = 0
446
+ if flags.contains(.control) { mods |= UInt32(controlKey) }
447
+ if flags.contains(.option) { mods |= UInt32(optionKey) }
448
+ if flags.contains(.shift) { mods |= UInt32(shiftKey) }
449
+ if flags.contains(.command) { mods |= UInt32(cmdKey) }
450
+ let spec = HotkeySpec(keyCode: UInt32(event.keyCode), modifiers: mods)
451
+ guard let text = spec.text, HotkeySpec(text) != nil else {
452
+ hint = "Add ⌃, ⌥ or ⌘"
453
+ return nil
454
+ }
455
+ value = text
456
+ stop()
457
+ return nil
458
+ }
459
+ }
460
+
461
+ private func stop() {
462
+ if let monitor { NSEvent.removeMonitor(monitor) }
463
+ monitor = nil
464
+ hint = ""
465
+ if model.recording == id { model.recording = nil }
466
+ }
467
+ }
468
+
469
+ // MARK: Window
470
+
471
+ @MainActor
472
+ final class SettingsWindow: NSObject, NSWindowDelegate {
473
+ let model = SettingsModel()
474
+ private var window: NSWindow?
475
+
476
+ func show() {
477
+ if window == nil {
478
+ let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 660, height: 640),
479
+ styleMask: [.titled, .closable, .miniaturizable],
480
+ backing: .buffered, defer: false)
481
+ w.title = "AgentX Voice Settings"
482
+ w.isReleasedWhenClosed = false
483
+ w.contentView = NSHostingView(rootView: SettingsView(model: model))
484
+ w.delegate = self
485
+ w.center()
486
+ window = w
487
+ }
488
+ model.launchAtLogin = LoginItem.isOn
489
+ model.load()
490
+ // An accessory app has to ask to come forward before its window can
491
+ // take typing; the window was opened on purpose, so that is fine.
492
+ NSApp.activate(ignoringOtherApps: true)
493
+ window?.makeKeyAndOrderFront(nil)
494
+ }
495
+
496
+ func windowWillClose(_ notification: Notification) {
497
+ model.recording = nil
498
+ }
499
+ }
500
+
501
+ extension Color {
502
+ /// "#RRGGBB"; the brand teal when it isn't one.
503
+ init(hex: String) {
504
+ if let c = OrbMath.parseHex(hex) { self.init(.sRGB, red: c.r, green: c.g, blue: c.b) }
505
+ else { self.init(nsColor: Brand.accent) }
506
+ }
507
+
508
+ /// "#RRGGBB" in sRGB.
509
+ var hex: String {
510
+ let c = NSColor(self).usingColorSpace(.sRGB) ?? .black
511
+ let v = [c.redComponent, c.greenComponent, c.blueComponent].map { Int(($0 * 255).rounded()).clamped(0, 255) }
512
+ return String(format: "#%02X%02X%02X", v[0], v[1], v[2])
513
+ }
514
+ }
515
+
516
+ private extension Int {
517
+ func clamped(_ lo: Int, _ hi: Int) -> Int { Swift.min(hi, Swift.max(lo, self)) }
518
+ }