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
@@ -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
+ }
@@ -37,8 +37,14 @@ struct VoiceChoice: Decodable {
37
37
  enum Speech {
38
38
  // MARK: Speech to text
39
39
 
40
- static func transcribe(wav: Data) async throws -> String {
41
- if let key = Config.elevenLabsKey {
40
+ /// `engine` is agentx.json's `voice.stt`: "auto" and "elevenlabs" use
41
+ /// ElevenLabs when there is a key, falling back to mlx-whisper; "local"
42
+ /// never sends audio off the Mac.
43
+ static func transcribe(wav: Data, engine: String = "auto") async throws -> String {
44
+ if engine == "elevenlabs" && Config.elevenLabsKey == nil {
45
+ Log.warn("speech to text is set to ElevenLabs but no key is set; using mlx-whisper")
46
+ }
47
+ if engine != "local", let key = Config.elevenLabsKey {
42
48
  do { return try await elevenLabsSTT(wav: wav, key: key) }
43
49
  catch { Log.warn("ElevenLabs STT failed (\(error.localizedDescription)); falling back to mlx-whisper") }
44
50
  }
@@ -34,7 +34,14 @@ final class StatusMenu: NSObject, NSMenuDelegate {
34
34
  var onHoldChanged: ((Bool) -> Void)?
35
35
  var onTargetChanged: ((String) -> Void)?
36
36
  var onPillChanged: ((Bool) -> Void)?
37
- var onOrbChanged: ((Bool) -> Void)?
37
+ var onAnimatedOrbChanged: ((Bool) -> Void)?
38
+ /// Hide the pill and stop speech, as its close button does.
39
+ var onHidePill: (() -> Void)?
40
+ /// Put the pill back in the bottom-right corner.
41
+ var onResetPosition: (() -> Void)?
42
+ /// Whether the pill is on screen, for "Hide pill".
43
+ var pillVisible: () -> Bool = { false }
44
+ var onSettings: (() -> Void)?
38
45
 
39
46
  override init() {
40
47
  super.init()
@@ -177,12 +184,17 @@ final class StatusMenu: NSObject, NSMenuDelegate {
177
184
  let pill = action("Show floating pill", #selector(togglePill), key: "")
178
185
  pill.state = Config.showPill ? .on : .off
179
186
  menu.addItem(pill)
180
- let orb = action("Show orb", #selector(toggleOrb), key: "")
181
- orb.state = Config.showOrb ? .on : .off
187
+ let orb = action("Animated orb", #selector(toggleOrb), key: "")
188
+ orb.state = Config.animatedOrb ? .on : .off
182
189
  menu.addItem(orb)
190
+ let hide = action("Hide pill", #selector(hidePill), key: "")
191
+ hide.isEnabled = pillVisible()
192
+ menu.addItem(hide)
193
+ menu.addItem(action("Reset position", #selector(resetPosition), key: ""))
183
194
 
184
195
  menu.addItem(.separator())
185
196
  menu.addItem(action("Settings…", #selector(openSettings), key: ","))
197
+ menu.addItem(action("Dashboard…", #selector(openDashboardHome), key: "d"))
186
198
  menu.addItem(action("History…", #selector(openHistory), key: "y"))
187
199
  menu.addItem(.separator())
188
200
  menu.addItem(NSMenuItem(title: "Quit AgentX Voice",
@@ -230,11 +242,15 @@ final class StatusMenu: NSObject, NSMenuDelegate {
230
242
  }
231
243
 
232
244
  @objc private func toggleOrb() {
233
- Config.showOrb.toggle()
234
- onOrbChanged?(Config.showOrb)
245
+ Config.animatedOrb.toggle()
246
+ onAnimatedOrbChanged?(Config.animatedOrb)
235
247
  }
236
248
 
237
- @objc private func openSettings() { openDashboard("/admin") }
249
+ @objc private func hidePill() { onHidePill?() }
250
+ @objc private func resetPosition() { onResetPosition?() }
251
+
252
+ @objc private func openSettings() { onSettings?() }
253
+ @objc private func openDashboardHome() { openDashboard("/admin") }
238
254
  @objc private func openHistory() { openDashboard("/activity") }
239
255
 
240
256
  private func openDashboard(_ path: String) {
@@ -0,0 +1,101 @@
1
+ import Foundation
2
+
3
+ /// What GET /voice/settings returns, edited in the settings window and
4
+ /// sent back as the difference. Foundation only, so the tests can build it.
5
+ struct VoiceSettings: Codable, Equatable {
6
+ struct Hotkeys: Codable, Equatable {
7
+ var talk: String
8
+ var stop: String
9
+ var paste: String
10
+ }
11
+ struct General: Codable, Equatable {
12
+ var provider: String
13
+ var fallback: String
14
+ var stt: String
15
+ var hotkeys: Hotkeys
16
+ }
17
+ struct Voice: Codable, Equatable {
18
+ var provider: String?
19
+ var system: String?
20
+ var systemPerLanguage: Bool
21
+ var elevenlabsVoiceId: String?
22
+ var rate: Double?
23
+ var narrate: String?
24
+ var priority: String?
25
+ var hotkey: String?
26
+ }
27
+ struct Speaks: Codable, Equatable {
28
+ let provider: String
29
+ let systemVoice: String?
30
+ }
31
+ struct Agent: Codable, Equatable, Identifiable {
32
+ let id: String
33
+ let name: String
34
+ var color: String
35
+ var colorSet: Bool
36
+ var voice: Voice
37
+ let speaks: Speaks
38
+ }
39
+ struct SystemVoice: Codable, Equatable, Identifiable {
40
+ let id: String
41
+ let label: String
42
+ let locale: String
43
+ }
44
+
45
+ var general: General
46
+ var agents: [Agent]
47
+ let systemVoices: [SystemVoice]
48
+ let menuHotkey: String
49
+
50
+ /// The change from `old` to this, as POST /voice/settings takes it:
51
+ /// only what differs, and NSNull for a field put back to its default.
52
+ func patch(from old: VoiceSettings) -> [String: Any] {
53
+ var out: [String: Any] = [:]
54
+
55
+ var general: [String: Any] = [:]
56
+ if self.general.provider != old.general.provider { general["provider"] = self.general.provider }
57
+ if self.general.stt != old.general.stt { general["stt"] = self.general.stt }
58
+ var keys: [String: Any] = [:]
59
+ if self.general.hotkeys.talk != old.general.hotkeys.talk { keys["talk"] = self.general.hotkeys.talk }
60
+ if self.general.hotkeys.stop != old.general.hotkeys.stop { keys["stop"] = self.general.hotkeys.stop }
61
+ if self.general.hotkeys.paste != old.general.hotkeys.paste { keys["paste"] = self.general.hotkeys.paste }
62
+ if !keys.isEmpty { general["hotkeys"] = keys }
63
+ if !general.isEmpty { out["general"] = general }
64
+
65
+ var agents: [String: Any] = [:]
66
+ for agent in self.agents {
67
+ guard let before = old.agents.first(where: { $0.id == agent.id }) else { continue }
68
+ var a: [String: Any] = [:]
69
+ func field<T: Equatable>(_ name: String, _ now: T?, _ was: T?) {
70
+ if now != was { a[name] = now.map { $0 as Any } ?? NSNull() }
71
+ }
72
+ field("provider", agent.voice.provider, before.voice.provider)
73
+ field("system", agent.voice.system, before.voice.system)
74
+ field("elevenlabsVoiceId", agent.voice.elevenlabsVoiceId.flatMap { $0.isEmpty ? nil : $0 },
75
+ before.voice.elevenlabsVoiceId)
76
+ field("rate", agent.voice.rate, before.voice.rate)
77
+ field("narrate", agent.voice.narrate, before.voice.narrate)
78
+ field("priority", agent.voice.priority, before.voice.priority)
79
+ field("hotkey", agent.voice.hotkey, before.voice.hotkey)
80
+ if agent.colorSet != before.colorSet || (agent.colorSet && agent.color != before.color) {
81
+ a["color"] = agent.colorSet ? agent.color as Any : NSNull()
82
+ }
83
+ if !a.isEmpty { agents[agent.id] = a }
84
+ }
85
+ if !agents.isEmpty { out["agents"] = agents }
86
+ return out
87
+ }
88
+
89
+ /// The fields a preview speaks with: the draft's voice for one agent.
90
+ func previewVoice(for id: String) -> [String: Any] {
91
+ guard let v = agents.first(where: { $0.id == id })?.voice else { return [:] }
92
+ var out: [String: Any] = [
93
+ "provider": v.provider.map { $0 as Any } ?? NSNull(),
94
+ "elevenlabsVoiceId": v.elevenlabsVoiceId.flatMap { $0.isEmpty ? nil : $0 }.map { $0 as Any } ?? NSNull(),
95
+ "rate": v.rate.map { $0 as Any } ?? NSNull(),
96
+ ]
97
+ // A per-language list is not shown in the window; leave it be.
98
+ if !v.systemPerLanguage { out["system"] = v.system.map { $0 as Any } ?? NSNull() }
99
+ return out
100
+ }
101
+ }