@lattices/cli 0.3.0 → 0.4.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 (95) hide show
  1. package/README.md +85 -9
  2. package/app/Package.swift +8 -1
  3. package/app/Sources/AdvisorLearningStore.swift +90 -0
  4. package/app/Sources/AgentSession.swift +377 -0
  5. package/app/Sources/AppDelegate.swift +44 -12
  6. package/app/Sources/AppShellView.swift +81 -8
  7. package/app/Sources/AudioProvider.swift +386 -0
  8. package/app/Sources/CheatSheetHUD.swift +261 -19
  9. package/app/Sources/DaemonProtocol.swift +13 -0
  10. package/app/Sources/DaemonServer.swift +8 -0
  11. package/app/Sources/DesktopModel.swift +164 -5
  12. package/app/Sources/DesktopModelTypes.swift +2 -0
  13. package/app/Sources/DiagnosticLog.swift +104 -2
  14. package/app/Sources/EventBus.swift +1 -0
  15. package/app/Sources/HUDBottomBar.swift +279 -0
  16. package/app/Sources/HUDController.swift +1158 -0
  17. package/app/Sources/HUDLeftBar.swift +849 -0
  18. package/app/Sources/HUDMinimap.swift +179 -0
  19. package/app/Sources/HUDRightBar.swift +774 -0
  20. package/app/Sources/HUDState.swift +367 -0
  21. package/app/Sources/HUDTopBar.swift +243 -0
  22. package/app/Sources/HandsOffSession.swift +733 -0
  23. package/app/Sources/HomeDashboardView.swift +125 -0
  24. package/app/Sources/HotkeyManager.swift +2 -0
  25. package/app/Sources/HotkeyStore.swift +45 -9
  26. package/app/Sources/IntentEngine.swift +925 -0
  27. package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
  28. package/app/Sources/Intents/DistributeIntent.swift +56 -0
  29. package/app/Sources/Intents/FocusIntent.swift +69 -0
  30. package/app/Sources/Intents/HelpIntent.swift +41 -0
  31. package/app/Sources/Intents/KillIntent.swift +47 -0
  32. package/app/Sources/Intents/LatticeIntent.swift +78 -0
  33. package/app/Sources/Intents/LaunchIntent.swift +67 -0
  34. package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
  35. package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
  36. package/app/Sources/Intents/ScanIntent.swift +52 -0
  37. package/app/Sources/Intents/SearchIntent.swift +190 -0
  38. package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
  39. package/app/Sources/Intents/TileIntent.swift +61 -0
  40. package/app/Sources/LatticesApi.swift +1235 -30
  41. package/app/Sources/LauncherHUD.swift +348 -0
  42. package/app/Sources/MainView.swift +147 -44
  43. package/app/Sources/OcrModel.swift +34 -1
  44. package/app/Sources/OmniSearchState.swift +99 -102
  45. package/app/Sources/OnboardingView.swift +457 -0
  46. package/app/Sources/PermissionChecker.swift +2 -12
  47. package/app/Sources/PiChatDock.swift +454 -0
  48. package/app/Sources/PiChatSession.swift +815 -0
  49. package/app/Sources/PiWorkspaceView.swift +364 -0
  50. package/app/Sources/PlacementSpec.swift +195 -0
  51. package/app/Sources/Preferences.swift +59 -0
  52. package/app/Sources/ProjectScanner.swift +1 -1
  53. package/app/Sources/ScreenMapState.swift +701 -55
  54. package/app/Sources/ScreenMapView.swift +843 -103
  55. package/app/Sources/ScreenMapWindowController.swift +22 -0
  56. package/app/Sources/SessionLayerStore.swift +285 -0
  57. package/app/Sources/SessionManager.swift +4 -1
  58. package/app/Sources/SettingsView.swift +186 -3
  59. package/app/Sources/Theme.swift +9 -8
  60. package/app/Sources/TmuxModel.swift +7 -0
  61. package/app/Sources/TmuxQuery.swift +27 -3
  62. package/app/Sources/VoiceChatView.swift +192 -0
  63. package/app/Sources/VoiceCommandWindow.swift +1594 -0
  64. package/app/Sources/VoiceIntentResolver.swift +671 -0
  65. package/app/Sources/VoxClient.swift +454 -0
  66. package/app/Sources/WindowTiler.swift +348 -87
  67. package/app/Sources/WorkspaceManager.swift +127 -18
  68. package/bin/client.ts +16 -0
  69. package/bin/{daemon-client.js → daemon-client.ts} +49 -30
  70. package/bin/handsoff-infer.ts +280 -0
  71. package/bin/handsoff-worker.ts +731 -0
  72. package/bin/{lattices-app.js → lattices-app.ts} +67 -32
  73. package/bin/lattices-dev +160 -0
  74. package/bin/{lattices.js → lattices.ts} +600 -137
  75. package/bin/project-twin.ts +645 -0
  76. package/docs/agent-execution-plan.md +562 -0
  77. package/docs/agents.md +142 -0
  78. package/docs/api.md +153 -34
  79. package/docs/app.md +29 -1
  80. package/docs/config.md +5 -1
  81. package/docs/handsoff-test-scenarios.md +84 -0
  82. package/docs/layers.md +20 -20
  83. package/docs/ocr.md +14 -5
  84. package/docs/overview.md +5 -1
  85. package/docs/presentation-execution-review.md +491 -0
  86. package/docs/prompts/hands-off-system.md +374 -0
  87. package/docs/prompts/hands-off-turn.md +30 -0
  88. package/docs/prompts/voice-advisor.md +31 -0
  89. package/docs/prompts/voice-fallback.md +23 -0
  90. package/docs/tiling-reference.md +167 -0
  91. package/docs/twins.md +138 -0
  92. package/docs/voice-command-protocol.md +278 -0
  93. package/docs/voice.md +219 -0
  94. package/package.json +21 -10
  95. package/bin/client.js +0 -4
@@ -0,0 +1,1158 @@
1
+ import AppKit
2
+ import Combine
3
+ import SwiftUI
4
+
5
+ // MARK: - KeyableHUDPanel
6
+
7
+ private class KeyableHUDPanel: NSPanel {
8
+ override var canBecomeKey: Bool { true }
9
+
10
+ /// Suppress NSBeep — our local event monitor handles all keys
11
+ override func keyDown(with event: NSEvent) {
12
+ // Don't call super — that's what triggers the system bonk sound
13
+ }
14
+
15
+ /// Allow performKeyEquivalent to pass through for event monitor
16
+ override func performKeyEquivalent(with event: NSEvent) -> Bool { false }
17
+ }
18
+
19
+ // MARK: - HUDController (singleton, cockpit-style HUD)
20
+ //
21
+ // Speed strategy:
22
+ // 1. Panels are pre-built at launch with content already rendered
23
+ // 2. Panels stay ordered (never orderOut on dismiss — just alpha=0)
24
+ // 3. Show = synchronous alphaValue=1 + makeKey (zero animation, instant paint)
25
+ // 4. Data refresh happens AFTER first paint
26
+ // 5. Dismiss = short slide-out animation (non-blocking, delightful)
27
+
28
+ final class HUDController {
29
+ static let shared = HUDController()
30
+
31
+ private var topPanel: NSPanel?
32
+ private var bottomPanel: NSPanel?
33
+ private var leftPanel: NSPanel?
34
+ private var rightPanel: NSPanel?
35
+ private var previewPanel: NSPanel?
36
+ private var minimapPanels: [NSPanel] = []
37
+ private var keyMonitor: Any?
38
+ private var clickMonitor: Any?
39
+ private var minimapObserver: AnyCancellable?
40
+ private var sidebarWidthObserver: AnyCancellable?
41
+ private var selectionObserver: AnyCancellable?
42
+ private var previewObserver: AnyCancellable?
43
+ private var previewImageObserver: AnyCancellable?
44
+ private let state = HUDState()
45
+ private let previewModel = WindowPreviewStore.shared
46
+
47
+ private let topHeight: CGFloat = 44
48
+ private let bottomHeight: CGFloat = 48
49
+ private let rightWidth: CGFloat = 400
50
+ private let previewWidth: CGFloat = 380
51
+ private let previewHeight: CGFloat = 240
52
+ private let previewGap: CGFloat = 14
53
+ private let expandedMapWidth: CGFloat = 380
54
+ private let expandedMapHeight: CGFloat = 240
55
+
56
+ private var leftWidth: CGFloat { state.leftSidebarWidth }
57
+
58
+ /// Track which screen panels are positioned on (for multi-monitor repositioning)
59
+ private var positionedScreen: NSScreen?
60
+ private var previewSettledItemID: String?
61
+ private var previewSettledAnchorScreenY: CGFloat?
62
+
63
+ var isVisible: Bool { leftPanel?.alphaValue ?? 0 > 0.5 }
64
+ private(set) var voiceBarVisible: Bool = false
65
+ private var voiceBarObserver: AnyCancellable?
66
+
67
+ func toggle() {
68
+ if isVisible { dismiss() } else { show() }
69
+ }
70
+
71
+ // MARK: - Voice bar (top panel only, for HandsOff mode)
72
+
73
+ private var voiceBarKeyMonitor: Any?
74
+
75
+ func showVoiceBar() {
76
+ guard !isVisible else { return } // full HUD is showing, no need
77
+ ensurePanels()
78
+
79
+ state.voiceActive = true
80
+
81
+ let screen = mouseScreen()
82
+ if positionedScreen != screen { positionAllPanels(on: screen) }
83
+
84
+ // Show only top + bottom bars
85
+ topPanel?.alphaValue = 1
86
+ topPanel?.orderFront(nil)
87
+ bottomPanel?.alphaValue = 1
88
+ bottomPanel?.orderFront(nil)
89
+ voiceBarVisible = true
90
+
91
+ // Escape key dismisses the voice bar
92
+ if voiceBarKeyMonitor == nil {
93
+ voiceBarKeyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in
94
+ if event.keyCode == 53 { // Escape
95
+ DispatchQueue.main.async { self?.hideVoiceBar() }
96
+ }
97
+ }
98
+ }
99
+
100
+ // Auto-hide 3s after HandsOff goes idle (turn complete)
101
+ voiceBarObserver = HandsOffSession.shared.$state.sink { [weak self] hsState in
102
+ if hsState == .idle {
103
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
104
+ guard let self, self.voiceBarVisible,
105
+ HandsOffSession.shared.state == .idle else { return }
106
+ self.hideVoiceBar()
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ func hideVoiceBar() {
113
+ guard voiceBarVisible else { return }
114
+ voiceBarObserver = nil
115
+ if let m = voiceBarKeyMonitor { NSEvent.removeMonitor(m); voiceBarKeyMonitor = nil }
116
+ state.voiceActive = false
117
+ voiceBarVisible = false
118
+
119
+ NSAnimationContext.runAnimationGroup({ ctx in
120
+ ctx.duration = 0.15
121
+ topPanel?.animator().alphaValue = 0
122
+ bottomPanel?.animator().alphaValue = 0
123
+ })
124
+ }
125
+
126
+ // MARK: - Warm up (call at launch)
127
+
128
+ func warmUp() {
129
+ ensurePanels()
130
+
131
+ let screen = NSScreen.main ?? NSScreen.screens.first!
132
+ positionAllPanels(on: screen)
133
+
134
+ // Order into window server at alpha 0 — instant show later
135
+ for p in allPanels {
136
+ p.orderFrontRegardless()
137
+ p.alphaValue = 0
138
+ p.ignoresMouseEvents = true
139
+ }
140
+ }
141
+
142
+ // MARK: - Show (instant first paint)
143
+
144
+ func show() {
145
+ ensurePanels()
146
+
147
+ state.query = ""
148
+ state.selectedIndex = 0
149
+ state.selectedItem = nil
150
+ state.pinnedItem = nil
151
+ state.hoveredPreviewItem = nil
152
+ state.hoverPreviewAnchorScreenY = nil
153
+ state.previewInteractionActive = false
154
+ state.selectedItems = []
155
+ state.focus = .search
156
+ previewSettledItemID = nil
157
+ previewSettledAnchorScreenY = nil
158
+ state.resetSectionDefaults(hasRunningProjects: ProjectScanner.shared.projects.contains(where: \.isRunning))
159
+
160
+ let screen = mouseScreen()
161
+ if positionedScreen != screen { positionAllPanels(on: screen) }
162
+
163
+ // Pre-compute tile grid BEFORE showing panels (captures real z-order)
164
+ DesktopModel.shared.poll()
165
+ precomputeTileGrid(on: screen)
166
+ prewarmLikelyPreviews()
167
+
168
+ // ── INSTANT SHOW ── alphaValue flip, zero animation
169
+ let isExpanded = state.minimapMode == .expanded
170
+ topPanel?.alphaValue = 1
171
+ topPanel?.ignoresMouseEvents = false
172
+ bottomPanel?.alphaValue = 1
173
+ bottomPanel?.ignoresMouseEvents = false
174
+ leftPanel?.alphaValue = 1
175
+ leftPanel?.ignoresMouseEvents = false
176
+ updateRightPanelVisibility(animated: false)
177
+ updatePreviewPanelVisibility(animated: false)
178
+ if isExpanded {
179
+ for panel in minimapPanels {
180
+ panel.alphaValue = 1
181
+ panel.ignoresMouseEvents = false
182
+ }
183
+ } else {
184
+ for panel in minimapPanels {
185
+ panel.ignoresMouseEvents = true
186
+ }
187
+ }
188
+ leftPanel?.makeKey()
189
+
190
+ installMonitors()
191
+
192
+ DispatchQueue.main.async { ProjectScanner.shared.scan() }
193
+ }
194
+
195
+ // MARK: - Dismiss (animated, delightful)
196
+
197
+ func dismiss() {
198
+ guard isVisible else { return }
199
+ removeMonitors()
200
+
201
+ // Restore untiled windows only if user actually tiled something
202
+ if !state.tiledWindows.isEmpty {
203
+ restoreUntiled()
204
+ } else {
205
+ // Just clean up tile state without moving anything
206
+ state.tileSnapshot = []
207
+ state.tileMode = false
208
+ }
209
+
210
+ if state.voiceActive {
211
+ if HandsOffSession.shared.state == .listening { HandsOffSession.shared.toggle() }
212
+ state.voiceActive = false
213
+ }
214
+
215
+ let sf = (positionedScreen ?? mouseScreen()).visibleFrame
216
+
217
+ NSAnimationContext.runAnimationGroup({ [weak self] ctx in
218
+ guard let self else { return }
219
+ ctx.duration = 0.12
220
+ ctx.timingFunction = CAMediaTimingFunction(name: .easeIn)
221
+ let sideHeight = max(0, sf.height - topHeight - bottomHeight)
222
+
223
+ topPanel?.animator().setFrame(
224
+ NSRect(x: sf.minX, y: sf.maxY,
225
+ width: sf.width, height: topHeight), display: false)
226
+ bottomPanel?.animator().setFrame(
227
+ NSRect(x: sf.minX, y: sf.minY - bottomHeight,
228
+ width: sf.width, height: bottomHeight), display: false)
229
+ leftPanel?.animator().setFrame(
230
+ NSRect(x: sf.minX - leftWidth * 0.3, y: sf.minY + bottomHeight, width: leftWidth, height: sideHeight), display: false)
231
+ rightPanel?.animator().setFrame(
232
+ NSRect(x: sf.maxX + rightWidth * 0.3 - rightWidth, y: sf.minY + bottomHeight, width: rightWidth, height: sideHeight), display: false)
233
+ for p in allPanels { p.animator().alphaValue = 0 }
234
+ }) { [weak self] in
235
+ guard let self, let screen = self.positionedScreen else { return }
236
+ self.positionAllPanels(on: screen)
237
+ for panel in self.allPanels {
238
+ panel.ignoresMouseEvents = true
239
+ }
240
+ }
241
+ }
242
+
243
+ // MARK: - Position panels on screen
244
+
245
+ private var allPanels: [NSPanel] {
246
+ [topPanel, bottomPanel, leftPanel, rightPanel, previewPanel].compactMap { $0 } + minimapPanels
247
+ }
248
+
249
+ private func positionAllPanels(on screen: NSScreen) {
250
+ let sf = screen.visibleFrame
251
+ let sideHeight = max(0, sf.height - topHeight - bottomHeight)
252
+
253
+ topPanel?.setFrame(NSRect(x: sf.minX, y: sf.maxY - topHeight,
254
+ width: sf.width, height: topHeight), display: false)
255
+ bottomPanel?.setFrame(NSRect(x: sf.minX, y: sf.minY,
256
+ width: sf.width, height: bottomHeight), display: false)
257
+ leftPanel?.setFrame(NSRect(x: sf.minX, y: sf.minY + bottomHeight,
258
+ width: leftWidth, height: sideHeight), display: false)
259
+ rightPanel?.setFrame(NSRect(x: sf.maxX - rightWidth, y: sf.minY + bottomHeight,
260
+ width: rightWidth, height: sideHeight), display: false)
261
+ if let previewPanel,
262
+ let frame = previewFrame(on: screen, itemID: previewSettledItemID ?? state.transientPreviewItem?.id) {
263
+ previewPanel.setFrame(frame, display: false)
264
+ }
265
+ positionMinimapPanels()
266
+ positionedScreen = screen
267
+ }
268
+
269
+ private func buildMinimapPanels(dismiss: @escaping () -> Void) {
270
+ minimapPanels.forEach { $0.orderOut(nil) }
271
+ minimapPanels.removeAll()
272
+
273
+ for i in 0..<NSScreen.screens.count {
274
+ let mp = makePanel()
275
+ let hosting = NSHostingView(rootView:
276
+ HUDMinimap(state: state, onDismiss: dismiss, screenIndex: i).preferredColorScheme(.dark))
277
+ hosting.sizingOptions = []
278
+ mp.contentView = hosting
279
+ mp.alphaValue = 0
280
+ minimapPanels.append(mp)
281
+ }
282
+ }
283
+
284
+ private func positionMinimapPanels() {
285
+ let screens = NSScreen.screens
286
+ let hudScreen = positionedScreen ?? screens.first!
287
+
288
+ for (i, mp) in minimapPanels.enumerated() {
289
+ guard i < screens.count else { continue }
290
+ let screen = screens[i]
291
+ let sf = screen.visibleFrame
292
+
293
+ if screen == hudScreen {
294
+ // On HUD screen: attach to left bar + bottom bar corner
295
+ mp.setFrame(NSRect(
296
+ x: sf.minX + leftWidth,
297
+ y: sf.minY + bottomHeight,
298
+ width: expandedMapWidth,
299
+ height: expandedMapHeight
300
+ ), display: false)
301
+ } else {
302
+ // On other screens: bottom-left corner
303
+ mp.setFrame(NSRect(
304
+ x: sf.minX + 12,
305
+ y: sf.minY + 12,
306
+ width: expandedMapWidth,
307
+ height: expandedMapHeight
308
+ ), display: false)
309
+ }
310
+ }
311
+ }
312
+
313
+ // MARK: - Build panels (once)
314
+
315
+ private func ensurePanels() -> Void {
316
+ guard topPanel == nil else { return }
317
+ let dismiss: () -> Void = { [weak self] in self?.dismiss() }
318
+
319
+ let tp = makePanel()
320
+ let tpHosting = NSHostingView(rootView:
321
+ HUDTopBar(state: state, onDismiss: dismiss).preferredColorScheme(.dark))
322
+ tpHosting.sizingOptions = []
323
+ tp.contentView = tpHosting
324
+
325
+ let bp = makePanel()
326
+ let bpHosting = NSHostingView(rootView:
327
+ HUDBottomBar(state: state, onDismiss: dismiss).preferredColorScheme(.dark))
328
+ bpHosting.sizingOptions = []
329
+ bp.contentView = bpHosting
330
+
331
+ let lp = makePanel(keyable: true)
332
+ let lpHosting = NSHostingView(rootView:
333
+ HUDLeftBar(state: state, onDismiss: dismiss).preferredColorScheme(.dark))
334
+ lpHosting.sizingOptions = []
335
+ lp.contentView = lpHosting
336
+
337
+ let rp = makePanel()
338
+ let rpHosting = NSHostingView(rootView:
339
+ HUDRightBar(state: state, onDismiss: dismiss).preferredColorScheme(.dark))
340
+ rpHosting.sizingOptions = []
341
+ rp.contentView = rpHosting
342
+
343
+ let pp = makePanel()
344
+ pp.hasShadow = true
345
+ pp.contentMinSize = NSSize(width: previewWidth, height: previewHeight)
346
+ pp.contentMaxSize = NSSize(width: previewWidth, height: previewHeight)
347
+ let ppHosting = NSHostingView(rootView:
348
+ HUDHoverPreviewView(state: state)
349
+ .frame(width: previewWidth, height: previewHeight)
350
+ .preferredColorScheme(.dark))
351
+ ppHosting.sizingOptions = []
352
+ pp.contentView = ppHosting
353
+
354
+ self.topPanel = tp
355
+ self.bottomPanel = bp
356
+ self.leftPanel = lp
357
+ self.rightPanel = rp
358
+ self.previewPanel = pp
359
+
360
+ // Create one minimap panel per screen
361
+ buildMinimapPanels(dismiss: dismiss)
362
+
363
+ // Observe minimap mode changes to show/hide expanded panels
364
+ minimapObserver = state.$minimapMode.sink { [weak self] mode in
365
+ guard let self else { return }
366
+ if mode == .expanded {
367
+ self.positionMinimapPanels()
368
+ for mp in self.minimapPanels {
369
+ mp.alphaValue = 1
370
+ mp.orderFront(nil)
371
+ }
372
+ } else {
373
+ for mp in self.minimapPanels { mp.alphaValue = 0 }
374
+ }
375
+ }
376
+
377
+ sidebarWidthObserver = state.$leftSidebarWidth
378
+ .removeDuplicates()
379
+ .sink { [weak self] _ in
380
+ guard let self, let screen = self.positionedScreen ?? NSScreen.main ?? NSScreen.screens.first else { return }
381
+ self.positionAllPanels(on: screen)
382
+ }
383
+
384
+ selectionObserver = state.$pinnedItem
385
+ .removeDuplicates()
386
+ .sink { [weak self] _ in
387
+ self?.updateRightPanelVisibility(animated: true)
388
+ }
389
+
390
+ previewObserver = Publishers.CombineLatest4(
391
+ state.$hoveredPreviewItem
392
+ .map { $0?.id }
393
+ .removeDuplicates(),
394
+ state.$pinnedItem
395
+ .map { $0?.id }
396
+ .removeDuplicates(),
397
+ state.$selectedItem
398
+ .map { $0?.id }
399
+ .removeDuplicates(),
400
+ state.$focus
401
+ .removeDuplicates()
402
+ )
403
+ .sink { [weak self] _, _, _, _ in
404
+ DispatchQueue.main.async {
405
+ self?.updatePreviewPanelVisibility(animated: true)
406
+ }
407
+ }
408
+
409
+ previewImageObserver = previewModel.objectWillChange
410
+ .sink { [weak self] _ in
411
+ DispatchQueue.main.async {
412
+ self?.updatePreviewPanelVisibility(animated: true)
413
+ }
414
+ }
415
+ }
416
+
417
+ private func updateRightPanelVisibility(animated: Bool) {
418
+ guard let rightPanel else { return }
419
+ let shouldShow = isVisible && state.pinnedItem != nil
420
+ let targetAlpha: CGFloat = shouldShow ? 1 : 0
421
+ rightPanel.ignoresMouseEvents = !shouldShow
422
+
423
+ guard rightPanel.alphaValue != targetAlpha else { return }
424
+
425
+ if animated {
426
+ NSAnimationContext.runAnimationGroup { context in
427
+ context.duration = 0.12
428
+ context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
429
+ rightPanel.animator().alphaValue = targetAlpha
430
+ }
431
+ } else {
432
+ rightPanel.alphaValue = targetAlpha
433
+ }
434
+ }
435
+
436
+ private func updatePreviewPanelVisibility(animated: Bool) {
437
+ guard let previewPanel else { return }
438
+ let targetItem = state.transientPreviewItem
439
+ let motionItem = commitPreviewMotionTarget(from: targetItem)
440
+
441
+ if let screen = positionedScreen ?? NSScreen.main ?? NSScreen.screens.first,
442
+ let frame = previewFrame(on: screen, itemID: motionItem?.id) {
443
+ if animated {
444
+ NSAnimationContext.runAnimationGroup { context in
445
+ context.duration = 0.18
446
+ context.timingFunction = CAMediaTimingFunction(controlPoints: 0.22, 1.0, 0.36, 1.0)
447
+ previewPanel.animator().setFrame(frame, display: false)
448
+ }
449
+ } else {
450
+ previewPanel.setFrame(frame, display: false)
451
+ }
452
+ }
453
+ let shouldShow = isVisible && motionItem != nil
454
+ let targetAlpha: CGFloat = shouldShow ? 1 : 0
455
+ previewPanel.ignoresMouseEvents = !shouldShow
456
+
457
+ guard previewPanel.alphaValue != targetAlpha else { return }
458
+
459
+ if animated {
460
+ NSAnimationContext.runAnimationGroup { context in
461
+ context.duration = 0.14
462
+ context.timingFunction = CAMediaTimingFunction(controlPoints: 0.22, 1.0, 0.36, 1.0)
463
+ previewPanel.animator().alphaValue = targetAlpha
464
+ }
465
+ } else {
466
+ previewPanel.alphaValue = targetAlpha
467
+ }
468
+ }
469
+
470
+ private func makePanel(keyable: Bool = false) -> NSPanel {
471
+ let p: NSPanel
472
+ if keyable {
473
+ p = KeyableHUDPanel(contentRect: .zero,
474
+ styleMask: [.borderless, .nonactivatingPanel],
475
+ backing: .buffered, defer: false)
476
+ } else {
477
+ p = NSPanel(contentRect: .zero,
478
+ styleMask: [.borderless, .nonactivatingPanel],
479
+ backing: .buffered, defer: false)
480
+ }
481
+ p.isOpaque = false
482
+ p.backgroundColor = .clear
483
+ p.level = .floating
484
+ p.hasShadow = true
485
+ p.hidesOnDeactivate = false
486
+ p.isReleasedWhenClosed = false
487
+ p.isMovableByWindowBackground = false
488
+ p.alphaValue = 0
489
+ // Visible to screen recorders (default .readWrite allows capture)
490
+ p.sharingType = .readOnly
491
+ // Keep composited even when transparent — eliminates ordering cost on show
492
+ p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
493
+ return p
494
+ }
495
+
496
+ private func previewFrame(on screen: NSScreen, itemID: String?) -> NSRect? {
497
+ guard itemID != nil else { return nil }
498
+ let sf = screen.visibleFrame
499
+ let leftFrame = leftPanel?.frame ?? NSRect(
500
+ x: sf.minX,
501
+ y: sf.minY + bottomHeight,
502
+ width: leftWidth,
503
+ height: max(0, sf.height - topHeight - bottomHeight)
504
+ )
505
+
506
+ let proposedX = leftFrame.maxX - 1
507
+ let maxX = sf.maxX - previewWidth - previewGap
508
+ let previewX = min(proposedX, maxX)
509
+
510
+ let anchorY = previewAnchorY(fallbackFrame: leftFrame)
511
+ let minY = leftFrame.minY + previewGap
512
+ let maxY = leftFrame.maxY - previewHeight - previewGap
513
+ let previewY = min(max(anchorY - previewHeight / 2, minY), maxY)
514
+
515
+ return NSRect(x: previewX, y: previewY, width: previewWidth, height: previewHeight)
516
+ }
517
+
518
+ private func previewAnchorY(fallbackFrame: NSRect) -> CGFloat {
519
+ previewSettledAnchorScreenY ?? state.hoverPreviewAnchorScreenY ?? fallbackFrame.midY
520
+ }
521
+
522
+ private func commitPreviewMotionTarget(from targetItem: HUDItem?) -> HUDItem? {
523
+ guard let targetItem else {
524
+ previewSettledItemID = nil
525
+ previewSettledAnchorScreenY = nil
526
+ return nil
527
+ }
528
+
529
+ let targetID = targetItem.id
530
+
531
+ if previewCanSettle(for: targetItem) {
532
+ previewSettledItemID = targetID
533
+ previewSettledAnchorScreenY = state.hoverPreviewAnchorScreenY ?? previewSettledAnchorScreenY
534
+ }
535
+
536
+ if previewSettledItemID == nil {
537
+ return previewCanSettle(for: targetItem) ? targetItem : nil
538
+ }
539
+
540
+ if previewSettledItemID == targetID {
541
+ return targetItem
542
+ }
543
+
544
+ return state.flatItems.first(where: { $0.id == previewSettledItemID }) ?? targetItem
545
+ }
546
+
547
+ private func previewCanSettle(for item: HUDItem) -> Bool {
548
+ guard let window = previewWindow(for: item) else { return false }
549
+ return previewModel.hasSettled(window.wid)
550
+ }
551
+
552
+ private func previewWindow(for item: HUDItem) -> WindowEntry? {
553
+ switch item {
554
+ case .window(let window):
555
+ return window
556
+ case .project(let project):
557
+ guard project.isRunning else { return nil }
558
+ return DesktopModel.shared.windowForSession(project.sessionName)
559
+ }
560
+ }
561
+
562
+ // MARK: - Keyboard routing
563
+
564
+ private func handleKey(_ event: NSEvent) -> NSEvent? {
565
+ let keyCode = event.keyCode
566
+
567
+ // Escape: tile mode → exit tile, search → list, otherwise dismiss
568
+ if keyCode == 53 {
569
+ if state.tileMode {
570
+ exitTileMode()
571
+ return nil
572
+ }
573
+ if state.focus == .search {
574
+ state.focus = .list
575
+ return nil
576
+ }
577
+ dismiss()
578
+ return nil
579
+ }
580
+
581
+ // Tab: cycle between search and list
582
+ if keyCode == 48 {
583
+ switch state.focus {
584
+ case .search:
585
+ if !state.flatItems.isEmpty {
586
+ state.focus = .list
587
+ if state.selectedItem == nil {
588
+ state.selectedIndex = 0
589
+ state.selectedItem = state.flatItems[safe: 0]
590
+ }
591
+ }
592
+ case .list, .inspector:
593
+ state.focus = .search
594
+ }
595
+ return nil
596
+ }
597
+
598
+ // Down arrow (Shift = extend multi-select)
599
+ if keyCode == 125 {
600
+ let shift = event.modifierFlags.contains(.shift)
601
+ if state.focus == .search {
602
+ state.focus = .list
603
+ if let firstItem = state.flatItems[safe: 0] {
604
+ state.selectSingle(firstItem, index: 0)
605
+ }
606
+ } else if state.focus == .list {
607
+ state.moveSelection(by: 1, extend: shift)
608
+ }
609
+ return nil
610
+ }
611
+
612
+ // Up arrow (Shift = extend multi-select)
613
+ if keyCode == 126 {
614
+ let shift = event.modifierFlags.contains(.shift)
615
+ if state.focus == .list {
616
+ if state.selectedIndex == 0 && !shift {
617
+ state.focus = .search
618
+ } else if state.selectedIndex > 0 {
619
+ state.moveSelection(by: -1, extend: shift)
620
+ }
621
+ }
622
+ return nil
623
+ }
624
+
625
+ // Enter: activate
626
+ if keyCode == 36 {
627
+ if let item = state.selectedItem, state.focus != .search {
628
+ activateItem(item)
629
+ }
630
+ return nil
631
+ }
632
+
633
+ // Option+V: toggle voice from ANY context (including search)
634
+ if keyCode == 9 && event.modifierFlags.contains(.option) {
635
+ toggleVoice()
636
+ return nil
637
+ }
638
+
639
+ // V key (keyCode 9): toggle voice mode (works from any non-search context)
640
+ if keyCode == 9 && state.focus != .search {
641
+ toggleVoice()
642
+ return nil
643
+ }
644
+
645
+ // M key (keyCode 46): cycle minimap mode (hidden → docked → expanded → hidden)
646
+ if keyCode == 46 && state.focus != .search {
647
+ switch state.minimapMode {
648
+ case .hidden: state.minimapMode = .docked
649
+ case .docked: state.minimapMode = .expanded
650
+ case .expanded: state.minimapMode = .hidden
651
+ }
652
+ return nil
653
+ }
654
+
655
+ // T key (keyCode 17): tile selected windows or toggle tile mode
656
+ if keyCode == 17 && state.focus != .search {
657
+ DiagnosticLog.shared.info("[TileKey] tileMode=\(state.tileMode) multiSelection=\(state.multiSelectionCount) items=\(state.effectiveSelectionIDs)")
658
+ if state.tileMode {
659
+ exitTileMode()
660
+ } else if !selectedWindowsForActions().isEmpty {
661
+ tileSelectedItems()
662
+ } else {
663
+ enterTileMode()
664
+ }
665
+ return nil
666
+ }
667
+
668
+ // D key (keyCode 2): detach selected projects or distribute selected windows
669
+ if keyCode == 2 && state.focus != .search {
670
+ if detachSelectedProjects() {
671
+ return nil
672
+ }
673
+ if distributeSelectedWindows() {
674
+ return nil
675
+ }
676
+ }
677
+
678
+ // Tile mode keys — H/J/K/L/F for tiling selected window
679
+ if state.tileMode && state.focus != .search {
680
+ let tileMap: [UInt16: TilePosition] = [
681
+ 4: .left, // H = left half
682
+ 37: .right, // L = right half
683
+ 40: .top, // K = top half
684
+ 38: .bottom, // J = bottom half
685
+ 3: .maximize, // F = maximize/fullscreen
686
+ // Quadrants: Y U B N
687
+ 16: .topLeft, // Y = top-left
688
+ 32: .topRight, // U = top-right
689
+ 11: .bottomLeft, // B = bottom-left
690
+ 45: .bottomRight,// N = bottom-right
691
+ ]
692
+ if let position = tileMap[keyCode] {
693
+ tileSelectedWindow(to: position)
694
+ return nil
695
+ }
696
+ }
697
+
698
+ // / key (keyCode 44): enter search mode
699
+ if keyCode == 44 && state.focus != .search {
700
+ state.focus = .search
701
+ return nil
702
+ }
703
+
704
+ // [ key (keyCode 33): cycle layer prev
705
+ if keyCode == 33 && state.focus != .search {
706
+ let ws = WorkspaceManager.shared
707
+ if let layers = ws.config?.layers, !layers.isEmpty {
708
+ let prev = ws.activeLayerIndex <= 0 ? layers.count - 1 : ws.activeLayerIndex - 1
709
+ ws.focusLayer(index: prev)
710
+ }
711
+ return nil
712
+ }
713
+
714
+ // ] key (keyCode 30): cycle layer next
715
+ if keyCode == 30 && state.focus != .search {
716
+ let ws = WorkspaceManager.shared
717
+ if let layers = ws.config?.layers, !layers.isEmpty {
718
+ let next = (ws.activeLayerIndex + 1) % layers.count
719
+ ws.focusLayer(index: next)
720
+ }
721
+ return nil
722
+ }
723
+
724
+ // Number keys 1-2: jump to section (when not in search)
725
+ if state.focus != .search {
726
+ let numberMap: [UInt16: Int] = [18: 1, 19: 2]
727
+ if let num = numberMap[keyCode] {
728
+ if !state.isSectionExpanded(num) {
729
+ state.toggleSection(num)
730
+ }
731
+ if let offset = state.sectionOffsets[num] {
732
+ state.focus = .list
733
+ if let item = state.flatItems[safe: offset] {
734
+ state.selectSingle(item, index: offset)
735
+ }
736
+ }
737
+ return nil
738
+ }
739
+ }
740
+
741
+ // In search mode, pass through to text field
742
+ if state.focus == .search { return event }
743
+
744
+ return event
745
+ }
746
+
747
+ private func toggleVoice() {
748
+ let enabling = !state.voiceActive
749
+ let timed = AppFeedback.shared.beginTimed(
750
+ "HUD voice toggle",
751
+ state: state,
752
+ feedback: enabling ? "Voice on" : "Voice off"
753
+ )
754
+ state.voiceActive.toggle()
755
+ HandsOffSession.shared.setAudibleFeedbackEnabled(state.voiceActive)
756
+ if state.voiceActive {
757
+ HandsOffSession.shared.start()
758
+ HandsOffSession.shared.toggle()
759
+ } else {
760
+ if HandsOffSession.shared.state == .listening {
761
+ HandsOffSession.shared.toggle()
762
+ }
763
+ }
764
+ DispatchQueue.main.async {
765
+ AppFeedback.shared.finish(timed)
766
+ }
767
+ }
768
+
769
+ // MARK: - Tile mode
770
+
771
+ /// Pre-compute tile grid on HUD show — top 10 frontmost windows, grid positions ready
772
+ private func precomputeTileGrid(on screen: NSScreen) {
773
+ let sf = screen.visibleFrame
774
+ let primaryH = NSScreen.screens.first?.frame.height ?? 900
775
+ let screenCGX = sf.origin.x
776
+ let screenCGY = primaryH - sf.origin.y - sf.height
777
+
778
+ // Get the focused window's wid via AX (always include it)
779
+ let focusedWid: UInt32? = {
780
+ guard let app = NSWorkspace.shared.frontmostApplication else { return nil }
781
+ let axApp = AXUIElementCreateApplication(app.processIdentifier)
782
+ var focusedValue: AnyObject?
783
+ guard AXUIElementCopyAttributeValue(axApp, kAXFocusedWindowAttribute as CFString, &focusedValue) == .success else { return nil }
784
+ let axWin = focusedValue as! AXUIElement
785
+ var widValue: CGWindowID = 0
786
+ let result = _AXUIElementGetWindow(axWin, &widValue)
787
+ return result == .success ? UInt32(widValue) : nil
788
+ }()
789
+
790
+ // Front 6 by z-order (most recently used first).
791
+ let allOnScreen = DesktopModel.shared.allWindows()
792
+ .filter { $0.isOnScreen && $0.app != "Lattices" && !$0.title.isEmpty }
793
+ .filter { win in
794
+ let cx = win.frame.x + win.frame.w / 2
795
+ let cy = win.frame.y + win.frame.h / 2
796
+ return cx >= Double(screenCGX) && cx < Double(screenCGX + sf.width) &&
797
+ cy >= Double(screenCGY) && cy < Double(screenCGY + sf.height)
798
+ }
799
+
800
+ let log = DiagnosticLog.shared
801
+ log.info("[TileGrid.input] screenSize=\(sf.width)x\(sf.height) onScreen=\(allOnScreen.count)")
802
+ for (i, w) in allOnScreen.prefix(10).enumerated() {
803
+ log.info("[TileGrid.eval] #\(i) z=\(w.zIndex) app=\(w.app) title=\(w.title.prefix(30))")
804
+ }
805
+
806
+ var windows = Array(allOnScreen.prefix(6))
807
+
808
+ // Ensure the focused window is always included
809
+ if let fwid = focusedWid,
810
+ !windows.contains(where: { $0.wid == fwid }),
811
+ let focusedWin = allOnScreen.first(where: { $0.wid == fwid }) {
812
+ if windows.count >= 6 {
813
+ windows[windows.count - 1] = focusedWin // swap out last
814
+ } else {
815
+ windows.append(focusedWin)
816
+ }
817
+ log.info("[TileGrid.focused] swapped in focusedWid=\(fwid) (\(focusedWin.app): \(focusedWin.title.prefix(30)))")
818
+ }
819
+
820
+ log.info("[TileGrid.result] picked=\(windows.count)")
821
+
822
+ let count = windows.count
823
+ guard count > 0 else { state.precomputedGrid = []; return }
824
+
825
+ let cols = Int(ceil(sqrt(Double(count))))
826
+ let rows = Int(ceil(Double(count) / Double(cols)))
827
+ let cellW = sf.width / CGFloat(cols)
828
+ let cellH = sf.height / CGFloat(rows)
829
+ let gap: CGFloat = 2
830
+
831
+ state.precomputedGrid = windows.enumerated().map { (i, win) in
832
+ let col = i % cols
833
+ let row = i / cols
834
+ let frame = CGRect(
835
+ x: screenCGX + CGFloat(col) * cellW + gap,
836
+ y: screenCGY + CGFloat(row) * cellH + gap,
837
+ width: cellW - gap * 2,
838
+ height: cellH - gap * 2
839
+ )
840
+ return (win.wid, win.pid, frame)
841
+ }
842
+ }
843
+
844
+ private func prewarmLikelyPreviews() {
845
+ let desktop = DesktopModel.shared
846
+ let windows = desktop.allWindows()
847
+ .filter { $0.app != "Lattices" }
848
+ .filter { !$0.title.isEmpty }
849
+ .filter { $0.title != $0.app }
850
+ .sorted { lhs, rhs in
851
+ let lhsDate = desktop.lastInteractionDate(for: lhs.wid) ?? .distantPast
852
+ let rhsDate = desktop.lastInteractionDate(for: rhs.wid) ?? .distantPast
853
+ if lhsDate != rhsDate {
854
+ return lhsDate > rhsDate
855
+ }
856
+ return lhs.zIndex < rhs.zIndex
857
+ }
858
+
859
+ previewModel.prewarm(windows: windows, limit: 4)
860
+ }
861
+
862
+ private func enterTileMode() {
863
+ guard !state.precomputedGrid.isEmpty else { return }
864
+ let timed = AppFeedback.shared.beginTimed(
865
+ "HUD enter tile mode",
866
+ state: state,
867
+ feedback: "Tile mode"
868
+ )
869
+
870
+ // Snapshot current positions (for restore on dismiss)
871
+ state.tileSnapshot = state.precomputedGrid.map { move in
872
+ // Look up current frame from DesktopModel
873
+ let win = DesktopModel.shared.windows[move.wid]
874
+ let currentFrame = win.map {
875
+ CGRect(x: $0.frame.x, y: $0.frame.y, width: $0.frame.w, height: $0.frame.h)
876
+ } ?? CGRect.zero
877
+ return HUDState.WindowSnapshot(wid: move.wid, pid: move.pid, frame: currentFrame)
878
+ }
879
+ state.tiledWindows = []
880
+ state.tileMode = true
881
+
882
+ // Apply pre-computed grid — instant
883
+ WindowTiler.batchMoveAndRaiseWindows(state.precomputedGrid)
884
+
885
+ // Auto-expand minimap
886
+ if state.minimapMode != .expanded {
887
+ state.minimapMode = .expanded
888
+ }
889
+
890
+ // Select first window
891
+ let firstWid = state.precomputedGrid.first?.wid
892
+ if let wid = firstWid,
893
+ let win = DesktopModel.shared.windows[wid],
894
+ let idx = state.flatItems.firstIndex(of: .window(win)) {
895
+ state.focus = .list
896
+ state.selectedIndex = idx
897
+ state.selectedItem = .window(win)
898
+ }
899
+
900
+ DispatchQueue.main.async {
901
+ AppFeedback.shared.finish(timed)
902
+ }
903
+ playCue("Tiled.")
904
+ }
905
+
906
+ private func exitTileMode() {
907
+ AppFeedback.shared.acknowledge(
908
+ "HUD exit tile mode",
909
+ state: state,
910
+ feedback: "Tile mode off"
911
+ )
912
+ state.tileMode = false
913
+ }
914
+
915
+ private func tileSelectedWindow(to position: TilePosition) {
916
+ guard let item = state.selectedItem,
917
+ case .window(let win) = item else { return }
918
+ let timed = AppFeedback.shared.beginTimed(
919
+ "HUD tile window",
920
+ state: state,
921
+ feedback: "Tiling \(win.title)"
922
+ )
923
+
924
+ let screen = positionedScreen ?? mouseScreen()
925
+ let frame = WindowTiler.tileFrame(for: position, on: screen)
926
+ WindowTiler.batchMoveAndRaiseWindows([(win.wid, win.pid, frame)])
927
+
928
+ state.tiledWindows.insert(win.wid)
929
+ DispatchQueue.main.async {
930
+ AppFeedback.shared.finish(timed)
931
+ }
932
+ playCue("Tiled.")
933
+ }
934
+
935
+ /// Restore windows that weren't explicitly tiled back to their original positions
936
+ private func restoreUntiled() {
937
+ guard !state.tileSnapshot.isEmpty else { return }
938
+
939
+ var restores: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
940
+ for snap in state.tileSnapshot {
941
+ if !state.tiledWindows.contains(snap.wid) {
942
+ restores.append((snap.wid, snap.pid, snap.frame))
943
+ }
944
+ }
945
+
946
+ if !restores.isEmpty {
947
+ WindowTiler.batchMoveAndRaiseWindows(restores)
948
+ }
949
+
950
+ state.tileSnapshot = []
951
+ state.tiledWindows = []
952
+ state.tileMode = false
953
+ }
954
+
955
+ /// Tile only the multi-selected windows from the sidebar
956
+ private func tileSelectedItems() {
957
+ let windows = selectedWindowsForActions()
958
+ guard !windows.isEmpty else { return }
959
+ let timed = AppFeedback.shared.beginTimed(
960
+ "HUD tile selection",
961
+ state: state,
962
+ feedback: "Tiling \(windows.count) window\(windows.count == 1 ? "" : "s")"
963
+ )
964
+
965
+ let screen = positionedScreen ?? mouseScreen()
966
+ let sf = screen.visibleFrame
967
+ let primaryH = NSScreen.screens.first?.frame.height ?? 900
968
+ let screenCGX = sf.origin.x
969
+ let screenCGY = primaryH - sf.origin.y - sf.height
970
+
971
+ // Snapshot for restore
972
+ state.tileSnapshot = windows.map { win in
973
+ HUDState.WindowSnapshot(
974
+ wid: win.wid, pid: win.pid,
975
+ frame: CGRect(x: win.frame.x, y: win.frame.y,
976
+ width: win.frame.w, height: win.frame.h)
977
+ )
978
+ }
979
+ state.tiledWindows = []
980
+ state.tileMode = true
981
+
982
+ // Grid layout
983
+ let count = windows.count
984
+ let cols = Int(ceil(sqrt(Double(count))))
985
+ let rows = Int(ceil(Double(count) / Double(cols)))
986
+ let cellW = sf.width / CGFloat(cols)
987
+ let cellH = sf.height / CGFloat(rows)
988
+ let gap: CGFloat = 2
989
+
990
+ var moves: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
991
+ for (i, win) in windows.enumerated() {
992
+ let col = i % cols
993
+ let row = i / cols
994
+ let frame = CGRect(
995
+ x: screenCGX + CGFloat(col) * cellW + gap,
996
+ y: screenCGY + CGFloat(row) * cellH + gap,
997
+ width: cellW - gap * 2,
998
+ height: cellH - gap * 2
999
+ )
1000
+ moves.append((win.wid, win.pid, frame))
1001
+ }
1002
+
1003
+ WindowTiler.batchMoveAndRaiseWindows(moves)
1004
+ state.precomputedGrid = moves
1005
+
1006
+ // Expand minimap
1007
+ if state.minimapMode != .expanded { state.minimapMode = .expanded }
1008
+
1009
+ DispatchQueue.main.async {
1010
+ AppFeedback.shared.finish(timed)
1011
+ }
1012
+ playCue("Tiled.")
1013
+ DiagnosticLog.shared.info("[TileGrid.selected] tiled \(windows.count) selected windows")
1014
+ }
1015
+
1016
+ private func detachSelectedProjects() -> Bool {
1017
+ let projects = selectedProjectsForActions().filter(\.isRunning)
1018
+ guard !projects.isEmpty else { return false }
1019
+ let timed = AppFeedback.shared.beginTimed(
1020
+ "HUD detach selection",
1021
+ state: state,
1022
+ feedback: "Detaching \(projects.count) project\(projects.count == 1 ? "" : "s")"
1023
+ )
1024
+
1025
+ for project in projects {
1026
+ SessionManager.detach(project: project)
1027
+ }
1028
+
1029
+ DispatchQueue.main.async {
1030
+ AppFeedback.shared.finish(timed)
1031
+ }
1032
+ playCue("Done.")
1033
+ DiagnosticLog.shared.info("[Detach.selected] detached \(projects.count) project(s)")
1034
+ dismiss()
1035
+ return true
1036
+ }
1037
+
1038
+ private func distributeSelectedWindows() -> Bool {
1039
+ let windows = selectedWindowsForActions()
1040
+ guard windows.count > 1 else { return false }
1041
+ let timed = AppFeedback.shared.beginTimed(
1042
+ "HUD distribute selection",
1043
+ state: state,
1044
+ feedback: "Distributing \(windows.count) windows"
1045
+ )
1046
+
1047
+ WindowTiler.batchRaiseAndDistribute(windows: windows.map { (wid: $0.wid, pid: $0.pid) })
1048
+ DispatchQueue.main.async {
1049
+ AppFeedback.shared.finish(timed)
1050
+ }
1051
+ playCue("Distributed.")
1052
+ DiagnosticLog.shared.info("[Distribute.selected] distributed \(windows.count) window(s)")
1053
+ return true
1054
+ }
1055
+
1056
+ private func selectedProjectsForActions() -> [Project] {
1057
+ let ids = state.effectiveSelectionIDs
1058
+ guard !ids.isEmpty else { return [] }
1059
+
1060
+ return state.flatItems.compactMap { item in
1061
+ guard ids.contains(item.id), case .project(let project) = item else { return nil }
1062
+ return project
1063
+ }
1064
+ }
1065
+
1066
+ private func selectedWindowsForActions() -> [WindowEntry] {
1067
+ let ids = state.effectiveSelectionIDs
1068
+ guard !ids.isEmpty else { return [] }
1069
+
1070
+ var seen = Set<UInt32>()
1071
+ var windows: [WindowEntry] = []
1072
+
1073
+ for item in state.flatItems {
1074
+ guard ids.contains(item.id) else { continue }
1075
+
1076
+ switch item {
1077
+ case .window(let window):
1078
+ if seen.insert(window.wid).inserted {
1079
+ windows.append(window)
1080
+ }
1081
+ case .project(let project):
1082
+ guard project.isRunning else { continue }
1083
+ let projectWindows = DesktopModel.shared.allWindows().filter { $0.latticesSession == project.sessionName }
1084
+ for window in projectWindows where seen.insert(window.wid).inserted {
1085
+ windows.append(window)
1086
+ }
1087
+ }
1088
+ }
1089
+
1090
+ return windows
1091
+ }
1092
+
1093
+ private func activateItem(_ item: HUDItem) {
1094
+ let (label, feedback): (String, String) = {
1095
+ switch item {
1096
+ case .project(let p):
1097
+ let verb = p.isRunning ? "Focus" : "Launch"
1098
+ return ("HUD \(verb.lowercased()) project", "\(verb) \(p.name)")
1099
+ case .window(let w):
1100
+ return ("HUD focus window", "Focus \(w.title)")
1101
+ }
1102
+ }()
1103
+ let timed = AppFeedback.shared.beginTimed(label, state: state, feedback: feedback)
1104
+ switch item {
1105
+ case .project(let p):
1106
+ SessionManager.launch(project: p)
1107
+ playCue(p.isRunning ? "Focused." : "Done.")
1108
+ case .window(let w):
1109
+ _ = WindowTiler.focusWindow(wid: w.wid, pid: w.pid)
1110
+ playCue("Focused.")
1111
+ }
1112
+ DispatchQueue.main.async {
1113
+ AppFeedback.shared.finish(timed)
1114
+ }
1115
+ dismiss()
1116
+ }
1117
+
1118
+ private func playCue(_ phrase: String) {
1119
+ HandsOffSession.shared.playCachedCue(phrase)
1120
+ }
1121
+
1122
+ // MARK: - Event monitors
1123
+
1124
+ private func installMonitors() {
1125
+ keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
1126
+ self?.handleKey(event) ?? event
1127
+ }
1128
+ clickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .keyDown]) { [weak self] event in
1129
+ guard let self else { return }
1130
+ if event.type == .keyDown {
1131
+ if event.keyCode == 53 { self.dismiss() }
1132
+ return
1133
+ }
1134
+ let loc = NSEvent.mouseLocation
1135
+ let inAny = self.allPanels
1136
+ .compactMap { $0 }
1137
+ .contains { $0.frame.contains(loc) }
1138
+ if !inAny { self.dismiss() }
1139
+ }
1140
+ }
1141
+
1142
+ private func removeMonitors() {
1143
+ if let m = keyMonitor { NSEvent.removeMonitor(m); keyMonitor = nil }
1144
+ if let m = clickMonitor { NSEvent.removeMonitor(m); clickMonitor = nil }
1145
+ }
1146
+
1147
+ private func mouseScreen() -> NSScreen {
1148
+ let loc = NSEvent.mouseLocation
1149
+ return NSScreen.screens.first(where: { $0.frame.contains(loc) })
1150
+ ?? NSScreen.main ?? NSScreen.screens.first!
1151
+ }
1152
+ }
1153
+
1154
+ private extension Array {
1155
+ subscript(safe index: Int) -> Element? {
1156
+ indices.contains(index) ? self[index] : nil
1157
+ }
1158
+ }