@lattices/cli 0.4.14 → 0.5.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 (180) hide show
  1. package/README.md +5 -7
  2. package/apps/mac/Info.plist +2 -2
  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/reference/dewey.config.ts +2 -2
  19. package/docs/release.md +171 -0
  20. package/docs/repo-structure.md +4 -5
  21. package/docs/voice.md +11 -27
  22. package/package.json +9 -10
  23. package/apps/mac/Package.swift +0 -27
  24. package/apps/mac/Sources/AppShell/App.swift +0 -26
  25. package/apps/mac/Sources/AppShell/AppActivationCoordinator.swift +0 -27
  26. package/apps/mac/Sources/AppShell/AppDelegate.swift +0 -189
  27. package/apps/mac/Sources/AppShell/AppServicesBootstrap.swift +0 -25
  28. package/apps/mac/Sources/AppShell/AppShellView.swift +0 -171
  29. package/apps/mac/Sources/AppShell/AppUpdater.swift +0 -305
  30. package/apps/mac/Sources/AppShell/CliActionLauncher.swift +0 -50
  31. package/apps/mac/Sources/AppShell/HomeDashboardView.swift +0 -133
  32. package/apps/mac/Sources/AppShell/HotkeyBootstrap.swift +0 -87
  33. package/apps/mac/Sources/AppShell/KeyRecorderView.swift +0 -210
  34. package/apps/mac/Sources/AppShell/LatticesRuntime.swift +0 -104
  35. package/apps/mac/Sources/AppShell/MainView.swift +0 -847
  36. package/apps/mac/Sources/AppShell/MainWindow.swift +0 -83
  37. package/apps/mac/Sources/AppShell/MenuBarController.swift +0 -177
  38. package/apps/mac/Sources/AppShell/OnboardingView.swift +0 -483
  39. package/apps/mac/Sources/AppShell/PermissionsAssistantView.swift +0 -366
  40. package/apps/mac/Sources/AppShell/PermissionsAssistantWindow.swift +0 -70
  41. package/apps/mac/Sources/AppShell/Preferences.swift +0 -297
  42. package/apps/mac/Sources/AppShell/SettingsView.swift +0 -3163
  43. package/apps/mac/Sources/AppShell/SettingsWindow.swift +0 -34
  44. package/apps/mac/Sources/AppShell/WorkspaceInspectorPresenter.swift +0 -13
  45. package/apps/mac/Sources/Core/Actions/HotkeyManager.swift +0 -256
  46. package/apps/mac/Sources/Core/Actions/HotkeyStore.swift +0 -399
  47. package/apps/mac/Sources/Core/Actions/IntentEngine.swift +0 -988
  48. package/apps/mac/Sources/Core/Actions/IntentSchema.swift +0 -94
  49. package/apps/mac/Sources/Core/Actions/Intents/CreateLayerIntent.swift +0 -54
  50. package/apps/mac/Sources/Core/Actions/Intents/DistributeIntent.swift +0 -56
  51. package/apps/mac/Sources/Core/Actions/Intents/FocusIntent.swift +0 -69
  52. package/apps/mac/Sources/Core/Actions/Intents/HelpIntent.swift +0 -41
  53. package/apps/mac/Sources/Core/Actions/Intents/KillIntent.swift +0 -47
  54. package/apps/mac/Sources/Core/Actions/Intents/LatticeIntent.swift +0 -53
  55. package/apps/mac/Sources/Core/Actions/Intents/LaunchIntent.swift +0 -67
  56. package/apps/mac/Sources/Core/Actions/Intents/ListSessionsIntent.swift +0 -32
  57. package/apps/mac/Sources/Core/Actions/Intents/ListWindowsIntent.swift +0 -30
  58. package/apps/mac/Sources/Core/Actions/Intents/ScanIntent.swift +0 -52
  59. package/apps/mac/Sources/Core/Actions/Intents/SearchIntent.swift +0 -190
  60. package/apps/mac/Sources/Core/Actions/Intents/SwitchLayerIntent.swift +0 -50
  61. package/apps/mac/Sources/Core/Actions/Intents/TileIntent.swift +0 -61
  62. package/apps/mac/Sources/Core/Actions/PaletteCommand.swift +0 -439
  63. package/apps/mac/Sources/Core/Actions/VoiceIntentResolver.swift +0 -713
  64. package/apps/mac/Sources/Core/Companion/CompanionActivityLog.swift +0 -70
  65. package/apps/mac/Sources/Core/Companion/CompanionKeyboardController.swift +0 -141
  66. package/apps/mac/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +0 -454
  67. package/apps/mac/Sources/Core/Companion/LatticesCompanionCockpit.swift +0 -555
  68. package/apps/mac/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +0 -629
  69. package/apps/mac/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +0 -204
  70. package/apps/mac/Sources/Core/Companion/LatticesDeckHost.swift +0 -1463
  71. package/apps/mac/Sources/Core/Daemon/DaemonProtocol.swift +0 -114
  72. package/apps/mac/Sources/Core/Daemon/DaemonServer.swift +0 -427
  73. package/apps/mac/Sources/Core/Daemon/LatticesApi.swift +0 -2965
  74. package/apps/mac/Sources/Core/Desktop/AccessibilityTextExtractor.swift +0 -111
  75. package/apps/mac/Sources/Core/Desktop/AppTypeClassifier.swift +0 -106
  76. package/apps/mac/Sources/Core/Desktop/DesktopModel.swift +0 -331
  77. package/apps/mac/Sources/Core/Desktop/DesktopModelTypes.swift +0 -73
  78. package/apps/mac/Sources/Core/Desktop/InventoryManager.swift +0 -35
  79. package/apps/mac/Sources/Core/Desktop/InventoryPath.swift +0 -43
  80. package/apps/mac/Sources/Core/Desktop/MouseFinder.swift +0 -527
  81. package/apps/mac/Sources/Core/Desktop/OcrModel.swift +0 -467
  82. package/apps/mac/Sources/Core/Desktop/OcrStore.swift +0 -329
  83. package/apps/mac/Sources/Core/Desktop/PlacementSpec.swift +0 -195
  84. package/apps/mac/Sources/Core/Desktop/SessionWindowLocator.swift +0 -139
  85. package/apps/mac/Sources/Core/Desktop/TilePickerView.swift +0 -209
  86. package/apps/mac/Sources/Core/Desktop/WindowCapture.swift +0 -33
  87. package/apps/mac/Sources/Core/Desktop/WindowDragSnapController.swift +0 -429
  88. package/apps/mac/Sources/Core/Desktop/WindowPreviewCard.swift +0 -100
  89. package/apps/mac/Sources/Core/Desktop/WindowPreviewStore.swift +0 -112
  90. package/apps/mac/Sources/Core/Desktop/WindowSelectionStore.swift +0 -76
  91. package/apps/mac/Sources/Core/Desktop/WindowTiler.swift +0 -2222
  92. package/apps/mac/Sources/Core/Input/EventTapBreaker.swift +0 -124
  93. package/apps/mac/Sources/Core/Input/EventTapThread.swift +0 -54
  94. package/apps/mac/Sources/Core/Input/InputCaptureResetCenter.swift +0 -20
  95. package/apps/mac/Sources/Core/Input/KeyboardRemapConfig.swift +0 -69
  96. package/apps/mac/Sources/Core/Input/KeyboardRemapController.swift +0 -346
  97. package/apps/mac/Sources/Core/Input/KeyboardRemapStore.swift +0 -141
  98. package/apps/mac/Sources/Core/Input/MouseGestureConfig.swift +0 -499
  99. package/apps/mac/Sources/Core/Input/MouseGestureController.swift +0 -2583
  100. package/apps/mac/Sources/Core/Input/MouseInputDeviceStore.swift +0 -98
  101. package/apps/mac/Sources/Core/Input/MouseInputEventViewer.swift +0 -272
  102. package/apps/mac/Sources/Core/Input/MouseShortcutStore.swift +0 -170
  103. package/apps/mac/Sources/Core/Input/SecureEventInputMonitor.swift +0 -39
  104. package/apps/mac/Sources/Core/Input/ShapeRecognizer.swift +0 -624
  105. package/apps/mac/Sources/Core/Input/TapBudgetMeter.swift +0 -56
  106. package/apps/mac/Sources/Core/Overlays/AppWindowShell.swift +0 -63
  107. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeState.swift +0 -1566
  108. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeView.swift +0 -1927
  109. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeWindow.swift +0 -196
  110. package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteView.swift +0 -307
  111. package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +0 -67
  112. package/apps/mac/Sources/Core/Overlays/HUD/CheatSheetHUD.swift +0 -576
  113. package/apps/mac/Sources/Core/Overlays/HUD/HUDBottomBar.swift +0 -279
  114. package/apps/mac/Sources/Core/Overlays/HUD/HUDController.swift +0 -1158
  115. package/apps/mac/Sources/Core/Overlays/HUD/HUDLeftBar.swift +0 -849
  116. package/apps/mac/Sources/Core/Overlays/HUD/HUDMinimap.swift +0 -179
  117. package/apps/mac/Sources/Core/Overlays/HUD/HUDRightBar.swift +0 -596
  118. package/apps/mac/Sources/Core/Overlays/HUD/HUDState.swift +0 -367
  119. package/apps/mac/Sources/Core/Overlays/HUD/HUDTopBar.swift +0 -243
  120. package/apps/mac/Sources/Core/Overlays/HUD/LauncherHUD.swift +0 -334
  121. package/apps/mac/Sources/Core/Overlays/HUD/LayerBezel.swift +0 -203
  122. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchState.swift +0 -280
  123. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchView.swift +0 -422
  124. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchWindow.swift +0 -94
  125. package/apps/mac/Sources/Core/Overlays/OverlayPanelShell.swift +0 -241
  126. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapState.swift +0 -3135
  127. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapView.swift +0 -3977
  128. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapWindowController.swift +0 -119
  129. package/apps/mac/Sources/Core/Overlays/ScreenOverlayCanvasController.swift +0 -1217
  130. package/apps/mac/Sources/Core/Overlays/Voice/VoiceCommandWindow.swift +0 -1575
  131. package/apps/mac/Sources/Core/Pi/PiAuthNextStepCard.swift +0 -148
  132. package/apps/mac/Sources/Core/Pi/PiAuthPromptCard.swift +0 -90
  133. package/apps/mac/Sources/Core/Pi/PiChatDock.swift +0 -564
  134. package/apps/mac/Sources/Core/Pi/PiChatSession.swift +0 -1948
  135. package/apps/mac/Sources/Core/Pi/PiInstallCallout.swift +0 -86
  136. package/apps/mac/Sources/Core/Pi/PiProviderSetupCallout.swift +0 -99
  137. package/apps/mac/Sources/Core/Pi/PiWorkspaceView.swift +0 -510
  138. package/apps/mac/Sources/Core/System/Capability.swift +0 -79
  139. package/apps/mac/Sources/Core/System/DiagnosticLog.swift +0 -373
  140. package/apps/mac/Sources/Core/System/EventBus.swift +0 -31
  141. package/apps/mac/Sources/Core/System/PermissionChecker.swift +0 -224
  142. package/apps/mac/Sources/Core/System/ProcessModel.swift +0 -199
  143. package/apps/mac/Sources/Core/System/ProcessQuery.swift +0 -151
  144. package/apps/mac/Sources/Core/System/SystemTelemetryMonitor.swift +0 -273
  145. package/apps/mac/Sources/Core/Voice/AdvisorLearningStore.swift +0 -90
  146. package/apps/mac/Sources/Core/Voice/AgentSession.swift +0 -377
  147. package/apps/mac/Sources/Core/Voice/AudioProvider.swift +0 -555
  148. package/apps/mac/Sources/Core/Voice/HandsOffSession.swift +0 -839
  149. package/apps/mac/Sources/Core/Voice/VoiceChatView.swift +0 -192
  150. package/apps/mac/Sources/Core/Voice/VoxClient.swift +0 -454
  151. package/apps/mac/Sources/Core/Workspace/Project.swift +0 -28
  152. package/apps/mac/Sources/Core/Workspace/ProjectScanner.swift +0 -141
  153. package/apps/mac/Sources/Core/Workspace/SessionLayerStore.swift +0 -285
  154. package/apps/mac/Sources/Core/Workspace/SessionManager.swift +0 -75
  155. package/apps/mac/Sources/Core/Workspace/Terminal/Terminal.swift +0 -259
  156. package/apps/mac/Sources/Core/Workspace/Terminal/TerminalQuery.swift +0 -156
  157. package/apps/mac/Sources/Core/Workspace/Terminal/TerminalSynthesizer.swift +0 -200
  158. package/apps/mac/Sources/Core/Workspace/Tmux/TmuxModel.swift +0 -60
  159. package/apps/mac/Sources/Core/Workspace/Tmux/TmuxQuery.swift +0 -105
  160. package/apps/mac/Sources/Core/Workspace/WorkspaceManager.swift +0 -1027
  161. package/apps/mac/Sources/UI/ActionRow.swift +0 -78
  162. package/apps/mac/Sources/UI/OrphanRow.swift +0 -129
  163. package/apps/mac/Sources/UI/ProjectRow.swift +0 -368
  164. package/apps/mac/Sources/UI/TabGroupRow.swift +0 -178
  165. package/apps/mac/Sources/UI/Theme.swift +0 -164
  166. package/apps/mac/Tests/StageDragTests.swift +0 -333
  167. package/apps/mac/Tests/StageJoinTests.swift +0 -313
  168. package/apps/mac/Tests/StageManagerTests.swift +0 -280
  169. package/apps/mac/Tests/StageTileTests.swift +0 -353
  170. package/swift/Package.swift +0 -20
  171. package/swift/Sources/DeckKit/DeckAction.swift +0 -51
  172. package/swift/Sources/DeckKit/DeckBridgeSecurity.swift +0 -152
  173. package/swift/Sources/DeckKit/DeckCockpit.swift +0 -82
  174. package/swift/Sources/DeckKit/DeckHost.swift +0 -7
  175. package/swift/Sources/DeckKit/DeckManifest.swift +0 -145
  176. package/swift/Sources/DeckKit/DeckRuntimeSnapshot.swift +0 -533
  177. package/swift/Sources/DeckKit/DeckTrackpad.swift +0 -63
  178. package/swift/Sources/DeckKit/DeckValue.swift +0 -93
  179. package/swift/Sources/DeckKit/DeckVoiceError.swift +0 -88
  180. 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
- }