@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,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
|
+
}
|