@lattices/cli 0.4.0 → 0.4.2
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.
- package/app/Info.plist +30 -0
- package/app/Lattices.app/Contents/Info.plist +8 -2
- package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/app/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
- package/app/Lattices.app/Contents/Resources/tap.wav +0 -0
- package/app/Lattices.app/Contents/_CodeSignature/CodeResources +139 -0
- package/app/Lattices.entitlements +15 -0
- package/app/Resources/tap.wav +0 -0
- package/app/Sources/ActionRow.swift +43 -26
- package/app/Sources/AppDelegate.swift +13 -7
- package/app/Sources/DesktopModel.swift +26 -2
- package/app/Sources/HandsOffSession.swift +121 -26
- package/app/Sources/HotkeyStore.swift +5 -1
- package/app/Sources/IntentEngine.swift +37 -0
- package/app/Sources/LatticesApi.swift +40 -0
- package/app/Sources/MainView.swift +73 -21
- package/app/Sources/MouseFinder.swift +222 -0
- package/app/Sources/ProjectScanner.swift +57 -44
- package/app/Tests/StageDragTests.swift +333 -0
- package/app/Tests/StageJoinTests.swift +313 -0
- package/app/Tests/StageManagerTests.swift +280 -0
- package/app/Tests/StageTileTests.swift +353 -0
- package/assets/AppIcon.icns +0 -0
- package/bin/handsoff-worker.ts +10 -1
- package/bin/lattices-app.ts +123 -39
- package/bin/lattices-dev +51 -3
- package/bin/lattices.ts +181 -7
- package/docs/agent-layer-guide.md +207 -0
- package/package.json +12 -4
|
@@ -97,6 +97,15 @@ final class HandsOffSession: ObservableObject {
|
|
|
97
97
|
private var workerBuffer = ""
|
|
98
98
|
private let workerQueue = DispatchQueue(label: "com.lattices.handsoff-worker", qos: .userInitiated)
|
|
99
99
|
private var lastCueAt: Date = .distantPast
|
|
100
|
+
private var workerRoot: String? {
|
|
101
|
+
if let idx = CommandLine.arguments.firstIndex(of: "--lattices-cli-root"),
|
|
102
|
+
CommandLine.arguments.indices.contains(idx + 1) {
|
|
103
|
+
return CommandLine.arguments[idx + 1]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let devRoot = NSHomeDirectory() + "/dev/lattices"
|
|
107
|
+
return FileManager.default.fileExists(atPath: devRoot) ? devRoot : nil
|
|
108
|
+
}
|
|
100
109
|
|
|
101
110
|
/// JSONL log for full turn data — ~/.lattices/handsoff.jsonl
|
|
102
111
|
private let turnLogPath = NSHomeDirectory() + "/.lattices/handsoff.jsonl"
|
|
@@ -122,7 +131,7 @@ final class HandsOffSession: ObservableObject {
|
|
|
122
131
|
// MARK: - Lifecycle
|
|
123
132
|
|
|
124
133
|
func start() {
|
|
125
|
-
|
|
134
|
+
// Worker startup is lazy — only start it when a voice turn or cached cue needs it.
|
|
126
135
|
}
|
|
127
136
|
|
|
128
137
|
func setAudibleFeedbackEnabled(_ enabled: Bool) {
|
|
@@ -170,9 +179,10 @@ final class HandsOffSession: ObservableObject {
|
|
|
170
179
|
}
|
|
171
180
|
}
|
|
172
181
|
|
|
173
|
-
|
|
182
|
+
@discardableResult
|
|
183
|
+
private func startWorker() -> Bool {
|
|
174
184
|
if workerProcess?.isRunning == true, workerStdin != nil {
|
|
175
|
-
return
|
|
185
|
+
return true
|
|
176
186
|
}
|
|
177
187
|
|
|
178
188
|
let bunPaths = [
|
|
@@ -182,19 +192,24 @@ final class HandsOffSession: ObservableObject {
|
|
|
182
192
|
]
|
|
183
193
|
guard let bunPath = bunPaths.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) else {
|
|
184
194
|
DiagnosticLog.shared.warn("HandsOff: bun not found, worker disabled")
|
|
185
|
-
return
|
|
195
|
+
return false
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
guard let workerRoot else {
|
|
199
|
+
DiagnosticLog.shared.warn("HandsOff: worker root not found, worker disabled")
|
|
200
|
+
return false
|
|
186
201
|
}
|
|
187
202
|
|
|
188
|
-
let scriptPath =
|
|
203
|
+
let scriptPath = workerRoot + "/bin/handsoff-worker.ts"
|
|
189
204
|
guard FileManager.default.fileExists(atPath: scriptPath) else {
|
|
190
205
|
DiagnosticLog.shared.warn("HandsOff: worker script not found at \(scriptPath)")
|
|
191
|
-
return
|
|
206
|
+
return false
|
|
192
207
|
}
|
|
193
208
|
|
|
194
209
|
let proc = Process()
|
|
195
210
|
proc.executableURL = URL(fileURLWithPath: bunPath)
|
|
196
211
|
proc.arguments = ["run", scriptPath]
|
|
197
|
-
proc.currentDirectoryURL = URL(fileURLWithPath:
|
|
212
|
+
proc.currentDirectoryURL = URL(fileURLWithPath: workerRoot)
|
|
198
213
|
|
|
199
214
|
var env = ProcessInfo.processInfo.environment
|
|
200
215
|
env.removeValue(forKey: "CLAUDECODE")
|
|
@@ -211,7 +226,7 @@ final class HandsOffSession: ObservableObject {
|
|
|
211
226
|
try proc.run()
|
|
212
227
|
} catch {
|
|
213
228
|
DiagnosticLog.shared.warn("HandsOff: failed to start worker — \(error)")
|
|
214
|
-
return
|
|
229
|
+
return false
|
|
215
230
|
}
|
|
216
231
|
|
|
217
232
|
workerProcess = proc
|
|
@@ -235,22 +250,29 @@ final class HandsOffSession: ObservableObject {
|
|
|
235
250
|
|
|
236
251
|
// Handle worker crash → restart
|
|
237
252
|
proc.terminationHandler = { [weak self] proc in
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
253
|
+
guard let self else { return }
|
|
254
|
+
let keepWarm = self.audibleFeedbackEnabled || self.state != .idle
|
|
255
|
+
let suffix = keepWarm ? ", restarting in 2s" : ", staying idle"
|
|
256
|
+
DiagnosticLog.shared.warn("HandsOff: worker exited (code \(proc.terminationStatus))\(suffix)")
|
|
257
|
+
self.workerProcess = nil
|
|
258
|
+
self.workerStdin = nil
|
|
259
|
+
guard keepWarm else { return }
|
|
241
260
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
|
242
|
-
self
|
|
261
|
+
self.startWorker()
|
|
243
262
|
}
|
|
244
263
|
}
|
|
245
264
|
|
|
246
265
|
// Ping to verify
|
|
247
266
|
sendToWorker(["cmd": "ping"])
|
|
248
267
|
DiagnosticLog.shared.info("HandsOff: worker started (pid \(proc.processIdentifier))")
|
|
268
|
+
return true
|
|
249
269
|
}
|
|
250
270
|
|
|
251
271
|
// MARK: - Worker communication
|
|
252
272
|
|
|
253
273
|
private var pendingCallback: (([String: Any]) -> Void)?
|
|
274
|
+
private var turnTimeoutWork: DispatchWorkItem?
|
|
275
|
+
private static let turnTimeoutSeconds: TimeInterval = 30
|
|
254
276
|
|
|
255
277
|
private func sendToWorker(_ dict: [String: Any]) {
|
|
256
278
|
guard let data = try? JSONSerialization.data(withJSONObject: dict),
|
|
@@ -335,17 +357,34 @@ final class HandsOffSession: ObservableObject {
|
|
|
335
357
|
case .listening:
|
|
336
358
|
finishListening()
|
|
337
359
|
case .thinking:
|
|
338
|
-
|
|
360
|
+
cancelTurn()
|
|
339
361
|
case .connecting:
|
|
340
362
|
cancel()
|
|
341
363
|
}
|
|
342
364
|
}
|
|
343
365
|
|
|
344
366
|
func cancel() {
|
|
367
|
+
cancelVoxSession()
|
|
345
368
|
state = .idle
|
|
346
369
|
DiagnosticLog.shared.info("HandsOff: cancelled")
|
|
347
370
|
}
|
|
348
371
|
|
|
372
|
+
private func cancelTurn() {
|
|
373
|
+
turnTimeoutWork?.cancel()
|
|
374
|
+
turnTimeoutWork = nil
|
|
375
|
+
pendingCallback = nil
|
|
376
|
+
state = .idle
|
|
377
|
+
DiagnosticLog.shared.warn("HandsOff: turn cancelled by user")
|
|
378
|
+
playSound("Funk")
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/// Cancel any active Vox recording session without transcribing.
|
|
382
|
+
private func cancelVoxSession() {
|
|
383
|
+
guard VoxClient.shared.activeSessionId != nil else { return }
|
|
384
|
+
DiagnosticLog.shared.info("HandsOff: cancelling Vox session")
|
|
385
|
+
VoxClient.shared.cancelSession()
|
|
386
|
+
}
|
|
387
|
+
|
|
349
388
|
// MARK: - Voice capture
|
|
350
389
|
|
|
351
390
|
private func beginListening() {
|
|
@@ -377,24 +416,46 @@ final class HandsOffSession: ObservableObject {
|
|
|
377
416
|
}
|
|
378
417
|
}
|
|
379
418
|
|
|
419
|
+
/// Guard against double-processing the transcript (session.final + completion can both deliver it).
|
|
420
|
+
private var turnProcessed = false
|
|
421
|
+
|
|
380
422
|
private func startDictation() {
|
|
381
423
|
state = .listening
|
|
382
424
|
lastTranscript = nil
|
|
425
|
+
turnProcessed = false
|
|
383
426
|
playSound("Tink")
|
|
384
427
|
|
|
385
428
|
DiagnosticLog.shared.info("HandsOff: listening...")
|
|
386
429
|
|
|
387
430
|
// Vox live session: startSession opens the mic, events flow on the start call ID.
|
|
388
|
-
//
|
|
431
|
+
// session.final arrives via onProgress, then the same data arrives via completion.
|
|
432
|
+
// We process the transcript from whichever arrives first to be resilient against
|
|
433
|
+
// connection drops between the two.
|
|
389
434
|
VoxClient.shared.startSession(
|
|
390
435
|
onProgress: { [weak self] event, data in
|
|
391
436
|
DispatchQueue.main.async {
|
|
392
|
-
|
|
437
|
+
guard let self else { return }
|
|
438
|
+
switch event {
|
|
439
|
+
case "session.state":
|
|
393
440
|
let sessionState = data["state"] as? String ?? ""
|
|
394
441
|
DiagnosticLog.shared.info("HandsOff: session → \(sessionState)")
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
442
|
+
// Vox cancelled the session (e.g. recording timeout)
|
|
443
|
+
if sessionState == "cancelled" {
|
|
444
|
+
let reason = data["reason"] as? String ?? "unknown"
|
|
445
|
+
DiagnosticLog.shared.warn("HandsOff: Vox cancelled session — \(reason)")
|
|
446
|
+
if self.state == .listening {
|
|
447
|
+
self.state = .idle
|
|
448
|
+
self.playSound("Basso")
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
case "session.final":
|
|
452
|
+
// Primary transcript delivery — process immediately
|
|
453
|
+
if let text = data["text"] as? String, !text.isEmpty {
|
|
454
|
+
self.lastTranscript = text
|
|
455
|
+
self.deliverTranscript(text)
|
|
456
|
+
}
|
|
457
|
+
default:
|
|
458
|
+
break
|
|
398
459
|
}
|
|
399
460
|
}
|
|
400
461
|
},
|
|
@@ -405,24 +466,36 @@ final class HandsOffSession: ObservableObject {
|
|
|
405
466
|
case .success(let data):
|
|
406
467
|
let text = data["text"] as? String ?? ""
|
|
407
468
|
if text.isEmpty {
|
|
408
|
-
self.
|
|
409
|
-
|
|
469
|
+
if !self.turnProcessed {
|
|
470
|
+
self.state = .idle
|
|
471
|
+
DiagnosticLog.shared.info("HandsOff: no speech detected")
|
|
472
|
+
}
|
|
410
473
|
} else {
|
|
474
|
+
// Fallback — deliver if session.final didn't already
|
|
411
475
|
self.lastTranscript = text
|
|
412
|
-
|
|
413
|
-
self.appendChat(.user, text: text)
|
|
414
|
-
self.processTurn(text)
|
|
476
|
+
self.deliverTranscript(text)
|
|
415
477
|
}
|
|
416
478
|
case .failure(let error):
|
|
417
|
-
self.
|
|
418
|
-
|
|
419
|
-
|
|
479
|
+
if !self.turnProcessed {
|
|
480
|
+
self.state = .idle
|
|
481
|
+
DiagnosticLog.shared.warn("HandsOff: session error — \(error.localizedDescription)")
|
|
482
|
+
self.playSound("Basso")
|
|
483
|
+
}
|
|
420
484
|
}
|
|
421
485
|
}
|
|
422
486
|
}
|
|
423
487
|
)
|
|
424
488
|
}
|
|
425
489
|
|
|
490
|
+
/// Deliver transcript exactly once — called from both session.final and completion.
|
|
491
|
+
private func deliverTranscript(_ text: String) {
|
|
492
|
+
guard !turnProcessed else { return }
|
|
493
|
+
turnProcessed = true
|
|
494
|
+
DiagnosticLog.shared.info("HandsOff: heard → '\(text)'")
|
|
495
|
+
appendChat(.user, text: text)
|
|
496
|
+
processTurn(text)
|
|
497
|
+
}
|
|
498
|
+
|
|
426
499
|
func finishListening() {
|
|
427
500
|
guard state == .listening else { return }
|
|
428
501
|
playSound("Tink")
|
|
@@ -433,6 +506,12 @@ final class HandsOffSession: ObservableObject {
|
|
|
433
506
|
|
|
434
507
|
private func processTurn(_ transcript: String) {
|
|
435
508
|
state = .thinking
|
|
509
|
+
guard startWorker() else {
|
|
510
|
+
state = .idle
|
|
511
|
+
DiagnosticLog.shared.warn("HandsOff: worker unavailable")
|
|
512
|
+
playSound("Basso")
|
|
513
|
+
return
|
|
514
|
+
}
|
|
436
515
|
turnCount += 1
|
|
437
516
|
|
|
438
517
|
let turnStart = Date()
|
|
@@ -449,9 +528,25 @@ final class HandsOffSession: ObservableObject {
|
|
|
449
528
|
"history": conversationHistory,
|
|
450
529
|
]
|
|
451
530
|
|
|
531
|
+
// Start turn timeout — forcibly reset if worker never responds
|
|
532
|
+
turnTimeoutWork?.cancel()
|
|
533
|
+
let timeout = DispatchWorkItem { [weak self] in
|
|
534
|
+
guard let self, self.state == .thinking else { return }
|
|
535
|
+
DiagnosticLog.shared.warn("HandsOff: ⏱ turn \(self.turnCount) timed out after \(Int(Self.turnTimeoutSeconds))s")
|
|
536
|
+
self.pendingCallback = nil
|
|
537
|
+
self.state = .idle
|
|
538
|
+
self.playSound("Basso")
|
|
539
|
+
}
|
|
540
|
+
turnTimeoutWork = timeout
|
|
541
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Self.turnTimeoutSeconds, execute: timeout)
|
|
542
|
+
|
|
452
543
|
sendToWorkerWithCallback(turnCmd) { [weak self] response in
|
|
453
544
|
guard let self else { return }
|
|
454
545
|
|
|
546
|
+
// Cancel the timeout — we got a response
|
|
547
|
+
self.turnTimeoutWork?.cancel()
|
|
548
|
+
self.turnTimeoutWork = nil
|
|
549
|
+
|
|
455
550
|
let turnMs = Int(Date().timeIntervalSince(turnStart) * 1000)
|
|
456
551
|
DiagnosticLog.shared.info("HandsOff: ⏱ turn \(self.turnCount) complete — \(turnMs)ms")
|
|
457
552
|
|
|
@@ -24,6 +24,7 @@ enum HotkeyAction: String, CaseIterable, Codable {
|
|
|
24
24
|
case handsOff
|
|
25
25
|
case unifiedWindow
|
|
26
26
|
case hud
|
|
27
|
+
case mouseFinder
|
|
27
28
|
// Layers
|
|
28
29
|
case layer1, layer2, layer3, layer4, layer5, layer6, layer7, layer8, layer9
|
|
29
30
|
case layerNext, layerPrev, layerTag
|
|
@@ -45,6 +46,7 @@ enum HotkeyAction: String, CaseIterable, Codable {
|
|
|
45
46
|
case .handsOff: return "Hands-Off Mode"
|
|
46
47
|
case .unifiedWindow: return "Unified Window"
|
|
47
48
|
case .hud: return "HUD"
|
|
49
|
+
case .mouseFinder: return "Find Mouse"
|
|
48
50
|
case .layer1: return "Layer 1"
|
|
49
51
|
case .layer2: return "Layer 2"
|
|
50
52
|
case .layer3: return "Layer 3"
|
|
@@ -76,7 +78,7 @@ enum HotkeyAction: String, CaseIterable, Codable {
|
|
|
76
78
|
|
|
77
79
|
var group: HotkeyGroup {
|
|
78
80
|
switch self {
|
|
79
|
-
case .palette, .screenMap, .bezel, .cheatSheet, .desktopInventory, .omniSearch, .voiceCommand, .handsOff, .unifiedWindow, .hud: return .app
|
|
81
|
+
case .palette, .screenMap, .bezel, .cheatSheet, .desktopInventory, .omniSearch, .voiceCommand, .handsOff, .unifiedWindow, .hud, .mouseFinder: return .app
|
|
80
82
|
case .layer1, .layer2, .layer3, .layer4, .layer5,
|
|
81
83
|
.layer6, .layer7, .layer8, .layer9,
|
|
82
84
|
.layerNext, .layerPrev, .layerTag: return .layers
|
|
@@ -96,6 +98,7 @@ enum HotkeyAction: String, CaseIterable, Codable {
|
|
|
96
98
|
case .handsOff: return 206
|
|
97
99
|
case .unifiedWindow: return 207
|
|
98
100
|
case .hud: return 208
|
|
101
|
+
case .mouseFinder: return 209
|
|
99
102
|
case .layer1: return 101
|
|
100
103
|
case .layer2: return 102
|
|
101
104
|
case .layer3: return 103
|
|
@@ -231,6 +234,7 @@ class HotkeyStore: ObservableObject {
|
|
|
231
234
|
bind(.handsOff, 46, cmdCtrl) // Ctrl+Cmd+M
|
|
232
235
|
bind(.omniSearch, 23, hyper) // Hyper+5
|
|
233
236
|
bind(.cheatSheet, 22, hyper) // Hyper+6
|
|
237
|
+
bind(.mouseFinder, 26, hyper) // Hyper+7
|
|
234
238
|
|
|
235
239
|
// Layers: Cmd+Option+1-9
|
|
236
240
|
let layerKeyCodes: [UInt32] = [18, 19, 20, 21, 23, 22, 26, 28, 25]
|
|
@@ -723,6 +723,43 @@ final class IntentEngine {
|
|
|
723
723
|
}
|
|
724
724
|
))
|
|
725
725
|
|
|
726
|
+
// ── Find / Summon Mouse ────────────────────────────────
|
|
727
|
+
|
|
728
|
+
register(IntentDef(
|
|
729
|
+
name: "find_mouse",
|
|
730
|
+
description: "Show a sonar pulse at the current mouse cursor position",
|
|
731
|
+
examples: [
|
|
732
|
+
"where's my mouse",
|
|
733
|
+
"find the cursor",
|
|
734
|
+
"I lost my mouse",
|
|
735
|
+
"find mouse",
|
|
736
|
+
"show cursor"
|
|
737
|
+
],
|
|
738
|
+
slots: [],
|
|
739
|
+
handler: { _ in
|
|
740
|
+
DispatchQueue.main.async { MouseFinder.shared.find() }
|
|
741
|
+
let pos = NSEvent.mouseLocation
|
|
742
|
+
return .object(["ok": .bool(true), "x": .int(Int(pos.x)), "y": .int(Int(pos.y))])
|
|
743
|
+
}
|
|
744
|
+
))
|
|
745
|
+
|
|
746
|
+
register(IntentDef(
|
|
747
|
+
name: "summon_mouse",
|
|
748
|
+
description: "Warp the mouse cursor to the center of the screen",
|
|
749
|
+
examples: [
|
|
750
|
+
"summon mouse",
|
|
751
|
+
"bring the cursor here",
|
|
752
|
+
"center the mouse",
|
|
753
|
+
"mouse come here",
|
|
754
|
+
"bring mouse back"
|
|
755
|
+
],
|
|
756
|
+
slots: [],
|
|
757
|
+
handler: { _ in
|
|
758
|
+
DispatchQueue.main.async { MouseFinder.shared.summon() }
|
|
759
|
+
return .object(["ok": .bool(true)])
|
|
760
|
+
}
|
|
761
|
+
))
|
|
762
|
+
|
|
726
763
|
// ── Undo / Restore ─────────────────────────────────────
|
|
727
764
|
|
|
728
765
|
register(IntentDef(
|
|
@@ -1818,6 +1818,46 @@ final class LatticesApi {
|
|
|
1818
1818
|
|
|
1819
1819
|
// ── Meta endpoint ───────────────────────────────────────
|
|
1820
1820
|
|
|
1821
|
+
// ── Mouse Finder ────────────────────────────────────────
|
|
1822
|
+
|
|
1823
|
+
api.register(Endpoint(
|
|
1824
|
+
method: "mouse.find",
|
|
1825
|
+
description: "Show a sonar pulse at the current mouse cursor position",
|
|
1826
|
+
access: .read,
|
|
1827
|
+
params: [],
|
|
1828
|
+
returns: .ok,
|
|
1829
|
+
handler: { _ in
|
|
1830
|
+
DispatchQueue.main.async { MouseFinder.shared.find() }
|
|
1831
|
+
let pos = NSEvent.mouseLocation
|
|
1832
|
+
return .object(["ok": .bool(true), "x": .int(Int(pos.x)), "y": .int(Int(pos.y))])
|
|
1833
|
+
}
|
|
1834
|
+
))
|
|
1835
|
+
|
|
1836
|
+
api.register(Endpoint(
|
|
1837
|
+
method: "mouse.summon",
|
|
1838
|
+
description: "Warp the mouse cursor to screen center (or a given point) and show a sonar pulse",
|
|
1839
|
+
access: .mutate,
|
|
1840
|
+
params: [
|
|
1841
|
+
Param(name: "x", type: "int", required: false, description: "Target X coordinate (screen, bottom-left origin)"),
|
|
1842
|
+
Param(name: "y", type: "int", required: false, description: "Target Y coordinate (screen, bottom-left origin)"),
|
|
1843
|
+
],
|
|
1844
|
+
returns: .ok,
|
|
1845
|
+
handler: { params in
|
|
1846
|
+
let target: CGPoint?
|
|
1847
|
+
if let x = params?["x"]?.intValue, let y = params?["y"]?.intValue {
|
|
1848
|
+
target = CGPoint(x: CGFloat(x), y: CGFloat(y))
|
|
1849
|
+
} else {
|
|
1850
|
+
target = nil
|
|
1851
|
+
}
|
|
1852
|
+
DispatchQueue.main.async { MouseFinder.shared.summon(to: target) }
|
|
1853
|
+
let pos = target ?? {
|
|
1854
|
+
let screen = NSScreen.main ?? NSScreen.screens[0]
|
|
1855
|
+
return CGPoint(x: screen.frame.midX, y: screen.frame.midY)
|
|
1856
|
+
}()
|
|
1857
|
+
return .object(["ok": .bool(true), "x": .int(Int(pos.x)), "y": .int(Int(pos.y))])
|
|
1858
|
+
}
|
|
1859
|
+
))
|
|
1860
|
+
|
|
1821
1861
|
api.register(Endpoint(
|
|
1822
1862
|
method: "api.schema",
|
|
1823
1863
|
description: "Get the full API schema including all methods and models",
|
|
@@ -283,27 +283,50 @@ struct MainView: View {
|
|
|
283
283
|
|
|
284
284
|
private var actionsSection: some View {
|
|
285
285
|
VStack(spacing: 0) {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
286
|
+
HStack {
|
|
287
|
+
Text("Quick Actions")
|
|
288
|
+
.font(Typo.monoBold(10))
|
|
289
|
+
.foregroundColor(Palette.textMuted)
|
|
290
|
+
|
|
291
|
+
Spacer()
|
|
292
|
+
|
|
293
|
+
Button("Help & shortcuts") {
|
|
294
|
+
ScreenMapWindowController.shared.showPage(.docs)
|
|
295
|
+
}
|
|
296
|
+
.buttonStyle(.plain)
|
|
297
|
+
.font(Typo.mono(9))
|
|
298
|
+
.foregroundColor(Palette.textMuted)
|
|
297
299
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
+
.padding(.horizontal, 14)
|
|
301
|
+
.padding(.top, 10)
|
|
302
|
+
.padding(.bottom, 6)
|
|
303
|
+
|
|
304
|
+
ActionRow(
|
|
305
|
+
label: "Command Palette",
|
|
306
|
+
detail: "Launch, attach, and control projects",
|
|
307
|
+
hotkeyTokens: hotkeyTokens(.palette),
|
|
308
|
+
icon: "command",
|
|
309
|
+
accentColor: Palette.running
|
|
310
|
+
) {
|
|
311
|
+
CommandPaletteWindow.shared.toggle()
|
|
300
312
|
}
|
|
301
|
-
ActionRow(
|
|
302
|
-
|
|
313
|
+
ActionRow(
|
|
314
|
+
label: "Workspace",
|
|
315
|
+
detail: "Screen map, inventory, and window context",
|
|
316
|
+
hotkeyTokens: hotkeyTokens(.unifiedWindow),
|
|
317
|
+
icon: "square.grid.2x2",
|
|
318
|
+
accentColor: Palette.text
|
|
319
|
+
) {
|
|
320
|
+
ScreenMapWindowController.shared.showPage(.home)
|
|
303
321
|
}
|
|
304
|
-
ActionRow(
|
|
305
|
-
|
|
306
|
-
|
|
322
|
+
ActionRow(
|
|
323
|
+
label: "Assistant",
|
|
324
|
+
detail: "Search now, or use voice when you need it",
|
|
325
|
+
hotkeyTokens: hotkeyTokens(.omniSearch),
|
|
326
|
+
icon: "magnifyingglass",
|
|
327
|
+
accentColor: AudioLayer.shared.isListening ? Palette.running : Palette.textDim
|
|
328
|
+
) {
|
|
329
|
+
showAssistant()
|
|
307
330
|
}
|
|
308
331
|
}
|
|
309
332
|
.padding(.vertical, 4)
|
|
@@ -351,9 +374,38 @@ struct MainView: View {
|
|
|
351
374
|
.buttonStyle(.plain)
|
|
352
375
|
}
|
|
353
376
|
|
|
354
|
-
private func
|
|
355
|
-
guard let binding = HotkeyStore.shared.bindings[action]
|
|
356
|
-
|
|
377
|
+
private func hotkeyTokens(_ action: HotkeyAction) -> [String] {
|
|
378
|
+
guard let binding = HotkeyStore.shared.bindings[action],
|
|
379
|
+
let key = binding.displayParts.last else { return [] }
|
|
380
|
+
|
|
381
|
+
let modifiers = Set(binding.displayParts.dropLast())
|
|
382
|
+
if modifiers == Set(["Ctrl", "Option", "Shift", "Cmd"]) {
|
|
383
|
+
return ["Hyper", shortenHotkeyToken(key)]
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return binding.displayParts.map(shortenHotkeyToken)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private func shortenHotkeyToken(_ token: String) -> String {
|
|
390
|
+
switch token {
|
|
391
|
+
case "Cmd": return "⌘"
|
|
392
|
+
case "Shift": return "⇧"
|
|
393
|
+
case "Option": return "⌥"
|
|
394
|
+
case "Ctrl": return "⌃"
|
|
395
|
+
case "Return": return "↩"
|
|
396
|
+
case "Escape": return "Esc"
|
|
397
|
+
case "Space": return "Space"
|
|
398
|
+
default: return token
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private func showAssistant() {
|
|
403
|
+
if AudioLayer.shared.isListening || VoiceCommandWindow.shared.isVisible {
|
|
404
|
+
VoiceCommandWindow.shared.toggle()
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
OmniSearchWindow.shared.show()
|
|
357
409
|
}
|
|
358
410
|
|
|
359
411
|
// MARK: - Empty state
|