@lattices/cli 0.4.2 → 0.4.6

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 (146) hide show
  1. package/README.md +3 -0
  2. package/app/Info.plist +2 -2
  3. package/app/Lattices.app/Contents/Info.plist +2 -2
  4. package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/app/Package.swift +6 -0
  6. package/app/Sources/AppShell/App.swift +20 -0
  7. package/app/Sources/{AppDelegate.swift → AppShell/AppDelegate.swift} +94 -34
  8. package/app/Sources/{AppShellView.swift → AppShell/AppShellView.swift} +12 -1
  9. package/app/Sources/AppShell/AppUpdater.swift +92 -0
  10. package/app/Sources/AppShell/CliActionLauncher.swift +50 -0
  11. package/app/Sources/{HomeDashboardView.swift → AppShell/HomeDashboardView.swift} +18 -10
  12. package/app/Sources/AppShell/LatticesRuntime.swift +61 -0
  13. package/app/Sources/{MainView.swift → AppShell/MainView.swift} +351 -191
  14. package/app/Sources/{OnboardingView.swift → AppShell/OnboardingView.swift} +30 -16
  15. package/app/Sources/{Preferences.swift → AppShell/Preferences.swift} +78 -0
  16. package/app/Sources/{SettingsView.swift → AppShell/SettingsView.swift} +869 -152
  17. package/app/Sources/{HotkeyStore.swift → Core/Actions/HotkeyStore.swift} +9 -5
  18. package/app/Sources/{IntentEngine.swift → Core/Actions/IntentEngine.swift} +51 -27
  19. package/app/Sources/Core/Actions/IntentSchema.swift +94 -0
  20. package/app/Sources/{Intents → Core/Actions/Intents}/LatticeIntent.swift +0 -25
  21. package/app/Sources/{PaletteCommand.swift → Core/Actions/PaletteCommand.swift} +26 -6
  22. package/app/Sources/{VoiceIntentResolver.swift → Core/Actions/VoiceIntentResolver.swift} +46 -4
  23. package/app/Sources/Core/Companion/CompanionActivityLog.swift +70 -0
  24. package/app/Sources/Core/Companion/CompanionKeyboardController.swift +141 -0
  25. package/app/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +438 -0
  26. package/app/Sources/Core/Companion/LatticesCompanionCockpit.swift +555 -0
  27. package/app/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +594 -0
  28. package/app/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +204 -0
  29. package/app/Sources/Core/Companion/LatticesDeckHost.swift +1463 -0
  30. package/app/Sources/{LatticesApi.swift → Core/Daemon/LatticesApi.swift} +125 -4
  31. package/app/Sources/{AppTypeClassifier.swift → Core/Desktop/AppTypeClassifier.swift} +36 -0
  32. package/app/Sources/{DesktopModel.swift → Core/Desktop/DesktopModel.swift} +6 -8
  33. package/app/Sources/Core/Desktop/MouseFinder.swift +527 -0
  34. package/app/Sources/Core/Desktop/SessionWindowLocator.swift +139 -0
  35. package/app/Sources/Core/Desktop/WindowDragSnapController.swift +628 -0
  36. package/app/Sources/Core/Desktop/WindowPreviewCard.swift +100 -0
  37. package/app/Sources/Core/Desktop/WindowPreviewStore.swift +113 -0
  38. package/app/Sources/Core/Desktop/WindowSelectionStore.swift +76 -0
  39. package/app/Sources/{WindowTiler.swift → Core/Desktop/WindowTiler.swift} +351 -172
  40. package/app/Sources/Core/Input/MouseGestureConfig.swift +364 -0
  41. package/app/Sources/Core/Input/MouseGestureController.swift +1203 -0
  42. package/app/Sources/Core/Input/MouseInputDeviceStore.swift +98 -0
  43. package/app/Sources/Core/Input/MouseInputEventViewer.swift +272 -0
  44. package/app/Sources/Core/Input/MouseShortcutStore.swift +107 -0
  45. package/app/Sources/{CommandModeState.swift → Core/Overlays/CommandMode/CommandModeState.swift} +127 -24
  46. package/app/Sources/{CommandModeView.swift → Core/Overlays/CommandMode/CommandModeView.swift} +492 -79
  47. package/app/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +67 -0
  48. package/app/Sources/{CheatSheetHUD.swift → Core/Overlays/HUD/CheatSheetHUD.swift} +1 -0
  49. package/app/Sources/{HUDRightBar.swift → Core/Overlays/HUD/HUDRightBar.swift} +23 -201
  50. package/app/Sources/{LauncherHUD.swift → Core/Overlays/HUD/LauncherHUD.swift} +12 -26
  51. package/app/Sources/{OmniSearchView.swift → Core/Overlays/OmniSearch/OmniSearchView.swift} +136 -2
  52. package/app/Sources/{OmniSearchWindow.swift → Core/Overlays/OmniSearch/OmniSearchWindow.swift} +21 -32
  53. package/app/Sources/Core/Overlays/OverlayPanelShell.swift +241 -0
  54. package/app/Sources/{ScreenMapState.swift → Core/Overlays/ScreenMap/ScreenMapState.swift} +116 -32
  55. package/app/Sources/{ScreenMapView.swift → Core/Overlays/ScreenMap/ScreenMapView.swift} +510 -524
  56. package/app/Sources/{ScreenMapWindowController.swift → Core/Overlays/ScreenMap/ScreenMapWindowController.swift} +12 -4
  57. package/app/Sources/{VoiceCommandWindow.swift → Core/Overlays/Voice/VoiceCommandWindow.swift} +46 -53
  58. package/app/Sources/Core/Pi/PiAuthNextStepCard.swift +148 -0
  59. package/app/Sources/Core/Pi/PiAuthPromptCard.swift +90 -0
  60. package/app/Sources/{PiChatDock.swift → Core/Pi/PiChatDock.swift} +137 -74
  61. package/app/Sources/{PiChatSession.swift → Core/Pi/PiChatSession.swift} +608 -108
  62. package/app/Sources/Core/Pi/PiInstallCallout.swift +86 -0
  63. package/app/Sources/Core/Pi/PiProviderSetupCallout.swift +99 -0
  64. package/app/Sources/{PiWorkspaceView.swift → Core/Pi/PiWorkspaceView.swift} +174 -77
  65. package/app/Sources/{PermissionChecker.swift → Core/System/PermissionChecker.swift} +76 -2
  66. package/app/Sources/Core/System/SystemTelemetryMonitor.swift +273 -0
  67. package/app/Sources/{HandsOffSession.swift → Core/Voice/HandsOffSession.swift} +15 -4
  68. package/app/Sources/{WorkspaceManager.swift → Core/Workspace/WorkspaceManager.swift} +288 -0
  69. package/bin/assistant-intelligence.ts +874 -0
  70. package/bin/handsoff-infer.ts +16 -209
  71. package/bin/handsoff-worker.ts +45 -258
  72. package/bin/lattices-app.ts +62 -0
  73. package/bin/lattices-dev +4 -0
  74. package/bin/lattices.ts +125 -14
  75. package/docs/agents.md +14 -0
  76. package/docs/api.md +55 -0
  77. package/docs/app.md +3 -0
  78. package/docs/companion-deck.md +180 -0
  79. package/docs/component-extraction-roadmap.md +392 -0
  80. package/docs/config.md +25 -0
  81. package/docs/tiling-reference.md +55 -0
  82. package/docs/voice-error-model.md +73 -0
  83. package/package.json +4 -1
  84. package/app/Sources/App.swift +0 -10
  85. package/app/Sources/CommandPaletteWindow.swift +0 -134
  86. package/app/Sources/MouseFinder.swift +0 -222
  87. /package/app/Sources/{KeyRecorderView.swift → AppShell/KeyRecorderView.swift} +0 -0
  88. /package/app/Sources/{MainWindow.swift → AppShell/MainWindow.swift} +0 -0
  89. /package/app/Sources/{SettingsWindow.swift → AppShell/SettingsWindow.swift} +0 -0
  90. /package/app/Sources/{HotkeyManager.swift → Core/Actions/HotkeyManager.swift} +0 -0
  91. /package/app/Sources/{Intents → Core/Actions/Intents}/CreateLayerIntent.swift +0 -0
  92. /package/app/Sources/{Intents → Core/Actions/Intents}/DistributeIntent.swift +0 -0
  93. /package/app/Sources/{Intents → Core/Actions/Intents}/FocusIntent.swift +0 -0
  94. /package/app/Sources/{Intents → Core/Actions/Intents}/HelpIntent.swift +0 -0
  95. /package/app/Sources/{Intents → Core/Actions/Intents}/KillIntent.swift +0 -0
  96. /package/app/Sources/{Intents → Core/Actions/Intents}/LaunchIntent.swift +0 -0
  97. /package/app/Sources/{Intents → Core/Actions/Intents}/ListSessionsIntent.swift +0 -0
  98. /package/app/Sources/{Intents → Core/Actions/Intents}/ListWindowsIntent.swift +0 -0
  99. /package/app/Sources/{Intents → Core/Actions/Intents}/ScanIntent.swift +0 -0
  100. /package/app/Sources/{Intents → Core/Actions/Intents}/SearchIntent.swift +0 -0
  101. /package/app/Sources/{Intents → Core/Actions/Intents}/SwitchLayerIntent.swift +0 -0
  102. /package/app/Sources/{Intents → Core/Actions/Intents}/TileIntent.swift +0 -0
  103. /package/app/Sources/{DaemonProtocol.swift → Core/Daemon/DaemonProtocol.swift} +0 -0
  104. /package/app/Sources/{DaemonServer.swift → Core/Daemon/DaemonServer.swift} +0 -0
  105. /package/app/Sources/{AccessibilityTextExtractor.swift → Core/Desktop/AccessibilityTextExtractor.swift} +0 -0
  106. /package/app/Sources/{DesktopModelTypes.swift → Core/Desktop/DesktopModelTypes.swift} +0 -0
  107. /package/app/Sources/{InventoryManager.swift → Core/Desktop/InventoryManager.swift} +0 -0
  108. /package/app/Sources/{InventoryPath.swift → Core/Desktop/InventoryPath.swift} +0 -0
  109. /package/app/Sources/{OcrModel.swift → Core/Desktop/OcrModel.swift} +0 -0
  110. /package/app/Sources/{OcrStore.swift → Core/Desktop/OcrStore.swift} +0 -0
  111. /package/app/Sources/{PlacementSpec.swift → Core/Desktop/PlacementSpec.swift} +0 -0
  112. /package/app/Sources/{TilePickerView.swift → Core/Desktop/TilePickerView.swift} +0 -0
  113. /package/app/Sources/{AppWindowShell.swift → Core/Overlays/AppWindowShell.swift} +0 -0
  114. /package/app/Sources/{CommandModeWindow.swift → Core/Overlays/CommandMode/CommandModeWindow.swift} +0 -0
  115. /package/app/Sources/{CommandPaletteView.swift → Core/Overlays/CommandPalette/CommandPaletteView.swift} +0 -0
  116. /package/app/Sources/{HUDBottomBar.swift → Core/Overlays/HUD/HUDBottomBar.swift} +0 -0
  117. /package/app/Sources/{HUDController.swift → Core/Overlays/HUD/HUDController.swift} +0 -0
  118. /package/app/Sources/{HUDLeftBar.swift → Core/Overlays/HUD/HUDLeftBar.swift} +0 -0
  119. /package/app/Sources/{HUDMinimap.swift → Core/Overlays/HUD/HUDMinimap.swift} +0 -0
  120. /package/app/Sources/{HUDState.swift → Core/Overlays/HUD/HUDState.swift} +0 -0
  121. /package/app/Sources/{HUDTopBar.swift → Core/Overlays/HUD/HUDTopBar.swift} +0 -0
  122. /package/app/Sources/{LayerBezel.swift → Core/Overlays/HUD/LayerBezel.swift} +0 -0
  123. /package/app/Sources/{OmniSearchState.swift → Core/Overlays/OmniSearch/OmniSearchState.swift} +0 -0
  124. /package/app/Sources/{DiagnosticLog.swift → Core/System/DiagnosticLog.swift} +0 -0
  125. /package/app/Sources/{EventBus.swift → Core/System/EventBus.swift} +0 -0
  126. /package/app/Sources/{ProcessModel.swift → Core/System/ProcessModel.swift} +0 -0
  127. /package/app/Sources/{ProcessQuery.swift → Core/System/ProcessQuery.swift} +0 -0
  128. /package/app/Sources/{AdvisorLearningStore.swift → Core/Voice/AdvisorLearningStore.swift} +0 -0
  129. /package/app/Sources/{AgentSession.swift → Core/Voice/AgentSession.swift} +0 -0
  130. /package/app/Sources/{AudioProvider.swift → Core/Voice/AudioProvider.swift} +0 -0
  131. /package/app/Sources/{VoiceChatView.swift → Core/Voice/VoiceChatView.swift} +0 -0
  132. /package/app/Sources/{VoxClient.swift → Core/Voice/VoxClient.swift} +0 -0
  133. /package/app/Sources/{Project.swift → Core/Workspace/Project.swift} +0 -0
  134. /package/app/Sources/{ProjectScanner.swift → Core/Workspace/ProjectScanner.swift} +0 -0
  135. /package/app/Sources/{SessionLayerStore.swift → Core/Workspace/SessionLayerStore.swift} +0 -0
  136. /package/app/Sources/{SessionManager.swift → Core/Workspace/SessionManager.swift} +0 -0
  137. /package/app/Sources/{Terminal.swift → Core/Workspace/Terminal/Terminal.swift} +0 -0
  138. /package/app/Sources/{TerminalQuery.swift → Core/Workspace/Terminal/TerminalQuery.swift} +0 -0
  139. /package/app/Sources/{TerminalSynthesizer.swift → Core/Workspace/Terminal/TerminalSynthesizer.swift} +0 -0
  140. /package/app/Sources/{TmuxModel.swift → Core/Workspace/Tmux/TmuxModel.swift} +0 -0
  141. /package/app/Sources/{TmuxQuery.swift → Core/Workspace/Tmux/TmuxQuery.swift} +0 -0
  142. /package/app/Sources/{ActionRow.swift → UI/ActionRow.swift} +0 -0
  143. /package/app/Sources/{OrphanRow.swift → UI/OrphanRow.swift} +0 -0
  144. /package/app/Sources/{ProjectRow.swift → UI/ProjectRow.swift} +0 -0
  145. /package/app/Sources/{TabGroupRow.swift → UI/TabGroupRow.swift} +0 -0
  146. /package/app/Sources/{Theme.swift → UI/Theme.swift} +0 -0
@@ -0,0 +1,273 @@
1
+ import DeckKit
2
+ import Foundation
3
+ import IOKit
4
+ import IOKit.ps
5
+
6
+ final class SystemTelemetryMonitor {
7
+ static let shared = SystemTelemetryMonitor()
8
+
9
+ private struct CPUTicks {
10
+ var user: UInt64
11
+ var system: UInt64
12
+ var idle: UInt64
13
+ var nice: UInt64
14
+
15
+ var total: UInt64 {
16
+ user + system + idle + nice
17
+ }
18
+ }
19
+
20
+ private struct BatterySample {
21
+ var percent: Double?
22
+ var isCharging: Bool?
23
+ var powerSource: String?
24
+ }
25
+
26
+ private struct CoreSample {
27
+ var sampledAt: Date
28
+ var cpuLoadPercent: Double?
29
+ var memoryUsedPercent: Double?
30
+ var gpuLoadPercent: Double?
31
+ var thermalPressurePercent: Double?
32
+ var thermalState: DeckThermalState?
33
+ var temperatureCelsius: Double?
34
+ var batteryPercent: Double?
35
+ var isCharging: Bool?
36
+ var powerSource: String?
37
+ }
38
+
39
+ private let lock = NSLock()
40
+ private var previousCPUTicks: [CPUTicks]?
41
+ private var cachedSample: CoreSample?
42
+ private var cachedAt: Date = .distantPast
43
+ private let minSampleInterval: TimeInterval = 0.8
44
+
45
+ private init() {}
46
+
47
+ func snapshot(windowCount: Int, sessionCount: Int) -> DeckSystemTelemetry {
48
+ let core = currentCoreSample()
49
+ return DeckSystemTelemetry(
50
+ sampledAt: core.sampledAt,
51
+ cpuLoadPercent: core.cpuLoadPercent,
52
+ memoryUsedPercent: core.memoryUsedPercent,
53
+ gpuLoadPercent: core.gpuLoadPercent,
54
+ thermalPressurePercent: core.thermalPressurePercent,
55
+ thermalState: core.thermalState,
56
+ temperatureCelsius: core.temperatureCelsius,
57
+ batteryPercent: core.batteryPercent,
58
+ isCharging: core.isCharging,
59
+ powerSource: core.powerSource,
60
+ windowCount: windowCount,
61
+ sessionCount: sessionCount
62
+ )
63
+ }
64
+ }
65
+
66
+ private extension SystemTelemetryMonitor {
67
+ private func currentCoreSample() -> CoreSample {
68
+ lock.lock()
69
+ defer { lock.unlock() }
70
+
71
+ let now = Date()
72
+ if let cachedSample, now.timeIntervalSince(cachedAt) < minSampleInterval {
73
+ return cachedSample
74
+ }
75
+
76
+ let thermal = readThermalState()
77
+ let battery = readBattery()
78
+ let sample = CoreSample(
79
+ sampledAt: now,
80
+ cpuLoadPercent: readCPULoadPercent(),
81
+ memoryUsedPercent: readMemoryUsedPercent(),
82
+ gpuLoadPercent: readGPULoadPercent(),
83
+ thermalPressurePercent: thermal.pressure,
84
+ thermalState: thermal.state,
85
+ temperatureCelsius: nil,
86
+ batteryPercent: battery.percent,
87
+ isCharging: battery.isCharging,
88
+ powerSource: battery.powerSource
89
+ )
90
+ cachedSample = sample
91
+ cachedAt = now
92
+ return sample
93
+ }
94
+
95
+ func readCPULoadPercent() -> Double? {
96
+ var cpuInfo: processor_info_array_t?
97
+ var processorCount: natural_t = 0
98
+ var infoCount: mach_msg_type_number_t = 0
99
+
100
+ let result = host_processor_info(
101
+ mach_host_self(),
102
+ PROCESSOR_CPU_LOAD_INFO,
103
+ &processorCount,
104
+ &cpuInfo,
105
+ &infoCount
106
+ )
107
+ guard result == KERN_SUCCESS, let cpuInfo else {
108
+ return nil
109
+ }
110
+
111
+ defer {
112
+ let size = vm_size_t(Int(infoCount) * MemoryLayout<integer_t>.stride)
113
+ vm_deallocate(mach_task_self_, vm_address_t(UInt(bitPattern: cpuInfo)), size)
114
+ }
115
+
116
+ let statesPerCPU = Int(CPU_STATE_MAX)
117
+ let info = UnsafeBufferPointer(start: cpuInfo, count: Int(infoCount))
118
+ let ticks: [CPUTicks] = (0..<Int(processorCount)).map { index in
119
+ let offset = index * statesPerCPU
120
+ return CPUTicks(
121
+ user: cpuTick(info[offset + Int(CPU_STATE_USER)]),
122
+ system: cpuTick(info[offset + Int(CPU_STATE_SYSTEM)]),
123
+ idle: cpuTick(info[offset + Int(CPU_STATE_IDLE)]),
124
+ nice: cpuTick(info[offset + Int(CPU_STATE_NICE)])
125
+ )
126
+ }
127
+
128
+ guard !ticks.isEmpty else { return nil }
129
+
130
+ defer { previousCPUTicks = ticks }
131
+
132
+ guard let previousCPUTicks, previousCPUTicks.count == ticks.count else {
133
+ let total = ticks.reduce(UInt64(0)) { $0 + $1.total }
134
+ let idle = ticks.reduce(UInt64(0)) { $0 + $1.idle }
135
+ guard total > 0 else { return nil }
136
+ return clampPercent(100.0 * Double(total - idle) / Double(total))
137
+ }
138
+
139
+ var busyDelta: UInt64 = 0
140
+ var totalDelta: UInt64 = 0
141
+ for (current, previous) in zip(ticks, previousCPUTicks) {
142
+ let total = current.total.saturatingSubtract(previous.total)
143
+ let idle = current.idle.saturatingSubtract(previous.idle)
144
+ totalDelta += total
145
+ busyDelta += total.saturatingSubtract(idle)
146
+ }
147
+
148
+ guard totalDelta > 0 else { return nil }
149
+ return clampPercent(100.0 * Double(busyDelta) / Double(totalDelta))
150
+ }
151
+
152
+ func readMemoryUsedPercent() -> Double? {
153
+ var stats = vm_statistics64()
154
+ var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64_data_t>.stride / MemoryLayout<integer_t>.stride)
155
+
156
+ let result = withUnsafeMutablePointer(to: &stats) {
157
+ $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
158
+ host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count)
159
+ }
160
+ }
161
+ guard result == KERN_SUCCESS else { return nil }
162
+
163
+ var pageSize = vm_size_t()
164
+ host_page_size(mach_host_self(), &pageSize)
165
+
166
+ let freePages = UInt64(stats.free_count) + UInt64(stats.speculative_count)
167
+ let freeBytes = freePages * UInt64(pageSize)
168
+ let totalBytes = ProcessInfo.processInfo.physicalMemory
169
+ guard totalBytes > 0 else { return nil }
170
+
171
+ let usedBytes = totalBytes > freeBytes ? totalBytes - freeBytes : 0
172
+ return clampPercent(100.0 * Double(usedBytes) / Double(totalBytes))
173
+ }
174
+
175
+ func readGPULoadPercent() -> Double? {
176
+ guard let matching = IOServiceMatching("IOAccelerator") else {
177
+ return nil
178
+ }
179
+
180
+ var iterator: io_iterator_t = 0
181
+ guard IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator) == KERN_SUCCESS else {
182
+ return nil
183
+ }
184
+ defer { IOObjectRelease(iterator) }
185
+
186
+ var values: [Double] = []
187
+ while true {
188
+ let service = IOIteratorNext(iterator)
189
+ if service == 0 { break }
190
+ defer { IOObjectRelease(service) }
191
+
192
+ guard let unmanaged = IORegistryEntryCreateCFProperty(
193
+ service,
194
+ "PerformanceStatistics" as CFString,
195
+ kCFAllocatorDefault,
196
+ 0
197
+ ) else {
198
+ continue
199
+ }
200
+
201
+ guard let stats = unmanaged.takeRetainedValue() as? [String: Any] else {
202
+ continue
203
+ }
204
+
205
+ for key in ["Device Utilization %", "Renderer Utilization %"] {
206
+ if let number = stats[key] as? NSNumber {
207
+ values.append(number.doubleValue)
208
+ }
209
+ }
210
+ }
211
+
212
+ guard !values.isEmpty else { return nil }
213
+ return clampPercent(values.reduce(0, +) / Double(values.count))
214
+ }
215
+
216
+ func readThermalState() -> (state: DeckThermalState, pressure: Double) {
217
+ switch ProcessInfo.processInfo.thermalState {
218
+ case .nominal:
219
+ return (.nominal, 10)
220
+ case .fair:
221
+ return (.fair, 35)
222
+ case .serious:
223
+ return (.serious, 70)
224
+ case .critical:
225
+ return (.critical, 100)
226
+ @unknown default:
227
+ return (.nominal, 10)
228
+ }
229
+ }
230
+
231
+ private func readBattery() -> BatterySample {
232
+ let powerInfo = IOPSCopyPowerSourcesInfo().takeRetainedValue()
233
+ let sourceList = IOPSCopyPowerSourcesList(powerInfo).takeRetainedValue() as [CFTypeRef]
234
+
235
+ for source in sourceList {
236
+ guard let description = IOPSGetPowerSourceDescription(powerInfo, source)?
237
+ .takeUnretainedValue() as? [String: Any] else {
238
+ continue
239
+ }
240
+
241
+ let current = description[kIOPSCurrentCapacityKey] as? Int
242
+ let max = description[kIOPSMaxCapacityKey] as? Int
243
+ let percent = current.flatMap { current in
244
+ max.flatMap { maxValue -> Double? in
245
+ guard maxValue > 0 else { return nil }
246
+ return clampPercent(100.0 * Double(current) / Double(maxValue))
247
+ }
248
+ }
249
+
250
+ return BatterySample(
251
+ percent: percent,
252
+ isCharging: description[kIOPSIsChargingKey] as? Bool,
253
+ powerSource: description[kIOPSPowerSourceStateKey] as? String
254
+ )
255
+ }
256
+
257
+ return BatterySample(percent: nil, isCharging: nil, powerSource: nil)
258
+ }
259
+
260
+ func clampPercent(_ value: Double) -> Double {
261
+ max(0, min(100, value))
262
+ }
263
+
264
+ func cpuTick(_ value: integer_t) -> UInt64 {
265
+ UInt64(UInt32(bitPattern: value))
266
+ }
267
+ }
268
+
269
+ private extension UInt64 {
270
+ func saturatingSubtract(_ other: UInt64) -> UInt64 {
271
+ self > other ? self - other : 0
272
+ }
273
+ }
@@ -43,7 +43,14 @@ final class HandsOffSession: ObservableObject {
43
43
  case thinking
44
44
  }
45
45
 
46
- @Published var state: State = .idle
46
+ @Published var state: State = .idle {
47
+ didSet {
48
+ if state != oldValue {
49
+ stateChangedAt = Date()
50
+ }
51
+ }
52
+ }
53
+ @Published private(set) var stateChangedAt: Date = Date()
47
54
  @Published var lastTranscript: String?
48
55
  @Published var lastResponse: String?
49
56
  @Published var audibleFeedbackEnabled: Bool = false
@@ -58,6 +65,7 @@ final class HandsOffSession: ObservableObject {
58
65
  let frame: WindowFrame
59
66
  }
60
67
  private(set) var frameHistory: [FrameSnapshot] = []
68
+ private(set) var frameHistoryUpdatedAt: Date?
61
69
 
62
70
  /// Snapshot current frames for all windows that are about to be moved.
63
71
  /// Stores frames in CG/AX coordinates (top-left origin) for direct use with batchRestoreWindows.
@@ -77,10 +85,12 @@ final class HandsOffSession: ObservableObject {
77
85
  break
78
86
  }
79
87
  }
88
+ frameHistoryUpdatedAt = frameHistory.isEmpty ? nil : Date()
80
89
  }
81
90
 
82
91
  func clearFrameHistory() {
83
92
  frameHistory.removeAll()
93
+ frameHistoryUpdatedAt = nil
84
94
  }
85
95
 
86
96
  /// Running chat log — visible in the voice chat panel. Persists across turns.
@@ -327,7 +337,9 @@ final class HandsOffSession: ObservableObject {
327
337
  }
328
338
  }
329
339
 
330
- // Single dispatch — all @Published mutations in one block
340
+ // Single dispatch — all @Published mutations in one block.
341
+ // The pending callback also mutates @Published state, so it must
342
+ // run on main with the rest of the turn completion.
331
343
  DispatchQueue.main.async { [weak self] in
332
344
  guard let self else { return }
333
345
  if let spoken { self.lastResponse = spoken }
@@ -342,9 +354,8 @@ final class HandsOffSession: ObservableObject {
342
354
  self.executeActions(actions)
343
355
  }
344
356
  self.state = .idle
357
+ cb?(json)
345
358
  }
346
-
347
- cb?(json)
348
359
  }
349
360
  }
350
361
 
@@ -63,6 +63,273 @@ struct LayoutConfig: Codable {
63
63
  struct GridFile: Codable {
64
64
  let presets: [String: GridPreset]?
65
65
  let layouts: [String: LayoutConfig]?
66
+ let snapZones: SnapZonesConfig?
67
+ }
68
+
69
+ enum SnapModifierKey: String, Codable, Equatable {
70
+ case command
71
+ case option
72
+ case control
73
+ case shift
74
+
75
+ var eventFlags: NSEvent.ModifierFlags {
76
+ switch self {
77
+ case .command:
78
+ return .command
79
+ case .option:
80
+ return .option
81
+ case .control:
82
+ return .control
83
+ case .shift:
84
+ return .shift
85
+ }
86
+ }
87
+
88
+ var cgEventFlags: CGEventFlags {
89
+ switch self {
90
+ case .command:
91
+ return .maskCommand
92
+ case .option:
93
+ return .maskAlternate
94
+ case .control:
95
+ return .maskControl
96
+ case .shift:
97
+ return .maskShift
98
+ }
99
+ }
100
+
101
+ var label: String {
102
+ rawValue.capitalized
103
+ }
104
+ }
105
+
106
+ enum SnapZoneTriggerSpec: Codable, Equatable {
107
+ case named(String)
108
+ case fractions(FractionalPlacement)
109
+
110
+ init(from decoder: Decoder) throws {
111
+ let container = try decoder.singleValueContainer()
112
+ if let named = try? container.decode(String.self) {
113
+ self = .named(named)
114
+ return
115
+ }
116
+
117
+ let preset = try container.decode(GridPreset.self)
118
+ guard let placement = FractionalPlacement(x: preset.x, y: preset.y, w: preset.w, h: preset.h) else {
119
+ throw DecodingError.dataCorruptedError(
120
+ in: container,
121
+ debugDescription: "snap zone trigger fractions must stay within 0...1"
122
+ )
123
+ }
124
+ self = .fractions(placement)
125
+ }
126
+
127
+ func encode(to encoder: Encoder) throws {
128
+ var container = encoder.singleValueContainer()
129
+ switch self {
130
+ case .named(let name):
131
+ try container.encode(name)
132
+ case .fractions(let placement):
133
+ try container.encode(GridPreset(x: placement.x, y: placement.y, w: placement.w, h: placement.h))
134
+ }
135
+ }
136
+ }
137
+
138
+ enum SnapZonePlacementSpec: Codable, Equatable {
139
+ case named(String)
140
+ case fractions(FractionalPlacement)
141
+
142
+ init(from decoder: Decoder) throws {
143
+ let container = try decoder.singleValueContainer()
144
+ if let named = try? container.decode(String.self) {
145
+ self = .named(named)
146
+ return
147
+ }
148
+
149
+ let preset = try container.decode(GridPreset.self)
150
+ guard let placement = FractionalPlacement(x: preset.x, y: preset.y, w: preset.w, h: preset.h) else {
151
+ throw DecodingError.dataCorruptedError(
152
+ in: container,
153
+ debugDescription: "snap zone placement fractions must stay within 0...1"
154
+ )
155
+ }
156
+ self = .fractions(placement)
157
+ }
158
+
159
+ func encode(to encoder: Encoder) throws {
160
+ var container = encoder.singleValueContainer()
161
+ switch self {
162
+ case .named(let name):
163
+ try container.encode(name)
164
+ case .fractions(let placement):
165
+ try container.encode(GridPreset(x: placement.x, y: placement.y, w: placement.w, h: placement.h))
166
+ }
167
+ }
168
+ }
169
+
170
+ struct SnapZoneDefinition: Codable, Equatable, Identifiable {
171
+ let rawID: String?
172
+ let label: String?
173
+ let placement: SnapZonePlacementSpec
174
+ let trigger: SnapZoneTriggerSpec
175
+ let priority: Int?
176
+
177
+ enum CodingKeys: String, CodingKey {
178
+ case rawID = "id"
179
+ case label
180
+ case placement
181
+ case trigger
182
+ case priority
183
+ }
184
+
185
+ var id: String {
186
+ let trimmed = rawID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
187
+ return trimmed.isEmpty ? fallbackID : trimmed
188
+ }
189
+
190
+ private var fallbackID: String {
191
+ switch placement {
192
+ case .named(let name):
193
+ return name
194
+ case .fractions(let fractions):
195
+ return "fractions-\(fractions.x)-\(fractions.y)-\(fractions.w)-\(fractions.h)"
196
+ }
197
+ }
198
+ }
199
+
200
+ struct SnapZonesConfig: Codable, Equatable {
201
+ let enabled: Bool?
202
+ let modifier: SnapModifierKey?
203
+ let zoneOpacity: Double?
204
+ let highlightOpacity: Double?
205
+ let previewOpacity: Double?
206
+ let cornerRadius: CGFloat?
207
+ let rules: [SnapZoneDefinition]?
208
+
209
+ enum CodingKeys: String, CodingKey {
210
+ case enabled
211
+ case modifier
212
+ case zoneOpacity
213
+ case highlightOpacity
214
+ case previewOpacity
215
+ case cornerRadius
216
+ case rules
217
+ case zones
218
+ }
219
+
220
+ init(
221
+ enabled: Bool?,
222
+ modifier: SnapModifierKey?,
223
+ zoneOpacity: Double?,
224
+ highlightOpacity: Double?,
225
+ previewOpacity: Double?,
226
+ cornerRadius: CGFloat?,
227
+ rules: [SnapZoneDefinition]?
228
+ ) {
229
+ self.enabled = enabled
230
+ self.modifier = modifier
231
+ self.zoneOpacity = zoneOpacity
232
+ self.highlightOpacity = highlightOpacity
233
+ self.previewOpacity = previewOpacity
234
+ self.cornerRadius = cornerRadius
235
+ self.rules = rules
236
+ }
237
+
238
+ init(from decoder: Decoder) throws {
239
+ let container = try decoder.container(keyedBy: CodingKeys.self)
240
+ enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled)
241
+ modifier = try container.decodeIfPresent(SnapModifierKey.self, forKey: .modifier)
242
+ zoneOpacity = try container.decodeIfPresent(Double.self, forKey: .zoneOpacity)
243
+ highlightOpacity = try container.decodeIfPresent(Double.self, forKey: .highlightOpacity)
244
+ previewOpacity = try container.decodeIfPresent(Double.self, forKey: .previewOpacity)
245
+ cornerRadius = try container.decodeIfPresent(CGFloat.self, forKey: .cornerRadius)
246
+ let decodedRules = try container.decodeIfPresent([SnapZoneDefinition].self, forKey: .rules)
247
+ let decodedZones = try container.decodeIfPresent([SnapZoneDefinition].self, forKey: .zones)
248
+ rules = decodedRules ?? decodedZones
249
+ }
250
+
251
+ func encode(to encoder: Encoder) throws {
252
+ var container = encoder.container(keyedBy: CodingKeys.self)
253
+ try container.encodeIfPresent(enabled, forKey: .enabled)
254
+ try container.encodeIfPresent(modifier, forKey: .modifier)
255
+ try container.encodeIfPresent(zoneOpacity, forKey: .zoneOpacity)
256
+ try container.encodeIfPresent(highlightOpacity, forKey: .highlightOpacity)
257
+ try container.encodeIfPresent(previewOpacity, forKey: .previewOpacity)
258
+ try container.encodeIfPresent(cornerRadius, forKey: .cornerRadius)
259
+ try container.encodeIfPresent(rules, forKey: .rules)
260
+ }
261
+
262
+ static let defaults = SnapZonesConfig(
263
+ enabled: true,
264
+ modifier: .command,
265
+ zoneOpacity: 0.10,
266
+ highlightOpacity: 0.22,
267
+ previewOpacity: 0.18,
268
+ cornerRadius: 18,
269
+ rules: [
270
+ SnapZoneDefinition(
271
+ rawID: "top-left",
272
+ label: "Top Left",
273
+ placement: .named("top-left"),
274
+ trigger: .fractions(FractionalPlacement(x: 0.00, y: 0.00, w: 0.24, h: 0.18)!),
275
+ priority: 40
276
+ ),
277
+ SnapZoneDefinition(
278
+ rawID: "maximize",
279
+ label: "Maximize",
280
+ placement: .named("maximize"),
281
+ trigger: .fractions(FractionalPlacement(x: 0.24, y: 0.00, w: 0.52, h: 0.12)!),
282
+ priority: 20
283
+ ),
284
+ SnapZoneDefinition(
285
+ rawID: "top-right",
286
+ label: "Top Right",
287
+ placement: .named("top-right"),
288
+ trigger: .fractions(FractionalPlacement(x: 0.76, y: 0.00, w: 0.24, h: 0.18)!),
289
+ priority: 40
290
+ ),
291
+ SnapZoneDefinition(
292
+ rawID: "left",
293
+ label: "Left",
294
+ placement: .named("left"),
295
+ trigger: .fractions(FractionalPlacement(x: 0.00, y: 0.18, w: 0.12, h: 0.64)!),
296
+ priority: 10
297
+ ),
298
+ SnapZoneDefinition(
299
+ rawID: "right",
300
+ label: "Right",
301
+ placement: .named("right"),
302
+ trigger: .fractions(FractionalPlacement(x: 0.88, y: 0.18, w: 0.12, h: 0.64)!),
303
+ priority: 10
304
+ ),
305
+ SnapZoneDefinition(
306
+ rawID: "bottom-left",
307
+ label: "Bottom Left",
308
+ placement: .named("bottom-left"),
309
+ trigger: .fractions(FractionalPlacement(x: 0.00, y: 0.82, w: 0.24, h: 0.18)!),
310
+ priority: 40
311
+ ),
312
+ SnapZoneDefinition(
313
+ rawID: "bottom-right",
314
+ label: "Bottom Right",
315
+ placement: .named("bottom-right"),
316
+ trigger: .fractions(FractionalPlacement(x: 0.76, y: 0.82, w: 0.24, h: 0.18)!),
317
+ priority: 40
318
+ ),
319
+ ]
320
+ )
321
+
322
+ func merged(over defaults: SnapZonesConfig = .defaults) -> SnapZonesConfig {
323
+ SnapZonesConfig(
324
+ enabled: enabled ?? defaults.enabled,
325
+ modifier: modifier ?? defaults.modifier,
326
+ zoneOpacity: zoneOpacity ?? defaults.zoneOpacity,
327
+ highlightOpacity: highlightOpacity ?? defaults.highlightOpacity,
328
+ previewOpacity: previewOpacity ?? defaults.previewOpacity,
329
+ cornerRadius: cornerRadius ?? defaults.cornerRadius,
330
+ rules: rules ?? defaults.rules
331
+ )
332
+ }
66
333
  }
67
334
 
68
335
  // MARK: - Manager
@@ -75,9 +342,11 @@ class WorkspaceManager: ObservableObject {
75
342
  @Published var isSwitching: Bool = false
76
343
  @Published var gridPresets: [String: GridPreset] = [:]
77
344
  @Published var gridLayouts: [String: LayoutConfig] = [:]
345
+ @Published var snapZonesConfig: SnapZonesConfig = .defaults
78
346
 
79
347
  private let configPath: String
80
348
  private let gridConfigPath: String
349
+ private let snapZonesConfigPath: String
81
350
  private var tmuxPath: String { TmuxQuery.resolvedPath ?? "/opt/homebrew/bin/tmux" }
82
351
  private let activeLayerKey = "lattices.activeLayerIndex"
83
352
 
@@ -85,6 +354,7 @@ class WorkspaceManager: ObservableObject {
85
354
  let home = FileManager.default.homeDirectoryForCurrentUser.path
86
355
  self.configPath = (home as NSString).appendingPathComponent(".lattices/workspace.json")
87
356
  self.gridConfigPath = (home as NSString).appendingPathComponent(".lattices/grid.json")
357
+ self.snapZonesConfigPath = (home as NSString).appendingPathComponent(".lattices/snap-zones.json")
88
358
  self.activeLayerIndex = UserDefaults.standard.integer(forKey: activeLayerKey)
89
359
  loadConfig()
90
360
  loadGridConfig()
@@ -137,6 +407,7 @@ class WorkspaceManager: ObservableObject {
137
407
  func loadGridConfig() {
138
408
  var presets: [String: GridPreset] = [:]
139
409
  var layouts: [String: LayoutConfig] = [:]
410
+ var snapZones = SnapZonesConfig.defaults
140
411
 
141
412
  // Load global ~/.lattices/grid.json
142
413
  if FileManager.default.fileExists(atPath: gridConfigPath),
@@ -145,11 +416,24 @@ class WorkspaceManager: ObservableObject {
145
416
  let gridFile = try JSONDecoder().decode(GridFile.self, from: data)
146
417
  if let p = gridFile.presets { presets.merge(p) { _, new in new } }
147
418
  if let l = gridFile.layouts { layouts.merge(l) { _, new in new } }
419
+ if let snap = gridFile.snapZones {
420
+ snapZones = snap.merged(over: snapZones)
421
+ }
148
422
  } catch {
149
423
  DiagnosticLog.shared.error("WorkspaceManager: failed to decode grid.json — \(error.localizedDescription)")
150
424
  }
151
425
  }
152
426
 
427
+ if FileManager.default.fileExists(atPath: snapZonesConfigPath),
428
+ let data = FileManager.default.contents(atPath: snapZonesConfigPath) {
429
+ do {
430
+ let config = try JSONDecoder().decode(SnapZonesConfig.self, from: data)
431
+ snapZones = config.merged(over: snapZones)
432
+ } catch {
433
+ DiagnosticLog.shared.error("WorkspaceManager: failed to decode snap-zones.json — \(error.localizedDescription)")
434
+ }
435
+ }
436
+
153
437
  // Merge per-project .lattices.json "grid" section on top
154
438
  let projectGridPath = ".lattices.json"
155
439
  if FileManager.default.fileExists(atPath: projectGridPath),
@@ -161,6 +445,9 @@ class WorkspaceManager: ObservableObject {
161
445
  let gridFile = try JSONDecoder().decode(GridFile.self, from: gridData)
162
446
  if let p = gridFile.presets { presets.merge(p) { _, new in new } }
163
447
  if let l = gridFile.layouts { layouts.merge(l) { _, new in new } }
448
+ if let snap = gridFile.snapZones {
449
+ snapZones = snap.merged(over: snapZones)
450
+ }
164
451
  }
165
452
  } catch {
166
453
  DiagnosticLog.shared.error("WorkspaceManager: failed to decode .lattices.json grid — \(error.localizedDescription)")
@@ -169,6 +456,7 @@ class WorkspaceManager: ObservableObject {
169
456
 
170
457
  self.gridPresets = presets
171
458
  self.gridLayouts = layouts
459
+ self.snapZonesConfig = snapZones
172
460
  }
173
461
 
174
462
  /// Resolve a tile string to fractions: check user presets first, then built-in TilePosition