@lattices/cli 0.3.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 (74) hide show
  1. package/README.md +155 -0
  2. package/app/Lattices.app/Contents/Info.plist +24 -0
  3. package/app/Package.swift +13 -0
  4. package/app/Sources/AccessibilityTextExtractor.swift +111 -0
  5. package/app/Sources/ActionRow.swift +61 -0
  6. package/app/Sources/App.swift +10 -0
  7. package/app/Sources/AppDelegate.swift +242 -0
  8. package/app/Sources/AppShellView.swift +62 -0
  9. package/app/Sources/AppTypeClassifier.swift +70 -0
  10. package/app/Sources/AppWindowShell.swift +63 -0
  11. package/app/Sources/CheatSheetHUD.swift +332 -0
  12. package/app/Sources/CommandModeState.swift +1362 -0
  13. package/app/Sources/CommandModeView.swift +1405 -0
  14. package/app/Sources/CommandModeWindow.swift +192 -0
  15. package/app/Sources/CommandPaletteView.swift +307 -0
  16. package/app/Sources/CommandPaletteWindow.swift +134 -0
  17. package/app/Sources/DaemonProtocol.swift +101 -0
  18. package/app/Sources/DaemonServer.swift +414 -0
  19. package/app/Sources/DesktopModel.swift +149 -0
  20. package/app/Sources/DesktopModelTypes.swift +71 -0
  21. package/app/Sources/DiagnosticLog.swift +271 -0
  22. package/app/Sources/EventBus.swift +30 -0
  23. package/app/Sources/HotkeyManager.swift +254 -0
  24. package/app/Sources/HotkeyStore.swift +338 -0
  25. package/app/Sources/InventoryManager.swift +35 -0
  26. package/app/Sources/InventoryPath.swift +43 -0
  27. package/app/Sources/KeyRecorderView.swift +210 -0
  28. package/app/Sources/LatticesApi.swift +1234 -0
  29. package/app/Sources/LayerBezel.swift +203 -0
  30. package/app/Sources/MainView.swift +479 -0
  31. package/app/Sources/MainWindow.swift +83 -0
  32. package/app/Sources/OcrModel.swift +430 -0
  33. package/app/Sources/OcrStore.swift +329 -0
  34. package/app/Sources/OmniSearchState.swift +283 -0
  35. package/app/Sources/OmniSearchView.swift +288 -0
  36. package/app/Sources/OmniSearchWindow.swift +105 -0
  37. package/app/Sources/OrphanRow.swift +129 -0
  38. package/app/Sources/PaletteCommand.swift +419 -0
  39. package/app/Sources/PermissionChecker.swift +125 -0
  40. package/app/Sources/Preferences.swift +99 -0
  41. package/app/Sources/ProcessModel.swift +199 -0
  42. package/app/Sources/ProcessQuery.swift +151 -0
  43. package/app/Sources/Project.swift +28 -0
  44. package/app/Sources/ProjectRow.swift +368 -0
  45. package/app/Sources/ProjectScanner.swift +128 -0
  46. package/app/Sources/ScreenMapState.swift +2387 -0
  47. package/app/Sources/ScreenMapView.swift +2820 -0
  48. package/app/Sources/ScreenMapWindowController.swift +89 -0
  49. package/app/Sources/SessionManager.swift +72 -0
  50. package/app/Sources/SettingsView.swift +1064 -0
  51. package/app/Sources/SettingsWindow.swift +20 -0
  52. package/app/Sources/TabGroupRow.swift +178 -0
  53. package/app/Sources/Terminal.swift +259 -0
  54. package/app/Sources/TerminalQuery.swift +156 -0
  55. package/app/Sources/TerminalSynthesizer.swift +200 -0
  56. package/app/Sources/Theme.swift +163 -0
  57. package/app/Sources/TilePickerView.swift +209 -0
  58. package/app/Sources/TmuxModel.swift +53 -0
  59. package/app/Sources/TmuxQuery.swift +81 -0
  60. package/app/Sources/WindowTiler.swift +1778 -0
  61. package/app/Sources/WorkspaceManager.swift +575 -0
  62. package/bin/client.js +4 -0
  63. package/bin/daemon-client.js +187 -0
  64. package/bin/lattices-app.js +221 -0
  65. package/bin/lattices.js +1551 -0
  66. package/docs/api.md +924 -0
  67. package/docs/app.md +297 -0
  68. package/docs/concepts.md +135 -0
  69. package/docs/config.md +245 -0
  70. package/docs/layers.md +410 -0
  71. package/docs/ocr.md +185 -0
  72. package/docs/overview.md +94 -0
  73. package/docs/quickstart.md +75 -0
  74. package/package.json +42 -0
@@ -0,0 +1,1362 @@
1
+ import AppKit
2
+ import Foundation
3
+
4
+ // MARK: - Phase
5
+
6
+ enum CommandModePhase: Equatable {
7
+ case idle
8
+ case inventory
9
+ case desktopInventory
10
+ case executing(String)
11
+ }
12
+
13
+ // MARK: - Inventory Snapshot
14
+
15
+ struct CommandModeInventory {
16
+ struct Item {
17
+ let name: String
18
+ let group: String // "Layer: X", "Group: Y", "Orphan"
19
+ let status: Status
20
+ let paneCount: Int
21
+ let tileHint: String? // "left", "right", etc.
22
+ }
23
+ enum Status { case running, attached, stopped }
24
+
25
+ let activeLayer: String?
26
+ let layerCount: Int
27
+ let items: [Item]
28
+ }
29
+
30
+ // MARK: - Chord
31
+
32
+ struct Chord {
33
+ let key: String // display label e.g. "a", "1"
34
+ let keyCode: UInt16
35
+ let label: String // e.g. "tile all"
36
+ let action: () -> Void
37
+ }
38
+
39
+ // MARK: - Desktop Inventory Mode
40
+
41
+ enum DesktopInventoryMode: Equatable {
42
+ case browsing
43
+ case tiling // t → tile picker
44
+ case gridPreview // s → preview grid layout before applying
45
+ case screenMap // m → interactive screen map editor
46
+ }
47
+
48
+ // DisplayGeometry, ScreenMapWindowEntry, ScreenMapEditorState, ScreenMapActionLog
49
+ // are defined in ScreenMapState.swift
50
+ // MARK: - Filter Presets
51
+
52
+ enum FilterPreset: String, CaseIterable {
53
+ case all = "All"
54
+ case terminals = "Terminals"
55
+ case editors = "Editors"
56
+ case browsers = "Browsers"
57
+ case lattices = "Lattices"
58
+ case currentSpace = "Current Space"
59
+
60
+ var appTypes: Set<AppType>? {
61
+ switch self {
62
+ case .all: return nil
63
+ case .terminals: return [.terminal]
64
+ case .editors: return [.editor]
65
+ case .browsers: return [.browser]
66
+ case .lattices: return nil // special case
67
+ case .currentSpace: return nil // special case
68
+ }
69
+ }
70
+
71
+ var keyIndex: Int? {
72
+ switch self {
73
+ case .all: return 1
74
+ case .terminals: return 2
75
+ case .editors: return 3
76
+ case .browsers: return 4
77
+ case .lattices: return 5
78
+ case .currentSpace: return 6
79
+ }
80
+ }
81
+
82
+ static func from(keyIndex: Int) -> FilterPreset? {
83
+ allCases.first { $0.keyIndex == keyIndex }
84
+ }
85
+ }
86
+
87
+ // MARK: - State Machine
88
+
89
+ final class CommandModeState: ObservableObject {
90
+ @Published var phase: CommandModePhase = .idle
91
+ @Published var inventory = CommandModeInventory(activeLayer: nil, layerCount: 0, items: [])
92
+ @Published var chords: [Chord] = []
93
+ @Published var desktopSnapshot: DesktopInventorySnapshot?
94
+ @Published var selectedWindowIds: Set<UInt32> = []
95
+ @Published var desktopMode: DesktopInventoryMode = .browsing
96
+ @Published var activePreset: FilterPreset? = nil
97
+ @Published var searchQuery: String = ""
98
+ @Published var isSearching: Bool = false
99
+
100
+ // MARK: - Marquee Drag State
101
+ @Published var isDragging: Bool = false
102
+ @Published var marqueeOrigin: CGPoint = .zero
103
+ @Published var marqueeCurrentPoint: CGPoint = .zero
104
+
105
+ /// Computed normalized rect from origin → current drag point
106
+ var marqueeRect: CGRect {
107
+ let x = min(marqueeOrigin.x, marqueeCurrentPoint.x)
108
+ let y = min(marqueeOrigin.y, marqueeCurrentPoint.y)
109
+ let w = abs(marqueeCurrentPoint.x - marqueeOrigin.x)
110
+ let h = abs(marqueeCurrentPoint.y - marqueeOrigin.y)
111
+ return CGRect(x: x, y: y, width: w, height: h)
112
+ }
113
+
114
+ /// Row frames in inventoryPanel coordinate space (updated by PreferenceKey)
115
+ var rowFrames: [UInt32: CGRect] = [:]
116
+
117
+ /// Raw mouse-down point for drag threshold detection (screen coordinates)
118
+ var dragStartPoint: NSPoint?
119
+
120
+ /// Selection state before drag started (for Cmd+drag additive mode)
121
+ private var preDragSelection: Set<UInt32> = []
122
+
123
+ // MARK: - Saved Positions (for restore after show & distribute)
124
+ /// Saved window frames before a show/distribute action — allows undo
125
+ @Published var savedPositions: [UInt32: (pid: Int32, frame: WindowFrame)]? = nil
126
+
127
+ /// Brief flash message shown after an action (auto-dismisses)
128
+ @Published var flashMessage: String? = nil
129
+
130
+ var onDismiss: (() -> Void)?
131
+ var onPanelResize: ((_ width: CGFloat, _ height: CGFloat) -> Void)?
132
+
133
+ /// Tracks the last item navigated to, for consistent Shift+arrow multi-select
134
+ private var cursorWindowId: UInt32?
135
+
136
+ // MARK: - Selection Helpers
137
+
138
+ /// Backwards-compat: returns single selected ID (first element)
139
+ var selectedWindowId: UInt32? {
140
+ selectedWindowIds.first
141
+ }
142
+
143
+ func isSelected(_ id: UInt32) -> Bool {
144
+ selectedWindowIds.contains(id)
145
+ }
146
+
147
+ func selectSingle(_ id: UInt32) {
148
+ selectedWindowIds = [id]
149
+ cursorWindowId = id
150
+ }
151
+
152
+ func toggleSelection(_ id: UInt32) {
153
+ if selectedWindowIds.contains(id) {
154
+ selectedWindowIds.remove(id)
155
+ } else {
156
+ selectedWindowIds.insert(id)
157
+ }
158
+ cursorWindowId = id
159
+ }
160
+
161
+ func clearSelection() {
162
+ selectedWindowIds = []
163
+ cursorWindowId = nil
164
+ isDragging = false
165
+ dragStartPoint = nil
166
+ }
167
+
168
+ /// Select contiguous range from cursor anchor to target (Shift+click)
169
+ func selectRange(to targetId: UInt32) {
170
+ guard let anchorId = cursorWindowId else { selectSingle(targetId); return }
171
+ let list = flatWindowList
172
+ guard let anchorIdx = list.firstIndex(where: { $0.id == anchorId }),
173
+ let targetIdx = list.firstIndex(where: { $0.id == targetId }) else {
174
+ selectSingle(targetId)
175
+ return
176
+ }
177
+ let lo = min(anchorIdx, targetIdx)
178
+ let hi = max(anchorIdx, targetIdx)
179
+ selectedWindowIds = Set(list[lo...hi].map(\.id))
180
+ // cursorWindowId stays as anchor for subsequent Shift+clicks
181
+ }
182
+
183
+ // MARK: - Marquee Drag
184
+
185
+ func beginDrag(at point: CGPoint, additive: Bool) {
186
+ preDragSelection = additive ? selectedWindowIds : []
187
+ marqueeOrigin = point
188
+ marqueeCurrentPoint = point
189
+ isDragging = true
190
+ }
191
+
192
+ func updateDrag(to point: CGPoint) {
193
+ marqueeCurrentPoint = point
194
+ updateMarqueeSelection()
195
+ }
196
+
197
+ func endDrag() {
198
+ isDragging = false
199
+ dragStartPoint = nil
200
+ preDragSelection = []
201
+ }
202
+
203
+ /// Select all rows whose frames intersect the current marquee rect
204
+ private func updateMarqueeSelection() {
205
+ let rect = marqueeRect
206
+ var hits = preDragSelection
207
+ for (wid, frame) in rowFrames {
208
+ if rect.intersects(frame) {
209
+ hits.insert(wid)
210
+ }
211
+ }
212
+ selectedWindowIds = hits
213
+ if let first = hits.first { cursorWindowId = first }
214
+ }
215
+
216
+ func activateSearch() {
217
+ isSearching = true
218
+ searchQuery = ""
219
+ clearSelection()
220
+ }
221
+
222
+ func deactivateSearch() {
223
+ isSearching = false
224
+ searchQuery = ""
225
+ }
226
+
227
+ /// Filtered desktop snapshot based on active preset and search query
228
+ var filteredSnapshot: DesktopInventorySnapshot? {
229
+ guard let snapshot = desktopSnapshot else { return nil }
230
+
231
+ let needsPresetFilter = activePreset != nil && activePreset != .all
232
+ let needsSearchFilter = isSearching && !searchQuery.isEmpty
233
+ guard needsPresetFilter || needsSearchFilter else { return snapshot }
234
+
235
+ let query = searchQuery.lowercased()
236
+
237
+ let filteredDisplays = snapshot.displays.compactMap { display -> DesktopInventorySnapshot.DisplayInfo? in
238
+ let filteredSpaces = display.spaces.compactMap { space -> DesktopInventorySnapshot.SpaceGroup? in
239
+ if let preset = activePreset, preset == .currentSpace && !space.isCurrent { return nil }
240
+
241
+ let filteredApps = space.apps.compactMap { appGroup -> DesktopInventorySnapshot.AppGroup? in
242
+ let filteredWindows = appGroup.windows.filter { win in
243
+ // Preset filter
244
+ if let preset = activePreset, preset != .all {
245
+ let passesPreset: Bool
246
+ switch preset {
247
+ case .lattices: passesPreset = win.isLattices
248
+ case .currentSpace: passesPreset = true
249
+ default:
250
+ if let types = preset.appTypes, let name = win.appName {
251
+ passesPreset = types.contains(AppTypeClassifier.classify(name))
252
+ } else {
253
+ passesPreset = false
254
+ }
255
+ }
256
+ if !passesPreset { return false }
257
+ }
258
+
259
+ // Search filter
260
+ if needsSearchFilter {
261
+ let matchesApp = win.appName?.lowercased().contains(query) ?? false
262
+ let matchesTitle = win.title.lowercased().contains(query)
263
+ let matchesLattices = win.latticesSession?.lowercased().contains(query) ?? false
264
+ let matchesOcr = OcrModel.shared.results[win.id]?.fullText
265
+ .lowercased().contains(query) ?? false
266
+ if !matchesApp && !matchesTitle && !matchesLattices && !matchesOcr { return false }
267
+ }
268
+
269
+ return true
270
+ }
271
+ guard !filteredWindows.isEmpty else { return nil }
272
+ return DesktopInventorySnapshot.AppGroup(
273
+ id: appGroup.id, appName: appGroup.appName, windows: filteredWindows
274
+ )
275
+ }
276
+ guard !filteredApps.isEmpty else { return nil }
277
+ return DesktopInventorySnapshot.SpaceGroup(
278
+ id: space.id, index: space.index, isCurrent: space.isCurrent, apps: filteredApps
279
+ )
280
+ }
281
+ guard !filteredSpaces.isEmpty else { return nil }
282
+ return DesktopInventorySnapshot.DisplayInfo(
283
+ id: display.id, name: display.name, resolution: display.resolution,
284
+ visibleFrame: display.visibleFrame, isMain: display.isMain,
285
+ spaceCount: display.spaceCount, currentSpaceIndex: display.currentSpaceIndex,
286
+ spaces: filteredSpaces
287
+ )
288
+ }
289
+ return DesktopInventorySnapshot(displays: filteredDisplays, timestamp: snapshot.timestamp)
290
+ }
291
+
292
+ /// Compact panel size for chord view
293
+ private let chordPanelSize: (CGFloat, CGFloat) = (580, 360)
294
+
295
+ /// Compute desktop inventory panel size based on display count, clamped to screen
296
+ private var desktopPanelSize: (CGFloat, CGFloat) {
297
+ let displayCount = max(1, desktopSnapshot?.displays.count ?? 1)
298
+ let ideal = CGFloat(displayCount) * 480 + CGFloat(displayCount - 1) + 32
299
+ let screenWidth = NSScreen.main?.visibleFrame.width ?? 1920
300
+ let width = min(ideal, screenWidth * 0.92)
301
+ let height: CGFloat = 640
302
+ return (width, height)
303
+ }
304
+
305
+ /// Flat window list for keyboard navigation (respects active filter)
306
+ var flatWindowList: [DesktopInventorySnapshot.InventoryWindowInfo] {
307
+ filteredSnapshot?.allWindows ?? []
308
+ }
309
+
310
+ var ocrMatchSnippets: [UInt32: String] {
311
+ guard isSearching, !searchQuery.isEmpty else { return [:] }
312
+ let query = searchQuery.lowercased()
313
+ let ocrResults = OcrModel.shared.results
314
+ var snippets: [UInt32: String] = [:]
315
+ for win in flatWindowList {
316
+ // Only show snippet if match came from OCR, not title/app
317
+ let matchesApp = win.appName?.lowercased().contains(query) ?? false
318
+ let matchesTitle = win.title.lowercased().contains(query)
319
+ let matchesLattices = win.latticesSession?.lowercased().contains(query) ?? false
320
+ if matchesApp || matchesTitle || matchesLattices { continue }
321
+ if let ocr = ocrResults[win.id],
322
+ let range = ocr.fullText.lowercased().range(of: query) {
323
+ snippets[win.id] = Self.extractSnippet(from: ocr.fullText, around: range)
324
+ }
325
+ }
326
+ return snippets
327
+ }
328
+
329
+ private static func extractSnippet(from text: String, around range: Range<String.Index>, maxLen: Int = 80) -> String {
330
+ let half = max(0, (maxLen - text.distance(from: range.lowerBound, to: range.upperBound)) / 2)
331
+ let start = text.index(range.lowerBound, offsetBy: -half, limitedBy: text.startIndex) ?? text.startIndex
332
+ let end = text.index(range.upperBound, offsetBy: half, limitedBy: text.endIndex) ?? text.endIndex
333
+ var s = String(text[start..<end])
334
+ .replacingOccurrences(of: "\n", with: " ")
335
+ .trimmingCharacters(in: .whitespaces)
336
+ if start > text.startIndex { s = "…" + s }
337
+ if end < text.endIndex { s += "…" }
338
+ return s
339
+ }
340
+
341
+ func enter() {
342
+ inventory = buildInventory()
343
+ chords = buildChords()
344
+ desktopSnapshot = buildDesktopInventory()
345
+ clearSelection()
346
+ desktopMode = .browsing
347
+ phase = .desktopInventory
348
+ // Don't call onPanelResize here — caller handles initial sizing
349
+ }
350
+
351
+ /// Returns true if the key was consumed
352
+ func handleKey(_ keyCode: UInt16, modifiers: NSEvent.ModifierFlags = []) -> Bool {
353
+ // Backtick (keyCode 50) toggles desktop inventory from either phase
354
+ if keyCode == 50 {
355
+ if isSearching {
356
+ deactivateSearch()
357
+ return true
358
+ }
359
+ if phase == .desktopInventory {
360
+ // Back to chord view
361
+ clearSelection()
362
+ desktopMode = .browsing
363
+ activePreset = nil
364
+ phase = .inventory
365
+ onPanelResize?(chordPanelSize.0, chordPanelSize.1)
366
+ return true
367
+ } else if phase == .inventory {
368
+ // Enter desktop inventory
369
+ let diag = DiagnosticLog.shared
370
+ desktopSnapshot = buildDesktopInventory()
371
+ clearSelection()
372
+ desktopMode = .browsing
373
+ phase = .desktopInventory
374
+ let size = desktopPanelSize
375
+ onPanelResize?(size.0, size.1)
376
+ if let snap = desktopSnapshot {
377
+ let totalWindows = snap.allWindows.count
378
+ let totalSpaces = snap.displays.reduce(0) { $0 + $1.spaces.count }
379
+ diag.info("Desktop inventory: \(snap.displays.count) display(s), \(totalSpaces) space(s), \(totalWindows) window(s)")
380
+ }
381
+ return true
382
+ }
383
+ }
384
+
385
+ // Route desktop inventory keys
386
+ if phase == .desktopInventory {
387
+ return handleDesktopInventoryKey(keyCode, modifiers: modifiers)
388
+ }
389
+
390
+ // Escape from chord view → dismiss
391
+ if keyCode == 53 {
392
+ dismiss()
393
+ return true
394
+ }
395
+
396
+ guard phase == .inventory else { return false }
397
+
398
+ // Check chord map
399
+ if let chord = chords.first(where: { $0.keyCode == keyCode }) {
400
+ phase = .executing(chord.label)
401
+ let action = chord.action
402
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
403
+ action()
404
+ self?.dismiss()
405
+ }
406
+ return true
407
+ }
408
+
409
+ // Unknown key — ignore
410
+ return true
411
+ }
412
+
413
+ // MARK: - Desktop Inventory Key Handling
414
+
415
+ private func handleDesktopInventoryKey(_ keyCode: UInt16, modifiers: NSEvent.ModifierFlags = []) -> Bool {
416
+ switch desktopMode {
417
+ case .browsing: return handleBrowsingKey(keyCode, modifiers: modifiers)
418
+ case .tiling: return handleTilingKey(keyCode, modifiers: modifiers)
419
+ case .gridPreview: return handleGridPreviewKey(keyCode)
420
+ case .screenMap: return true // handled by standalone ScreenMapWindowController
421
+ }
422
+ }
423
+
424
+ // MARK: Browsing — ↑↓ within column, ←→ between displays, Enter → actions
425
+
426
+ private func handleBrowsingKey(_ keyCode: UInt16, modifiers: NSEvent.ModifierFlags = []) -> Bool {
427
+ // Cmd+A → select all visible windows (works during search too — selects filtered results)
428
+ if keyCode == 0 && modifiers.contains(.command) {
429
+ let allIds = Set(flatWindowList.map(\.id))
430
+ if selectedWindowIds == allIds {
431
+ clearSelection() // toggle off
432
+ } else {
433
+ selectedWindowIds = allIds
434
+ }
435
+ if isSearching { deactivateSearch() }
436
+ return true
437
+ }
438
+
439
+ switch keyCode {
440
+ case 53: // Escape — always dismiss
441
+ onDismiss?()
442
+ return true
443
+
444
+ case 126: // ↑
445
+ if modifiers.contains(.shift) {
446
+ extendSelectionVertical(-1)
447
+ } else {
448
+ moveSelectionVertical(-1)
449
+ }
450
+ return true
451
+
452
+ case 125: // ↓
453
+ if modifiers.contains(.shift) {
454
+ extendSelectionVertical(1)
455
+ } else {
456
+ moveSelectionVertical(1)
457
+ }
458
+ return true
459
+
460
+ case 38: // j
461
+ if isSearching { return false }
462
+ if modifiers.contains(.shift) {
463
+ extendSelectionVertical(1)
464
+ } else {
465
+ moveSelectionVertical(1)
466
+ }
467
+ return true
468
+
469
+ case 40: // k
470
+ if isSearching { return false }
471
+ if modifiers.contains(.shift) {
472
+ extendSelectionVertical(-1)
473
+ } else {
474
+ moveSelectionVertical(-1)
475
+ }
476
+ return true
477
+
478
+ case 123: // ← → jump to previous display
479
+ moveSelectionToDisplay(delta: -1)
480
+ return true
481
+
482
+ case 124: // → → jump to next display
483
+ moveSelectionToDisplay(delta: 1)
484
+ return true
485
+
486
+ case 36: // Enter
487
+ if isSearching {
488
+ // Select first match and bring to front
489
+ if let first = flatWindowList.first {
490
+ selectSingle(first.id)
491
+ bringSelectedToFront()
492
+ }
493
+ deactivateSearch()
494
+ return true
495
+ }
496
+ if !selectedWindowIds.isEmpty {
497
+ if selectedWindowIds.count > 1 {
498
+ bringAllSelectedToFront()
499
+ } else {
500
+ bringSelectedToFront()
501
+ }
502
+ } else {
503
+ moveSelectionVertical(1) // select first window
504
+ }
505
+ return true
506
+
507
+ case 44: // / → activate search
508
+ if !isSearching {
509
+ activateSearch()
510
+ return true
511
+ }
512
+ return false
513
+
514
+ case 3: // f → focus window directly
515
+ if isSearching && selectedWindowIds.isEmpty { return false }
516
+ if isSearching { deactivateSearch() }
517
+ if !selectedWindowIds.isEmpty {
518
+ if selectedWindowIds.count > 1 {
519
+ focusAllSelected()
520
+ } else {
521
+ focusSelectedWindow()
522
+ }
523
+ }
524
+ return true
525
+
526
+ case 17: // t → enter tiling mode directly
527
+ if isSearching && selectedWindowIds.isEmpty { return false }
528
+ if isSearching { deactivateSearch() }
529
+ if !selectedWindowIds.isEmpty {
530
+ desktopMode = .tiling
531
+ }
532
+ return true
533
+
534
+ case 1: // s → grid preview (or show & distribute if single)
535
+ if isSearching && selectedWindowIds.isEmpty { return false }
536
+ if isSearching { deactivateSearch() }
537
+ if !selectedWindowIds.isEmpty {
538
+ desktopMode = .gridPreview
539
+ }
540
+ return true
541
+
542
+ case 4: // h → highlight window directly
543
+ if isSearching && selectedWindowIds.isEmpty { return false }
544
+ if isSearching { deactivateSearch() }
545
+ if !selectedWindowIds.isEmpty {
546
+ if selectedWindowIds.count > 1 {
547
+ highlightAllSelected()
548
+ } else {
549
+ highlightSelectedWindow()
550
+ }
551
+ }
552
+ return true
553
+
554
+ case 46: // m → screen map editor (standalone window)
555
+ if isSearching { deactivateSearch() }
556
+ ScreenMapWindowController.shared.show()
557
+ return true
558
+
559
+ case 18, 19, 20, 21, 23, 22: // 1-6 → filter presets (only when no selection and not searching)
560
+ if isSearching { return false }
561
+ if selectedWindowIds.isEmpty {
562
+ let keyToIndex: [UInt16: Int] = [18: 1, 19: 2, 20: 3, 21: 4, 23: 5, 22: 6]
563
+ if let idx = keyToIndex[keyCode], let preset = FilterPreset.from(keyIndex: idx) {
564
+ if activePreset == preset {
565
+ activePreset = nil // toggle off
566
+ } else {
567
+ activePreset = preset
568
+ }
569
+ clearSelection()
570
+ }
571
+ }
572
+ return true
573
+
574
+ default:
575
+ if isSearching { return false }
576
+ return true
577
+ }
578
+ }
579
+
580
+ // MARK: Tiling — position keys
581
+
582
+ private func handleTilingKey(_ keyCode: UInt16, modifiers: NSEvent.ModifierFlags = []) -> Bool {
583
+ switch keyCode {
584
+ case 53: // Escape — always dismiss
585
+ onDismiss?()
586
+ return true
587
+
588
+ case 123: tileSelectedWindow(to: .left); return true // ←
589
+ case 124: tileSelectedWindow(to: .right); return true // →
590
+ case 126: // ↑ — shift=maximize, plain=top half
591
+ if modifiers.contains(.shift) {
592
+ tileSelectedWindow(to: .maximize)
593
+ } else {
594
+ tileSelectedWindow(to: .top)
595
+ }
596
+ return true
597
+ case 125: tileSelectedWindow(to: .bottom); return true // ↓
598
+ case 18: tileSelectedWindow(to: .topLeft); return true // 1
599
+ case 19: tileSelectedWindow(to: .topRight); return true // 2
600
+ case 20: tileSelectedWindow(to: .bottomLeft); return true // 3
601
+ case 21: tileSelectedWindow(to: .bottomRight); return true// 4
602
+ case 23: tileSelectedWindow(to: .leftThird); return true // 5
603
+ case 22: tileSelectedWindow(to: .centerThird); return true// 6
604
+ case 26: tileSelectedWindow(to: .rightThird); return true // 7
605
+ case 8: tileSelectedWindow(to: .center); return true // c
606
+ case 2: distributeSelectedHorizontally(); return true // d → distribute
607
+
608
+ default:
609
+ return true
610
+ }
611
+ }
612
+
613
+ // MARK: Grid Preview — Enter/s to apply, Esc to cancel
614
+
615
+ private func handleGridPreviewKey(_ keyCode: UInt16) -> Bool {
616
+ switch keyCode {
617
+ case 53: // Escape — always dismiss
618
+ onDismiss?()
619
+ return true
620
+
621
+ case 36, 1: // Enter or s → apply the layout
622
+ showAndDistributeSelected()
623
+ desktopMode = .browsing
624
+ return true
625
+
626
+ default:
627
+ return true
628
+ }
629
+ }
630
+
631
+ /// Windows arranged in grid order for preview
632
+ var gridPreviewWindows: [DesktopInventorySnapshot.InventoryWindowInfo] {
633
+ flatWindowList.filter { selectedWindowIds.contains($0.id) }
634
+ }
635
+
636
+ /// Grid shape for current selection
637
+ var gridPreviewShape: [Int] {
638
+ WindowTiler.gridShape(for: selectedWindowIds.count)
639
+ }
640
+
641
+ // MARK: - Selection Actions
642
+
643
+ /// Move selection up/down within the flat window list (stays in same display column when possible)
644
+ private func moveSelectionVertical(_ delta: Int) {
645
+ guard let snapshot = filteredSnapshot else { return }
646
+
647
+ let anchor = cursorWindowId ?? selectedWindowId
648
+ if let anchor = anchor,
649
+ let displayIdx = displayIndex(for: anchor, in: snapshot) {
650
+ let displayWindows = windowsInDisplay(displayIdx, snapshot: snapshot)
651
+ if let localIdx = displayWindows.firstIndex(where: { $0.id == anchor }) {
652
+ let newIdx = max(0, min(displayWindows.count - 1, localIdx + delta))
653
+ selectSingle(displayWindows[newIdx].id)
654
+ }
655
+ } else {
656
+ // No selection — pick first window in first display
657
+ let windows = flatWindowList
658
+ guard !windows.isEmpty else { return }
659
+ if let id = delta > 0 ? windows.first?.id : windows.last?.id {
660
+ selectSingle(id)
661
+ }
662
+ }
663
+
664
+ if let wid = cursorWindowId, let win = flatWindowList.first(where: { $0.id == wid }) {
665
+ let title = win.title.isEmpty ? "(untitled)" : String(win.title.prefix(30))
666
+ DiagnosticLog.shared.info("Select: wid=\(wid) \"\(title)\"")
667
+ }
668
+ }
669
+
670
+ /// Extend selection up/down (Shift+arrow) — adds items without removing existing selection
671
+ private func extendSelectionVertical(_ delta: Int) {
672
+ guard let snapshot = filteredSnapshot else { return }
673
+
674
+ let anchor = cursorWindowId ?? selectedWindowId
675
+ if let anchor = anchor,
676
+ let displayIdx = displayIndex(for: anchor, in: snapshot) {
677
+ let displayWindows = windowsInDisplay(displayIdx, snapshot: snapshot)
678
+ if let localIdx = displayWindows.firstIndex(where: { $0.id == anchor }) {
679
+ let newIdx = max(0, min(displayWindows.count - 1, localIdx + delta))
680
+ let newId = displayWindows[newIdx].id
681
+ selectedWindowIds.insert(newId)
682
+ cursorWindowId = newId
683
+ }
684
+ } else {
685
+ let windows = flatWindowList
686
+ guard !windows.isEmpty else { return }
687
+ if let id = delta > 0 ? windows.first?.id : windows.last?.id {
688
+ selectedWindowIds.insert(id)
689
+ cursorWindowId = id
690
+ }
691
+ }
692
+ }
693
+
694
+ /// Jump selection to the adjacent display column
695
+ private func moveSelectionToDisplay(delta: Int) {
696
+ guard let snapshot = filteredSnapshot, snapshot.displays.count > 1 else { return }
697
+
698
+ let displayCount = snapshot.displays.count
699
+
700
+ // Find current display index
701
+ let currentDisplayIdx: Int
702
+ if let wid = selectedWindowId, let idx = displayIndex(for: wid, in: snapshot) {
703
+ currentDisplayIdx = idx
704
+ } else {
705
+ // No selection — start from first or last display
706
+ currentDisplayIdx = delta > 0 ? -1 : displayCount
707
+ }
708
+
709
+ let targetIdx = currentDisplayIdx + delta
710
+ guard targetIdx >= 0, targetIdx < displayCount else { return }
711
+
712
+ // Find the position in the current display for context
713
+ let targetWindows = windowsInDisplay(targetIdx, snapshot: snapshot)
714
+ guard !targetWindows.isEmpty else { return }
715
+
716
+ // Try to land at a similar position (same row index)
717
+ if let wid = selectedWindowId,
718
+ let srcIdx = displayIndex(for: wid, in: snapshot) {
719
+ let srcWindows = windowsInDisplay(srcIdx, snapshot: snapshot)
720
+ let srcPos = srcWindows.firstIndex(where: { $0.id == wid }) ?? 0
721
+ let targetPos = min(srcPos, targetWindows.count - 1)
722
+ selectSingle(targetWindows[targetPos].id)
723
+ } else if let id = targetWindows.first?.id {
724
+ selectSingle(id)
725
+ }
726
+
727
+ DiagnosticLog.shared.info("Jump to display \(targetIdx + 1)")
728
+ }
729
+
730
+ // MARK: - Display Helpers
731
+
732
+ /// Get the display index for a given window ID
733
+ private func displayIndex(for wid: UInt32, in snapshot: DesktopInventorySnapshot) -> Int? {
734
+ for (dIdx, display) in snapshot.displays.enumerated() {
735
+ for space in display.spaces {
736
+ for app in space.apps {
737
+ if app.windows.contains(where: { $0.id == wid }) {
738
+ return dIdx
739
+ }
740
+ }
741
+ }
742
+ }
743
+ return nil
744
+ }
745
+
746
+ /// Get all windows in a display as a flat list (preserving space/app order)
747
+ private func windowsInDisplay(_ displayIdx: Int, snapshot: DesktopInventorySnapshot) -> [DesktopInventorySnapshot.InventoryWindowInfo] {
748
+ guard displayIdx < snapshot.displays.count else { return [] }
749
+ return snapshot.displays[displayIdx].spaces.flatMap { $0.apps.flatMap { $0.windows } }
750
+ }
751
+
752
+ private func bringSelectedToFront() {
753
+ guard let wid = selectedWindowId,
754
+ let window = flatWindowList.first(where: { $0.id == wid }) else { return }
755
+ DiagnosticLog.shared.info("Front: wid=\(wid) pid=\(window.pid)")
756
+ WindowTiler.raiseWindowAndReactivate(wid: wid, pid: window.pid)
757
+ }
758
+
759
+ private func bringAllSelectedToFront() {
760
+ let windows = flatWindowList.filter { selectedWindowIds.contains($0.id) }
761
+ guard !windows.isEmpty else { return }
762
+ DiagnosticLog.shared.info("Front all: \(windows.count) windows")
763
+ WindowTiler.raiseWindowsAndReactivate(windows: windows.map { (wid: $0.id, pid: $0.pid) })
764
+ }
765
+
766
+ private func focusSelectedWindow() {
767
+ guard let wid = selectedWindowId,
768
+ let window = flatWindowList.first(where: { $0.id == wid }) else { return }
769
+ DiagnosticLog.shared.info("Focus: wid=\(wid) pid=\(window.pid)")
770
+ WindowTiler.raiseWindowAndReactivate(wid: wid, pid: window.pid)
771
+ }
772
+
773
+ private func highlightSelectedWindow() {
774
+ guard let wid = selectedWindowId else { return }
775
+ DiagnosticLog.shared.info("Highlight: wid=\(wid)")
776
+ WindowTiler.highlightWindowById(wid: wid)
777
+ }
778
+
779
+ private func tileSelectedWindow(to position: TilePosition) {
780
+ if selectedWindowIds.count > 1 {
781
+ tileAllSelected(to: position)
782
+ return
783
+ }
784
+ guard let wid = selectedWindowId,
785
+ let window = flatWindowList.first(where: { $0.id == wid }) else { return }
786
+
787
+ DiagnosticLog.shared.info("Tile: wid=\(wid) → \(position.rawValue)")
788
+ WindowTiler.tileWindowById(wid: wid, pid: window.pid, to: position)
789
+
790
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
791
+ self?.desktopSnapshot = self?.buildDesktopInventory()
792
+ }
793
+ }
794
+
795
+ private func tileAllSelected(to position: TilePosition) {
796
+ let windows = flatWindowList.filter { selectedWindowIds.contains($0.id) }
797
+ guard !windows.isEmpty else { return }
798
+
799
+ // For left/right with 2+ windows: distribute evenly across width
800
+ if windows.count >= 2 && (position == .left || position == .right) {
801
+ distributeSelectedHorizontally()
802
+ return
803
+ }
804
+
805
+ DiagnosticLog.shared.info("Tile all \(windows.count): \(position.rawValue)")
806
+ for win in windows {
807
+ WindowTiler.tileWindowById(wid: win.id, pid: win.pid, to: position)
808
+ }
809
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
810
+ self?.desktopSnapshot = self?.buildDesktopInventory()
811
+ }
812
+ }
813
+
814
+ private func distributeSelectedHorizontally() {
815
+ let windows = flatWindowList.filter { selectedWindowIds.contains($0.id) }
816
+ guard windows.count >= 2 else { return }
817
+ DiagnosticLog.shared.info("Distribute H: \(windows.count) windows")
818
+ WindowTiler.tileDistributeHorizontally(windows: windows.map { (wid: $0.id, pid: $0.pid) })
819
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
820
+ self?.desktopSnapshot = self?.buildDesktopInventory()
821
+ }
822
+ }
823
+
824
+ // MARK: - Batch Actions (multi-select)
825
+
826
+ func focusAllSelected() {
827
+ let windows = flatWindowList.filter { selectedWindowIds.contains($0.id) }
828
+ guard !windows.isEmpty else { return }
829
+ DiagnosticLog.shared.info("Focus all: \(windows.count) windows")
830
+ WindowTiler.raiseWindowsAndReactivate(windows: windows.map { (wid: $0.id, pid: $0.pid) })
831
+ }
832
+
833
+ func highlightAllSelected() {
834
+ let wids = flatWindowList.filter { selectedWindowIds.contains($0.id) }.map(\.id)
835
+ guard !wids.isEmpty else { return }
836
+ DiagnosticLog.shared.info("Highlight all: \(wids.count) windows")
837
+ for wid in wids {
838
+ WindowTiler.highlightWindowById(wid: wid)
839
+ }
840
+ }
841
+
842
+ /// Show all selected windows (raise to front) without changing layout
843
+ func showAllSelected() {
844
+ let windows = flatWindowList.filter { selectedWindowIds.contains($0.id) }
845
+ guard !windows.isEmpty else { return }
846
+ savePositions(for: windows)
847
+ WindowTiler.raiseWindowsAndReactivate(windows: windows.map { (wid: $0.id, pid: $0.pid) })
848
+ flash("Showing \(windows.count) window\(windows.count == 1 ? "" : "s")")
849
+ }
850
+
851
+ /// Show all selected windows AND distribute in smart grid — single batch operation
852
+ func showAndDistributeSelected() {
853
+ let windows = flatWindowList.filter { selectedWindowIds.contains($0.id) }
854
+ guard !windows.isEmpty else { return }
855
+ savePositions(for: windows)
856
+ WindowTiler.batchRaiseAndDistribute(windows: windows.map { (wid: $0.id, pid: $0.pid) })
857
+ let shape = WindowTiler.gridShape(for: windows.count)
858
+ let grid = shape.map(String.init).joined(separator: "+")
859
+ flash("\(windows.count) windows [\(grid)]")
860
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
861
+ self?.desktopSnapshot = self?.buildDesktopInventory()
862
+ }
863
+ }
864
+
865
+ /// Distribute selected in smart grid without raising
866
+ func distributeSelected() {
867
+ let windows = flatWindowList.filter { selectedWindowIds.contains($0.id) }
868
+ guard !windows.isEmpty else { return }
869
+ savePositions(for: windows)
870
+ WindowTiler.batchRaiseAndDistribute(windows: windows.map { (wid: $0.id, pid: $0.pid) })
871
+ let shape = WindowTiler.gridShape(for: windows.count)
872
+ let grid = shape.map(String.init).joined(separator: "+")
873
+ flash("\(windows.count) windows [\(grid)]")
874
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
875
+ self?.desktopSnapshot = self?.buildDesktopInventory()
876
+ }
877
+ }
878
+
879
+ /// Save current positions of windows so they can be restored later
880
+ private func savePositions(for windows: [DesktopInventorySnapshot.InventoryWindowInfo]) {
881
+ // Don't overwrite if already saved (allow chaining actions)
882
+ guard savedPositions == nil else { return }
883
+ var positions: [UInt32: (pid: Int32, frame: WindowFrame)] = [:]
884
+ for win in windows {
885
+ positions[win.id] = (pid: win.pid, frame: win.frame)
886
+ }
887
+ savedPositions = positions
888
+ DiagnosticLog.shared.info("Saved positions for \(positions.count) windows")
889
+ }
890
+
891
+ /// Restore windows to their saved positions — single batch operation
892
+ func restorePositions() {
893
+ guard let positions = savedPositions else { return }
894
+ DiagnosticLog.shared.info("Restoring \(positions.count) window positions")
895
+ let restores = positions.map { (wid: $0.key, pid: $0.value.pid, frame: $0.value.frame) }
896
+ WindowTiler.batchRestoreWindows(restores)
897
+ savedPositions = nil
898
+ flash("Restored \(restores.count) window\(restores.count == 1 ? "" : "s")")
899
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
900
+ self?.desktopSnapshot = self?.buildDesktopInventory()
901
+ }
902
+ }
903
+
904
+ /// Accept the current layout — discard saved positions
905
+ func discardSavedPositions() {
906
+ savedPositions = nil
907
+ DiagnosticLog.shared.info("Accepted layout, discarded saved positions")
908
+ }
909
+
910
+ /// Show a brief flash message that auto-dismisses
911
+ func flash(_ message: String) {
912
+ flashMessage = message
913
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
914
+ if self?.flashMessage == message { self?.flashMessage = nil }
915
+ }
916
+ }
917
+
918
+ /// Copy a text representation of the desktop inventory to clipboard
919
+ func copyInventoryToClipboard() {
920
+ guard let snapshot = desktopSnapshot else { return }
921
+ var lines: [String] = ["DESKTOP INVENTORY"]
922
+ lines.append(String(repeating: "─", count: 60))
923
+
924
+ for display in snapshot.displays {
925
+ lines.append("")
926
+ lines.append("\(display.name) \(display.visibleFrame.w)×\(display.visibleFrame.h) (\(display.spaceCount) spaces)")
927
+ for space in display.spaces {
928
+ let tag = space.isCurrent ? " ◀ active" : ""
929
+ let winCount = space.apps.reduce(0) { $0 + $1.windows.count }
930
+ lines.append(" Space \(space.index)\(tag) (\(winCount) windows)")
931
+ for app in space.apps {
932
+ if app.windows.count == 1, let win = app.windows.first {
933
+ let tile = win.tilePosition?.label ?? "—"
934
+ let title = win.title.isEmpty ? "(untitled)" : win.title
935
+ let dmx = win.isLattices ? " [lattices]" : ""
936
+ let path = win.inventoryPath?.description ?? ""
937
+ lines.append(" \(app.appName) \(title)\(dmx) \(Int(win.frame.w))×\(Int(win.frame.h)) \(tile) \(path)")
938
+ } else {
939
+ lines.append(" \(app.appName)")
940
+ for win in app.windows {
941
+ let tile = win.tilePosition?.label ?? "—"
942
+ let title = win.title.isEmpty ? "(untitled)" : win.title
943
+ let dmx = win.isLattices ? " [lattices]" : ""
944
+ let path = win.inventoryPath?.description ?? ""
945
+ lines.append(" \(title)\(dmx) \(Int(win.frame.w))×\(Int(win.frame.h)) \(tile) \(path)")
946
+ }
947
+ }
948
+ }
949
+ }
950
+ }
951
+
952
+ let text = lines.joined(separator: "\n")
953
+ NSPasteboard.general.clearContents()
954
+ NSPasteboard.general.setString(text, forType: .string)
955
+ DiagnosticLog.shared.success("Copied inventory to clipboard (\(text.count) chars)")
956
+ }
957
+
958
+ func dismiss() {
959
+ phase = .idle
960
+ onDismiss?()
961
+ }
962
+
963
+ // MARK: - Inventory Builder
964
+
965
+ private func buildInventory() -> CommandModeInventory {
966
+ let workspace = WorkspaceManager.shared
967
+ let tmux = TmuxModel.shared
968
+ let inventoryMgr = InventoryManager.shared
969
+
970
+ // Refresh inventory so orphans are current
971
+ inventoryMgr.refresh()
972
+
973
+ let activeLayer = workspace.activeLayer
974
+ let layerCount = workspace.config?.layers?.count ?? 0
975
+
976
+ var items: [CommandModeInventory.Item] = []
977
+
978
+ // Active layer projects
979
+ if let layer = activeLayer {
980
+ for lp in layer.projects {
981
+ if let groupId = lp.group, let group = workspace.group(byId: groupId) {
982
+ let running = workspace.isGroupRunning(group)
983
+ let paneCount = group.tabs.count
984
+ items.append(.init(
985
+ name: group.label,
986
+ group: "Layer: \(layer.label)",
987
+ status: running ? .running : .stopped,
988
+ paneCount: paneCount,
989
+ tileHint: lp.tile
990
+ ))
991
+ } else if let path = lp.path {
992
+ let name = (path as NSString).lastPathComponent
993
+ let sessionName = WorkspaceManager.sessionName(for: path)
994
+ let session = tmux.sessions.first(where: { $0.name == sessionName })
995
+ let status: CommandModeInventory.Status
996
+ if let s = session {
997
+ status = s.attached ? .attached : .running
998
+ } else {
999
+ status = .stopped
1000
+ }
1001
+ items.append(.init(
1002
+ name: name,
1003
+ group: "Layer: \(layer.label)",
1004
+ status: status,
1005
+ paneCount: session?.panes.count ?? 0,
1006
+ tileHint: lp.tile
1007
+ ))
1008
+ }
1009
+ }
1010
+ }
1011
+
1012
+ // Tab groups not in active layer
1013
+ if let groups = workspace.config?.groups {
1014
+ let layerGroupIds = Set(activeLayer?.projects.compactMap(\.group) ?? [])
1015
+ for group in groups where !layerGroupIds.contains(group.id) {
1016
+ let running = workspace.isGroupRunning(group)
1017
+ items.append(.init(
1018
+ name: group.label,
1019
+ group: "Group: \(group.label)",
1020
+ status: running ? .running : .stopped,
1021
+ paneCount: group.tabs.count,
1022
+ tileHint: nil
1023
+ ))
1024
+ }
1025
+ }
1026
+
1027
+ // Orphans
1028
+ for orphan in inventoryMgr.orphans {
1029
+ items.append(.init(
1030
+ name: orphan.name,
1031
+ group: "Orphan",
1032
+ status: orphan.attached ? .attached : .running,
1033
+ paneCount: orphan.panes.count,
1034
+ tileHint: nil
1035
+ ))
1036
+ }
1037
+
1038
+ return CommandModeInventory(
1039
+ activeLayer: activeLayer?.label,
1040
+ layerCount: layerCount,
1041
+ items: items
1042
+ )
1043
+ }
1044
+
1045
+ // MARK: - Desktop Inventory Builder
1046
+
1047
+ private func buildDesktopInventory() -> DesktopInventorySnapshot {
1048
+ let originalScreens = NSScreen.screens
1049
+ let displaySpaces = WindowTiler.getDisplaySpaces()
1050
+ let primaryHeight = originalScreens.first?.frame.height ?? 0
1051
+
1052
+ // Sort screens left-to-right by frame origin, tie-break top-to-bottom
1053
+ let sortedScreens = originalScreens.sorted {
1054
+ if $0.frame.origin.x != $1.frame.origin.x {
1055
+ return $0.frame.origin.x < $1.frame.origin.x
1056
+ }
1057
+ return $0.frame.origin.y > $1.frame.origin.y
1058
+ }
1059
+ // Map sorted index → original index for displaySpaces lookup
1060
+ let sortedToOriginal = sortedScreens.map { s in originalScreens.firstIndex(where: { $0 === s })! }
1061
+ let screens = sortedScreens
1062
+
1063
+ // Build space-to-display mapping: spaceId → (displayIndex, spaceIndex)
1064
+ var spaceToDisplay: [Int: (displayIdx: Int, spaceIdx: Int)] = [:]
1065
+ for (dIdx, ds) in displaySpaces.enumerated() {
1066
+ for space in ds.spaces {
1067
+ spaceToDisplay[space.id] = (dIdx, space.index)
1068
+ }
1069
+ }
1070
+
1071
+ // Current space IDs per display
1072
+ let currentSpaceIds = Set(displaySpaces.map(\.currentSpaceId))
1073
+
1074
+ // Query ALL windows (not just on-screen) to capture every space
1075
+ guard let rawList = CGWindowListCopyWindowInfo(
1076
+ [.optionAll, .excludeDesktopElements],
1077
+ kCGNullWindowID
1078
+ ) as? [[String: Any]] else {
1079
+ return DesktopInventorySnapshot(displays: [], timestamp: Date())
1080
+ }
1081
+
1082
+ // Parse raw CG window info
1083
+ struct RawWindow {
1084
+ let wid: UInt32; let app: String; let pid: Int32
1085
+ let title: String; let frame: WindowFrame
1086
+ let latticesSession: String?; let spaceIds: [Int]
1087
+ }
1088
+
1089
+ // System/helper processes that create layer-0 windows users don't care about
1090
+ let blockedApps: Set<String> = [
1091
+ // macOS system
1092
+ "WindowServer", "Dock", "SystemUIServer", "Control Center",
1093
+ "Notification Center", "NotificationCenter", "Spotlight", "WindowManager",
1094
+ "TextInputMenuAgent", "TextInputSwitcher", "universalAccessAuthWarn",
1095
+ "AXVisualSupportAgent", "loginwindow", "ScreenSaverEngine",
1096
+ // UI service helpers (run as XPC, show popover/autofill UI)
1097
+ "AutoFill", "AuthenticationServicesHelper", "CursorUIViewService",
1098
+ "SharedWebCredentialViewService", "CoreServicesUIAgent",
1099
+ "UserNotificationCenter", "SecurityAgent", "OSDUIHelper",
1100
+ "PassKit UIService", "QuickLookUIService", "ScopedBookmarkAgent",
1101
+ // Dev tool helpers
1102
+ "Instruments", "FileMerge",
1103
+ ]
1104
+ // Also block apps whose name ends with known helper suffixes
1105
+ let blockedSuffixes = ["UIService", "UIHelper", "Agent", "Helper", "ViewService"]
1106
+
1107
+ let ownPid = ProcessInfo.processInfo.processIdentifier
1108
+ let rawCount = rawList.count
1109
+
1110
+ var allWindows: [RawWindow] = []
1111
+ for info in rawList {
1112
+ guard let wid = info[kCGWindowNumber as String] as? UInt32,
1113
+ let ownerName = info[kCGWindowOwnerName as String] as? String,
1114
+ let pid = info[kCGWindowOwnerPID as String] as? Int32,
1115
+ let boundsDict = info[kCGWindowBounds as String] as? NSDictionary
1116
+ else { continue }
1117
+
1118
+ // Skip our own windows
1119
+ guard pid != ownPid else { continue }
1120
+
1121
+ // Skip known system/helper processes
1122
+ guard !blockedApps.contains(ownerName) else { continue }
1123
+ if blockedSuffixes.contains(where: { ownerName.hasSuffix($0) }) { continue }
1124
+
1125
+ var rect = CGRect.zero
1126
+ guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect),
1127
+ rect.width >= 100, rect.height >= 50 else { continue }
1128
+
1129
+ let layer = info[kCGWindowLayer as String] as? Int ?? 0
1130
+ guard layer == 0 else { continue }
1131
+
1132
+ let title = info[kCGWindowName as String] as? String ?? ""
1133
+ let isOnScreen = info[kCGWindowIsOnscreen as String] as? Bool ?? false
1134
+ let spaceIds = WindowTiler.getSpacesForWindow(wid)
1135
+
1136
+ // Skip windows not assigned to any space (background helpers)
1137
+ guard !spaceIds.isEmpty else { continue }
1138
+
1139
+ // For windows on a current space, require them to be actually visible.
1140
+ // This filters hidden helper windows (AutoFill, CursorUIViewService, etc.)
1141
+ // while keeping real windows on other spaces.
1142
+ let isOnCurrentSpace = spaceIds.contains(where: { currentSpaceIds.contains($0) })
1143
+ if isOnCurrentSpace && !isOnScreen { continue }
1144
+
1145
+ let frame = WindowFrame(x: Double(rect.origin.x), y: Double(rect.origin.y),
1146
+ w: Double(rect.width), h: Double(rect.height))
1147
+
1148
+ var latticesSession: String?
1149
+ if let range = title.range(of: #"\[lattices:([^\]]+)\]"#, options: .regularExpression) {
1150
+ let match = String(title[range])
1151
+ latticesSession = String(match.dropFirst(9).dropLast(1))
1152
+ }
1153
+
1154
+ allWindows.append(RawWindow(wid: wid, app: ownerName, pid: pid, title: title,
1155
+ frame: frame, latticesSession: latticesSession, spaceIds: spaceIds))
1156
+ }
1157
+
1158
+ DiagnosticLog.shared.info("Desktop scan: \(rawCount) raw → \(allWindows.count) after filter")
1159
+
1160
+ // Assign each window to (display, space)
1161
+ struct AssignedWindow {
1162
+ let win: RawWindow; let displayIdx: Int; let spaceId: Int; let spaceIdx: Int; let isOnScreen: Bool
1163
+ }
1164
+
1165
+ var assigned: [AssignedWindow] = []
1166
+ for win in allWindows {
1167
+ // Primary: use space→display mapping
1168
+ for sid in win.spaceIds {
1169
+ if let mapping = spaceToDisplay[sid] {
1170
+ assigned.append(AssignedWindow(
1171
+ win: win,
1172
+ displayIdx: mapping.displayIdx,
1173
+ spaceId: sid,
1174
+ spaceIdx: mapping.spaceIdx,
1175
+ isOnScreen: currentSpaceIds.contains(sid)
1176
+ ))
1177
+ break // assign to first known space
1178
+ }
1179
+ }
1180
+
1181
+ // Fallback: match by frame center (no space info)
1182
+ if !win.spaceIds.contains(where: { spaceToDisplay[$0] != nil }) {
1183
+ let cx = win.frame.x + win.frame.w / 2
1184
+ let cy = win.frame.y + win.frame.h / 2
1185
+ let nsCy = primaryHeight - cy
1186
+ for (sIdx, screen) in screens.enumerated() {
1187
+ if screen.frame.contains(NSPoint(x: cx, y: nsCy)) {
1188
+ let origIdx = sortedToOriginal[sIdx]
1189
+ let ds = origIdx < displaySpaces.count ? displaySpaces[origIdx] : nil
1190
+ let currentSid = ds?.currentSpaceId ?? 0
1191
+ let currentIdx = ds?.spaces.first(where: { $0.isCurrent })?.index ?? 1
1192
+ assigned.append(AssignedWindow(
1193
+ win: win, displayIdx: origIdx,
1194
+ spaceId: currentSid, spaceIdx: currentIdx, isOnScreen: true
1195
+ ))
1196
+ break
1197
+ }
1198
+ }
1199
+ }
1200
+ }
1201
+
1202
+ // Build hierarchical: Display → Space → App → Windows
1203
+ var displays: [DesktopInventorySnapshot.DisplayInfo] = []
1204
+
1205
+ for (screenIdx, screen) in screens.enumerated() {
1206
+ let frame = screen.frame
1207
+ let visible = screen.visibleFrame
1208
+ let name = screen.localizedName
1209
+
1210
+ let originalIdx = sortedToOriginal[screenIdx]
1211
+ let ds = originalIdx < displaySpaces.count ? displaySpaces[originalIdx] : nil
1212
+ let spaceCount = ds?.spaces.count ?? 1
1213
+ let currentSpaceIdx = ds?.spaces.first(where: { $0.isCurrent })?.index ?? 1
1214
+
1215
+ let screenWindows = assigned.filter { $0.displayIdx == originalIdx }
1216
+
1217
+ // Group by space
1218
+ var windowsBySpace: [Int: [AssignedWindow]] = [:]
1219
+ for aw in screenWindows {
1220
+ windowsBySpace[aw.spaceId, default: []].append(aw)
1221
+ }
1222
+
1223
+ // Build SpaceGroups sorted by space index
1224
+ let isMain = screen == NSScreen.main
1225
+ let displayLabel = InventoryPath.displayName(for: screen, isMain: isMain)
1226
+ var spaceGroups: [DesktopInventorySnapshot.SpaceGroup] = []
1227
+ let allSpacesForDisplay = ds?.spaces ?? []
1228
+
1229
+ for spaceInfo in allSpacesForDisplay {
1230
+ let spaceWindows = windowsBySpace[spaceInfo.id] ?? []
1231
+ guard !spaceWindows.isEmpty else { continue }
1232
+
1233
+ // Group by app within space
1234
+ var appGroups: [String: [AssignedWindow]] = [:]
1235
+ for aw in spaceWindows {
1236
+ appGroups[aw.win.app, default: []].append(aw)
1237
+ }
1238
+
1239
+ var groups: [DesktopInventorySnapshot.AppGroup] = []
1240
+ for appName in appGroups.keys.sorted() {
1241
+ let wins = appGroups[appName]!
1242
+ let appType = AppTypeClassifier.classify(appName)
1243
+ let inventoryWindows = wins.map { aw -> DesktopInventorySnapshot.InventoryWindowInfo in
1244
+ let tile = aw.isOnScreen ? WindowTiler.inferTilePosition(frame: aw.win.frame, screen: screen) : nil
1245
+ let path = InventoryPath(
1246
+ display: displayLabel,
1247
+ space: "space\(aw.spaceIdx)",
1248
+ appType: appType.rawValue,
1249
+ appName: appName,
1250
+ windowTitle: aw.win.title.isEmpty ? "untitled" : aw.win.title
1251
+ )
1252
+ return DesktopInventorySnapshot.InventoryWindowInfo(
1253
+ id: aw.win.wid,
1254
+ pid: aw.win.pid,
1255
+ title: aw.win.title,
1256
+ frame: aw.win.frame,
1257
+ tilePosition: tile,
1258
+ isLattices: aw.win.latticesSession != nil,
1259
+ latticesSession: aw.win.latticesSession,
1260
+ spaceIndex: aw.spaceIdx,
1261
+ isOnScreen: aw.isOnScreen,
1262
+ inventoryPath: path,
1263
+ appName: appName
1264
+ )
1265
+ }
1266
+ groups.append(DesktopInventorySnapshot.AppGroup(
1267
+ id: "\(spaceInfo.id)-\(appName)",
1268
+ appName: appName,
1269
+ windows: inventoryWindows
1270
+ ))
1271
+ }
1272
+
1273
+ spaceGroups.append(DesktopInventorySnapshot.SpaceGroup(
1274
+ id: spaceInfo.id,
1275
+ index: spaceInfo.index,
1276
+ isCurrent: spaceInfo.isCurrent,
1277
+ apps: groups
1278
+ ))
1279
+ }
1280
+
1281
+ displays.append(DesktopInventorySnapshot.DisplayInfo(
1282
+ id: ds?.displayId ?? "display-\(screenIdx)",
1283
+ name: name,
1284
+ resolution: (w: Int(frame.width), h: Int(frame.height)),
1285
+ visibleFrame: (w: Int(visible.width), h: Int(visible.height)),
1286
+ isMain: isMain,
1287
+ spaceCount: spaceCount,
1288
+ currentSpaceIndex: currentSpaceIdx,
1289
+ spaces: spaceGroups
1290
+ ))
1291
+ }
1292
+
1293
+ return DesktopInventorySnapshot(displays: displays, timestamp: Date())
1294
+ }
1295
+
1296
+ // MARK: - Chord Map
1297
+
1298
+ private func buildChords() -> [Chord] {
1299
+ let workspace = WorkspaceManager.shared
1300
+
1301
+ var chords: [Chord] = []
1302
+
1303
+ // [a] tile all — re-tile active layer's windows
1304
+ chords.append(Chord(key: "a", keyCode: 0, label: "tile all") {
1305
+ WorkspaceManager.shared.retileCurrentLayer()
1306
+ })
1307
+
1308
+ // [s] split — tile two most recent left/right
1309
+ chords.append(Chord(key: "s", keyCode: 1, label: "split") {
1310
+ let running = ProjectScanner.shared.projects.filter(\.isRunning)
1311
+ let term = Preferences.shared.terminal
1312
+ if running.count >= 2 {
1313
+ WindowTiler.tile(session: running[0].sessionName, terminal: term, to: .left)
1314
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
1315
+ WindowTiler.tile(session: running[1].sessionName, terminal: term, to: .right)
1316
+ }
1317
+ } else if running.count == 1 {
1318
+ WindowTiler.tile(session: running[0].sessionName, terminal: term, to: .maximize)
1319
+ }
1320
+ })
1321
+
1322
+ // [m] maximize — maximize frontmost terminal
1323
+ chords.append(Chord(key: "m", keyCode: 46, label: "maximize") {
1324
+ let term = Preferences.shared.terminal
1325
+ // Find frontmost running project
1326
+ let running = ProjectScanner.shared.projects.filter(\.isRunning)
1327
+ if let first = running.first {
1328
+ WindowTiler.tile(session: first.sessionName, terminal: term, to: .maximize)
1329
+ }
1330
+ })
1331
+
1332
+ // [1]-[3] layer focus (dynamic)
1333
+ let layers = workspace.config?.layers ?? []
1334
+ let layerKeyCodes: [UInt16] = [18, 19, 20] // 1, 2, 3
1335
+ for (i, layer) in layers.prefix(3).enumerated() {
1336
+ let idx = i
1337
+ chords.append(Chord(key: "\(i + 1)", keyCode: layerKeyCodes[i], label: layer.label.lowercased()) {
1338
+ WorkspaceManager.shared.tileLayer(index: idx)
1339
+ })
1340
+ }
1341
+
1342
+ // [l] launch layer — explicitly start non-running projects
1343
+ chords.append(Chord(key: "l", keyCode: 37, label: "launch layer") {
1344
+ let ws = WorkspaceManager.shared
1345
+ ws.tileLayer(index: ws.activeLayerIndex, launch: true, force: true)
1346
+ })
1347
+
1348
+ // [r] refresh
1349
+ chords.append(Chord(key: "r", keyCode: 15, label: "refresh") {
1350
+ ProjectScanner.shared.scan()
1351
+ TmuxModel.shared.poll()
1352
+ InventoryManager.shared.refresh()
1353
+ })
1354
+
1355
+ // [p] palette
1356
+ chords.append(Chord(key: "p", keyCode: 35, label: "palette") {
1357
+ CommandPaletteWindow.shared.show()
1358
+ })
1359
+
1360
+ return chords
1361
+ }
1362
+ }