@lattices/cli 0.4.0 → 0.4.1
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/AppDelegate.swift +1 -0
- package/app/Sources/DesktopModel.swift +26 -2
- package/app/Sources/HandsOffSession.swift +83 -14
- package/app/Sources/HotkeyStore.swift +5 -1
- package/app/Sources/IntentEngine.swift +37 -0
- package/app/Sources/LatticesApi.swift +40 -0
- 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 +120 -38
- package/bin/lattices-dev +51 -3
- package/bin/lattices.ts +181 -7
- package/docs/agent-layer-guide.md +207 -0
- package/package.json +10 -3
package/app/Info.plist
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>CFBundleIdentifier</key>
|
|
6
|
+
<string>com.arach.lattices</string>
|
|
7
|
+
<key>CFBundleName</key>
|
|
8
|
+
<string>Lattices</string>
|
|
9
|
+
<key>CFBundleDisplayName</key>
|
|
10
|
+
<string>Lattices</string>
|
|
11
|
+
<key>CFBundleExecutable</key>
|
|
12
|
+
<string>Lattices</string>
|
|
13
|
+
<key>CFBundleIconFile</key>
|
|
14
|
+
<string>AppIcon</string>
|
|
15
|
+
<key>CFBundlePackageType</key>
|
|
16
|
+
<string>APPL</string>
|
|
17
|
+
<key>CFBundleVersion</key>
|
|
18
|
+
<string>0.4.1</string>
|
|
19
|
+
<key>CFBundleShortVersionString</key>
|
|
20
|
+
<string>0.4.1</string>
|
|
21
|
+
<key>LSMinimumSystemVersion</key>
|
|
22
|
+
<string>13.0</string>
|
|
23
|
+
<key>LSUIElement</key>
|
|
24
|
+
<true/>
|
|
25
|
+
<key>NSHighResolutionCapable</key>
|
|
26
|
+
<true/>
|
|
27
|
+
<key>NSSupportsAutomaticTermination</key>
|
|
28
|
+
<true/>
|
|
29
|
+
</dict>
|
|
30
|
+
</plist>
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
<string>com.arach.lattices</string>
|
|
7
7
|
<key>CFBundleName</key>
|
|
8
8
|
<string>Lattices</string>
|
|
9
|
+
<key>CFBundleDisplayName</key>
|
|
10
|
+
<string>Lattices</string>
|
|
9
11
|
<key>CFBundleExecutable</key>
|
|
10
12
|
<string>Lattices</string>
|
|
11
13
|
<key>CFBundleIconFile</key>
|
|
@@ -13,11 +15,15 @@
|
|
|
13
15
|
<key>CFBundlePackageType</key>
|
|
14
16
|
<string>APPL</string>
|
|
15
17
|
<key>CFBundleVersion</key>
|
|
16
|
-
<string>1</string>
|
|
18
|
+
<string>0.4.1</string>
|
|
17
19
|
<key>CFBundleShortVersionString</key>
|
|
18
|
-
<string>0.1
|
|
20
|
+
<string>0.4.1</string>
|
|
21
|
+
<key>LSMinimumSystemVersion</key>
|
|
22
|
+
<string>13.0</string>
|
|
19
23
|
<key>LSUIElement</key>
|
|
20
24
|
<true/>
|
|
25
|
+
<key>NSHighResolutionCapable</key>
|
|
26
|
+
<true/>
|
|
21
27
|
<key>NSSupportsAutomaticTermination</key>
|
|
22
28
|
<true/>
|
|
23
29
|
</dict>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>files</key>
|
|
6
|
+
<dict>
|
|
7
|
+
<key>Resources/AppIcon.icns</key>
|
|
8
|
+
<data>
|
|
9
|
+
3sIZmtGJHMo2S/XvIMl46SOHcFY=
|
|
10
|
+
</data>
|
|
11
|
+
<key>Resources/tap.wav</key>
|
|
12
|
+
<data>
|
|
13
|
+
eOpp5td/ovQGMumXPpwy4Vyt/uc=
|
|
14
|
+
</data>
|
|
15
|
+
</dict>
|
|
16
|
+
<key>files2</key>
|
|
17
|
+
<dict>
|
|
18
|
+
<key>Resources/AppIcon.icns</key>
|
|
19
|
+
<dict>
|
|
20
|
+
<key>hash2</key>
|
|
21
|
+
<data>
|
|
22
|
+
LZsztS/9I1hmuQmDOk+anfxOpqVryB3y4a1kwSaUK4s=
|
|
23
|
+
</data>
|
|
24
|
+
</dict>
|
|
25
|
+
<key>Resources/tap.wav</key>
|
|
26
|
+
<dict>
|
|
27
|
+
<key>hash2</key>
|
|
28
|
+
<data>
|
|
29
|
+
K4QV08FuKEJR29hhgUbEG7Em3J6zHYpGKmGWdnZopzs=
|
|
30
|
+
</data>
|
|
31
|
+
</dict>
|
|
32
|
+
</dict>
|
|
33
|
+
<key>rules</key>
|
|
34
|
+
<dict>
|
|
35
|
+
<key>^Resources/</key>
|
|
36
|
+
<true/>
|
|
37
|
+
<key>^Resources/.*\.lproj/</key>
|
|
38
|
+
<dict>
|
|
39
|
+
<key>optional</key>
|
|
40
|
+
<true/>
|
|
41
|
+
<key>weight</key>
|
|
42
|
+
<real>1000</real>
|
|
43
|
+
</dict>
|
|
44
|
+
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
|
45
|
+
<dict>
|
|
46
|
+
<key>omit</key>
|
|
47
|
+
<true/>
|
|
48
|
+
<key>weight</key>
|
|
49
|
+
<real>1100</real>
|
|
50
|
+
</dict>
|
|
51
|
+
<key>^Resources/Base\.lproj/</key>
|
|
52
|
+
<dict>
|
|
53
|
+
<key>weight</key>
|
|
54
|
+
<real>1010</real>
|
|
55
|
+
</dict>
|
|
56
|
+
<key>^version.plist$</key>
|
|
57
|
+
<true/>
|
|
58
|
+
</dict>
|
|
59
|
+
<key>rules2</key>
|
|
60
|
+
<dict>
|
|
61
|
+
<key>.*\.dSYM($|/)</key>
|
|
62
|
+
<dict>
|
|
63
|
+
<key>weight</key>
|
|
64
|
+
<real>11</real>
|
|
65
|
+
</dict>
|
|
66
|
+
<key>^(.*/)?\.DS_Store$</key>
|
|
67
|
+
<dict>
|
|
68
|
+
<key>omit</key>
|
|
69
|
+
<true/>
|
|
70
|
+
<key>weight</key>
|
|
71
|
+
<real>2000</real>
|
|
72
|
+
</dict>
|
|
73
|
+
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
|
|
74
|
+
<dict>
|
|
75
|
+
<key>nested</key>
|
|
76
|
+
<true/>
|
|
77
|
+
<key>weight</key>
|
|
78
|
+
<real>10</real>
|
|
79
|
+
</dict>
|
|
80
|
+
<key>^.*</key>
|
|
81
|
+
<true/>
|
|
82
|
+
<key>^Info\.plist$</key>
|
|
83
|
+
<dict>
|
|
84
|
+
<key>omit</key>
|
|
85
|
+
<true/>
|
|
86
|
+
<key>weight</key>
|
|
87
|
+
<real>20</real>
|
|
88
|
+
</dict>
|
|
89
|
+
<key>^PkgInfo$</key>
|
|
90
|
+
<dict>
|
|
91
|
+
<key>omit</key>
|
|
92
|
+
<true/>
|
|
93
|
+
<key>weight</key>
|
|
94
|
+
<real>20</real>
|
|
95
|
+
</dict>
|
|
96
|
+
<key>^Resources/</key>
|
|
97
|
+
<dict>
|
|
98
|
+
<key>weight</key>
|
|
99
|
+
<real>20</real>
|
|
100
|
+
</dict>
|
|
101
|
+
<key>^Resources/.*\.lproj/</key>
|
|
102
|
+
<dict>
|
|
103
|
+
<key>optional</key>
|
|
104
|
+
<true/>
|
|
105
|
+
<key>weight</key>
|
|
106
|
+
<real>1000</real>
|
|
107
|
+
</dict>
|
|
108
|
+
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
|
109
|
+
<dict>
|
|
110
|
+
<key>omit</key>
|
|
111
|
+
<true/>
|
|
112
|
+
<key>weight</key>
|
|
113
|
+
<real>1100</real>
|
|
114
|
+
</dict>
|
|
115
|
+
<key>^Resources/Base\.lproj/</key>
|
|
116
|
+
<dict>
|
|
117
|
+
<key>weight</key>
|
|
118
|
+
<real>1010</real>
|
|
119
|
+
</dict>
|
|
120
|
+
<key>^[^/]+$</key>
|
|
121
|
+
<dict>
|
|
122
|
+
<key>nested</key>
|
|
123
|
+
<true/>
|
|
124
|
+
<key>weight</key>
|
|
125
|
+
<real>10</real>
|
|
126
|
+
</dict>
|
|
127
|
+
<key>^embedded\.provisionprofile$</key>
|
|
128
|
+
<dict>
|
|
129
|
+
<key>weight</key>
|
|
130
|
+
<real>20</real>
|
|
131
|
+
</dict>
|
|
132
|
+
<key>^version\.plist$</key>
|
|
133
|
+
<dict>
|
|
134
|
+
<key>weight</key>
|
|
135
|
+
<real>20</real>
|
|
136
|
+
</dict>
|
|
137
|
+
</dict>
|
|
138
|
+
</dict>
|
|
139
|
+
</plist>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<!-- App sandbox disabled — Lattices needs direct access to tmux, processes, and the filesystem -->
|
|
6
|
+
<key>com.apple.security.app-sandbox</key>
|
|
7
|
+
<false/>
|
|
8
|
+
|
|
9
|
+
<!-- Network: localhost WebSocket daemon for CLI/agent communication -->
|
|
10
|
+
<key>com.apple.security.network.server</key>
|
|
11
|
+
<true/>
|
|
12
|
+
<key>com.apple.security.network.client</key>
|
|
13
|
+
<true/>
|
|
14
|
+
</dict>
|
|
15
|
+
</plist>
|
|
Binary file
|
|
@@ -99,6 +99,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
store.register(action: .hud) { HUDController.shared.toggle() }
|
|
102
|
+
store.register(action: .mouseFinder) { MouseFinder.shared.find() }
|
|
102
103
|
|
|
103
104
|
// Pre-render HUD panels off-screen for instant first open
|
|
104
105
|
DispatchQueue.main.async { HUDController.shared.warmUp() }
|
|
@@ -134,7 +134,24 @@ final class DesktopModel: ObservableObject {
|
|
|
134
134
|
|
|
135
135
|
// MARK: - Polling
|
|
136
136
|
|
|
137
|
+
private var lastPollTime: Date = .distantPast
|
|
138
|
+
private static let minPollInterval: TimeInterval = 1.0
|
|
139
|
+
|
|
140
|
+
/// Poll only if stale. Call `forcePoll()` to bypass the freshness check.
|
|
137
141
|
func poll() {
|
|
142
|
+
let now = Date()
|
|
143
|
+
guard now.timeIntervalSince(lastPollTime) >= Self.minPollInterval else { return }
|
|
144
|
+
lastPollTime = now
|
|
145
|
+
performPoll()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// Force a poll regardless of freshness — use sparingly.
|
|
149
|
+
func forcePoll() {
|
|
150
|
+
lastPollTime = Date()
|
|
151
|
+
performPoll()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private func performPoll() {
|
|
138
155
|
guard let list = CGWindowListCopyWindowInfo(
|
|
139
156
|
[.optionAll, .excludeDesktopElements],
|
|
140
157
|
kCGNullWindowID
|
|
@@ -225,8 +242,11 @@ final class DesktopModel: ObservableObject {
|
|
|
225
242
|
if markFrontmost, let frontmostWid {
|
|
226
243
|
interactions[frontmostWid] = interactionTime
|
|
227
244
|
}
|
|
228
|
-
|
|
229
|
-
|
|
245
|
+
// Only publish if something actually changed — avoids unnecessary SwiftUI re-renders
|
|
246
|
+
if changed || markFrontmost {
|
|
247
|
+
self.windows = fresh
|
|
248
|
+
self.interactionDates = interactions
|
|
249
|
+
}
|
|
230
250
|
self.lastFrontmostWid = frontmostWid
|
|
231
251
|
}
|
|
232
252
|
|
|
@@ -255,6 +275,10 @@ final class DesktopModel: ObservableObject {
|
|
|
255
275
|
|
|
256
276
|
for (pid, wids) in byPid {
|
|
257
277
|
let axApp = AXUIElementCreateApplication(pid)
|
|
278
|
+
|
|
279
|
+
// Set a timeout so unresponsive apps (video calls, etc.) don't block the poll
|
|
280
|
+
AXUIElementSetMessagingTimeout(axApp, 0.3)
|
|
281
|
+
|
|
258
282
|
var axWindowsRef: CFTypeRef?
|
|
259
283
|
guard AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &axWindowsRef) == .success,
|
|
260
284
|
let axWindows = axWindowsRef as? [AXUIElement] else { continue }
|
|
@@ -251,6 +251,8 @@ final class HandsOffSession: ObservableObject {
|
|
|
251
251
|
// MARK: - Worker communication
|
|
252
252
|
|
|
253
253
|
private var pendingCallback: (([String: Any]) -> Void)?
|
|
254
|
+
private var turnTimeoutWork: DispatchWorkItem?
|
|
255
|
+
private static let turnTimeoutSeconds: TimeInterval = 30
|
|
254
256
|
|
|
255
257
|
private func sendToWorker(_ dict: [String: Any]) {
|
|
256
258
|
guard let data = try? JSONSerialization.data(withJSONObject: dict),
|
|
@@ -335,17 +337,34 @@ final class HandsOffSession: ObservableObject {
|
|
|
335
337
|
case .listening:
|
|
336
338
|
finishListening()
|
|
337
339
|
case .thinking:
|
|
338
|
-
|
|
340
|
+
cancelTurn()
|
|
339
341
|
case .connecting:
|
|
340
342
|
cancel()
|
|
341
343
|
}
|
|
342
344
|
}
|
|
343
345
|
|
|
344
346
|
func cancel() {
|
|
347
|
+
cancelVoxSession()
|
|
345
348
|
state = .idle
|
|
346
349
|
DiagnosticLog.shared.info("HandsOff: cancelled")
|
|
347
350
|
}
|
|
348
351
|
|
|
352
|
+
private func cancelTurn() {
|
|
353
|
+
turnTimeoutWork?.cancel()
|
|
354
|
+
turnTimeoutWork = nil
|
|
355
|
+
pendingCallback = nil
|
|
356
|
+
state = .idle
|
|
357
|
+
DiagnosticLog.shared.warn("HandsOff: turn cancelled by user")
|
|
358
|
+
playSound("Funk")
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/// Cancel any active Vox recording session without transcribing.
|
|
362
|
+
private func cancelVoxSession() {
|
|
363
|
+
guard VoxClient.shared.activeSessionId != nil else { return }
|
|
364
|
+
DiagnosticLog.shared.info("HandsOff: cancelling Vox session")
|
|
365
|
+
VoxClient.shared.cancelSession()
|
|
366
|
+
}
|
|
367
|
+
|
|
349
368
|
// MARK: - Voice capture
|
|
350
369
|
|
|
351
370
|
private func beginListening() {
|
|
@@ -377,24 +396,46 @@ final class HandsOffSession: ObservableObject {
|
|
|
377
396
|
}
|
|
378
397
|
}
|
|
379
398
|
|
|
399
|
+
/// Guard against double-processing the transcript (session.final + completion can both deliver it).
|
|
400
|
+
private var turnProcessed = false
|
|
401
|
+
|
|
380
402
|
private func startDictation() {
|
|
381
403
|
state = .listening
|
|
382
404
|
lastTranscript = nil
|
|
405
|
+
turnProcessed = false
|
|
383
406
|
playSound("Tink")
|
|
384
407
|
|
|
385
408
|
DiagnosticLog.shared.info("HandsOff: listening...")
|
|
386
409
|
|
|
387
410
|
// Vox live session: startSession opens the mic, events flow on the start call ID.
|
|
388
|
-
//
|
|
411
|
+
// session.final arrives via onProgress, then the same data arrives via completion.
|
|
412
|
+
// We process the transcript from whichever arrives first to be resilient against
|
|
413
|
+
// connection drops between the two.
|
|
389
414
|
VoxClient.shared.startSession(
|
|
390
415
|
onProgress: { [weak self] event, data in
|
|
391
416
|
DispatchQueue.main.async {
|
|
392
|
-
|
|
417
|
+
guard let self else { return }
|
|
418
|
+
switch event {
|
|
419
|
+
case "session.state":
|
|
393
420
|
let sessionState = data["state"] as? String ?? ""
|
|
394
421
|
DiagnosticLog.shared.info("HandsOff: session → \(sessionState)")
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
422
|
+
// Vox cancelled the session (e.g. recording timeout)
|
|
423
|
+
if sessionState == "cancelled" {
|
|
424
|
+
let reason = data["reason"] as? String ?? "unknown"
|
|
425
|
+
DiagnosticLog.shared.warn("HandsOff: Vox cancelled session — \(reason)")
|
|
426
|
+
if self.state == .listening {
|
|
427
|
+
self.state = .idle
|
|
428
|
+
self.playSound("Basso")
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
case "session.final":
|
|
432
|
+
// Primary transcript delivery — process immediately
|
|
433
|
+
if let text = data["text"] as? String, !text.isEmpty {
|
|
434
|
+
self.lastTranscript = text
|
|
435
|
+
self.deliverTranscript(text)
|
|
436
|
+
}
|
|
437
|
+
default:
|
|
438
|
+
break
|
|
398
439
|
}
|
|
399
440
|
}
|
|
400
441
|
},
|
|
@@ -405,24 +446,36 @@ final class HandsOffSession: ObservableObject {
|
|
|
405
446
|
case .success(let data):
|
|
406
447
|
let text = data["text"] as? String ?? ""
|
|
407
448
|
if text.isEmpty {
|
|
408
|
-
self.
|
|
409
|
-
|
|
449
|
+
if !self.turnProcessed {
|
|
450
|
+
self.state = .idle
|
|
451
|
+
DiagnosticLog.shared.info("HandsOff: no speech detected")
|
|
452
|
+
}
|
|
410
453
|
} else {
|
|
454
|
+
// Fallback — deliver if session.final didn't already
|
|
411
455
|
self.lastTranscript = text
|
|
412
|
-
|
|
413
|
-
self.appendChat(.user, text: text)
|
|
414
|
-
self.processTurn(text)
|
|
456
|
+
self.deliverTranscript(text)
|
|
415
457
|
}
|
|
416
458
|
case .failure(let error):
|
|
417
|
-
self.
|
|
418
|
-
|
|
419
|
-
|
|
459
|
+
if !self.turnProcessed {
|
|
460
|
+
self.state = .idle
|
|
461
|
+
DiagnosticLog.shared.warn("HandsOff: session error — \(error.localizedDescription)")
|
|
462
|
+
self.playSound("Basso")
|
|
463
|
+
}
|
|
420
464
|
}
|
|
421
465
|
}
|
|
422
466
|
}
|
|
423
467
|
)
|
|
424
468
|
}
|
|
425
469
|
|
|
470
|
+
/// Deliver transcript exactly once — called from both session.final and completion.
|
|
471
|
+
private func deliverTranscript(_ text: String) {
|
|
472
|
+
guard !turnProcessed else { return }
|
|
473
|
+
turnProcessed = true
|
|
474
|
+
DiagnosticLog.shared.info("HandsOff: heard → '\(text)'")
|
|
475
|
+
appendChat(.user, text: text)
|
|
476
|
+
processTurn(text)
|
|
477
|
+
}
|
|
478
|
+
|
|
426
479
|
func finishListening() {
|
|
427
480
|
guard state == .listening else { return }
|
|
428
481
|
playSound("Tink")
|
|
@@ -449,9 +502,25 @@ final class HandsOffSession: ObservableObject {
|
|
|
449
502
|
"history": conversationHistory,
|
|
450
503
|
]
|
|
451
504
|
|
|
505
|
+
// Start turn timeout — forcibly reset if worker never responds
|
|
506
|
+
turnTimeoutWork?.cancel()
|
|
507
|
+
let timeout = DispatchWorkItem { [weak self] in
|
|
508
|
+
guard let self, self.state == .thinking else { return }
|
|
509
|
+
DiagnosticLog.shared.warn("HandsOff: ⏱ turn \(self.turnCount) timed out after \(Int(Self.turnTimeoutSeconds))s")
|
|
510
|
+
self.pendingCallback = nil
|
|
511
|
+
self.state = .idle
|
|
512
|
+
self.playSound("Basso")
|
|
513
|
+
}
|
|
514
|
+
turnTimeoutWork = timeout
|
|
515
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Self.turnTimeoutSeconds, execute: timeout)
|
|
516
|
+
|
|
452
517
|
sendToWorkerWithCallback(turnCmd) { [weak self] response in
|
|
453
518
|
guard let self else { return }
|
|
454
519
|
|
|
520
|
+
// Cancel the timeout — we got a response
|
|
521
|
+
self.turnTimeoutWork?.cancel()
|
|
522
|
+
self.turnTimeoutWork = nil
|
|
523
|
+
|
|
455
524
|
let turnMs = Int(Date().timeIntervalSince(turnStart) * 1000)
|
|
456
525
|
DiagnosticLog.shared.info("HandsOff: ⏱ turn \(self.turnCount) complete — \(turnMs)ms")
|
|
457
526
|
|
|
@@ -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",
|