@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
@@ -0,0 +1,774 @@
1
+ import AppKit
2
+ import SwiftUI
3
+
4
+ // MARK: - HUDRightBar (inspector + conversation)
5
+
6
+ struct HUDRightBar: View {
7
+ @ObservedObject var state: HUDState
8
+ @ObservedObject private var handsOff = HandsOffSession.shared
9
+ @ObservedObject private var desktop = DesktopModel.shared
10
+ @ObservedObject private var previewModel = WindowPreviewStore.shared
11
+ var onDismiss: () -> Void
12
+
13
+ var body: some View {
14
+ VStack(spacing: 0) {
15
+ // Top half: inspector
16
+ inspectorPane
17
+ .frame(maxHeight: .infinity)
18
+
19
+ Rectangle().fill(Palette.border).frame(height: 0.5)
20
+
21
+ // Bottom half: conversation
22
+ conversationPane
23
+ .frame(maxHeight: .infinity)
24
+ }
25
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
26
+ .background(Palette.bg)
27
+ }
28
+
29
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
30
+ // MARK: - Inspector (top half)
31
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
32
+
33
+ @ViewBuilder
34
+ private var inspectorPane: some View {
35
+ if let item = state.pinnedItem {
36
+ detailView(for: item)
37
+ } else {
38
+ inspectorEmpty
39
+ }
40
+ }
41
+
42
+ private var inspectorEmpty: some View {
43
+ VStack(spacing: 6) {
44
+ Spacer()
45
+ Image(systemName: "sidebar.right")
46
+ .font(.system(size: 22))
47
+ .foregroundColor(Palette.textMuted.opacity(0.3))
48
+ Text("Select an item")
49
+ .font(Typo.mono(11))
50
+ .foregroundColor(Palette.textMuted)
51
+ Spacer()
52
+ }
53
+ }
54
+
55
+ @ViewBuilder
56
+ private func detailView(for item: HUDItem) -> some View {
57
+ switch item {
58
+ case .project(let p): projectDetail(p)
59
+ case .window(let w): windowDetail(w)
60
+ }
61
+ }
62
+
63
+ private func projectDetail(_ project: Project) -> some View {
64
+ VStack(alignment: .leading, spacing: 0) {
65
+ HStack(spacing: 8) {
66
+ Circle()
67
+ .fill(project.isRunning ? Palette.running : Palette.textMuted.opacity(0.3))
68
+ .frame(width: 8, height: 8)
69
+ Text(project.name)
70
+ .font(Typo.monoBold(13))
71
+ .foregroundColor(Palette.text)
72
+ Spacer()
73
+ if project.isRunning {
74
+ Text("running")
75
+ .font(Typo.mono(9))
76
+ .foregroundColor(Palette.running)
77
+ .padding(.horizontal, 5)
78
+ .padding(.vertical, 2)
79
+ .background(RoundedRectangle(cornerRadius: 3).fill(Palette.running.opacity(0.10)))
80
+ }
81
+ }
82
+ .padding(.horizontal, 16)
83
+ .padding(.vertical, 10)
84
+
85
+ if let previewWindow = projectPreviewWindow(project) {
86
+ previewSection(for: previewWindow, title: "Window Preview")
87
+ }
88
+
89
+ Rectangle().fill(Palette.border).frame(height: 0.5)
90
+
91
+ ScrollView {
92
+ VStack(alignment: .leading, spacing: 8) {
93
+ metaRow("Path", value: project.path)
94
+ metaRow("Session", value: project.sessionName)
95
+ if !project.paneSummary.isEmpty { metaRow("Summary", value: project.paneSummary) }
96
+ if let dev = project.devCommand { metaRow("Dev", value: dev) }
97
+ }
98
+ .padding(.horizontal, 16)
99
+ .padding(.vertical, 10)
100
+ }
101
+
102
+ HStack(spacing: 8) {
103
+ actionButton(project.isRunning ? "Focus" : "Launch",
104
+ icon: project.isRunning ? "eye" : "play.fill") {
105
+ SessionManager.launch(project: project)
106
+ HandsOffSession.shared.playCachedCue(project.isRunning ? "Focused." : "Done.")
107
+ onDismiss()
108
+ }
109
+ }
110
+ .padding(.horizontal, 16)
111
+ .padding(.bottom, 10)
112
+ }
113
+ }
114
+
115
+ private func windowDetail(_ window: WindowEntry) -> some View {
116
+ VStack(alignment: .leading, spacing: 0) {
117
+ VStack(alignment: .leading, spacing: 3) {
118
+ Text(window.title)
119
+ .font(Typo.monoBold(12))
120
+ .foregroundColor(Palette.text)
121
+ .lineLimit(1)
122
+ Text(window.app)
123
+ .font(Typo.mono(10))
124
+ .foregroundColor(Palette.textMuted)
125
+ }
126
+ .padding(.horizontal, 16)
127
+ .padding(.vertical, 10)
128
+
129
+ previewSection(for: window, title: "Live Preview")
130
+
131
+ Rectangle().fill(Palette.border).frame(height: 0.5)
132
+
133
+ ScrollView {
134
+ VStack(alignment: .leading, spacing: 8) {
135
+ metaRow("WID", value: "\(window.wid)")
136
+ metaRow("Frame", value: "\(Int(window.frame.x)),\(Int(window.frame.y)) \(Int(window.frame.w))×\(Int(window.frame.h))")
137
+ if let lastUsed = desktop.lastInteractionDate(for: window.wid) {
138
+ metaRow("Last used", value: relativeTime(lastUsed))
139
+ }
140
+ if let session = window.latticesSession { metaRow("Session", value: session) }
141
+ }
142
+ .padding(.horizontal, 16)
143
+ .padding(.vertical, 10)
144
+ }
145
+
146
+ HStack(spacing: 8) {
147
+ actionButton("Focus", icon: "eye") {
148
+ _ = WindowTiler.focusWindow(wid: window.wid, pid: window.pid)
149
+ HandsOffSession.shared.playCachedCue("Focused.")
150
+ onDismiss()
151
+ }
152
+ }
153
+ .padding(.horizontal, 16)
154
+ .padding(.bottom, 10)
155
+ }
156
+ }
157
+
158
+ @ViewBuilder
159
+ private func previewSection(for window: WindowEntry, title: String) -> some View {
160
+ VStack(alignment: .leading, spacing: 10) {
161
+ HStack(spacing: 8) {
162
+ Text(title)
163
+ .font(Typo.monoBold(10))
164
+ .foregroundColor(Palette.textMuted)
165
+ Spacer()
166
+ Text("no focus")
167
+ .font(Typo.mono(9))
168
+ .foregroundColor(Palette.textDim)
169
+ }
170
+
171
+ ZStack {
172
+ RoundedRectangle(cornerRadius: 10)
173
+ .fill(Palette.surface.opacity(0.8))
174
+ .overlay(
175
+ RoundedRectangle(cornerRadius: 10)
176
+ .strokeBorder(Palette.border, lineWidth: 0.5)
177
+ )
178
+
179
+ if let image = previewModel.image(for: window.wid) {
180
+ Image(nsImage: image)
181
+ .resizable()
182
+ .aspectRatio(contentMode: .fit)
183
+ .clipShape(RoundedRectangle(cornerRadius: 8))
184
+ .padding(8)
185
+ } else if previewModel.isLoading(window.wid) {
186
+ previewPlaceholder(
187
+ icon: "photo",
188
+ title: "Capturing preview",
189
+ subtitle: window.app
190
+ )
191
+ } else {
192
+ previewPlaceholder(
193
+ icon: "eye.slash",
194
+ title: "Preview unavailable",
195
+ subtitle: window.app
196
+ )
197
+ }
198
+ }
199
+ .frame(maxWidth: .infinity)
200
+ .frame(height: 190)
201
+ .clipped()
202
+ }
203
+ .padding(.horizontal, 16)
204
+ .padding(.vertical, 12)
205
+ .task(id: window.wid) {
206
+ previewModel.load(window: window)
207
+ }
208
+ }
209
+
210
+ private func previewPlaceholder(icon: String, title: String, subtitle: String) -> some View {
211
+ VStack(spacing: 8) {
212
+ Image(systemName: icon)
213
+ .font(.system(size: 18, weight: .medium))
214
+ .foregroundColor(Palette.textMuted.opacity(0.7))
215
+ Text(title)
216
+ .font(Typo.monoBold(10))
217
+ .foregroundColor(Palette.textMuted)
218
+ Text(subtitle)
219
+ .font(Typo.mono(9))
220
+ .foregroundColor(Palette.textDim)
221
+ .lineLimit(1)
222
+ }
223
+ .padding(16)
224
+ }
225
+
226
+ private func projectPreviewWindow(_ project: Project) -> WindowEntry? {
227
+ guard project.isRunning else { return nil }
228
+ return desktop.windowForSession(project.sessionName)
229
+ }
230
+
231
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
232
+ // MARK: - Conversation (bottom half)
233
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
234
+
235
+ private var conversationPane: some View {
236
+ VStack(spacing: 0) {
237
+ // Header with voice state
238
+ conversationHeader
239
+
240
+ Rectangle().fill(Palette.border).frame(height: 0.5)
241
+
242
+ // Messages
243
+ if handsOff.conversationHistory.isEmpty {
244
+ conversationEmpty
245
+ } else {
246
+ conversationMessages
247
+ }
248
+ }
249
+ }
250
+
251
+ private var conversationHeader: some View {
252
+ HStack(spacing: 8) {
253
+ // Voice indicator
254
+ voiceIndicator
255
+
256
+ Text("Voice")
257
+ .font(Typo.monoBold(11))
258
+ .foregroundColor(Palette.text)
259
+
260
+ Spacer()
261
+
262
+ // State badge
263
+ if state.voiceActive {
264
+ stateBadge
265
+ }
266
+
267
+ // V toggle hint
268
+ Text("V")
269
+ .font(Typo.geistMonoBold(9))
270
+ .foregroundColor(state.voiceActive ? Palette.text : Palette.textMuted)
271
+ .frame(width: 18, height: 18)
272
+ .background(
273
+ RoundedRectangle(cornerRadius: 3)
274
+ .fill(state.voiceActive ? Palette.running.opacity(0.2) : Palette.surface)
275
+ .overlay(
276
+ RoundedRectangle(cornerRadius: 3)
277
+ .strokeBorder(state.voiceActive ? Palette.running.opacity(0.4) : Palette.border, lineWidth: 0.5)
278
+ )
279
+ )
280
+ }
281
+ .padding(.horizontal, 14)
282
+ .padding(.vertical, 8)
283
+ }
284
+
285
+ private var voiceIndicator: some View {
286
+ Circle()
287
+ .fill(voiceColor)
288
+ .frame(width: 8, height: 8)
289
+ .overlay(
290
+ // Pulse animation when listening
291
+ Circle()
292
+ .stroke(voiceColor.opacity(0.4), lineWidth: 1.5)
293
+ .scaleEffect(handsOff.state == .listening ? 1.8 : 1.0)
294
+ .opacity(handsOff.state == .listening ? 0 : 1)
295
+ .animation(
296
+ handsOff.state == .listening
297
+ ? .easeOut(duration: 1.0).repeatForever(autoreverses: false)
298
+ : .default,
299
+ value: handsOff.state
300
+ )
301
+ )
302
+ }
303
+
304
+ private var voiceColor: Color {
305
+ switch handsOff.state {
306
+ case .idle: return state.voiceActive ? Palette.running : Palette.textMuted.opacity(0.3)
307
+ case .connecting: return Palette.detach
308
+ case .listening: return Palette.running
309
+ case .thinking: return Palette.detach
310
+ }
311
+ }
312
+
313
+ private var stateBadge: some View {
314
+ let label: String = {
315
+ switch handsOff.state {
316
+ case .idle: return "ready"
317
+ case .connecting: return "connecting"
318
+ case .listening: return "listening"
319
+ case .thinking: return "thinking"
320
+ }
321
+ }()
322
+
323
+ return Text(label)
324
+ .font(Typo.mono(9))
325
+ .foregroundColor(voiceColor)
326
+ .padding(.horizontal, 6)
327
+ .padding(.vertical, 2)
328
+ .background(
329
+ RoundedRectangle(cornerRadius: 3)
330
+ .fill(voiceColor.opacity(0.10))
331
+ )
332
+ }
333
+
334
+ private var conversationEmpty: some View {
335
+ VStack(spacing: 6) {
336
+ Spacer()
337
+ Image(systemName: "waveform")
338
+ .font(.system(size: 20))
339
+ .foregroundColor(Palette.textMuted.opacity(0.3))
340
+ Text(state.voiceActive ? "Listening..." : "Press V to talk")
341
+ .font(Typo.mono(11))
342
+ .foregroundColor(Palette.textMuted)
343
+ Spacer()
344
+ }
345
+ }
346
+
347
+ private var conversationMessages: some View {
348
+ ScrollViewReader { proxy in
349
+ ScrollView {
350
+ LazyVStack(alignment: .leading, spacing: 6) {
351
+ ForEach(Array(handsOff.conversationHistory.enumerated()), id: \.offset) { index, msg in
352
+ messageBubble(msg, index: index)
353
+ }
354
+ }
355
+ .padding(.horizontal, 12)
356
+ .padding(.vertical, 8)
357
+ }
358
+ .onChange(of: handsOff.conversationHistory.count) { _ in
359
+ // Auto-scroll to bottom
360
+ if let last = handsOff.conversationHistory.indices.last {
361
+ proxy.scrollTo(last, anchor: .bottom)
362
+ }
363
+ }
364
+ }
365
+ }
366
+
367
+ private func messageBubble(_ msg: [String: String], index: Int) -> some View {
368
+ let role = msg["role"] ?? "unknown"
369
+ let content = msg["content"] ?? ""
370
+ let isUser = role == "user"
371
+
372
+ return HStack {
373
+ if isUser { Spacer(minLength: 40) }
374
+
375
+ VStack(alignment: isUser ? .trailing : .leading, spacing: 2) {
376
+ Text(isUser ? "you" : "lattices")
377
+ .font(Typo.monoBold(8))
378
+ .foregroundColor(Palette.textMuted)
379
+ .textCase(.uppercase)
380
+
381
+ Text(content)
382
+ .font(Typo.mono(11))
383
+ .foregroundColor(Palette.text)
384
+ .padding(.horizontal, 10)
385
+ .padding(.vertical, 6)
386
+ .background(
387
+ RoundedRectangle(cornerRadius: 8)
388
+ .fill(isUser ? Palette.surfaceHov : Palette.surface)
389
+ )
390
+ .textSelection(.enabled)
391
+ }
392
+ .id(index)
393
+
394
+ if !isUser { Spacer(minLength: 40) }
395
+ }
396
+ }
397
+
398
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
399
+ // MARK: - Shared helpers
400
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
401
+
402
+ private func metaRow(_ label: String, value: String) -> some View {
403
+ VStack(alignment: .leading, spacing: 2) {
404
+ Text(label)
405
+ .font(Typo.monoBold(9))
406
+ .foregroundColor(Palette.textMuted)
407
+ .textCase(.uppercase)
408
+ Text(value)
409
+ .font(Typo.mono(11))
410
+ .foregroundColor(Palette.text)
411
+ .textSelection(.enabled)
412
+ }
413
+ }
414
+
415
+ private func actionButton(_ title: String, icon: String, action: @escaping () -> Void) -> some View {
416
+ Button(action: action) {
417
+ HStack(spacing: 5) {
418
+ Image(systemName: icon)
419
+ .font(.system(size: 10))
420
+ Text(title)
421
+ .font(Typo.monoBold(10))
422
+ }
423
+ .foregroundColor(Palette.text)
424
+ .padding(.horizontal, 12)
425
+ .padding(.vertical, 6)
426
+ .background(
427
+ RoundedRectangle(cornerRadius: 5)
428
+ .fill(Palette.surface)
429
+ .overlay(
430
+ RoundedRectangle(cornerRadius: 5)
431
+ .strokeBorder(Palette.border, lineWidth: 0.5)
432
+ )
433
+ )
434
+ }
435
+ .buttonStyle(.plain)
436
+ }
437
+
438
+ private func relativeTime(_ date: Date) -> String {
439
+ let seconds = max(0, Int(Date().timeIntervalSince(date)))
440
+ if seconds < 60 { return "\(seconds)s ago" }
441
+ if seconds < 3600 { return "\(seconds / 60)m ago" }
442
+ if seconds < 86_400 { return "\(seconds / 3600)h ago" }
443
+ return "\(seconds / 86_400)d ago"
444
+ }
445
+ }
446
+
447
+ struct HUDHoverPreviewView: View {
448
+ @ObservedObject var state: HUDState
449
+ @ObservedObject private var previewModel = WindowPreviewStore.shared
450
+ @ObservedObject private var desktop = DesktopModel.shared
451
+ @State private var renderedWindow: WindowEntry?
452
+ @State private var renderedWindowID: UInt32?
453
+ @State private var renderedImage: NSImage?
454
+
455
+ private var activeWindow: WindowEntry? {
456
+ guard let item = state.transientPreviewItem else { return nil }
457
+ return previewWindow(for: item)
458
+ }
459
+
460
+ private var previewToken: String {
461
+ guard let window = activeWindow else { return "none" }
462
+ return "\(window.wid)-\(previewModel.image(for: window.wid) != nil)"
463
+ }
464
+
465
+ var body: some View {
466
+ Group {
467
+ if let window = renderedWindow ?? activeWindow {
468
+ Button {
469
+ state.pinInspectorCandidate(source: "preview")
470
+ } label: {
471
+ VStack(alignment: .leading, spacing: 10) {
472
+ HStack(spacing: 8) {
473
+ VStack(alignment: .leading, spacing: 2) {
474
+ Text(window.title)
475
+ .font(Typo.monoBold(12))
476
+ .foregroundColor(Palette.text)
477
+ .lineLimit(1)
478
+ Text(window.app)
479
+ .font(Typo.mono(10))
480
+ .foregroundColor(Palette.textMuted)
481
+ .lineLimit(1)
482
+ }
483
+ Spacer()
484
+ Text("inspect")
485
+ .font(Typo.mono(9))
486
+ .foregroundColor(Palette.textDim)
487
+ }
488
+
489
+ ZStack {
490
+ RoundedRectangle(cornerRadius: 12)
491
+ .fill(Palette.bg.opacity(0.96))
492
+ .overlay(
493
+ RoundedRectangle(cornerRadius: 12)
494
+ .strokeBorder(Palette.border, lineWidth: 0.5)
495
+ )
496
+
497
+ if let renderedImage {
498
+ Image(nsImage: renderedImage)
499
+ .resizable()
500
+ .aspectRatio(contentMode: .fit)
501
+ .clipShape(RoundedRectangle(cornerRadius: 9))
502
+ .padding(10)
503
+ .id(renderedWindowID ?? window.wid)
504
+ .transition(.opacity)
505
+ .opacity(isHoldingPreviousPreview(for: window) ? 0.88 : 1)
506
+ } else if previewModel.isLoading(window.wid) {
507
+ previewPlaceholder(
508
+ icon: "photo",
509
+ title: "Capturing preview",
510
+ subtitle: window.app
511
+ )
512
+ } else {
513
+ previewPlaceholder(
514
+ icon: "eye.slash",
515
+ title: "Preview unavailable",
516
+ subtitle: window.app
517
+ )
518
+ }
519
+
520
+ if isHoldingPreviousPreview(for: window) {
521
+ loadingOverlay(label: "Loading next preview")
522
+ }
523
+ }
524
+ .frame(height: 190)
525
+ }
526
+ .padding(14)
527
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
528
+ .background(
529
+ UnevenRoundedRectangle(
530
+ cornerRadii: .init(
531
+ topLeading: 6,
532
+ bottomLeading: 6,
533
+ bottomTrailing: 16,
534
+ topTrailing: 16
535
+ ),
536
+ style: .continuous
537
+ )
538
+ .fill(Palette.bg.opacity(0.94))
539
+ .overlay(
540
+ UnevenRoundedRectangle(
541
+ cornerRadii: .init(
542
+ topLeading: 6,
543
+ bottomLeading: 6,
544
+ bottomTrailing: 16,
545
+ topTrailing: 16
546
+ ),
547
+ style: .continuous
548
+ )
549
+ .strokeBorder(Palette.border, lineWidth: 0.5)
550
+ )
551
+ )
552
+ .contentShape(Rectangle())
553
+ }
554
+ .buttonStyle(.plain)
555
+ .shadow(color: Color.black.opacity(0.22), radius: 18, x: 0, y: 10)
556
+ .onHover { isHovering in
557
+ state.previewInteractionActive = isHovering
558
+ guard !isHovering else { return }
559
+ let hoveredItemID = state.hoveredPreviewItem?.id
560
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
561
+ guard hoveredItemID == self.state.hoveredPreviewItem?.id,
562
+ !self.state.previewInteractionActive else { return }
563
+ self.state.hoveredPreviewItem = nil
564
+ self.state.hoverPreviewAnchorScreenY = nil
565
+ }
566
+ }
567
+ } else {
568
+ Color.clear
569
+ }
570
+ }
571
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
572
+ .background(Color.clear)
573
+ .onAppear {
574
+ syncRenderedPreview(animated: false)
575
+ }
576
+ .onChange(of: state.transientPreviewItem?.id) { _ in
577
+ syncRenderedPreview(animated: true)
578
+ }
579
+ .onChange(of: previewToken) { _ in
580
+ syncRenderedPreview(animated: true)
581
+ }
582
+ }
583
+
584
+ private func previewWindow(for item: HUDItem) -> WindowEntry? {
585
+ switch item {
586
+ case .window(let window):
587
+ return window
588
+ case .project(let project):
589
+ guard project.isRunning else { return nil }
590
+ return desktop.windowForSession(project.sessionName)
591
+ }
592
+ }
593
+
594
+ private func previewPlaceholder(icon: String, title: String, subtitle: String) -> some View {
595
+ VStack(spacing: 8) {
596
+ Image(systemName: icon)
597
+ .font(.system(size: 18, weight: .medium))
598
+ .foregroundColor(Palette.textMuted.opacity(0.7))
599
+ Text(title)
600
+ .font(Typo.monoBold(10))
601
+ .foregroundColor(Palette.textMuted)
602
+ Text(subtitle)
603
+ .font(Typo.mono(9))
604
+ .foregroundColor(Palette.textDim)
605
+ .lineLimit(1)
606
+ }
607
+ .padding(16)
608
+ }
609
+
610
+ private func loadingOverlay(label: String) -> some View {
611
+ VStack {
612
+ Spacer()
613
+ HStack {
614
+ Spacer()
615
+ HStack(spacing: 6) {
616
+ Image(systemName: "sparkles")
617
+ .font(.system(size: 9, weight: .medium))
618
+ .foregroundColor(Palette.text)
619
+ Text(label)
620
+ .font(Typo.mono(9))
621
+ .foregroundColor(Palette.text)
622
+ }
623
+ .padding(.horizontal, 10)
624
+ .padding(.vertical, 6)
625
+ .background(
626
+ Capsule()
627
+ .fill(Palette.bg.opacity(0.88))
628
+ .overlay(
629
+ Capsule()
630
+ .strokeBorder(Palette.border, lineWidth: 0.5)
631
+ )
632
+ )
633
+ .padding(14)
634
+ }
635
+ }
636
+ }
637
+
638
+ private func isHoldingPreviousPreview(for window: WindowEntry) -> Bool {
639
+ guard let renderedWindowID else { return false }
640
+ return renderedWindowID != window.wid && previewModel.image(for: window.wid) == nil
641
+ }
642
+
643
+ private func syncRenderedPreview(animated: Bool) {
644
+ guard let window = activeWindow else { return }
645
+
646
+ previewModel.load(window: window)
647
+
648
+ guard let image = previewModel.image(for: window.wid) else { return }
649
+ guard renderedWindowID != window.wid || renderedImage == nil || renderedWindow?.title != window.title else { return }
650
+
651
+ let apply = {
652
+ renderedWindow = window
653
+ renderedWindowID = window.wid
654
+ renderedImage = image
655
+ }
656
+
657
+ if animated {
658
+ withAnimation(.easeInOut(duration: 0.16)) {
659
+ apply()
660
+ }
661
+ } else {
662
+ apply()
663
+ }
664
+ }
665
+ }
666
+
667
+ final class WindowPreviewStore: ObservableObject {
668
+ static let shared = WindowPreviewStore()
669
+
670
+ @Published private var images: [UInt32: NSImage] = [:]
671
+ @Published private var loading: Set<UInt32> = []
672
+
673
+ private var lastAttemptAt: [UInt32: Date] = [:]
674
+ private var accessOrder: [UInt32] = [] // LRU: oldest first
675
+ private let maxCached = 15
676
+ private let queue = DispatchQueue(label: "com.arach.lattices.hud-preview", qos: .userInitiated)
677
+ private let previewMaxSize = NSSize(width: 360, height: 190)
678
+
679
+ func image(for wid: UInt32) -> NSImage? {
680
+ if images[wid] != nil { touchLRU(wid) }
681
+ return images[wid]
682
+ }
683
+
684
+ private func touchLRU(_ wid: UInt32) {
685
+ accessOrder.removeAll { $0 == wid }
686
+ accessOrder.append(wid)
687
+ }
688
+
689
+ private func evictIfNeeded() {
690
+ while images.count > maxCached, let oldest = accessOrder.first {
691
+ accessOrder.removeFirst()
692
+ images.removeValue(forKey: oldest)
693
+ lastAttemptAt.removeValue(forKey: oldest)
694
+ }
695
+ }
696
+
697
+ func hasSettled(_ wid: UInt32) -> Bool {
698
+ images[wid] != nil || (lastAttemptAt[wid] != nil && !loading.contains(wid))
699
+ }
700
+
701
+ func isLoading(_ wid: UInt32) -> Bool {
702
+ loading.contains(wid)
703
+ }
704
+
705
+ func prewarm(windows: [WindowEntry], limit: Int = 4) {
706
+ for window in windows.prefix(limit) {
707
+ load(window: window)
708
+ }
709
+ }
710
+
711
+ func load(window: WindowEntry) {
712
+ if images[window.wid] != nil || loading.contains(window.wid) {
713
+ return
714
+ }
715
+
716
+ let now = Date()
717
+ if let lastAttemptAt = lastAttemptAt[window.wid], now.timeIntervalSince(lastAttemptAt) < 1.0 {
718
+ return
719
+ }
720
+ lastAttemptAt[window.wid] = now
721
+
722
+ loading.insert(window.wid)
723
+ let wid = window.wid
724
+ let frame = window.frame
725
+ let startedAt = Date()
726
+
727
+ queue.async { [weak self] in
728
+ guard let self else { return }
729
+
730
+ let cgImage = CGWindowListCreateImage(
731
+ .null,
732
+ .optionIncludingWindow,
733
+ CGWindowID(wid),
734
+ [.boundsIgnoreFraming, .nominalResolution]
735
+ )
736
+
737
+ let image = cgImage.map {
738
+ NSImage(
739
+ cgImage: $0,
740
+ size: self.previewSize(for: frame)
741
+ )
742
+ }
743
+
744
+ DispatchQueue.main.async {
745
+ self.loading.remove(wid)
746
+ let elapsedMs = Int(Date().timeIntervalSince(startedAt) * 1000)
747
+ if let image {
748
+ self.images[wid] = image
749
+ self.touchLRU(wid)
750
+ self.evictIfNeeded()
751
+ if elapsedMs >= 80 {
752
+ DiagnosticLog.shared.info("HUDPreview: captured wid=\(wid) in \(elapsedMs)ms")
753
+ }
754
+ } else {
755
+ DiagnosticLog.shared.info("HUDPreview: capture unavailable wid=\(wid) after \(elapsedMs)ms")
756
+ }
757
+ }
758
+ }
759
+ }
760
+
761
+ private func previewSize(for frame: WindowFrame) -> NSSize {
762
+ let width = max(CGFloat(frame.w), CGFloat(1))
763
+ let height = max(CGFloat(frame.h), CGFloat(1))
764
+ let scale = min(
765
+ previewMaxSize.width / width,
766
+ previewMaxSize.height / height,
767
+ CGFloat(1)
768
+ )
769
+ return NSSize(
770
+ width: max(CGFloat(1), width * scale),
771
+ height: max(CGFloat(1), height * scale)
772
+ )
773
+ }
774
+ }