@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.
- package/README.md +85 -9
- package/app/Info.plist +30 -0
- package/app/Lattices.app/Contents/Info.plist +8 -2
- package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/app/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
- package/app/Lattices.app/Contents/Resources/tap.wav +0 -0
- package/app/Lattices.app/Contents/_CodeSignature/CodeResources +139 -0
- package/app/Lattices.entitlements +15 -0
- package/app/Package.swift +8 -1
- package/app/Resources/tap.wav +0 -0
- package/app/Sources/AdvisorLearningStore.swift +90 -0
- package/app/Sources/AgentSession.swift +377 -0
- package/app/Sources/AppDelegate.swift +45 -12
- package/app/Sources/AppShellView.swift +81 -8
- package/app/Sources/AudioProvider.swift +386 -0
- package/app/Sources/CheatSheetHUD.swift +261 -19
- package/app/Sources/DaemonProtocol.swift +13 -0
- package/app/Sources/DaemonServer.swift +8 -0
- package/app/Sources/DesktopModel.swift +189 -6
- package/app/Sources/DesktopModelTypes.swift +2 -0
- package/app/Sources/DiagnosticLog.swift +104 -2
- package/app/Sources/EventBus.swift +1 -0
- package/app/Sources/HUDBottomBar.swift +279 -0
- package/app/Sources/HUDController.swift +1158 -0
- package/app/Sources/HUDLeftBar.swift +849 -0
- package/app/Sources/HUDMinimap.swift +179 -0
- package/app/Sources/HUDRightBar.swift +774 -0
- package/app/Sources/HUDState.swift +367 -0
- package/app/Sources/HUDTopBar.swift +243 -0
- package/app/Sources/HandsOffSession.swift +802 -0
- package/app/Sources/HomeDashboardView.swift +125 -0
- package/app/Sources/HotkeyManager.swift +2 -0
- package/app/Sources/HotkeyStore.swift +49 -9
- package/app/Sources/IntentEngine.swift +962 -0
- package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
- package/app/Sources/Intents/DistributeIntent.swift +56 -0
- package/app/Sources/Intents/FocusIntent.swift +69 -0
- package/app/Sources/Intents/HelpIntent.swift +41 -0
- package/app/Sources/Intents/KillIntent.swift +47 -0
- package/app/Sources/Intents/LatticeIntent.swift +78 -0
- package/app/Sources/Intents/LaunchIntent.swift +67 -0
- package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
- package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
- package/app/Sources/Intents/ScanIntent.swift +52 -0
- package/app/Sources/Intents/SearchIntent.swift +190 -0
- package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
- package/app/Sources/Intents/TileIntent.swift +61 -0
- package/app/Sources/LatticesApi.swift +1275 -30
- package/app/Sources/LauncherHUD.swift +348 -0
- package/app/Sources/MainView.swift +147 -44
- package/app/Sources/MouseFinder.swift +222 -0
- package/app/Sources/OcrModel.swift +34 -1
- package/app/Sources/OmniSearchState.swift +99 -102
- package/app/Sources/OnboardingView.swift +457 -0
- package/app/Sources/PermissionChecker.swift +2 -12
- package/app/Sources/PiChatDock.swift +454 -0
- package/app/Sources/PiChatSession.swift +815 -0
- package/app/Sources/PiWorkspaceView.swift +364 -0
- package/app/Sources/PlacementSpec.swift +195 -0
- package/app/Sources/Preferences.swift +59 -0
- package/app/Sources/ProjectScanner.swift +58 -45
- package/app/Sources/ScreenMapState.swift +701 -55
- package/app/Sources/ScreenMapView.swift +843 -103
- package/app/Sources/ScreenMapWindowController.swift +22 -0
- package/app/Sources/SessionLayerStore.swift +285 -0
- package/app/Sources/SessionManager.swift +4 -1
- package/app/Sources/SettingsView.swift +186 -3
- package/app/Sources/Theme.swift +9 -8
- package/app/Sources/TmuxModel.swift +7 -0
- package/app/Sources/TmuxQuery.swift +27 -3
- package/app/Sources/VoiceChatView.swift +192 -0
- package/app/Sources/VoiceCommandWindow.swift +1594 -0
- package/app/Sources/VoiceIntentResolver.swift +671 -0
- package/app/Sources/VoxClient.swift +454 -0
- package/app/Sources/WindowTiler.swift +348 -87
- package/app/Sources/WorkspaceManager.swift +127 -18
- package/app/Tests/StageDragTests.swift +333 -0
- package/app/Tests/StageJoinTests.swift +313 -0
- package/app/Tests/StageManagerTests.swift +280 -0
- package/app/Tests/StageTileTests.swift +353 -0
- package/assets/AppIcon.icns +0 -0
- package/bin/client.ts +16 -0
- package/bin/{daemon-client.js → daemon-client.ts} +49 -30
- package/bin/handsoff-infer.ts +280 -0
- package/bin/handsoff-worker.ts +740 -0
- package/bin/lattices-app.ts +338 -0
- package/bin/lattices-dev +208 -0
- package/bin/{lattices.js → lattices.ts} +777 -140
- package/bin/project-twin.ts +645 -0
- package/docs/agent-execution-plan.md +562 -0
- package/docs/agent-layer-guide.md +207 -0
- package/docs/agents.md +142 -0
- package/docs/api.md +153 -34
- package/docs/app.md +29 -1
- package/docs/config.md +5 -1
- package/docs/handsoff-test-scenarios.md +84 -0
- package/docs/layers.md +20 -20
- package/docs/ocr.md +14 -5
- package/docs/overview.md +5 -1
- package/docs/presentation-execution-review.md +491 -0
- package/docs/prompts/hands-off-system.md +374 -0
- package/docs/prompts/hands-off-turn.md +30 -0
- package/docs/prompts/voice-advisor.md +31 -0
- package/docs/prompts/voice-fallback.md +23 -0
- package/docs/tiling-reference.md +167 -0
- package/docs/twins.md +138 -0
- package/docs/voice-command-protocol.md +278 -0
- package/docs/voice.md +219 -0
- package/package.json +29 -11
- package/bin/client.js +0 -4
- package/bin/lattices-app.js +0 -221
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
import CoreGraphics
|
|
3
|
+
import AppKit
|
|
4
|
+
|
|
5
|
+
// Private APIs (same as WindowTiler uses)
|
|
6
|
+
@_silgen_name("_AXUIElementGetWindow")
|
|
7
|
+
func _AXUIElementGetWindow(_ element: AXUIElement, _ windowID: UnsafeMutablePointer<CGWindowID>) -> AXError
|
|
8
|
+
|
|
9
|
+
private let skyLight: UnsafeMutableRawPointer? = dlopen(
|
|
10
|
+
"/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_NOW)
|
|
11
|
+
|
|
12
|
+
private typealias SLSMainConnectionIDFunc = @convention(c) () -> Int32
|
|
13
|
+
private typealias SLSDisableUpdateFunc = @convention(c) (Int32) -> Int32
|
|
14
|
+
private typealias SLSReenableUpdateFunc = @convention(c) (Int32) -> Int32
|
|
15
|
+
|
|
16
|
+
private let _SLSMainConnectionID: SLSMainConnectionIDFunc? = {
|
|
17
|
+
guard let sl = skyLight, let sym = dlsym(sl, "SLSMainConnectionID") else { return nil }
|
|
18
|
+
return unsafeBitCast(sym, to: SLSMainConnectionIDFunc.self)
|
|
19
|
+
}()
|
|
20
|
+
private let _SLSDisableUpdate: SLSDisableUpdateFunc? = {
|
|
21
|
+
guard let sl = skyLight, let sym = dlsym(sl, "SLSDisableUpdate") else { return nil }
|
|
22
|
+
return unsafeBitCast(sym, to: SLSDisableUpdateFunc.self)
|
|
23
|
+
}()
|
|
24
|
+
private let _SLSReenableUpdate: SLSReenableUpdateFunc? = {
|
|
25
|
+
guard let sl = skyLight, let sym = dlsym(sl, "SLSReenableUpdate") else { return nil }
|
|
26
|
+
return unsafeBitCast(sym, to: SLSReenableUpdateFunc.self)
|
|
27
|
+
}()
|
|
28
|
+
|
|
29
|
+
/// Tile windows within the current Stage Manager stage.
|
|
30
|
+
/// Run ONE test at a time: swift test --filter StageTileTests/testMosaic
|
|
31
|
+
final class StageTileTests: XCTestCase {
|
|
32
|
+
|
|
33
|
+
struct LiveWindow {
|
|
34
|
+
let wid: UInt32
|
|
35
|
+
let app: String
|
|
36
|
+
let pid: Int32
|
|
37
|
+
let title: String
|
|
38
|
+
let bounds: CGRect
|
|
39
|
+
let isOnScreen: Bool
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func getRealWindows() -> [LiveWindow] {
|
|
43
|
+
guard let list = CGWindowListCopyWindowInfo(
|
|
44
|
+
[.optionAll, .excludeDesktopElements],
|
|
45
|
+
kCGNullWindowID
|
|
46
|
+
) as? [[String: Any]] else { return [] }
|
|
47
|
+
|
|
48
|
+
let skip: Set<String> = [
|
|
49
|
+
"Window Server", "Dock", "Control Center", "SystemUIServer",
|
|
50
|
+
"Notification Center", "Spotlight", "WindowManager", "Lattices",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
return list.compactMap { info in
|
|
54
|
+
guard let wid = info[kCGWindowNumber as String] as? UInt32,
|
|
55
|
+
let owner = info[kCGWindowOwnerName as String] as? String,
|
|
56
|
+
let pid = info[kCGWindowOwnerPID as String] as? Int32,
|
|
57
|
+
let boundsDict = info[kCGWindowBounds as String] as? NSDictionary
|
|
58
|
+
else { return nil }
|
|
59
|
+
|
|
60
|
+
var rect = CGRect.zero
|
|
61
|
+
guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect) else { return nil }
|
|
62
|
+
let title = info[kCGWindowName as String] as? String ?? ""
|
|
63
|
+
let layer = info[kCGWindowLayer as String] as? Int ?? 0
|
|
64
|
+
let isOnScreen = info[kCGWindowIsOnscreen as String] as? Bool ?? false
|
|
65
|
+
|
|
66
|
+
guard layer == 0, rect.width >= 50, rect.height >= 50 else { return nil }
|
|
67
|
+
guard !skip.contains(owner) else { return nil }
|
|
68
|
+
|
|
69
|
+
return LiveWindow(wid: wid, app: owner, pid: pid, title: title,
|
|
70
|
+
bounds: rect, isOnScreen: isOnScreen)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func getActiveStage() -> [LiveWindow] {
|
|
75
|
+
getRealWindows().filter { $0.isOnScreen && $0.bounds.width > 250 }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func detectStripWidth() -> CGFloat {
|
|
79
|
+
let thumbnails = getRealWindows().filter {
|
|
80
|
+
$0.isOnScreen && $0.bounds.width < 250 && $0.bounds.height < 250
|
|
81
|
+
&& $0.bounds.origin.x >= 0 && $0.bounds.origin.x < 300
|
|
82
|
+
}
|
|
83
|
+
if thumbnails.isEmpty { return 0 }
|
|
84
|
+
let maxRight = thumbnails.map { $0.bounds.maxX }.max() ?? 0
|
|
85
|
+
return maxRight + 12
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func stageArea() -> CGRect {
|
|
89
|
+
guard let screen = NSScreen.main else { return .zero }
|
|
90
|
+
let visible = screen.visibleFrame
|
|
91
|
+
let screenHeight = screen.frame.height
|
|
92
|
+
let cgY = screenHeight - visible.origin.y - visible.height
|
|
93
|
+
let strip = detectStripWidth()
|
|
94
|
+
return CGRect(
|
|
95
|
+
x: visible.origin.x + strip,
|
|
96
|
+
y: cgY,
|
|
97
|
+
width: visible.width - strip,
|
|
98
|
+
height: visible.height
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func printStageState(label: String) {
|
|
103
|
+
let active = getActiveStage()
|
|
104
|
+
print("\n[\(label)] — \(active.count) windows")
|
|
105
|
+
for w in active {
|
|
106
|
+
print(" \(w.app) [\(w.wid)] \"\(w.title.prefix(40))\" — \(Int(w.bounds.origin.x)),\(Int(w.bounds.origin.y)) \(Int(w.bounds.width))x\(Int(w.bounds.height))")
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// MARK: - Batch tile (no app activation — avoids SM stage switches)
|
|
111
|
+
|
|
112
|
+
func batchTile(_ moves: [(wid: UInt32, pid: Int32, frame: CGRect)]) {
|
|
113
|
+
guard !moves.isEmpty else { return }
|
|
114
|
+
|
|
115
|
+
var byPid: [Int32: [(wid: UInt32, target: CGRect)]] = [:]
|
|
116
|
+
for move in moves {
|
|
117
|
+
byPid[move.pid, default: []].append((wid: move.wid, target: move.frame))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Freeze screen
|
|
121
|
+
let cid = _SLSMainConnectionID?()
|
|
122
|
+
if let cid { _ = _SLSDisableUpdate?(cid) }
|
|
123
|
+
|
|
124
|
+
for (pid, windowMoves) in byPid {
|
|
125
|
+
let appRef = AXUIElementCreateApplication(pid)
|
|
126
|
+
AXUIElementSetAttributeValue(appRef, "AXEnhancedUserInterface" as CFString, false as CFTypeRef)
|
|
127
|
+
|
|
128
|
+
var windowsRef: CFTypeRef?
|
|
129
|
+
guard AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef) == .success,
|
|
130
|
+
let axWindows = windowsRef as? [AXUIElement] else { continue }
|
|
131
|
+
|
|
132
|
+
var axByWid: [UInt32: AXUIElement] = [:]
|
|
133
|
+
for axWin in axWindows {
|
|
134
|
+
var windowId: CGWindowID = 0
|
|
135
|
+
if _AXUIElementGetWindow(axWin, &windowId) == .success {
|
|
136
|
+
axByWid[windowId] = axWin
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for wm in windowMoves {
|
|
141
|
+
guard let axWin = axByWid[wm.wid] else { continue }
|
|
142
|
+
|
|
143
|
+
var newSize = CGSize(width: wm.target.width, height: wm.target.height)
|
|
144
|
+
var newPos = CGPoint(x: wm.target.origin.x, y: wm.target.origin.y)
|
|
145
|
+
|
|
146
|
+
if let sv = AXValueCreate(.cgSize, &newSize) {
|
|
147
|
+
AXUIElementSetAttributeValue(axWin, kAXSizeAttribute as CFString, sv)
|
|
148
|
+
}
|
|
149
|
+
if let pv = AXValueCreate(.cgPoint, &newPos) {
|
|
150
|
+
AXUIElementSetAttributeValue(axWin, kAXPositionAttribute as CFString, pv)
|
|
151
|
+
}
|
|
152
|
+
if let sv = AXValueCreate(.cgSize, &newSize) {
|
|
153
|
+
AXUIElementSetAttributeValue(axWin, kAXSizeAttribute as CFString, sv)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
AXUIElementPerformAction(axWin, kAXRaiseAction as CFString)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
AXUIElementSetAttributeValue(appRef, "AXEnhancedUserInterface" as CFString, true as CFTypeRef)
|
|
160
|
+
// NO app.activate() — just move windows in place without triggering SM
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if let cid { _ = _SLSReenableUpdate?(cid) }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func gridShape(for count: Int) -> [Int] {
|
|
167
|
+
switch count {
|
|
168
|
+
case 1: return [1]
|
|
169
|
+
case 2: return [2]
|
|
170
|
+
case 3: return [1, 2]
|
|
171
|
+
case 4: return [2, 2]
|
|
172
|
+
case 5: return [3, 2]
|
|
173
|
+
case 6: return [3, 3]
|
|
174
|
+
default:
|
|
175
|
+
let cols = Int(ceil(sqrt(Double(count) * 1.5)))
|
|
176
|
+
var rows: [Int] = []
|
|
177
|
+
var remaining = count
|
|
178
|
+
while remaining > 0 {
|
|
179
|
+
rows.append(min(cols, remaining))
|
|
180
|
+
remaining -= cols
|
|
181
|
+
}
|
|
182
|
+
return rows
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// MARK: - Layouts (run one at a time)
|
|
187
|
+
|
|
188
|
+
/// swift test --filter StageTileTests/testMosaic
|
|
189
|
+
func testMosaic() throws {
|
|
190
|
+
let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
|
|
191
|
+
try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
|
|
192
|
+
|
|
193
|
+
let windows = getActiveStage()
|
|
194
|
+
guard windows.count >= 2 else {
|
|
195
|
+
print("Need >= 2 windows in active stage, got \(windows.count)")
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let area = stageArea()
|
|
200
|
+
let gap: CGFloat = 6
|
|
201
|
+
let shape = gridShape(for: windows.count)
|
|
202
|
+
|
|
203
|
+
print("MOSAIC: \(windows.count) windows → \(shape)")
|
|
204
|
+
printStageState(label: "BEFORE")
|
|
205
|
+
|
|
206
|
+
var moves: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
|
|
207
|
+
var idx = 0
|
|
208
|
+
let rows = shape.count
|
|
209
|
+
let rowH = (area.height - gap * CGFloat(rows + 1)) / CGFloat(rows)
|
|
210
|
+
|
|
211
|
+
for (row, cols) in shape.enumerated() {
|
|
212
|
+
let colW = (area.width - gap * CGFloat(cols + 1)) / CGFloat(cols)
|
|
213
|
+
for col in 0..<cols {
|
|
214
|
+
guard idx < windows.count else { break }
|
|
215
|
+
let win = windows[idx]
|
|
216
|
+
moves.append((wid: win.wid, pid: win.pid, frame: CGRect(
|
|
217
|
+
x: area.origin.x + gap + CGFloat(col) * (colW + gap),
|
|
218
|
+
y: area.origin.y + gap + CGFloat(row) * (rowH + gap),
|
|
219
|
+
width: colW,
|
|
220
|
+
height: rowH
|
|
221
|
+
)))
|
|
222
|
+
idx += 1
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
batchTile(moves)
|
|
227
|
+
Thread.sleep(forTimeInterval: 0.3)
|
|
228
|
+
printStageState(label: "AFTER")
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/// swift test --filter StageTileTests/testMainSidebar
|
|
232
|
+
func testMainSidebar() throws {
|
|
233
|
+
let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
|
|
234
|
+
try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
|
|
235
|
+
|
|
236
|
+
let windows = getActiveStage()
|
|
237
|
+
guard windows.count >= 2 else { return }
|
|
238
|
+
|
|
239
|
+
let area = stageArea()
|
|
240
|
+
let gap: CGFloat = 6
|
|
241
|
+
let mainW = (area.width - gap * 3) * 0.65
|
|
242
|
+
let sideW = (area.width - gap * 3) * 0.35
|
|
243
|
+
let sideCount = windows.count - 1
|
|
244
|
+
let sideH = (area.height - gap * CGFloat(sideCount + 1)) / CGFloat(sideCount)
|
|
245
|
+
|
|
246
|
+
print("MAIN + SIDEBAR: 1 main (65%) + \(sideCount) stacked")
|
|
247
|
+
printStageState(label: "BEFORE")
|
|
248
|
+
|
|
249
|
+
var moves: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
|
|
250
|
+
|
|
251
|
+
moves.append((wid: windows[0].wid, pid: windows[0].pid, frame: CGRect(
|
|
252
|
+
x: area.origin.x + gap,
|
|
253
|
+
y: area.origin.y + gap,
|
|
254
|
+
width: mainW,
|
|
255
|
+
height: area.height - gap * 2
|
|
256
|
+
)))
|
|
257
|
+
|
|
258
|
+
for i in 0..<sideCount {
|
|
259
|
+
let win = windows[i + 1]
|
|
260
|
+
moves.append((wid: win.wid, pid: win.pid, frame: CGRect(
|
|
261
|
+
x: area.origin.x + gap * 2 + mainW,
|
|
262
|
+
y: area.origin.y + gap + CGFloat(i) * (sideH + gap),
|
|
263
|
+
width: sideW,
|
|
264
|
+
height: sideH
|
|
265
|
+
)))
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
batchTile(moves)
|
|
269
|
+
Thread.sleep(forTimeInterval: 0.3)
|
|
270
|
+
printStageState(label: "AFTER")
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/// swift test --filter StageTileTests/testColumns
|
|
274
|
+
func testColumns() throws {
|
|
275
|
+
let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
|
|
276
|
+
try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
|
|
277
|
+
|
|
278
|
+
let windows = getActiveStage()
|
|
279
|
+
guard windows.count >= 2 else { return }
|
|
280
|
+
|
|
281
|
+
let area = stageArea()
|
|
282
|
+
let gap: CGFloat = 6
|
|
283
|
+
let colW = (area.width - gap * CGFloat(windows.count + 1)) / CGFloat(windows.count)
|
|
284
|
+
|
|
285
|
+
print("COLUMNS: \(windows.count) equal")
|
|
286
|
+
printStageState(label: "BEFORE")
|
|
287
|
+
|
|
288
|
+
let moves = windows.enumerated().map { (i, win) in
|
|
289
|
+
(wid: win.wid, pid: win.pid, frame: CGRect(
|
|
290
|
+
x: area.origin.x + gap + CGFloat(i) * (colW + gap),
|
|
291
|
+
y: area.origin.y + gap,
|
|
292
|
+
width: colW,
|
|
293
|
+
height: area.height - gap * 2
|
|
294
|
+
))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
batchTile(moves)
|
|
298
|
+
Thread.sleep(forTimeInterval: 0.3)
|
|
299
|
+
printStageState(label: "AFTER")
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/// swift test --filter StageTileTests/testTallWide
|
|
303
|
+
func testTallWide() throws {
|
|
304
|
+
let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
|
|
305
|
+
try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
|
|
306
|
+
|
|
307
|
+
let windows = getActiveStage()
|
|
308
|
+
guard windows.count >= 2 else { return }
|
|
309
|
+
|
|
310
|
+
let area = stageArea()
|
|
311
|
+
let gap: CGFloat = 6
|
|
312
|
+
|
|
313
|
+
// Terminal-like apps go tall on the left
|
|
314
|
+
let terminalApps = Set(["iTerm2", "Terminal", "Alacritty", "kitty", "Warp"])
|
|
315
|
+
let sorted = windows.sorted { a, b in
|
|
316
|
+
let aT = terminalApps.contains(a.app)
|
|
317
|
+
let bT = terminalApps.contains(b.app)
|
|
318
|
+
if aT != bT { return aT }
|
|
319
|
+
return a.wid < b.wid
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
let tallW = (area.width - gap * 3) * 0.45
|
|
323
|
+
let wideW = (area.width - gap * 3) * 0.55
|
|
324
|
+
let wideCount = sorted.count - 1
|
|
325
|
+
let wideH = (area.height - gap * CGFloat(wideCount + 1)) / CGFloat(wideCount)
|
|
326
|
+
|
|
327
|
+
print("TALL + WIDE: terminal left (45%), \(wideCount) stacked right (55%)")
|
|
328
|
+
printStageState(label: "BEFORE")
|
|
329
|
+
|
|
330
|
+
var moves: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
|
|
331
|
+
|
|
332
|
+
moves.append((wid: sorted[0].wid, pid: sorted[0].pid, frame: CGRect(
|
|
333
|
+
x: area.origin.x + gap,
|
|
334
|
+
y: area.origin.y + gap,
|
|
335
|
+
width: tallW,
|
|
336
|
+
height: area.height - gap * 2
|
|
337
|
+
)))
|
|
338
|
+
|
|
339
|
+
for i in 0..<wideCount {
|
|
340
|
+
let win = sorted[i + 1]
|
|
341
|
+
moves.append((wid: win.wid, pid: win.pid, frame: CGRect(
|
|
342
|
+
x: area.origin.x + gap * 2 + tallW,
|
|
343
|
+
y: area.origin.y + gap + CGFloat(i) * (wideH + gap),
|
|
344
|
+
width: wideW,
|
|
345
|
+
height: wideH
|
|
346
|
+
)))
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
batchTile(moves)
|
|
350
|
+
Thread.sleep(forTimeInterval: 0.3)
|
|
351
|
+
printStageState(label: "AFTER")
|
|
352
|
+
}
|
|
353
|
+
}
|
|
Binary file
|
package/bin/client.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Public API — re-exports from daemon-client for a cleaner import path.
|
|
2
|
+
// Usage: import { daemonCall, isDaemonRunning } from '@lattices/cli'
|
|
3
|
+
|
|
4
|
+
export { daemonCall, isDaemonRunning } from "./daemon-client.ts";
|
|
5
|
+
export {
|
|
6
|
+
ProjectTwin,
|
|
7
|
+
createProjectTwin,
|
|
8
|
+
readOpenScoutRelayContext,
|
|
9
|
+
type OpenScoutRelayContext,
|
|
10
|
+
type ProjectTwinEvent,
|
|
11
|
+
type ProjectTwinInvokeRequest,
|
|
12
|
+
type ProjectTwinOptions,
|
|
13
|
+
type ProjectTwinResult,
|
|
14
|
+
type ProjectTwinState,
|
|
15
|
+
type ProjectTwinThinkingLevel,
|
|
16
|
+
} from "./project-twin.ts";
|
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
// Lightweight WebSocket client for lattices daemon (ws://127.0.0.1:9399)
|
|
2
2
|
// Uses Node `net` module with manual HTTP upgrade + minimal WS framing.
|
|
3
|
-
// Zero npm dependencies.
|
|
3
|
+
// Zero npm dependencies.
|
|
4
4
|
|
|
5
|
-
import { createConnection } from "node:net";
|
|
5
|
+
import { createConnection, type Socket } from "node:net";
|
|
6
6
|
import { randomBytes } from "node:crypto";
|
|
7
7
|
|
|
8
8
|
const DAEMON_HOST = "127.0.0.1";
|
|
9
9
|
const DAEMON_PORT = 9399;
|
|
10
10
|
|
|
11
|
+
interface ParsedFrame {
|
|
12
|
+
payload: string;
|
|
13
|
+
rest: Buffer<ArrayBuffer>;
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
/**
|
|
12
17
|
* Send a JSON-RPC-style request to the daemon and return the response.
|
|
13
|
-
* @param {string} method
|
|
14
|
-
* @param {object} [params]
|
|
15
|
-
* @param {number} [timeoutMs=3000]
|
|
16
|
-
* @returns {Promise<object>} The result field from the response
|
|
17
18
|
*/
|
|
18
|
-
export async function daemonCall(
|
|
19
|
+
export async function daemonCall(
|
|
20
|
+
method: string,
|
|
21
|
+
params?: Record<string, unknown> | null,
|
|
22
|
+
timeoutMs = 3000
|
|
23
|
+
): Promise<unknown> {
|
|
19
24
|
const id = randomBytes(4).toString("hex");
|
|
20
25
|
const request = JSON.stringify({ id, method, params: params ?? null });
|
|
21
26
|
|
|
@@ -62,8 +67,8 @@ export async function daemonCall(method, params, timeoutMs = 3000) {
|
|
|
62
67
|
socket.write(upgrade);
|
|
63
68
|
});
|
|
64
69
|
|
|
65
|
-
socket.on("data", (chunk) => {
|
|
66
|
-
buffer = Buffer.concat([buffer, chunk])
|
|
70
|
+
socket.on("data", (chunk: Buffer) => {
|
|
71
|
+
buffer = Buffer.concat([buffer, chunk]) as Buffer<ArrayBuffer>;
|
|
67
72
|
|
|
68
73
|
if (!upgraded) {
|
|
69
74
|
const headerEnd = buffer.indexOf("\r\n\r\n");
|
|
@@ -82,23 +87,38 @@ export async function daemonCall(method, params, timeoutMs = 3000) {
|
|
|
82
87
|
sendFrame(socket, request);
|
|
83
88
|
}
|
|
84
89
|
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
// The daemon can push broadcast events before the RPC response.
|
|
91
|
+
// Keep consuming frames until we see our matching response id.
|
|
92
|
+
while (true) {
|
|
93
|
+
const result = parseFrame(buffer);
|
|
94
|
+
if (!result) break;
|
|
88
95
|
buffer = result.rest;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(result.payload);
|
|
99
|
+
if (parsed.event) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (parsed.id !== id) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (!settled) {
|
|
106
|
+
settled = true;
|
|
107
|
+
cleanup();
|
|
94
108
|
if (parsed.error) {
|
|
95
109
|
reject(new Error(parsed.error));
|
|
96
110
|
} else {
|
|
97
111
|
resolve(parsed.result);
|
|
98
112
|
}
|
|
99
|
-
}
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
} catch {
|
|
116
|
+
if (!settled) {
|
|
117
|
+
settled = true;
|
|
118
|
+
cleanup();
|
|
100
119
|
reject(new Error("Invalid JSON response from daemon"));
|
|
101
120
|
}
|
|
121
|
+
return;
|
|
102
122
|
}
|
|
103
123
|
}
|
|
104
124
|
});
|
|
@@ -107,9 +127,8 @@ export async function daemonCall(method, params, timeoutMs = 3000) {
|
|
|
107
127
|
|
|
108
128
|
/**
|
|
109
129
|
* Check if the daemon is reachable.
|
|
110
|
-
* @returns {Promise<boolean>}
|
|
111
130
|
*/
|
|
112
|
-
export async function isDaemonRunning() {
|
|
131
|
+
export async function isDaemonRunning(): Promise<boolean> {
|
|
113
132
|
try {
|
|
114
133
|
await daemonCall("daemon.status", null, 1000);
|
|
115
134
|
return true;
|
|
@@ -120,12 +139,12 @@ export async function isDaemonRunning() {
|
|
|
120
139
|
|
|
121
140
|
// MARK: - WebSocket framing helpers
|
|
122
141
|
|
|
123
|
-
function sendFrame(socket, text) {
|
|
142
|
+
function sendFrame(socket: Socket, text: string): void {
|
|
124
143
|
const payload = Buffer.from(text, "utf8");
|
|
125
144
|
const mask = randomBytes(4);
|
|
126
145
|
const len = payload.length;
|
|
127
146
|
|
|
128
|
-
let header;
|
|
147
|
+
let header: Buffer;
|
|
129
148
|
if (len < 126) {
|
|
130
149
|
header = Buffer.alloc(2);
|
|
131
150
|
header[0] = 0x81; // FIN + text opcode
|
|
@@ -145,17 +164,17 @@ function sendFrame(socket, text) {
|
|
|
145
164
|
// Mask payload
|
|
146
165
|
const masked = Buffer.alloc(payload.length);
|
|
147
166
|
for (let i = 0; i < payload.length; i++) {
|
|
148
|
-
masked[i] = payload[i] ^ mask[i % 4]
|
|
167
|
+
masked[i] = payload[i]! ^ mask[i % 4]!;
|
|
149
168
|
}
|
|
150
169
|
|
|
151
170
|
socket.write(Buffer.concat([header, mask, masked]));
|
|
152
171
|
}
|
|
153
172
|
|
|
154
|
-
function parseFrame(buf) {
|
|
173
|
+
function parseFrame(buf: Buffer): ParsedFrame | null {
|
|
155
174
|
if (buf.length < 2) return null;
|
|
156
175
|
|
|
157
|
-
const
|
|
158
|
-
let payloadLen = buf[1] & 0x7f;
|
|
176
|
+
const isMasked = (buf[1]! & 0x80) !== 0;
|
|
177
|
+
let payloadLen = buf[1]! & 0x7f;
|
|
159
178
|
let offset = 2;
|
|
160
179
|
|
|
161
180
|
if (payloadLen === 126) {
|
|
@@ -168,20 +187,20 @@ function parseFrame(buf) {
|
|
|
168
187
|
offset = 10;
|
|
169
188
|
}
|
|
170
189
|
|
|
171
|
-
if (
|
|
190
|
+
if (isMasked) offset += 4;
|
|
172
191
|
if (buf.length < offset + payloadLen) return null;
|
|
173
192
|
|
|
174
193
|
let payload = buf.subarray(offset, offset + payloadLen);
|
|
175
|
-
if (
|
|
194
|
+
if (isMasked) {
|
|
176
195
|
const maskKey = buf.subarray(offset - 4, offset);
|
|
177
196
|
payload = Buffer.alloc(payloadLen);
|
|
178
197
|
for (let i = 0; i < payloadLen; i++) {
|
|
179
|
-
payload[i] = buf[offset + i] ^ maskKey[i % 4]
|
|
198
|
+
payload[i] = buf[offset + i]! ^ maskKey[i % 4]!;
|
|
180
199
|
}
|
|
181
200
|
}
|
|
182
201
|
|
|
183
202
|
return {
|
|
184
203
|
payload: payload.toString("utf8"),
|
|
185
|
-
rest: buf.subarray(offset + payloadLen)
|
|
204
|
+
rest: buf.subarray(offset + payloadLen) as Buffer<ArrayBuffer>,
|
|
186
205
|
};
|
|
187
206
|
}
|