@lattices/cli 0.4.14 → 0.6.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 (181) hide show
  1. package/README.md +5 -7
  2. package/apps/mac/Info.plist +4 -4
  3. package/apps/mac/Lattices.app/Contents/Info.plist +4 -12
  4. package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/bin/lattices-app.ts +110 -17
  6. package/bin/lattices-build +125 -0
  7. package/bin/lattices-dev +89 -16
  8. package/bin/lattices.ts +977 -16
  9. package/docs/agents.md +81 -4
  10. package/docs/ai-chat-ux-review.md +416 -0
  11. package/docs/api.md +135 -3
  12. package/docs/app.md +30 -8
  13. package/docs/config.md +4 -0
  14. package/docs/mouse-gestures.md +60 -1
  15. package/docs/proposals/LAT-004-interactive-overlay-actors.md +1 -1
  16. package/docs/proposals/LAT-005-action-runtime-product-spine.md +914 -0
  17. package/docs/proposals/LAT-006-mira-in-lattices.md +553 -0
  18. package/docs/proposals/LAT-007-unified-app-shell.md +128 -0
  19. package/docs/reference/dewey.config.ts +2 -2
  20. package/docs/release.md +171 -0
  21. package/docs/repo-structure.md +5 -5
  22. package/docs/voice.md +11 -27
  23. package/package.json +11 -10
  24. package/apps/mac/Package.swift +0 -27
  25. package/apps/mac/Sources/AppShell/App.swift +0 -26
  26. package/apps/mac/Sources/AppShell/AppActivationCoordinator.swift +0 -27
  27. package/apps/mac/Sources/AppShell/AppDelegate.swift +0 -189
  28. package/apps/mac/Sources/AppShell/AppServicesBootstrap.swift +0 -25
  29. package/apps/mac/Sources/AppShell/AppShellView.swift +0 -171
  30. package/apps/mac/Sources/AppShell/AppUpdater.swift +0 -305
  31. package/apps/mac/Sources/AppShell/CliActionLauncher.swift +0 -50
  32. package/apps/mac/Sources/AppShell/HomeDashboardView.swift +0 -133
  33. package/apps/mac/Sources/AppShell/HotkeyBootstrap.swift +0 -87
  34. package/apps/mac/Sources/AppShell/KeyRecorderView.swift +0 -210
  35. package/apps/mac/Sources/AppShell/LatticesRuntime.swift +0 -104
  36. package/apps/mac/Sources/AppShell/MainView.swift +0 -847
  37. package/apps/mac/Sources/AppShell/MainWindow.swift +0 -83
  38. package/apps/mac/Sources/AppShell/MenuBarController.swift +0 -177
  39. package/apps/mac/Sources/AppShell/OnboardingView.swift +0 -483
  40. package/apps/mac/Sources/AppShell/PermissionsAssistantView.swift +0 -366
  41. package/apps/mac/Sources/AppShell/PermissionsAssistantWindow.swift +0 -70
  42. package/apps/mac/Sources/AppShell/Preferences.swift +0 -297
  43. package/apps/mac/Sources/AppShell/SettingsView.swift +0 -3163
  44. package/apps/mac/Sources/AppShell/SettingsWindow.swift +0 -34
  45. package/apps/mac/Sources/AppShell/WorkspaceInspectorPresenter.swift +0 -13
  46. package/apps/mac/Sources/Core/Actions/HotkeyManager.swift +0 -256
  47. package/apps/mac/Sources/Core/Actions/HotkeyStore.swift +0 -399
  48. package/apps/mac/Sources/Core/Actions/IntentEngine.swift +0 -988
  49. package/apps/mac/Sources/Core/Actions/IntentSchema.swift +0 -94
  50. package/apps/mac/Sources/Core/Actions/Intents/CreateLayerIntent.swift +0 -54
  51. package/apps/mac/Sources/Core/Actions/Intents/DistributeIntent.swift +0 -56
  52. package/apps/mac/Sources/Core/Actions/Intents/FocusIntent.swift +0 -69
  53. package/apps/mac/Sources/Core/Actions/Intents/HelpIntent.swift +0 -41
  54. package/apps/mac/Sources/Core/Actions/Intents/KillIntent.swift +0 -47
  55. package/apps/mac/Sources/Core/Actions/Intents/LatticeIntent.swift +0 -53
  56. package/apps/mac/Sources/Core/Actions/Intents/LaunchIntent.swift +0 -67
  57. package/apps/mac/Sources/Core/Actions/Intents/ListSessionsIntent.swift +0 -32
  58. package/apps/mac/Sources/Core/Actions/Intents/ListWindowsIntent.swift +0 -30
  59. package/apps/mac/Sources/Core/Actions/Intents/ScanIntent.swift +0 -52
  60. package/apps/mac/Sources/Core/Actions/Intents/SearchIntent.swift +0 -190
  61. package/apps/mac/Sources/Core/Actions/Intents/SwitchLayerIntent.swift +0 -50
  62. package/apps/mac/Sources/Core/Actions/Intents/TileIntent.swift +0 -61
  63. package/apps/mac/Sources/Core/Actions/PaletteCommand.swift +0 -439
  64. package/apps/mac/Sources/Core/Actions/VoiceIntentResolver.swift +0 -713
  65. package/apps/mac/Sources/Core/Companion/CompanionActivityLog.swift +0 -70
  66. package/apps/mac/Sources/Core/Companion/CompanionKeyboardController.swift +0 -141
  67. package/apps/mac/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +0 -454
  68. package/apps/mac/Sources/Core/Companion/LatticesCompanionCockpit.swift +0 -555
  69. package/apps/mac/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +0 -629
  70. package/apps/mac/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +0 -204
  71. package/apps/mac/Sources/Core/Companion/LatticesDeckHost.swift +0 -1463
  72. package/apps/mac/Sources/Core/Daemon/DaemonProtocol.swift +0 -114
  73. package/apps/mac/Sources/Core/Daemon/DaemonServer.swift +0 -427
  74. package/apps/mac/Sources/Core/Daemon/LatticesApi.swift +0 -2965
  75. package/apps/mac/Sources/Core/Desktop/AccessibilityTextExtractor.swift +0 -111
  76. package/apps/mac/Sources/Core/Desktop/AppTypeClassifier.swift +0 -106
  77. package/apps/mac/Sources/Core/Desktop/DesktopModel.swift +0 -331
  78. package/apps/mac/Sources/Core/Desktop/DesktopModelTypes.swift +0 -73
  79. package/apps/mac/Sources/Core/Desktop/InventoryManager.swift +0 -35
  80. package/apps/mac/Sources/Core/Desktop/InventoryPath.swift +0 -43
  81. package/apps/mac/Sources/Core/Desktop/MouseFinder.swift +0 -527
  82. package/apps/mac/Sources/Core/Desktop/OcrModel.swift +0 -467
  83. package/apps/mac/Sources/Core/Desktop/OcrStore.swift +0 -329
  84. package/apps/mac/Sources/Core/Desktop/PlacementSpec.swift +0 -195
  85. package/apps/mac/Sources/Core/Desktop/SessionWindowLocator.swift +0 -139
  86. package/apps/mac/Sources/Core/Desktop/TilePickerView.swift +0 -209
  87. package/apps/mac/Sources/Core/Desktop/WindowCapture.swift +0 -33
  88. package/apps/mac/Sources/Core/Desktop/WindowDragSnapController.swift +0 -429
  89. package/apps/mac/Sources/Core/Desktop/WindowPreviewCard.swift +0 -100
  90. package/apps/mac/Sources/Core/Desktop/WindowPreviewStore.swift +0 -112
  91. package/apps/mac/Sources/Core/Desktop/WindowSelectionStore.swift +0 -76
  92. package/apps/mac/Sources/Core/Desktop/WindowTiler.swift +0 -2222
  93. package/apps/mac/Sources/Core/Input/EventTapBreaker.swift +0 -124
  94. package/apps/mac/Sources/Core/Input/EventTapThread.swift +0 -54
  95. package/apps/mac/Sources/Core/Input/InputCaptureResetCenter.swift +0 -20
  96. package/apps/mac/Sources/Core/Input/KeyboardRemapConfig.swift +0 -69
  97. package/apps/mac/Sources/Core/Input/KeyboardRemapController.swift +0 -346
  98. package/apps/mac/Sources/Core/Input/KeyboardRemapStore.swift +0 -141
  99. package/apps/mac/Sources/Core/Input/MouseGestureConfig.swift +0 -499
  100. package/apps/mac/Sources/Core/Input/MouseGestureController.swift +0 -2583
  101. package/apps/mac/Sources/Core/Input/MouseInputDeviceStore.swift +0 -98
  102. package/apps/mac/Sources/Core/Input/MouseInputEventViewer.swift +0 -272
  103. package/apps/mac/Sources/Core/Input/MouseShortcutStore.swift +0 -170
  104. package/apps/mac/Sources/Core/Input/SecureEventInputMonitor.swift +0 -39
  105. package/apps/mac/Sources/Core/Input/ShapeRecognizer.swift +0 -624
  106. package/apps/mac/Sources/Core/Input/TapBudgetMeter.swift +0 -56
  107. package/apps/mac/Sources/Core/Overlays/AppWindowShell.swift +0 -63
  108. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeState.swift +0 -1566
  109. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeView.swift +0 -1927
  110. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeWindow.swift +0 -196
  111. package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteView.swift +0 -307
  112. package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +0 -67
  113. package/apps/mac/Sources/Core/Overlays/HUD/CheatSheetHUD.swift +0 -576
  114. package/apps/mac/Sources/Core/Overlays/HUD/HUDBottomBar.swift +0 -279
  115. package/apps/mac/Sources/Core/Overlays/HUD/HUDController.swift +0 -1158
  116. package/apps/mac/Sources/Core/Overlays/HUD/HUDLeftBar.swift +0 -849
  117. package/apps/mac/Sources/Core/Overlays/HUD/HUDMinimap.swift +0 -179
  118. package/apps/mac/Sources/Core/Overlays/HUD/HUDRightBar.swift +0 -596
  119. package/apps/mac/Sources/Core/Overlays/HUD/HUDState.swift +0 -367
  120. package/apps/mac/Sources/Core/Overlays/HUD/HUDTopBar.swift +0 -243
  121. package/apps/mac/Sources/Core/Overlays/HUD/LauncherHUD.swift +0 -334
  122. package/apps/mac/Sources/Core/Overlays/HUD/LayerBezel.swift +0 -203
  123. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchState.swift +0 -280
  124. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchView.swift +0 -422
  125. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchWindow.swift +0 -94
  126. package/apps/mac/Sources/Core/Overlays/OverlayPanelShell.swift +0 -241
  127. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapState.swift +0 -3135
  128. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapView.swift +0 -3977
  129. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapWindowController.swift +0 -119
  130. package/apps/mac/Sources/Core/Overlays/ScreenOverlayCanvasController.swift +0 -1217
  131. package/apps/mac/Sources/Core/Overlays/Voice/VoiceCommandWindow.swift +0 -1575
  132. package/apps/mac/Sources/Core/Pi/PiAuthNextStepCard.swift +0 -148
  133. package/apps/mac/Sources/Core/Pi/PiAuthPromptCard.swift +0 -90
  134. package/apps/mac/Sources/Core/Pi/PiChatDock.swift +0 -564
  135. package/apps/mac/Sources/Core/Pi/PiChatSession.swift +0 -1948
  136. package/apps/mac/Sources/Core/Pi/PiInstallCallout.swift +0 -86
  137. package/apps/mac/Sources/Core/Pi/PiProviderSetupCallout.swift +0 -99
  138. package/apps/mac/Sources/Core/Pi/PiWorkspaceView.swift +0 -510
  139. package/apps/mac/Sources/Core/System/Capability.swift +0 -79
  140. package/apps/mac/Sources/Core/System/DiagnosticLog.swift +0 -373
  141. package/apps/mac/Sources/Core/System/EventBus.swift +0 -31
  142. package/apps/mac/Sources/Core/System/PermissionChecker.swift +0 -224
  143. package/apps/mac/Sources/Core/System/ProcessModel.swift +0 -199
  144. package/apps/mac/Sources/Core/System/ProcessQuery.swift +0 -151
  145. package/apps/mac/Sources/Core/System/SystemTelemetryMonitor.swift +0 -273
  146. package/apps/mac/Sources/Core/Voice/AdvisorLearningStore.swift +0 -90
  147. package/apps/mac/Sources/Core/Voice/AgentSession.swift +0 -377
  148. package/apps/mac/Sources/Core/Voice/AudioProvider.swift +0 -555
  149. package/apps/mac/Sources/Core/Voice/HandsOffSession.swift +0 -839
  150. package/apps/mac/Sources/Core/Voice/VoiceChatView.swift +0 -192
  151. package/apps/mac/Sources/Core/Voice/VoxClient.swift +0 -454
  152. package/apps/mac/Sources/Core/Workspace/Project.swift +0 -28
  153. package/apps/mac/Sources/Core/Workspace/ProjectScanner.swift +0 -141
  154. package/apps/mac/Sources/Core/Workspace/SessionLayerStore.swift +0 -285
  155. package/apps/mac/Sources/Core/Workspace/SessionManager.swift +0 -75
  156. package/apps/mac/Sources/Core/Workspace/Terminal/Terminal.swift +0 -259
  157. package/apps/mac/Sources/Core/Workspace/Terminal/TerminalQuery.swift +0 -156
  158. package/apps/mac/Sources/Core/Workspace/Terminal/TerminalSynthesizer.swift +0 -200
  159. package/apps/mac/Sources/Core/Workspace/Tmux/TmuxModel.swift +0 -60
  160. package/apps/mac/Sources/Core/Workspace/Tmux/TmuxQuery.swift +0 -105
  161. package/apps/mac/Sources/Core/Workspace/WorkspaceManager.swift +0 -1027
  162. package/apps/mac/Sources/UI/ActionRow.swift +0 -78
  163. package/apps/mac/Sources/UI/OrphanRow.swift +0 -129
  164. package/apps/mac/Sources/UI/ProjectRow.swift +0 -368
  165. package/apps/mac/Sources/UI/TabGroupRow.swift +0 -178
  166. package/apps/mac/Sources/UI/Theme.swift +0 -164
  167. package/apps/mac/Tests/StageDragTests.swift +0 -333
  168. package/apps/mac/Tests/StageJoinTests.swift +0 -313
  169. package/apps/mac/Tests/StageManagerTests.swift +0 -280
  170. package/apps/mac/Tests/StageTileTests.swift +0 -353
  171. package/swift/Package.swift +0 -20
  172. package/swift/Sources/DeckKit/DeckAction.swift +0 -51
  173. package/swift/Sources/DeckKit/DeckBridgeSecurity.swift +0 -152
  174. package/swift/Sources/DeckKit/DeckCockpit.swift +0 -82
  175. package/swift/Sources/DeckKit/DeckHost.swift +0 -7
  176. package/swift/Sources/DeckKit/DeckManifest.swift +0 -145
  177. package/swift/Sources/DeckKit/DeckRuntimeSnapshot.swift +0 -533
  178. package/swift/Sources/DeckKit/DeckTrackpad.swift +0 -63
  179. package/swift/Sources/DeckKit/DeckValue.swift +0 -93
  180. package/swift/Sources/DeckKit/DeckVoiceError.swift +0 -88
  181. package/swift/Tests/DeckKitTests/DeckKitTests.swift +0 -286
@@ -1,1027 +0,0 @@
1
- import AppKit
2
- import CryptoKit
3
- import Foundation
4
-
5
- // MARK: - Data Model
6
-
7
- struct TabGroupTab: Codable {
8
- let path: String
9
- let label: String?
10
- }
11
-
12
- struct TabGroup: Codable, Identifiable {
13
- let id: String
14
- let label: String
15
- let tabs: [TabGroupTab]
16
- }
17
-
18
- struct LayerProject: Codable {
19
- let path: String?
20
- let group: String?
21
- let tile: String?
22
- let display: Int?
23
- let app: String? // match by owner app name (e.g. "Google Chrome", "Xcode")
24
- let title: String? // substring match on window title (case-insensitive)
25
- let url: String? // URL to open if no matching window found
26
- let launch: String? // app name to launch if not running (via `open -a`)
27
- }
28
-
29
- struct Layer: Codable, Identifiable {
30
- let id: String
31
- let label: String
32
- let projects: [LayerProject]
33
- }
34
-
35
- struct WorkspaceConfig: Codable {
36
- let name: String
37
- let groups: [TabGroup]?
38
- let layers: [Layer]?
39
- }
40
-
41
- // MARK: - Grid Presets & Named Layouts
42
-
43
- struct GridPreset: Codable {
44
- let x: CGFloat
45
- let y: CGFloat
46
- let w: CGFloat
47
- let h: CGFloat
48
-
49
- var fractions: (CGFloat, CGFloat, CGFloat, CGFloat) { (x, y, w, h) }
50
- }
51
-
52
- struct LayoutWindowSpec: Codable {
53
- let app: String
54
- let tile: String // TilePosition name or preset name
55
- let display: Int? // spatial display number (1-based), nil = current
56
- let title: String? // optional title match for disambiguation
57
- }
58
-
59
- struct LayoutConfig: Codable {
60
- let windows: [LayoutWindowSpec]
61
- }
62
-
63
- struct GridFile: Codable {
64
- let presets: [String: GridPreset]?
65
- let layouts: [String: LayoutConfig]?
66
- let snapZones: SnapZonesConfig?
67
- }
68
-
69
- enum SnapModifierKey: String, Codable, Equatable, CaseIterable, Identifiable {
70
- case command
71
- case option
72
- case control
73
- case shift
74
-
75
- var id: String { rawValue }
76
-
77
- var label: String {
78
- switch self {
79
- case .command:
80
- return "Command"
81
- case .option:
82
- return "Option"
83
- case .control:
84
- return "Control"
85
- case .shift:
86
- return "Shift"
87
- }
88
- }
89
-
90
- var shortLabel: String {
91
- switch self {
92
- case .command:
93
- return "Cmd"
94
- case .option:
95
- return "Opt"
96
- case .control:
97
- return "Ctrl"
98
- case .shift:
99
- return "Shift"
100
- }
101
- }
102
-
103
- var eventFlags: NSEvent.ModifierFlags {
104
- switch self {
105
- case .command:
106
- return .command
107
- case .option:
108
- return .option
109
- case .control:
110
- return .control
111
- case .shift:
112
- return .shift
113
- }
114
- }
115
-
116
- var cgEventFlags: CGEventFlags {
117
- switch self {
118
- case .command:
119
- return .maskCommand
120
- case .option:
121
- return .maskAlternate
122
- case .control:
123
- return .maskControl
124
- case .shift:
125
- return .maskShift
126
- }
127
- }
128
-
129
- }
130
-
131
- enum SnapZoneTriggerSpec: Codable, Equatable {
132
- case named(String)
133
- case fractions(FractionalPlacement)
134
-
135
- init(from decoder: Decoder) throws {
136
- let container = try decoder.singleValueContainer()
137
- if let named = try? container.decode(String.self) {
138
- self = .named(named)
139
- return
140
- }
141
-
142
- let preset = try container.decode(GridPreset.self)
143
- guard let placement = FractionalPlacement(x: preset.x, y: preset.y, w: preset.w, h: preset.h) else {
144
- throw DecodingError.dataCorruptedError(
145
- in: container,
146
- debugDescription: "snap zone trigger fractions must stay within 0...1"
147
- )
148
- }
149
- self = .fractions(placement)
150
- }
151
-
152
- func encode(to encoder: Encoder) throws {
153
- var container = encoder.singleValueContainer()
154
- switch self {
155
- case .named(let name):
156
- try container.encode(name)
157
- case .fractions(let placement):
158
- try container.encode(GridPreset(x: placement.x, y: placement.y, w: placement.w, h: placement.h))
159
- }
160
- }
161
- }
162
-
163
- enum SnapZonePlacementSpec: Codable, Equatable {
164
- case named(String)
165
- case fractions(FractionalPlacement)
166
-
167
- init(from decoder: Decoder) throws {
168
- let container = try decoder.singleValueContainer()
169
- if let named = try? container.decode(String.self) {
170
- self = .named(named)
171
- return
172
- }
173
-
174
- let preset = try container.decode(GridPreset.self)
175
- guard let placement = FractionalPlacement(x: preset.x, y: preset.y, w: preset.w, h: preset.h) else {
176
- throw DecodingError.dataCorruptedError(
177
- in: container,
178
- debugDescription: "snap zone placement fractions must stay within 0...1"
179
- )
180
- }
181
- self = .fractions(placement)
182
- }
183
-
184
- func encode(to encoder: Encoder) throws {
185
- var container = encoder.singleValueContainer()
186
- switch self {
187
- case .named(let name):
188
- try container.encode(name)
189
- case .fractions(let placement):
190
- try container.encode(GridPreset(x: placement.x, y: placement.y, w: placement.w, h: placement.h))
191
- }
192
- }
193
- }
194
-
195
- struct SnapZoneDefinition: Codable, Equatable, Identifiable {
196
- let rawID: String?
197
- let label: String?
198
- let placement: SnapZonePlacementSpec
199
- let trigger: SnapZoneTriggerSpec
200
- let priority: Int?
201
-
202
- enum CodingKeys: String, CodingKey {
203
- case rawID = "id"
204
- case label
205
- case placement
206
- case trigger
207
- case priority
208
- }
209
-
210
- var id: String {
211
- let trimmed = rawID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
212
- return trimmed.isEmpty ? fallbackID : trimmed
213
- }
214
-
215
- private var fallbackID: String {
216
- switch placement {
217
- case .named(let name):
218
- return name
219
- case .fractions(let fractions):
220
- return "fractions-\(fractions.x)-\(fractions.y)-\(fractions.w)-\(fractions.h)"
221
- }
222
- }
223
- }
224
-
225
- struct SnapZonesConfig: Codable, Equatable {
226
- let enabled: Bool?
227
- let modifier: SnapModifierKey?
228
- let zoneOpacity: Double?
229
- let highlightOpacity: Double?
230
- let previewOpacity: Double?
231
- let cornerRadius: CGFloat?
232
- let rules: [SnapZoneDefinition]?
233
-
234
- enum CodingKeys: String, CodingKey {
235
- case enabled
236
- case modifier
237
- case zoneOpacity
238
- case highlightOpacity
239
- case previewOpacity
240
- case cornerRadius
241
- case rules
242
- case zones
243
- }
244
-
245
- init(
246
- enabled: Bool?,
247
- modifier: SnapModifierKey?,
248
- zoneOpacity: Double?,
249
- highlightOpacity: Double?,
250
- previewOpacity: Double?,
251
- cornerRadius: CGFloat?,
252
- rules: [SnapZoneDefinition]?
253
- ) {
254
- self.enabled = enabled
255
- self.modifier = modifier
256
- self.zoneOpacity = zoneOpacity
257
- self.highlightOpacity = highlightOpacity
258
- self.previewOpacity = previewOpacity
259
- self.cornerRadius = cornerRadius
260
- self.rules = rules
261
- }
262
-
263
- init(from decoder: Decoder) throws {
264
- let container = try decoder.container(keyedBy: CodingKeys.self)
265
- enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled)
266
- modifier = try container.decodeIfPresent(SnapModifierKey.self, forKey: .modifier)
267
- zoneOpacity = try container.decodeIfPresent(Double.self, forKey: .zoneOpacity)
268
- highlightOpacity = try container.decodeIfPresent(Double.self, forKey: .highlightOpacity)
269
- previewOpacity = try container.decodeIfPresent(Double.self, forKey: .previewOpacity)
270
- cornerRadius = try container.decodeIfPresent(CGFloat.self, forKey: .cornerRadius)
271
- let decodedRules = try container.decodeIfPresent([SnapZoneDefinition].self, forKey: .rules)
272
- let decodedZones = try container.decodeIfPresent([SnapZoneDefinition].self, forKey: .zones)
273
- rules = decodedRules ?? decodedZones
274
- }
275
-
276
- func encode(to encoder: Encoder) throws {
277
- var container = encoder.container(keyedBy: CodingKeys.self)
278
- try container.encodeIfPresent(enabled, forKey: .enabled)
279
- try container.encodeIfPresent(modifier, forKey: .modifier)
280
- try container.encodeIfPresent(zoneOpacity, forKey: .zoneOpacity)
281
- try container.encodeIfPresent(highlightOpacity, forKey: .highlightOpacity)
282
- try container.encodeIfPresent(previewOpacity, forKey: .previewOpacity)
283
- try container.encodeIfPresent(cornerRadius, forKey: .cornerRadius)
284
- try container.encodeIfPresent(rules, forKey: .rules)
285
- }
286
-
287
- static let defaults = SnapZonesConfig(
288
- enabled: true,
289
- modifier: .command,
290
- zoneOpacity: 0.10,
291
- highlightOpacity: 0.22,
292
- previewOpacity: 0.18,
293
- cornerRadius: 18,
294
- rules: [
295
- SnapZoneDefinition(
296
- rawID: "top-left",
297
- label: "Top Left",
298
- placement: .named("top-left"),
299
- trigger: .fractions(FractionalPlacement(x: 0.00, y: 0.00, w: 0.24, h: 0.18)!),
300
- priority: 40
301
- ),
302
- SnapZoneDefinition(
303
- rawID: "maximize",
304
- label: "Maximize",
305
- placement: .named("maximize"),
306
- trigger: .fractions(FractionalPlacement(x: 0.24, y: 0.00, w: 0.52, h: 0.12)!),
307
- priority: 20
308
- ),
309
- SnapZoneDefinition(
310
- rawID: "top-right",
311
- label: "Top Right",
312
- placement: .named("top-right"),
313
- trigger: .fractions(FractionalPlacement(x: 0.76, y: 0.00, w: 0.24, h: 0.18)!),
314
- priority: 40
315
- ),
316
- SnapZoneDefinition(
317
- rawID: "left",
318
- label: "Left",
319
- placement: .named("left"),
320
- trigger: .fractions(FractionalPlacement(x: 0.00, y: 0.18, w: 0.12, h: 0.64)!),
321
- priority: 10
322
- ),
323
- SnapZoneDefinition(
324
- rawID: "right",
325
- label: "Right",
326
- placement: .named("right"),
327
- trigger: .fractions(FractionalPlacement(x: 0.88, y: 0.18, w: 0.12, h: 0.64)!),
328
- priority: 10
329
- ),
330
- SnapZoneDefinition(
331
- rawID: "bottom-left",
332
- label: "Bottom Left",
333
- placement: .named("bottom-left"),
334
- trigger: .fractions(FractionalPlacement(x: 0.00, y: 0.82, w: 0.24, h: 0.18)!),
335
- priority: 40
336
- ),
337
- SnapZoneDefinition(
338
- rawID: "bottom-right",
339
- label: "Bottom Right",
340
- placement: .named("bottom-right"),
341
- trigger: .fractions(FractionalPlacement(x: 0.76, y: 0.82, w: 0.24, h: 0.18)!),
342
- priority: 40
343
- ),
344
- ]
345
- )
346
-
347
- func merged(over defaults: SnapZonesConfig = .defaults) -> SnapZonesConfig {
348
- SnapZonesConfig(
349
- enabled: enabled ?? defaults.enabled,
350
- modifier: modifier ?? defaults.modifier,
351
- zoneOpacity: zoneOpacity ?? defaults.zoneOpacity,
352
- highlightOpacity: highlightOpacity ?? defaults.highlightOpacity,
353
- previewOpacity: previewOpacity ?? defaults.previewOpacity,
354
- cornerRadius: cornerRadius ?? defaults.cornerRadius,
355
- rules: rules ?? defaults.rules
356
- )
357
- }
358
- }
359
-
360
- // MARK: - Manager
361
-
362
- class WorkspaceManager: ObservableObject {
363
- static let shared = WorkspaceManager()
364
-
365
- @Published var config: WorkspaceConfig?
366
- @Published var activeLayerIndex: Int = 0
367
- @Published var isSwitching: Bool = false
368
- @Published var gridPresets: [String: GridPreset] = [:]
369
- @Published var gridLayouts: [String: LayoutConfig] = [:]
370
- @Published var snapZonesConfig: SnapZonesConfig = .defaults
371
-
372
- private let configPath: String
373
- private let gridConfigPath: String
374
- private let snapZonesConfigPath: String
375
- private var tmuxPath: String { TmuxQuery.resolvedPath ?? "/opt/homebrew/bin/tmux" }
376
- private let activeLayerKey = "lattices.activeLayerIndex"
377
-
378
- init() {
379
- let home = FileManager.default.homeDirectoryForCurrentUser.path
380
- self.configPath = (home as NSString).appendingPathComponent(".lattices/workspace.json")
381
- self.gridConfigPath = (home as NSString).appendingPathComponent(".lattices/grid.json")
382
- self.snapZonesConfigPath = (home as NSString).appendingPathComponent(".lattices/snap-zones.json")
383
- self.activeLayerIndex = UserDefaults.standard.integer(forKey: activeLayerKey)
384
- loadConfig()
385
- loadGridConfig()
386
- }
387
-
388
- var activeLayer: Layer? {
389
- guard let config, let layers = config.layers, activeLayerIndex < layers.count else { return nil }
390
- return layers[activeLayerIndex]
391
- }
392
-
393
- /// Look up a layer index by id or label (case-insensitive)
394
- func layerIndex(named name: String) -> Int? {
395
- guard let layers = config?.layers else { return nil }
396
- // Try exact id match first
397
- if let i = layers.firstIndex(where: { $0.id == name }) { return i }
398
- // Then case-insensitive id
399
- if let i = layers.firstIndex(where: { $0.id.localizedCaseInsensitiveCompare(name) == .orderedSame }) { return i }
400
- // Then case-insensitive label
401
- if let i = layers.firstIndex(where: { $0.label.localizedCaseInsensitiveCompare(name) == .orderedSame }) { return i }
402
- return nil
403
- }
404
-
405
- // MARK: - Config I/O
406
-
407
- func loadConfig() {
408
- guard FileManager.default.fileExists(atPath: configPath),
409
- let data = FileManager.default.contents(atPath: configPath) else {
410
- config = nil
411
- return
412
- }
413
- do {
414
- config = try JSONDecoder().decode(WorkspaceConfig.self, from: data)
415
- // Clamp saved index
416
- if let config, let layers = config.layers, activeLayerIndex >= layers.count {
417
- activeLayerIndex = 0
418
- }
419
- } catch {
420
- DiagnosticLog.shared.error("WorkspaceManager: failed to decode workspace.json — \(error.localizedDescription)")
421
- config = nil
422
- }
423
- }
424
-
425
- func reloadConfig() {
426
- loadConfig()
427
- loadGridConfig()
428
- }
429
-
430
- // MARK: - Grid Config I/O
431
-
432
- func loadGridConfig() {
433
- var presets: [String: GridPreset] = [:]
434
- var layouts: [String: LayoutConfig] = [:]
435
- var snapZones = SnapZonesConfig.defaults
436
-
437
- // Load global ~/.lattices/grid.json
438
- if FileManager.default.fileExists(atPath: gridConfigPath),
439
- let data = FileManager.default.contents(atPath: gridConfigPath) {
440
- do {
441
- let gridFile = try JSONDecoder().decode(GridFile.self, from: data)
442
- if let p = gridFile.presets { presets.merge(p) { _, new in new } }
443
- if let l = gridFile.layouts { layouts.merge(l) { _, new in new } }
444
- if let snap = gridFile.snapZones {
445
- snapZones = snap.merged(over: snapZones)
446
- }
447
- } catch {
448
- DiagnosticLog.shared.error("WorkspaceManager: failed to decode grid.json — \(error.localizedDescription)")
449
- }
450
- }
451
-
452
- if FileManager.default.fileExists(atPath: snapZonesConfigPath),
453
- let data = FileManager.default.contents(atPath: snapZonesConfigPath) {
454
- do {
455
- let config = try JSONDecoder().decode(SnapZonesConfig.self, from: data)
456
- snapZones = config.merged(over: snapZones)
457
- } catch {
458
- DiagnosticLog.shared.error("WorkspaceManager: failed to decode snap-zones.json — \(error.localizedDescription)")
459
- }
460
- }
461
-
462
- // Merge per-project .lattices.json "grid" section on top
463
- let projectGridPath = ".lattices.json"
464
- if FileManager.default.fileExists(atPath: projectGridPath),
465
- let data = FileManager.default.contents(atPath: projectGridPath) {
466
- do {
467
- if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
468
- let gridDict = json["grid"] {
469
- let gridData = try JSONSerialization.data(withJSONObject: gridDict)
470
- let gridFile = try JSONDecoder().decode(GridFile.self, from: gridData)
471
- if let p = gridFile.presets { presets.merge(p) { _, new in new } }
472
- if let l = gridFile.layouts { layouts.merge(l) { _, new in new } }
473
- if let snap = gridFile.snapZones {
474
- snapZones = snap.merged(over: snapZones)
475
- }
476
- }
477
- } catch {
478
- DiagnosticLog.shared.error("WorkspaceManager: failed to decode .lattices.json grid — \(error.localizedDescription)")
479
- }
480
- }
481
-
482
- self.gridPresets = presets
483
- self.gridLayouts = layouts
484
- self.snapZonesConfig = snapZones
485
- }
486
-
487
- func updateSnapModifier(_ modifier: SnapModifierKey) {
488
- let updated = SnapZonesConfig(
489
- enabled: snapZonesConfig.enabled,
490
- modifier: modifier,
491
- zoneOpacity: snapZonesConfig.zoneOpacity,
492
- highlightOpacity: snapZonesConfig.highlightOpacity,
493
- previewOpacity: snapZonesConfig.previewOpacity,
494
- cornerRadius: snapZonesConfig.cornerRadius,
495
- rules: snapZonesConfig.rules
496
- )
497
-
498
- do {
499
- let url = URL(fileURLWithPath: snapZonesConfigPath)
500
- try FileManager.default.createDirectory(
501
- at: url.deletingLastPathComponent(),
502
- withIntermediateDirectories: true
503
- )
504
-
505
- let encoder = JSONEncoder()
506
- encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
507
- let data = try encoder.encode(updated)
508
- try data.write(to: url, options: .atomic)
509
-
510
- loadGridConfig()
511
- DiagnosticLog.shared.info("WorkspaceManager: updated snap modifier to \(modifier.rawValue)")
512
- } catch {
513
- DiagnosticLog.shared.error("WorkspaceManager: failed to write snap-zones.json — \(error.localizedDescription)")
514
- }
515
- }
516
-
517
- /// Resolve a tile string to fractions: check user presets first, then built-in TilePosition
518
- func resolveTileFractions(_ tile: String) -> (CGFloat, CGFloat, CGFloat, CGFloat)? {
519
- resolvePlacement(tile)?.fractions
520
- }
521
-
522
- func resolvePlacement(_ tile: String) -> PlacementSpec? {
523
- if let preset = gridPresets[tile],
524
- let placement = FractionalPlacement(x: preset.x, y: preset.y, w: preset.w, h: preset.h) {
525
- return .fractions(placement)
526
- }
527
- return PlacementSpec(string: tile)
528
- }
529
-
530
- // MARK: - Tab Groups
531
-
532
- func group(byId id: String) -> TabGroup? {
533
- config?.groups?.first(where: { $0.id == id })
534
- }
535
-
536
- func isGroupRunning(_ group: TabGroup) -> Bool {
537
- group.tabs.contains { tab in
538
- let name = Self.sessionName(for: tab.path)
539
- return shell([tmuxPath, "has-session", "-t", name]) == 0
540
- }
541
- }
542
-
543
- /// Count how many tabs in the group have running sessions
544
- func runningTabCount(_ group: TabGroup) -> Int {
545
- group.tabs.filter { tab in
546
- let name = Self.sessionName(for: tab.path)
547
- return shell([tmuxPath, "has-session", "-t", name]) == 0
548
- }.count
549
- }
550
-
551
- /// Launch a group by opening each tab as a separate iTerm/Terminal tab
552
- func launchGroup(_ group: TabGroup) {
553
- let terminal = Preferences.shared.terminal
554
- for (i, tab) in group.tabs.enumerated() {
555
- let label = tab.label ?? (tab.path as NSString).lastPathComponent
556
- DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.4) {
557
- if i == 0 {
558
- terminal.launch(command: "/opt/homebrew/bin/lattices start", in: tab.path)
559
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
560
- terminal.nameTab(label)
561
- }
562
- } else {
563
- terminal.launchTab(command: "/opt/homebrew/bin/lattices start", in: tab.path, tabName: label)
564
- }
565
- }
566
- }
567
- }
568
-
569
- /// Kill all individual tab sessions for a group
570
- func killGroup(_ group: TabGroup) {
571
- for tab in group.tabs {
572
- let name = Self.sessionName(for: tab.path)
573
- let task = Process()
574
- task.executableURL = URL(fileURLWithPath: tmuxPath)
575
- task.arguments = ["kill-session", "-t", name]
576
- task.standardOutput = FileHandle.nullDevice
577
- task.standardError = FileHandle.nullDevice
578
- try? task.run()
579
- task.waitUntilExit()
580
- }
581
- }
582
-
583
- /// Focus a specific tab's session in the terminal
584
- func focusTab(group: TabGroup, tabIndex: Int) {
585
- guard tabIndex >= 0, tabIndex < group.tabs.count else { return }
586
- let tab = group.tabs[tabIndex]
587
- let sessionName = Self.sessionName(for: tab.path)
588
- let terminal = Preferences.shared.terminal
589
- terminal.focusOrAttach(session: sessionName)
590
- }
591
-
592
- /// Run a command and return exit code
593
- private func shell(_ args: [String]) -> Int32 {
594
- let task = Process()
595
- task.executableURL = URL(fileURLWithPath: args[0])
596
- task.arguments = Array(args.dropFirst())
597
- task.standardOutput = FileHandle.nullDevice
598
- task.standardError = FileHandle.nullDevice
599
- try? task.run()
600
- task.waitUntilExit()
601
- return task.terminationStatus
602
- }
603
-
604
- // MARK: - Display Helper
605
-
606
- /// Resolve a display index to an NSScreen (falls back to first screen)
607
- private func screen(for displayIndex: Int?) -> NSScreen? {
608
- let screens = NSScreen.screens
609
- guard !screens.isEmpty else { return nil }
610
- let idx = displayIndex ?? 0
611
- return idx < screens.count ? screens[idx] : screens[0]
612
- }
613
-
614
- // MARK: - Window Lookup
615
-
616
- /// Find a tracked window for a session name (instant — uses DesktopModel cache)
617
- private func windowForSession(_ sessionName: String) -> WindowEntry? {
618
- DesktopModel.shared.windowForSession(sessionName)
619
- }
620
-
621
- /// Resolve a session name to a tile target: (wid, pid, frame).
622
- /// Returns nil if the window isn't tracked or has no tile position.
623
- private func batchTarget(session: String, position: PlacementSpec, screen: NSScreen) -> (wid: UInt32, pid: Int32, frame: CGRect)? {
624
- guard let entry = windowForSession(session) else { return nil }
625
- let frame = WindowTiler.tileFrame(for: position, on: screen)
626
- return (entry.wid, entry.pid, frame)
627
- }
628
-
629
- // MARK: - Tiling
630
-
631
- /// Re-tile the current layer without switching (for "tile all")
632
- func retileCurrentLayer() {
633
- tileLayer(index: activeLayerIndex, launch: false, force: true)
634
- }
635
-
636
- /// Count running projects+groups in a layer
637
- func layerRunningCount(index: Int) -> (running: Int, total: Int) {
638
- guard let config, let layers = config.layers, index < layers.count else { return (0, 0) }
639
- let layer = layers[index]
640
- let scanner = ProjectScanner.shared
641
- var running = 0
642
- let total = layer.projects.count
643
-
644
- for lp in layer.projects {
645
- if let groupId = lp.group, let group = group(byId: groupId) {
646
- if isGroupRunning(group) { running += 1 }
647
- } else if let appName = lp.app {
648
- if DesktopModel.shared.windowForApp(app: appName, title: lp.title) != nil { running += 1 }
649
- } else if let path = lp.path {
650
- let project = scanner.projects.first(where: { $0.path == path })
651
- if project?.isRunning == true { running += 1 }
652
- }
653
- }
654
- return (running, total)
655
- }
656
-
657
- // MARK: - Layer Focus (raise only)
658
-
659
- /// Switch to a layer by raising all its windows in place — no launching, no tiling, no moving.
660
- /// This is the default hotkey action: just bring the layer's windows to the front.
661
- func focusLayer(index: Int) {
662
- guard let config, let layers = config.layers, index < layers.count else { return }
663
- if index == activeLayerIndex { return }
664
-
665
- let diag = DiagnosticLog.shared
666
- let t = diag.startTimed("focusLayer \(activeLayerIndex)→\(index)")
667
-
668
- DesktopModel.shared.poll()
669
-
670
- let targetLayer = layers[index]
671
- var windowsToRaise: [(wid: UInt32, pid: Int32)] = []
672
-
673
- for lp in targetLayer.projects {
674
- if let groupId = lp.group, let grp = group(byId: groupId) {
675
- // Raise all tab windows in the group
676
- for tab in grp.tabs {
677
- let sessionName = Self.sessionName(for: tab.path)
678
- if let entry = windowForSession(sessionName) {
679
- windowsToRaise.append((entry.wid, entry.pid))
680
- }
681
- }
682
- continue
683
- }
684
-
685
- if let appName = lp.app {
686
- if let entry = DesktopModel.shared.windowForApp(app: appName, title: lp.title) {
687
- windowsToRaise.append((entry.wid, entry.pid))
688
- }
689
- continue
690
- }
691
-
692
- guard let path = lp.path else { continue }
693
- let sessionName = Self.sessionName(for: path)
694
- if let entry = windowForSession(sessionName) {
695
- windowsToRaise.append((entry.wid, entry.pid))
696
- }
697
-
698
- // Also raise companion windows
699
- let companions = projectWindows(at: path)
700
- for cw in companions {
701
- guard let appName = cw.app else { continue }
702
- if let entry = DesktopModel.shared.windowForApp(app: appName, title: cw.title) {
703
- windowsToRaise.append((entry.wid, entry.pid))
704
- }
705
- }
706
- }
707
-
708
- if !windowsToRaise.isEmpty {
709
- WindowTiler.raiseWindowsAndReactivate(windows: windowsToRaise)
710
- }
711
-
712
- activeLayerIndex = index
713
- UserDefaults.standard.set(index, forKey: activeLayerKey)
714
-
715
- let allLabels = layers.map(\.label)
716
- LayerBezel.shared.show(label: targetLayer.label, index: index, total: layers.count, allLabels: allLabels)
717
- HandsOffSession.shared.playCachedCue("Switched.")
718
-
719
- diag.finish(t)
720
- }
721
-
722
- // MARK: - Unified Layer Tiling
723
-
724
- /// Unified entry point for arranging a layer's windows.
725
- ///
726
- /// | launch | force | Behavior |
727
- /// |--------|-------|----------|
728
- /// | false | false | Tile running projects only (focus) |
729
- /// | true | false | Launch stopped + tile all, skip if same layer |
730
- /// | true | true | Re-launch current layer |
731
- /// | false | true | Re-tile current layer |
732
- func tileLayer(index: Int, launch: Bool = false, force: Bool = false) {
733
- guard let config, let layers = config.layers, index < layers.count else { return }
734
- if launch && !force && index == activeLayerIndex { return }
735
-
736
- let diag = DiagnosticLog.shared
737
- let label = launch ? "tileLayer(launch)" : "tileLayer(focus)"
738
- let overall = diag.startTimed("\(label) \(activeLayerIndex)→\(index)")
739
-
740
- isSwitching = true
741
- let terminal = Preferences.shared.terminal
742
- let scanner = ProjectScanner.shared
743
- let targetLayer = layers[index]
744
-
745
- // Fresh poll so we see windows on all Spaces before matching
746
- DesktopModel.shared.poll()
747
-
748
- // Tile debug log (written to ~/.lattices/tile-debug.log)
749
- let debugPath = (FileManager.default.homeDirectoryForCurrentUser.path as NSString).appendingPathComponent(".lattices/tile-debug.log")
750
- var debugLines: [String] = ["tileLayer index=\(index) launch=\(launch) force=\(force) layer=\(targetLayer.id)"]
751
-
752
- // Phase 1: classify each project
753
- var batchMoves: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
754
- var fallbacks: [(session: String, position: PlacementSpec, screen: NSScreen)] = []
755
- var launchQueue: [(session: String, position: PlacementSpec?, screen: NSScreen, launchAction: () -> Void)] = []
756
-
757
- // Log screen info
758
- for (i, s) in NSScreen.screens.enumerated() {
759
- debugLines.append("screen[\(i)]: frame=\(s.frame) visible=\(s.visibleFrame)")
760
- }
761
-
762
- for lp in targetLayer.projects {
763
- guard let lpScreen = screen(for: lp.display) else { continue }
764
-
765
- if let groupId = lp.group, let grp = group(byId: groupId) {
766
- let firstTabSession = grp.tabs.first.map { Self.sessionName(for: $0.path) } ?? ""
767
- let position = lp.tile.flatMap { resolvePlacement($0) }
768
- let groupRunning = isGroupRunning(grp)
769
-
770
- if groupRunning, let pos = position,
771
- let target = batchTarget(session: firstTabSession, position: pos, screen: lpScreen) {
772
- batchMoves.append(target)
773
- } else if !groupRunning && launch {
774
- diag.info(" launch group: \(grp.label)")
775
- launchQueue.append((firstTabSession, position, lpScreen, { [weak self] in
776
- self?.launchGroup(grp)
777
- }))
778
- } else if groupRunning, let pos = position {
779
- // Running but not in DesktopModel — fallback
780
- fallbacks.append((firstTabSession, pos, lpScreen))
781
- } else if !groupRunning {
782
- diag.info(" skip (not running): \(grp.label)")
783
- }
784
- continue
785
- }
786
-
787
- // App-based window matching
788
- if let appName = lp.app {
789
- let position = lp.tile.flatMap { resolvePlacement($0) }
790
- if let entry = DesktopModel.shared.windowForApp(app: appName, title: lp.title) {
791
- if let pos = position {
792
- let frame = WindowTiler.tileFrame(for: pos, on: lpScreen)
793
- batchMoves.append((entry.wid, entry.pid, frame))
794
- }
795
- } else if let found = Self.findAppWindow(app: appName, title: lp.title) {
796
- // Window exists but wasn't in DesktopModel (e.g. different Space) — tile it
797
- diag.info(" found app via CGWindowList fallback: \(appName) wid=\(found.wid)")
798
- if let pos = position {
799
- let frame = WindowTiler.tileFrame(for: pos, on: lpScreen)
800
- batchMoves.append((found.wid, found.pid, frame))
801
- }
802
- } else if launch {
803
- diag.info(" launch app: \(appName)")
804
- let capturedLp = lp
805
- let capturedScreen = lpScreen
806
- launchQueue.append(("app:\(appName)", nil, capturedScreen, { [weak self] in
807
- self?.launchAppEntry(capturedLp)
808
- }))
809
- // Queue a delayed tile after launch
810
- if let pos = position {
811
- let capturedTitle = lp.title
812
- let delay = Double(launchQueue.count) * 0.5 + 1.0
813
- DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
814
- DesktopModel.shared.poll()
815
- if let entry = DesktopModel.shared.windowForApp(app: appName, title: capturedTitle) {
816
- let frame = WindowTiler.tileFrame(for: pos, on: capturedScreen)
817
- WindowTiler.batchMoveAndRaiseWindows([(entry.wid, entry.pid, frame)])
818
- }
819
- }
820
- }
821
- } else {
822
- diag.info(" skip (not found): \(appName)")
823
- }
824
- continue
825
- }
826
-
827
- guard let path = lp.path else { continue }
828
- let sessionName = Self.sessionName(for: path)
829
- let project = scanner.projects.first(where: { $0.path == path })
830
- let position = lp.tile.flatMap { resolvePlacement($0) }
831
- // Check scanner first, fall back to direct tmux check for projects without .lattices.json
832
- let isRunning = project?.isRunning == true || shell([tmuxPath, "has-session", "-t", sessionName]) == 0
833
-
834
- if isRunning {
835
- let foundWindow = windowForSession(sessionName)
836
- let msg = " \(sessionName): running=\(isRunning) window=\(foundWindow?.wid ?? 0) tile=\(position?.wireValue ?? "nil") desktopCount=\(DesktopModel.shared.windows.count)"
837
- diag.info(msg)
838
- debugLines.append(msg)
839
- if let pos = position,
840
- let target = batchTarget(session: sessionName, position: pos, screen: lpScreen) {
841
- batchMoves.append(target)
842
- debugLines.append(" → batch move wid=\(target.wid) frame=\(target.frame)")
843
- } else if let pos = position {
844
- fallbacks.append((sessionName, pos, lpScreen))
845
- debugLines.append(" → fallback \(pos.wireValue)")
846
- }
847
- } else if launch {
848
- if let project {
849
- let t = diag.startTimed("launch: \(project.name)")
850
- SessionManager.launch(project: project)
851
- diag.finish(t)
852
- } else {
853
- diag.info(" launch (direct): \(sessionName)")
854
- terminal.launch(command: "/opt/homebrew/bin/lattices start", in: path)
855
- }
856
- launchQueue.append((sessionName, position, lpScreen, {}))
857
- } else {
858
- diag.info(" skip (not running): \(sessionName)")
859
- }
860
-
861
- // Compose companion windows from project's .lattices.json "windows" array
862
- let companions = projectWindows(at: path)
863
- for cw in companions {
864
- guard let appName = cw.app else { continue }
865
- let cwScreen = screen(for: cw.display ?? lp.display) ?? lpScreen
866
- let cwPosition = cw.tile.flatMap { resolvePlacement($0) }
867
- if let entry = DesktopModel.shared.windowForApp(app: appName, title: cw.title) {
868
- if let pos = cwPosition {
869
- let frame = WindowTiler.tileFrame(for: pos, on: cwScreen)
870
- batchMoves.append((entry.wid, entry.pid, frame))
871
- }
872
- } else if launch {
873
- diag.info(" launch companion: \(appName)")
874
- let capturedCw = cw
875
- launchQueue.append(("app:\(appName)", nil, cwScreen, { [weak self] in
876
- self?.launchAppEntry(capturedCw)
877
- }))
878
- if let pos = cwPosition {
879
- let capturedTitle = cw.title
880
- let capturedScreen = cwScreen
881
- let delay = Double(launchQueue.count) * 0.5 + 1.0
882
- DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
883
- DesktopModel.shared.poll()
884
- if let entry = DesktopModel.shared.windowForApp(app: appName, title: capturedTitle) {
885
- let frame = WindowTiler.tileFrame(for: pos, on: capturedScreen)
886
- WindowTiler.batchMoveAndRaiseWindows([(entry.wid, entry.pid, frame)])
887
- }
888
- }
889
- }
890
- }
891
- }
892
- }
893
-
894
- // Write debug log
895
- debugLines.append("batchMoves=\(batchMoves.count) fallbacks=\(fallbacks.count) launchQueue=\(launchQueue.count)")
896
- try? debugLines.joined(separator: "\n").write(toFile: debugPath, atomically: true, encoding: .utf8)
897
-
898
- // Phase 2: batch tile all tracked windows
899
- if !batchMoves.isEmpty {
900
- let t = diag.startTimed("batch tile \(batchMoves.count) windows")
901
- WindowTiler.batchMoveAndRaiseWindows(batchMoves)
902
- diag.finish(t)
903
- }
904
-
905
- // Phase 3: fallback for running-but-untracked windows
906
- for (i, fb) in fallbacks.enumerated() {
907
- let delay = Double(i) * 0.15 + 0.1
908
- DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
909
- diag.info(" tile fallback: \(fb.session) → \(fb.position.wireValue)")
910
- WindowTiler.navigateToWindow(session: fb.session, terminal: terminal)
911
- WindowTiler.tile(session: fb.session, terminal: terminal, to: fb.position, on: fb.screen)
912
- }
913
- }
914
-
915
- // Phase 4: staggered tile for newly-launched windows
916
- for (i, item) in launchQueue.enumerated() {
917
- let delay = Double(i) * 0.15 + 0.2
918
- DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
919
- item.launchAction()
920
- if let pos = item.position {
921
- let t = diag.startTimed("tile launched: \(item.session) → \(pos.wireValue)")
922
- WindowTiler.tile(session: item.session, terminal: terminal, to: pos, on: item.screen)
923
- diag.finish(t)
924
- }
925
- }
926
- }
927
-
928
- activeLayerIndex = index
929
- UserDefaults.standard.set(index, forKey: activeLayerKey)
930
-
931
- // Show layer bezel
932
- let totalLayers = layers.count
933
- let allLabels = layers.map(\.label)
934
- LayerBezel.shared.show(label: targetLayer.label, index: index, total: totalLayers, allLabels: allLabels)
935
-
936
- let maxDelay = max(
937
- fallbacks.isEmpty ? 0.0 : Double(fallbacks.count) * 0.15 + 0.3,
938
- launchQueue.isEmpty ? 0.0 : Double(launchQueue.count) * 0.15 + 0.5
939
- )
940
- let cleanupDelay = max(0.2, maxDelay)
941
- DispatchQueue.main.asyncAfter(deadline: .now() + cleanupDelay) {
942
- scanner.refreshStatus()
943
- self.isSwitching = false
944
- diag.finish(overall)
945
- }
946
- }
947
-
948
- // MARK: - Per-Project Window Config
949
-
950
- /// Read companion window entries from a project's .lattices.json "windows" array
951
- func projectWindows(at projectPath: String) -> [LayerProject] {
952
- let configPath = (projectPath as NSString).appendingPathComponent(".lattices.json")
953
- guard let data = FileManager.default.contents(atPath: configPath),
954
- let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
955
- let windowsArray = json["windows"] else { return [] }
956
- do {
957
- let windowsData = try JSONSerialization.data(withJSONObject: windowsArray)
958
- return try JSONDecoder().decode([LayerProject].self, from: windowsData)
959
- } catch {
960
- DiagnosticLog.shared.error("WorkspaceManager: failed to decode windows in \(configPath) — \(error.localizedDescription)")
961
- return []
962
- }
963
- }
964
-
965
- // MARK: - App Launch Helper
966
-
967
- /// Launch an app-based layer project (open URL or launch app by name)
968
- private func launchAppEntry(_ lp: LayerProject) {
969
- if let urlStr = lp.url, let url = URL(string: urlStr) {
970
- NSWorkspace.shared.open(url)
971
- } else if let appName = lp.launch ?? lp.app {
972
- let task = Process()
973
- task.executableURL = URL(fileURLWithPath: "/usr/bin/open")
974
- task.arguments = ["-a", appName]
975
- task.standardOutput = FileHandle.nullDevice
976
- task.standardError = FileHandle.nullDevice
977
- try? task.run()
978
- }
979
- }
980
-
981
- // MARK: - App Window Fallback (CGWindowList .optionAll)
982
-
983
- /// Find an app window across ALL Spaces via CGWindowList (bypasses DesktopModel cache)
984
- static func findAppWindow(app: String, title: String?) -> (wid: UInt32, pid: Int32)? {
985
- guard let list = CGWindowListCopyWindowInfo(
986
- [.optionAll, .excludeDesktopElements],
987
- kCGNullWindowID
988
- ) as? [[String: Any]] else { return nil }
989
-
990
- for info in list {
991
- guard let ownerName = info[kCGWindowOwnerName as String] as? String,
992
- ownerName.localizedCaseInsensitiveContains(app),
993
- let wid = info[kCGWindowNumber as String] as? UInt32,
994
- let pid = info[kCGWindowOwnerPID as String] as? Int32,
995
- let layer = info[kCGWindowLayer as String] as? Int, layer == 0,
996
- let boundsDict = info[kCGWindowBounds as String] as? NSDictionary
997
- else { continue }
998
-
999
- var rect = CGRect.zero
1000
- guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect),
1001
- rect.width >= 50, rect.height >= 50 else { continue }
1002
-
1003
- if let title {
1004
- let windowTitle = info[kCGWindowName as String] as? String ?? ""
1005
- guard windowTitle.localizedCaseInsensitiveContains(title) else { continue }
1006
- }
1007
-
1008
- return (wid, pid)
1009
- }
1010
- return nil
1011
- }
1012
-
1013
- // MARK: - Session Name Helper
1014
-
1015
- /// Replicates Project.sessionName logic from a bare path
1016
- static func sessionName(for path: String) -> String {
1017
- let name = (path as NSString).lastPathComponent
1018
- let base = name.replacingOccurrences(
1019
- of: "[^a-zA-Z0-9_-]",
1020
- with: "-",
1021
- options: .regularExpression
1022
- )
1023
- let hash = SHA256.hash(data: Data(path.utf8))
1024
- let short = hash.prefix(3).map { String(format: "%02x", $0) }.joined()
1025
- return "\(base)-\(short)"
1026
- }
1027
- }