@lattices/cli 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +85 -9
  2. package/app/Info.plist +30 -0
  3. package/app/Lattices.app/Contents/Info.plist +8 -2
  4. package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/app/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
  6. package/app/Lattices.app/Contents/Resources/tap.wav +0 -0
  7. package/app/Lattices.app/Contents/_CodeSignature/CodeResources +139 -0
  8. package/app/Lattices.entitlements +15 -0
  9. package/app/Package.swift +8 -1
  10. package/app/Resources/tap.wav +0 -0
  11. package/app/Sources/AdvisorLearningStore.swift +90 -0
  12. package/app/Sources/AgentSession.swift +377 -0
  13. package/app/Sources/AppDelegate.swift +45 -12
  14. package/app/Sources/AppShellView.swift +81 -8
  15. package/app/Sources/AudioProvider.swift +386 -0
  16. package/app/Sources/CheatSheetHUD.swift +261 -19
  17. package/app/Sources/DaemonProtocol.swift +13 -0
  18. package/app/Sources/DaemonServer.swift +8 -0
  19. package/app/Sources/DesktopModel.swift +189 -6
  20. package/app/Sources/DesktopModelTypes.swift +2 -0
  21. package/app/Sources/DiagnosticLog.swift +104 -2
  22. package/app/Sources/EventBus.swift +1 -0
  23. package/app/Sources/HUDBottomBar.swift +279 -0
  24. package/app/Sources/HUDController.swift +1158 -0
  25. package/app/Sources/HUDLeftBar.swift +849 -0
  26. package/app/Sources/HUDMinimap.swift +179 -0
  27. package/app/Sources/HUDRightBar.swift +774 -0
  28. package/app/Sources/HUDState.swift +367 -0
  29. package/app/Sources/HUDTopBar.swift +243 -0
  30. package/app/Sources/HandsOffSession.swift +802 -0
  31. package/app/Sources/HomeDashboardView.swift +125 -0
  32. package/app/Sources/HotkeyManager.swift +2 -0
  33. package/app/Sources/HotkeyStore.swift +49 -9
  34. package/app/Sources/IntentEngine.swift +962 -0
  35. package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
  36. package/app/Sources/Intents/DistributeIntent.swift +56 -0
  37. package/app/Sources/Intents/FocusIntent.swift +69 -0
  38. package/app/Sources/Intents/HelpIntent.swift +41 -0
  39. package/app/Sources/Intents/KillIntent.swift +47 -0
  40. package/app/Sources/Intents/LatticeIntent.swift +78 -0
  41. package/app/Sources/Intents/LaunchIntent.swift +67 -0
  42. package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
  43. package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
  44. package/app/Sources/Intents/ScanIntent.swift +52 -0
  45. package/app/Sources/Intents/SearchIntent.swift +190 -0
  46. package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
  47. package/app/Sources/Intents/TileIntent.swift +61 -0
  48. package/app/Sources/LatticesApi.swift +1275 -30
  49. package/app/Sources/LauncherHUD.swift +348 -0
  50. package/app/Sources/MainView.swift +147 -44
  51. package/app/Sources/MouseFinder.swift +222 -0
  52. package/app/Sources/OcrModel.swift +34 -1
  53. package/app/Sources/OmniSearchState.swift +99 -102
  54. package/app/Sources/OnboardingView.swift +457 -0
  55. package/app/Sources/PermissionChecker.swift +2 -12
  56. package/app/Sources/PiChatDock.swift +454 -0
  57. package/app/Sources/PiChatSession.swift +815 -0
  58. package/app/Sources/PiWorkspaceView.swift +364 -0
  59. package/app/Sources/PlacementSpec.swift +195 -0
  60. package/app/Sources/Preferences.swift +59 -0
  61. package/app/Sources/ProjectScanner.swift +58 -45
  62. package/app/Sources/ScreenMapState.swift +701 -55
  63. package/app/Sources/ScreenMapView.swift +843 -103
  64. package/app/Sources/ScreenMapWindowController.swift +22 -0
  65. package/app/Sources/SessionLayerStore.swift +285 -0
  66. package/app/Sources/SessionManager.swift +4 -1
  67. package/app/Sources/SettingsView.swift +186 -3
  68. package/app/Sources/Theme.swift +9 -8
  69. package/app/Sources/TmuxModel.swift +7 -0
  70. package/app/Sources/TmuxQuery.swift +27 -3
  71. package/app/Sources/VoiceChatView.swift +192 -0
  72. package/app/Sources/VoiceCommandWindow.swift +1594 -0
  73. package/app/Sources/VoiceIntentResolver.swift +671 -0
  74. package/app/Sources/VoxClient.swift +454 -0
  75. package/app/Sources/WindowTiler.swift +348 -87
  76. package/app/Sources/WorkspaceManager.swift +127 -18
  77. package/app/Tests/StageDragTests.swift +333 -0
  78. package/app/Tests/StageJoinTests.swift +313 -0
  79. package/app/Tests/StageManagerTests.swift +280 -0
  80. package/app/Tests/StageTileTests.swift +353 -0
  81. package/assets/AppIcon.icns +0 -0
  82. package/bin/client.ts +16 -0
  83. package/bin/{daemon-client.js → daemon-client.ts} +49 -30
  84. package/bin/handsoff-infer.ts +280 -0
  85. package/bin/handsoff-worker.ts +740 -0
  86. package/bin/lattices-app.ts +338 -0
  87. package/bin/lattices-dev +208 -0
  88. package/bin/{lattices.js → lattices.ts} +777 -140
  89. package/bin/project-twin.ts +645 -0
  90. package/docs/agent-execution-plan.md +562 -0
  91. package/docs/agent-layer-guide.md +207 -0
  92. package/docs/agents.md +142 -0
  93. package/docs/api.md +153 -34
  94. package/docs/app.md +29 -1
  95. package/docs/config.md +5 -1
  96. package/docs/handsoff-test-scenarios.md +84 -0
  97. package/docs/layers.md +20 -20
  98. package/docs/ocr.md +14 -5
  99. package/docs/overview.md +5 -1
  100. package/docs/presentation-execution-review.md +491 -0
  101. package/docs/prompts/hands-off-system.md +374 -0
  102. package/docs/prompts/hands-off-turn.md +30 -0
  103. package/docs/prompts/voice-advisor.md +31 -0
  104. package/docs/prompts/voice-fallback.md +23 -0
  105. package/docs/tiling-reference.md +167 -0
  106. package/docs/twins.md +138 -0
  107. package/docs/voice-command-protocol.md +278 -0
  108. package/docs/voice.md +219 -0
  109. package/package.json +29 -11
  110. package/bin/client.js +0 -4
  111. package/bin/lattices-app.js +0 -221
@@ -78,7 +78,7 @@ class WorkspaceManager: ObservableObject {
78
78
 
79
79
  private let configPath: String
80
80
  private let gridConfigPath: String
81
- private let tmuxPath = "/opt/homebrew/bin/tmux"
81
+ private var tmuxPath: String { TmuxQuery.resolvedPath ?? "/opt/homebrew/bin/tmux" }
82
82
  private let activeLayerKey = "lattices.activeLayerIndex"
83
83
 
84
84
  init() {
@@ -173,13 +173,15 @@ class WorkspaceManager: ObservableObject {
173
173
 
174
174
  /// Resolve a tile string to fractions: check user presets first, then built-in TilePosition
175
175
  func resolveTileFractions(_ tile: String) -> (CGFloat, CGFloat, CGFloat, CGFloat)? {
176
- if let preset = gridPresets[tile] {
177
- return preset.fractions
178
- }
179
- if let position = TilePosition(rawValue: tile) {
180
- return position.rect
176
+ resolvePlacement(tile)?.fractions
177
+ }
178
+
179
+ func resolvePlacement(_ tile: String) -> PlacementSpec? {
180
+ if let preset = gridPresets[tile],
181
+ let placement = FractionalPlacement(x: preset.x, y: preset.y, w: preset.w, h: preset.h) {
182
+ return .fractions(placement)
181
183
  }
182
- return nil
184
+ return PlacementSpec(string: tile)
183
185
  }
184
186
 
185
187
  // MARK: - Tab Groups
@@ -275,7 +277,7 @@ class WorkspaceManager: ObservableObject {
275
277
 
276
278
  /// Resolve a session name to a tile target: (wid, pid, frame).
277
279
  /// Returns nil if the window isn't tracked or has no tile position.
278
- private func batchTarget(session: String, position: TilePosition, screen: NSScreen) -> (wid: UInt32, pid: Int32, frame: CGRect)? {
280
+ private func batchTarget(session: String, position: PlacementSpec, screen: NSScreen) -> (wid: UInt32, pid: Int32, frame: CGRect)? {
279
281
  guard let entry = windowForSession(session) else { return nil }
280
282
  let frame = WindowTiler.tileFrame(for: position, on: screen)
281
283
  return (entry.wid, entry.pid, frame)
@@ -309,6 +311,71 @@ class WorkspaceManager: ObservableObject {
309
311
  return (running, total)
310
312
  }
311
313
 
314
+ // MARK: - Layer Focus (raise only)
315
+
316
+ /// Switch to a layer by raising all its windows in place — no launching, no tiling, no moving.
317
+ /// This is the default hotkey action: just bring the layer's windows to the front.
318
+ func focusLayer(index: Int) {
319
+ guard let config, let layers = config.layers, index < layers.count else { return }
320
+ if index == activeLayerIndex { return }
321
+
322
+ let diag = DiagnosticLog.shared
323
+ let t = diag.startTimed("focusLayer \(activeLayerIndex)→\(index)")
324
+
325
+ DesktopModel.shared.poll()
326
+
327
+ let targetLayer = layers[index]
328
+ var windowsToRaise: [(wid: UInt32, pid: Int32)] = []
329
+
330
+ for lp in targetLayer.projects {
331
+ if let groupId = lp.group, let grp = group(byId: groupId) {
332
+ // Raise all tab windows in the group
333
+ for tab in grp.tabs {
334
+ let sessionName = Self.sessionName(for: tab.path)
335
+ if let entry = windowForSession(sessionName) {
336
+ windowsToRaise.append((entry.wid, entry.pid))
337
+ }
338
+ }
339
+ continue
340
+ }
341
+
342
+ if let appName = lp.app {
343
+ if let entry = DesktopModel.shared.windowForApp(app: appName, title: lp.title) {
344
+ windowsToRaise.append((entry.wid, entry.pid))
345
+ }
346
+ continue
347
+ }
348
+
349
+ guard let path = lp.path else { continue }
350
+ let sessionName = Self.sessionName(for: path)
351
+ if let entry = windowForSession(sessionName) {
352
+ windowsToRaise.append((entry.wid, entry.pid))
353
+ }
354
+
355
+ // Also raise companion windows
356
+ let companions = projectWindows(at: path)
357
+ for cw in companions {
358
+ guard let appName = cw.app else { continue }
359
+ if let entry = DesktopModel.shared.windowForApp(app: appName, title: cw.title) {
360
+ windowsToRaise.append((entry.wid, entry.pid))
361
+ }
362
+ }
363
+ }
364
+
365
+ if !windowsToRaise.isEmpty {
366
+ WindowTiler.raiseWindowsAndReactivate(windows: windowsToRaise)
367
+ }
368
+
369
+ activeLayerIndex = index
370
+ UserDefaults.standard.set(index, forKey: activeLayerKey)
371
+
372
+ let allLabels = layers.map(\.label)
373
+ LayerBezel.shared.show(label: targetLayer.label, index: index, total: layers.count, allLabels: allLabels)
374
+ HandsOffSession.shared.playCachedCue("Switched.")
375
+
376
+ diag.finish(t)
377
+ }
378
+
312
379
  // MARK: - Unified Layer Tiling
313
380
 
314
381
  /// Unified entry point for arranging a layer's windows.
@@ -332,14 +399,17 @@ class WorkspaceManager: ObservableObject {
332
399
  let scanner = ProjectScanner.shared
333
400
  let targetLayer = layers[index]
334
401
 
402
+ // Fresh poll so we see windows on all Spaces before matching
403
+ DesktopModel.shared.poll()
404
+
335
405
  // Tile debug log (written to ~/.lattices/tile-debug.log)
336
406
  let debugPath = (FileManager.default.homeDirectoryForCurrentUser.path as NSString).appendingPathComponent(".lattices/tile-debug.log")
337
407
  var debugLines: [String] = ["tileLayer index=\(index) launch=\(launch) force=\(force) layer=\(targetLayer.id)"]
338
408
 
339
409
  // Phase 1: classify each project
340
410
  var batchMoves: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
341
- var fallbacks: [(session: String, position: TilePosition, screen: NSScreen)] = []
342
- var launchQueue: [(session: String, position: TilePosition?, screen: NSScreen, launchAction: () -> Void)] = []
411
+ var fallbacks: [(session: String, position: PlacementSpec, screen: NSScreen)] = []
412
+ var launchQueue: [(session: String, position: PlacementSpec?, screen: NSScreen, launchAction: () -> Void)] = []
343
413
 
344
414
  // Log screen info
345
415
  for (i, s) in NSScreen.screens.enumerated() {
@@ -351,7 +421,7 @@ class WorkspaceManager: ObservableObject {
351
421
 
352
422
  if let groupId = lp.group, let grp = group(byId: groupId) {
353
423
  let firstTabSession = grp.tabs.first.map { Self.sessionName(for: $0.path) } ?? ""
354
- let position = lp.tile.flatMap { TilePosition(rawValue: $0) }
424
+ let position = lp.tile.flatMap { resolvePlacement($0) }
355
425
  let groupRunning = isGroupRunning(grp)
356
426
 
357
427
  if groupRunning, let pos = position,
@@ -373,12 +443,19 @@ class WorkspaceManager: ObservableObject {
373
443
 
374
444
  // App-based window matching
375
445
  if let appName = lp.app {
376
- let position = lp.tile.flatMap { TilePosition(rawValue: $0) }
446
+ let position = lp.tile.flatMap { resolvePlacement($0) }
377
447
  if let entry = DesktopModel.shared.windowForApp(app: appName, title: lp.title) {
378
448
  if let pos = position {
379
449
  let frame = WindowTiler.tileFrame(for: pos, on: lpScreen)
380
450
  batchMoves.append((entry.wid, entry.pid, frame))
381
451
  }
452
+ } else if let found = Self.findAppWindow(app: appName, title: lp.title) {
453
+ // Window exists but wasn't in DesktopModel (e.g. different Space) — tile it
454
+ diag.info(" found app via CGWindowList fallback: \(appName) wid=\(found.wid)")
455
+ if let pos = position {
456
+ let frame = WindowTiler.tileFrame(for: pos, on: lpScreen)
457
+ batchMoves.append((found.wid, found.pid, frame))
458
+ }
382
459
  } else if launch {
383
460
  diag.info(" launch app: \(appName)")
384
461
  let capturedLp = lp
@@ -407,13 +484,13 @@ class WorkspaceManager: ObservableObject {
407
484
  guard let path = lp.path else { continue }
408
485
  let sessionName = Self.sessionName(for: path)
409
486
  let project = scanner.projects.first(where: { $0.path == path })
410
- let position = lp.tile.flatMap { TilePosition(rawValue: $0) }
487
+ let position = lp.tile.flatMap { resolvePlacement($0) }
411
488
  // Check scanner first, fall back to direct tmux check for projects without .lattices.json
412
489
  let isRunning = project?.isRunning == true || shell([tmuxPath, "has-session", "-t", sessionName]) == 0
413
490
 
414
491
  if isRunning {
415
492
  let foundWindow = windowForSession(sessionName)
416
- let msg = " \(sessionName): running=\(isRunning) window=\(foundWindow?.wid ?? 0) tile=\(position?.rawValue ?? "nil") desktopCount=\(DesktopModel.shared.windows.count)"
493
+ let msg = " \(sessionName): running=\(isRunning) window=\(foundWindow?.wid ?? 0) tile=\(position?.wireValue ?? "nil") desktopCount=\(DesktopModel.shared.windows.count)"
417
494
  diag.info(msg)
418
495
  debugLines.append(msg)
419
496
  if let pos = position,
@@ -422,7 +499,7 @@ class WorkspaceManager: ObservableObject {
422
499
  debugLines.append(" → batch move wid=\(target.wid) frame=\(target.frame)")
423
500
  } else if let pos = position {
424
501
  fallbacks.append((sessionName, pos, lpScreen))
425
- debugLines.append(" → fallback \(pos.rawValue)")
502
+ debugLines.append(" → fallback \(pos.wireValue)")
426
503
  }
427
504
  } else if launch {
428
505
  if let project {
@@ -443,7 +520,7 @@ class WorkspaceManager: ObservableObject {
443
520
  for cw in companions {
444
521
  guard let appName = cw.app else { continue }
445
522
  let cwScreen = screen(for: cw.display ?? lp.display) ?? lpScreen
446
- let cwPosition = cw.tile.flatMap { TilePosition(rawValue: $0) }
523
+ let cwPosition = cw.tile.flatMap { resolvePlacement($0) }
447
524
  if let entry = DesktopModel.shared.windowForApp(app: appName, title: cw.title) {
448
525
  if let pos = cwPosition {
449
526
  let frame = WindowTiler.tileFrame(for: pos, on: cwScreen)
@@ -486,7 +563,7 @@ class WorkspaceManager: ObservableObject {
486
563
  for (i, fb) in fallbacks.enumerated() {
487
564
  let delay = Double(i) * 0.15 + 0.1
488
565
  DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
489
- diag.info(" tile fallback: \(fb.session) → \(fb.position.rawValue)")
566
+ diag.info(" tile fallback: \(fb.session) → \(fb.position.wireValue)")
490
567
  WindowTiler.navigateToWindow(session: fb.session, terminal: terminal)
491
568
  WindowTiler.tile(session: fb.session, terminal: terminal, to: fb.position, on: fb.screen)
492
569
  }
@@ -498,7 +575,7 @@ class WorkspaceManager: ObservableObject {
498
575
  DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
499
576
  item.launchAction()
500
577
  if let pos = item.position {
501
- let t = diag.startTimed("tile launched: \(item.session) → \(pos.rawValue)")
578
+ let t = diag.startTimed("tile launched: \(item.session) → \(pos.wireValue)")
502
579
  WindowTiler.tile(session: item.session, terminal: terminal, to: pos, on: item.screen)
503
580
  diag.finish(t)
504
581
  }
@@ -558,6 +635,38 @@ class WorkspaceManager: ObservableObject {
558
635
  }
559
636
  }
560
637
 
638
+ // MARK: - App Window Fallback (CGWindowList .optionAll)
639
+
640
+ /// Find an app window across ALL Spaces via CGWindowList (bypasses DesktopModel cache)
641
+ static func findAppWindow(app: String, title: String?) -> (wid: UInt32, pid: Int32)? {
642
+ guard let list = CGWindowListCopyWindowInfo(
643
+ [.optionAll, .excludeDesktopElements],
644
+ kCGNullWindowID
645
+ ) as? [[String: Any]] else { return nil }
646
+
647
+ for info in list {
648
+ guard let ownerName = info[kCGWindowOwnerName as String] as? String,
649
+ ownerName.localizedCaseInsensitiveContains(app),
650
+ let wid = info[kCGWindowNumber as String] as? UInt32,
651
+ let pid = info[kCGWindowOwnerPID as String] as? Int32,
652
+ let layer = info[kCGWindowLayer as String] as? Int, layer == 0,
653
+ let boundsDict = info[kCGWindowBounds as String] as? NSDictionary
654
+ else { continue }
655
+
656
+ var rect = CGRect.zero
657
+ guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect),
658
+ rect.width >= 50, rect.height >= 50 else { continue }
659
+
660
+ if let title {
661
+ let windowTitle = info[kCGWindowName as String] as? String ?? ""
662
+ guard windowTitle.localizedCaseInsensitiveContains(title) else { continue }
663
+ }
664
+
665
+ return (wid, pid)
666
+ }
667
+ return nil
668
+ }
669
+
561
670
  // MARK: - Session Name Helper
562
671
 
563
672
  /// Replicates Project.sessionName logic from a bare path
@@ -0,0 +1,333 @@
1
+ import XCTest
2
+ import CoreGraphics
3
+ import AppKit
4
+
5
+ /// Attempt to create stages by simulating drag gestures from the strip.
6
+ ///
7
+ /// When a user drags a strip thumbnail into the center stage, SM joins them.
8
+ /// Can we replicate this with synthetic mouse events?
9
+ final class StageDragTests: XCTestCase {
10
+
11
+ // MARK: - Helpers
12
+
13
+ struct LiveWindow {
14
+ let wid: UInt32
15
+ let app: String
16
+ let pid: Int32
17
+ let title: String
18
+ let bounds: CGRect
19
+ let isOnScreen: Bool
20
+ }
21
+
22
+ func getRealWindows() -> [LiveWindow] {
23
+ guard let list = CGWindowListCopyWindowInfo(
24
+ [.optionAll, .excludeDesktopElements],
25
+ kCGNullWindowID
26
+ ) as? [[String: Any]] else { return [] }
27
+
28
+ let skip: Set<String> = [
29
+ "Window Server", "Dock", "Control Center", "SystemUIServer",
30
+ "Notification Center", "Spotlight", "WindowManager", "Lattices",
31
+ ]
32
+
33
+ return list.compactMap { info in
34
+ guard let wid = info[kCGWindowNumber as String] as? UInt32,
35
+ let owner = info[kCGWindowOwnerName as String] as? String,
36
+ let pid = info[kCGWindowOwnerPID as String] as? Int32,
37
+ let boundsDict = info[kCGWindowBounds as String] as? NSDictionary
38
+ else { return nil }
39
+
40
+ var rect = CGRect.zero
41
+ guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect) else { return nil }
42
+ let title = info[kCGWindowName as String] as? String ?? ""
43
+ let layer = info[kCGWindowLayer as String] as? Int ?? 0
44
+ let isOnScreen = info[kCGWindowIsOnscreen as String] as? Bool ?? false
45
+
46
+ guard layer == 0, rect.width >= 50, rect.height >= 50 else { return nil }
47
+ guard !skip.contains(owner) else { return nil }
48
+
49
+ return LiveWindow(wid: wid, app: owner, pid: pid, title: title,
50
+ bounds: rect, isOnScreen: isOnScreen)
51
+ }
52
+ }
53
+
54
+ /// Find strip thumbnails (small onscreen windows on the left edge)
55
+ func getStripThumbnails() -> [LiveWindow] {
56
+ getRealWindows().filter {
57
+ $0.isOnScreen && $0.bounds.width < 250 && $0.bounds.height < 250
58
+ && $0.bounds.origin.x < 220 && $0.bounds.origin.x >= 0
59
+ }
60
+ }
61
+
62
+ /// Find active stage windows (large onscreen windows)
63
+ func getActiveStage() -> [LiveWindow] {
64
+ getRealWindows().filter { $0.isOnScreen && $0.bounds.width > 250 }
65
+ }
66
+
67
+ func printStageState(label: String) {
68
+ let active = getActiveStage()
69
+ let strip = getStripThumbnails()
70
+ print("\n[\(label)]")
71
+ print(" Active: \(active.map { "\($0.app)(\($0.wid))" }.joined(separator: ", "))")
72
+ print(" Strip: \(Set(strip.map(\.app)).sorted().joined(separator: ", "))")
73
+ }
74
+
75
+ // MARK: - Synthetic mouse event helpers
76
+
77
+ /// Post a mouse event at a screen coordinate
78
+ func postMouse(_ type: CGEventType, at point: CGPoint, button: CGMouseButton = .left) {
79
+ guard let event = CGEvent(
80
+ mouseEventSource: nil,
81
+ mouseType: type,
82
+ mouseCursorPosition: point,
83
+ mouseButton: button
84
+ ) else { return }
85
+ event.post(tap: .cghidEventTap)
86
+ }
87
+
88
+ /// Simulate a smooth drag from point A to point B
89
+ func simulateDrag(from start: CGPoint, to end: CGPoint, steps: Int = 30, duration: TimeInterval = 0.4) {
90
+ // Mouse down at start
91
+ postMouse(.leftMouseDown, at: start)
92
+ Thread.sleep(forTimeInterval: 0.05)
93
+
94
+ // Interpolate drag path
95
+ for i in 1...steps {
96
+ let t = CGFloat(i) / CGFloat(steps)
97
+ let x = start.x + (end.x - start.x) * t
98
+ let y = start.y + (end.y - start.y) * t
99
+ postMouse(.leftMouseDragged, at: CGPoint(x: x, y: y))
100
+ Thread.sleep(forTimeInterval: duration / TimeInterval(steps))
101
+ }
102
+
103
+ // Mouse up at end
104
+ postMouse(.leftMouseUp, at: end)
105
+ }
106
+
107
+ // MARK: - Approach 5: Drag strip thumbnail to center
108
+
109
+ func testJoinByDragFromStrip() throws {
110
+ let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
111
+ try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
112
+
113
+ let thumbnails = getStripThumbnails()
114
+ let active = getActiveStage()
115
+
116
+ guard !thumbnails.isEmpty else {
117
+ print("No strip thumbnails found")
118
+ return
119
+ }
120
+ guard let anchor = active.first else {
121
+ print("No active stage window")
122
+ return
123
+ }
124
+
125
+ // Target specific apps: Chrome, iTerm2, Vox
126
+ let activeApps = Set(active.map(\.app))
127
+ let preferred = ["Google Chrome", "iTerm2", "Vox"]
128
+ guard let thumb = thumbnails.first(where: { preferred.contains($0.app) && !activeApps.contains($0.app) })
129
+ ?? thumbnails.first(where: { !activeApps.contains($0.app) }) else {
130
+ print("No suitable strip thumbnail found")
131
+ return
132
+ }
133
+
134
+ let thumbCenter = CGPoint(x: thumb.bounds.midX, y: thumb.bounds.midY)
135
+ let stageCenter = CGPoint(x: anchor.bounds.midX, y: anchor.bounds.midY)
136
+
137
+ print("Dragging \(thumb.app) thumbnail from strip (\(Int(thumbCenter.x)),\(Int(thumbCenter.y))) to center (\(Int(stageCenter.x)),\(Int(stageCenter.y)))")
138
+ printStageState(label: "BEFORE")
139
+
140
+ simulateDrag(from: thumbCenter, to: stageCenter, steps: 40, duration: 0.6)
141
+
142
+ Thread.sleep(forTimeInterval: 0.8)
143
+ printStageState(label: "After drag to center")
144
+
145
+ let finalActive = getActiveStage()
146
+ let finalApps = Set(finalActive.map(\.app))
147
+ let joined = finalApps.contains(thumb.app) && activeApps.isSubset(of: finalApps)
148
+ print("\nResult: \(thumb.app) joined stage? \(joined)")
149
+ print("Active apps: \(finalApps.sorted())")
150
+ }
151
+
152
+ // MARK: - Approach 6: Drag thumbnail to top half (join zone)
153
+
154
+ func testJoinByDragToTopHalf() throws {
155
+ let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
156
+ try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
157
+
158
+ let thumbnails = getStripThumbnails()
159
+ let active = getActiveStage()
160
+
161
+ guard !thumbnails.isEmpty, let anchor = active.first else { return }
162
+
163
+ let activeApps = Set(active.map(\.app))
164
+ let preferred6 = ["Google Chrome", "iTerm2", "Vox"]
165
+ guard let thumb = thumbnails.first(where: { preferred6.contains($0.app) && !activeApps.contains($0.app) })
166
+ ?? thumbnails.first(where: { !activeApps.contains($0.app) }) else { return }
167
+
168
+ // SM has specific drop zones. Try dragging to the top half of the active
169
+ // stage area — this might be the "join" zone vs "replace" zone.
170
+ let thumbCenter = CGPoint(x: thumb.bounds.midX, y: thumb.bounds.midY)
171
+ let topHalf = CGPoint(x: anchor.bounds.midX, y: anchor.bounds.origin.y + 100)
172
+
173
+ print("Dragging \(thumb.app) to top-half of active area (\(Int(topHalf.x)),\(Int(topHalf.y)))")
174
+ printStageState(label: "BEFORE")
175
+
176
+ simulateDrag(from: thumbCenter, to: topHalf, steps: 50, duration: 0.8)
177
+
178
+ Thread.sleep(forTimeInterval: 0.8)
179
+ printStageState(label: "After drag to top half")
180
+
181
+ let finalApps = Set(getActiveStage().map(\.app))
182
+ let joined = finalApps.contains(thumb.app) && activeApps.isSubset(of: finalApps)
183
+ print("\nResult: joined? \(joined) — active: \(finalApps.sorted())")
184
+ }
185
+
186
+ // MARK: - Approach 7: Long press on thumbnail, then drag
187
+
188
+ func testJoinByLongPressDrag() throws {
189
+ let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
190
+ try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
191
+
192
+ let thumbnails = getStripThumbnails()
193
+ let active = getActiveStage()
194
+
195
+ guard !thumbnails.isEmpty, let anchor = active.first else { return }
196
+
197
+ let activeApps = Set(active.map(\.app))
198
+ let preferred7 = ["Google Chrome", "iTerm2", "Vox"]
199
+ guard let thumb = thumbnails.first(where: { preferred7.contains($0.app) && !activeApps.contains($0.app) })
200
+ ?? thumbnails.first(where: { !activeApps.contains($0.app) }) else { return }
201
+
202
+ let thumbCenter = CGPoint(x: thumb.bounds.midX, y: thumb.bounds.midY)
203
+ let stageCenter = CGPoint(x: anchor.bounds.midX, y: anchor.bounds.midY)
204
+
205
+ print("Long-press + drag \(thumb.app) from strip to center")
206
+ printStageState(label: "BEFORE")
207
+
208
+ // Long press: mouse down + wait
209
+ postMouse(.leftMouseDown, at: thumbCenter)
210
+ Thread.sleep(forTimeInterval: 0.8) // Hold for long press recognition
211
+
212
+ // Slow drag out of strip area
213
+ let steps = 60
214
+ let duration: TimeInterval = 1.0
215
+ for i in 1...steps {
216
+ let t = CGFloat(i) / CGFloat(steps)
217
+ let x = thumbCenter.x + (stageCenter.x - thumbCenter.x) * t
218
+ let y = thumbCenter.y + (stageCenter.y - thumbCenter.y) * t
219
+ postMouse(.leftMouseDragged, at: CGPoint(x: x, y: y))
220
+ Thread.sleep(forTimeInterval: duration / TimeInterval(steps))
221
+ }
222
+
223
+ // Hold at destination briefly
224
+ Thread.sleep(forTimeInterval: 0.3)
225
+ postMouse(.leftMouseUp, at: stageCenter)
226
+
227
+ Thread.sleep(forTimeInterval: 1.0)
228
+ printStageState(label: "After long-press drag")
229
+
230
+ let finalApps = Set(getActiveStage().map(\.app))
231
+ let joined = finalApps.contains(thumb.app) && activeApps.isSubset(of: finalApps)
232
+ print("\nResult: joined? \(joined) — active: \(finalApps.sorted())")
233
+ }
234
+
235
+ // MARK: - Approach 8: Click thumbnail while holding Option key
236
+
237
+ func testJoinByOptionClick() throws {
238
+ let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
239
+ try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
240
+
241
+ let thumbnails = getStripThumbnails()
242
+ let active = getActiveStage()
243
+
244
+ guard !thumbnails.isEmpty else { return }
245
+
246
+ let activeApps = Set(active.map(\.app))
247
+ let preferred8 = ["Google Chrome", "iTerm2", "Vox"]
248
+ guard let thumb = thumbnails.first(where: { preferred8.contains($0.app) && !activeApps.contains($0.app) })
249
+ ?? thumbnails.first(where: { !activeApps.contains($0.app) }) else { return }
250
+
251
+ let thumbCenter = CGPoint(x: thumb.bounds.midX, y: thumb.bounds.midY)
252
+
253
+ print("Option-clicking \(thumb.app) thumbnail at (\(Int(thumbCenter.x)),\(Int(thumbCenter.y)))")
254
+ printStageState(label: "BEFORE")
255
+
256
+ // Option + click on strip thumbnail
257
+ guard let downEvent = CGEvent(
258
+ mouseEventSource: nil,
259
+ mouseType: .leftMouseDown,
260
+ mouseCursorPosition: thumbCenter,
261
+ mouseButton: .left
262
+ ) else { return }
263
+ downEvent.flags = .maskAlternate // Option key
264
+ downEvent.post(tap: .cghidEventTap)
265
+
266
+ Thread.sleep(forTimeInterval: 0.05)
267
+
268
+ guard let upEvent = CGEvent(
269
+ mouseEventSource: nil,
270
+ mouseType: .leftMouseUp,
271
+ mouseCursorPosition: thumbCenter,
272
+ mouseButton: .left
273
+ ) else { return }
274
+ upEvent.flags = .maskAlternate
275
+ upEvent.post(tap: .cghidEventTap)
276
+
277
+ Thread.sleep(forTimeInterval: 0.8)
278
+ printStageState(label: "After Option-click")
279
+
280
+ let finalApps = Set(getActiveStage().map(\.app))
281
+ let joined = finalApps.contains(thumb.app) && activeApps.isSubset(of: finalApps)
282
+ print("\nResult: joined? \(joined) — active: \(finalApps.sorted())")
283
+ }
284
+
285
+ // MARK: - Approach 9: Shift-click (common modifier for "add to selection")
286
+
287
+ func testJoinByShiftClick() throws {
288
+ let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
289
+ try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
290
+
291
+ let thumbnails = getStripThumbnails()
292
+ let active = getActiveStage()
293
+
294
+ guard !thumbnails.isEmpty else { return }
295
+
296
+ let activeApps = Set(active.map(\.app))
297
+ let preferred9 = ["Google Chrome", "iTerm2", "Vox"]
298
+ guard let thumb = thumbnails.first(where: { preferred9.contains($0.app) && !activeApps.contains($0.app) })
299
+ ?? thumbnails.first(where: { !activeApps.contains($0.app) }) else { return }
300
+
301
+ let thumbCenter = CGPoint(x: thumb.bounds.midX, y: thumb.bounds.midY)
302
+
303
+ print("Shift-clicking \(thumb.app) thumbnail")
304
+ printStageState(label: "BEFORE")
305
+
306
+ guard let downEvent = CGEvent(
307
+ mouseEventSource: nil,
308
+ mouseType: .leftMouseDown,
309
+ mouseCursorPosition: thumbCenter,
310
+ mouseButton: .left
311
+ ) else { return }
312
+ downEvent.flags = .maskShift
313
+ downEvent.post(tap: .cghidEventTap)
314
+
315
+ Thread.sleep(forTimeInterval: 0.05)
316
+
317
+ guard let upEvent = CGEvent(
318
+ mouseEventSource: nil,
319
+ mouseType: .leftMouseUp,
320
+ mouseCursorPosition: thumbCenter,
321
+ mouseButton: .left
322
+ ) else { return }
323
+ upEvent.flags = .maskShift
324
+ upEvent.post(tap: .cghidEventTap)
325
+
326
+ Thread.sleep(forTimeInterval: 0.8)
327
+ printStageState(label: "After Shift-click")
328
+
329
+ let finalApps = Set(getActiveStage().map(\.app))
330
+ let joined = finalApps.contains(thumb.app) && activeApps.isSubset(of: finalApps)
331
+ print("\nResult: joined? \(joined) — active: \(finalApps.sorted())")
332
+ }
333
+ }