@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,367 @@
|
|
|
1
|
+
import Combine
|
|
2
|
+
import CoreGraphics
|
|
3
|
+
import Foundation
|
|
4
|
+
|
|
5
|
+
// MARK: - HUDItem
|
|
6
|
+
|
|
7
|
+
enum HUDItem: Identifiable, Equatable {
|
|
8
|
+
case project(Project)
|
|
9
|
+
case window(WindowEntry)
|
|
10
|
+
|
|
11
|
+
var id: String {
|
|
12
|
+
switch self {
|
|
13
|
+
case .project(let p): return "project-\(p.id)"
|
|
14
|
+
case .window(let w): return "window-\(w.wid)"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
var displayName: String {
|
|
19
|
+
switch self {
|
|
20
|
+
case .project(let p): return p.name
|
|
21
|
+
case .window(let w): return w.title
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
var appName: String? {
|
|
26
|
+
switch self {
|
|
27
|
+
case .project: return nil
|
|
28
|
+
case .window(let w): return w.app
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static func == (lhs: HUDItem, rhs: HUDItem) -> Bool {
|
|
33
|
+
lhs.id == rhs.id
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// MARK: - Focus target
|
|
38
|
+
|
|
39
|
+
enum HUDFocus: Equatable {
|
|
40
|
+
case search // typing into the left bar search field
|
|
41
|
+
case list // navigating the left bar item list
|
|
42
|
+
case inspector // tabbed over to the right bar
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// MARK: - HUDState
|
|
46
|
+
|
|
47
|
+
final class HUDState: ObservableObject {
|
|
48
|
+
private static let leftSidebarWidthKey = "hud.leftSidebarWidth"
|
|
49
|
+
private static let defaultLeftSidebarWidth: CGFloat = 320
|
|
50
|
+
private static let minLeftSidebarWidth: CGFloat = 260
|
|
51
|
+
private static let maxLeftSidebarWidth: CGFloat = 420
|
|
52
|
+
|
|
53
|
+
@Published var selectedItem: HUDItem?
|
|
54
|
+
@Published var pinnedItem: HUDItem?
|
|
55
|
+
@Published var hoveredPreviewItem: HUDItem?
|
|
56
|
+
@Published var feedbackMessage: String?
|
|
57
|
+
@Published var query: String = ""
|
|
58
|
+
@Published var selectedIndex: Int = 0
|
|
59
|
+
@Published var focus: HUDFocus = .search
|
|
60
|
+
@Published var voiceActive: Bool = false
|
|
61
|
+
@Published var expandedSections: Set<Int> = [2]
|
|
62
|
+
@Published var leftSidebarWidth: CGFloat = {
|
|
63
|
+
let saved = UserDefaults.standard.double(forKey: HUDState.leftSidebarWidthKey)
|
|
64
|
+
if saved > 0 {
|
|
65
|
+
return min(max(CGFloat(saved), HUDState.minLeftSidebarWidth), HUDState.maxLeftSidebarWidth)
|
|
66
|
+
}
|
|
67
|
+
return HUDState.defaultLeftSidebarWidth
|
|
68
|
+
}()
|
|
69
|
+
|
|
70
|
+
/// Multi-select for tiling — set of item IDs
|
|
71
|
+
@Published var selectedItems: Set<String> = []
|
|
72
|
+
|
|
73
|
+
enum MinimapMode: Equatable { case hidden, docked, expanded }
|
|
74
|
+
@Published var minimapMode: MinimapMode = .docked
|
|
75
|
+
|
|
76
|
+
// MARK: - Tile mode
|
|
77
|
+
|
|
78
|
+
@Published var tileMode: Bool = false
|
|
79
|
+
|
|
80
|
+
/// Snapshot of window positions taken when entering tile mode
|
|
81
|
+
struct WindowSnapshot {
|
|
82
|
+
let wid: UInt32
|
|
83
|
+
let pid: Int32
|
|
84
|
+
let frame: CGRect
|
|
85
|
+
}
|
|
86
|
+
var tileSnapshot: [WindowSnapshot] = []
|
|
87
|
+
var tiledWindows: Set<UInt32> = []
|
|
88
|
+
|
|
89
|
+
/// Pre-computed grid layout — calculated on HUD show, applied instantly on T press
|
|
90
|
+
var precomputedGrid: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
|
|
91
|
+
|
|
92
|
+
/// Snapshot of the flat item list — set by HUDLeftBar so key handler can index into it
|
|
93
|
+
var flatItems: [HUDItem] = []
|
|
94
|
+
|
|
95
|
+
var hoverPreviewAnchorScreenY: CGFloat?
|
|
96
|
+
var previewInteractionActive: Bool = false
|
|
97
|
+
|
|
98
|
+
/// Section offsets for number-key jumping (1=Projects, 2=Terminals, 3=Chrome, 4=Claude)
|
|
99
|
+
var sectionOffsets: [Int: Int] = [:]
|
|
100
|
+
|
|
101
|
+
private var selectionAnchorID: String?
|
|
102
|
+
private var touchedSections: Set<Int> = []
|
|
103
|
+
private var feedbackClearWorkItem: DispatchWorkItem?
|
|
104
|
+
|
|
105
|
+
var leftSidebarWidthRange: ClosedRange<CGFloat> {
|
|
106
|
+
Self.minLeftSidebarWidth...Self.maxLeftSidebarWidth
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func setLeftSidebarWidth(_ width: CGFloat) {
|
|
110
|
+
let clamped = min(max(width, Self.minLeftSidebarWidth), Self.maxLeftSidebarWidth)
|
|
111
|
+
guard abs(clamped - leftSidebarWidth) > 0.5 else { return }
|
|
112
|
+
leftSidebarWidth = clamped
|
|
113
|
+
UserDefaults.standard.set(Double(clamped), forKey: Self.leftSidebarWidthKey)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func isSectionExpanded(_ key: Int) -> Bool {
|
|
117
|
+
expandedSections.contains(key)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
func toggleSection(_ key: Int) {
|
|
121
|
+
if expandedSections.contains(key) {
|
|
122
|
+
expandedSections.remove(key)
|
|
123
|
+
} else {
|
|
124
|
+
expandedSections.insert(key)
|
|
125
|
+
}
|
|
126
|
+
touchedSections.insert(key)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func resetSectionDefaults(hasRunningProjects: Bool) {
|
|
130
|
+
expandedSections = [2]
|
|
131
|
+
if hasRunningProjects {
|
|
132
|
+
expandedSections.insert(1)
|
|
133
|
+
}
|
|
134
|
+
touchedSections = []
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func syncAutoSectionDefaults(hasRunningProjects: Bool) {
|
|
138
|
+
if !touchedSections.contains(2) {
|
|
139
|
+
expandedSections.insert(2)
|
|
140
|
+
}
|
|
141
|
+
if !touchedSections.contains(1) {
|
|
142
|
+
if hasRunningProjects {
|
|
143
|
+
expandedSections.insert(1)
|
|
144
|
+
} else {
|
|
145
|
+
expandedSections.remove(1)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
var effectiveSelectionIDs: Set<String> {
|
|
151
|
+
if selectedItems.isEmpty {
|
|
152
|
+
if let selectedItem {
|
|
153
|
+
return [selectedItem.id]
|
|
154
|
+
}
|
|
155
|
+
return []
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
var ids = selectedItems
|
|
159
|
+
if let selectedItem {
|
|
160
|
+
ids.insert(selectedItem.id)
|
|
161
|
+
}
|
|
162
|
+
return ids
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
var multiSelectionCount: Int {
|
|
166
|
+
let count = effectiveSelectionIDs.count
|
|
167
|
+
return count > 1 ? count : 0
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
var transientPreviewItem: HUDItem? {
|
|
171
|
+
if let hoveredPreviewItem {
|
|
172
|
+
return hoveredPreviewItem
|
|
173
|
+
}
|
|
174
|
+
if pinnedItem == nil && focus == .list {
|
|
175
|
+
return selectedItem
|
|
176
|
+
}
|
|
177
|
+
return nil
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
var inspectorCandidateItem: HUDItem? {
|
|
181
|
+
if let pinnedItem {
|
|
182
|
+
return pinnedItem
|
|
183
|
+
}
|
|
184
|
+
if let hoveredPreviewItem {
|
|
185
|
+
return hoveredPreviewItem
|
|
186
|
+
}
|
|
187
|
+
return selectedItem
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func clearMultiSelection() {
|
|
191
|
+
selectedItems = []
|
|
192
|
+
if let selectedItem {
|
|
193
|
+
selectionAnchorID = selectedItem.id
|
|
194
|
+
} else {
|
|
195
|
+
selectionAnchorID = nil
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
func selectSingle(_ item: HUDItem, index: Int) {
|
|
200
|
+
selectedItem = item
|
|
201
|
+
selectedIndex = index
|
|
202
|
+
selectedItems = []
|
|
203
|
+
selectionAnchorID = item.id
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
func showFeedback(_ message: String, autoClearAfter delay: TimeInterval = 0.9) {
|
|
207
|
+
feedbackClearWorkItem?.cancel()
|
|
208
|
+
feedbackMessage = message
|
|
209
|
+
|
|
210
|
+
let clearWorkItem = DispatchWorkItem { [weak self] in
|
|
211
|
+
guard self?.feedbackMessage == message else { return }
|
|
212
|
+
self?.feedbackMessage = nil
|
|
213
|
+
}
|
|
214
|
+
feedbackClearWorkItem = clearWorkItem
|
|
215
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: clearWorkItem)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
func pinInspector(_ item: HUDItem, source: String = "selection") {
|
|
219
|
+
let timed = AppFeedback.shared.beginTimed(
|
|
220
|
+
"HUD inspect (\(source))",
|
|
221
|
+
state: self,
|
|
222
|
+
feedback: "Inspecting \(item.displayName)"
|
|
223
|
+
)
|
|
224
|
+
if let idx = flatItems.firstIndex(of: item) {
|
|
225
|
+
selectedIndex = idx
|
|
226
|
+
}
|
|
227
|
+
selectedItem = item
|
|
228
|
+
pinnedItem = item
|
|
229
|
+
hoveredPreviewItem = nil
|
|
230
|
+
selectedItems = []
|
|
231
|
+
selectionAnchorID = item.id
|
|
232
|
+
focus = .inspector
|
|
233
|
+
DispatchQueue.main.async {
|
|
234
|
+
AppFeedback.shared.finish(timed)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
func pinInspectorCandidate(source: String = "preview") {
|
|
239
|
+
guard let item = inspectorCandidateItem else { return }
|
|
240
|
+
pinInspector(item, source: source)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
func toggleSelection(_ item: HUDItem, index: Int, in items: [HUDItem]) {
|
|
244
|
+
let anchorID = selectionAnchorID ?? selectedItem?.id ?? item.id
|
|
245
|
+
|
|
246
|
+
if selectedItems.isEmpty, let current = selectedItem {
|
|
247
|
+
selectedItems = [current.id]
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if selectedItems.contains(item.id) {
|
|
251
|
+
selectedItems.remove(item.id)
|
|
252
|
+
} else {
|
|
253
|
+
selectedItems.insert(item.id)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if selectedItems.isEmpty {
|
|
257
|
+
selectSingle(item, index: index)
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if selectedItems.count == 1,
|
|
262
|
+
let remainingID = selectedItems.first,
|
|
263
|
+
let remainingIndex = items.firstIndex(where: { $0.id == remainingID }),
|
|
264
|
+
let remainingItem = items[safe: remainingIndex] {
|
|
265
|
+
selectSingle(remainingItem, index: remainingIndex)
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
selectedItem = item
|
|
270
|
+
selectedIndex = index
|
|
271
|
+
selectionAnchorID = anchorID
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
func selectRange(to item: HUDItem, index: Int, in items: [HUDItem]) {
|
|
275
|
+
guard !items.isEmpty else { return }
|
|
276
|
+
|
|
277
|
+
let anchorID = selectionAnchorID ?? selectedItem?.id ?? item.id
|
|
278
|
+
guard let anchorIndex = items.firstIndex(where: { $0.id == anchorID }) else {
|
|
279
|
+
selectSingle(item, index: index)
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let lower = min(anchorIndex, index)
|
|
284
|
+
let upper = max(anchorIndex, index)
|
|
285
|
+
let ids = Set(items[lower...upper].map(\.id))
|
|
286
|
+
|
|
287
|
+
selectedItem = item
|
|
288
|
+
selectedIndex = index
|
|
289
|
+
selectedItems = ids.count > 1 ? ids : []
|
|
290
|
+
selectionAnchorID = anchorID
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
func moveSelection(by delta: Int, extend: Bool) {
|
|
294
|
+
let items = flatItems
|
|
295
|
+
guard !items.isEmpty else { return }
|
|
296
|
+
|
|
297
|
+
let currentIndex: Int
|
|
298
|
+
if let selectedItem,
|
|
299
|
+
let idx = items.firstIndex(of: selectedItem) {
|
|
300
|
+
currentIndex = idx
|
|
301
|
+
} else {
|
|
302
|
+
currentIndex = max(0, min(items.count - 1, selectedIndex))
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let nextIndex = max(0, min(items.count - 1, currentIndex + delta))
|
|
306
|
+
guard let nextItem = items[safe: nextIndex] else { return }
|
|
307
|
+
|
|
308
|
+
if extend {
|
|
309
|
+
selectRange(to: nextItem, index: nextIndex, in: items)
|
|
310
|
+
} else {
|
|
311
|
+
selectSingle(nextItem, index: nextIndex)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
func reconcileSelection(with items: [HUDItem]) {
|
|
316
|
+
let validIDs = Set(items.map(\.id))
|
|
317
|
+
|
|
318
|
+
selectedItems = selectedItems.intersection(validIDs)
|
|
319
|
+
if let selectedItem, !validIDs.contains(selectedItem.id) {
|
|
320
|
+
self.selectedItem = nil
|
|
321
|
+
}
|
|
322
|
+
if let pinnedItem, !validIDs.contains(pinnedItem.id) {
|
|
323
|
+
self.pinnedItem = nil
|
|
324
|
+
}
|
|
325
|
+
if let hoveredPreviewItem, !validIDs.contains(hoveredPreviewItem.id) {
|
|
326
|
+
self.hoveredPreviewItem = nil
|
|
327
|
+
}
|
|
328
|
+
if let selectionAnchorID, !validIDs.contains(selectionAnchorID) {
|
|
329
|
+
self.selectionAnchorID = selectedItem?.id
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
guard !items.isEmpty else {
|
|
333
|
+
selectedItem = nil
|
|
334
|
+
pinnedItem = nil
|
|
335
|
+
hoveredPreviewItem = nil
|
|
336
|
+
selectedIndex = 0
|
|
337
|
+
selectedItems = []
|
|
338
|
+
selectionAnchorID = nil
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if selectedItem == nil {
|
|
343
|
+
let clampedIndex = max(0, min(items.count - 1, selectedIndex))
|
|
344
|
+
if let fallback = items[safe: clampedIndex] {
|
|
345
|
+
selectedItem = fallback
|
|
346
|
+
selectedIndex = clampedIndex
|
|
347
|
+
if selectionAnchorID == nil {
|
|
348
|
+
selectionAnchorID = fallback.id
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if let selectedItem,
|
|
355
|
+
let itemIndex = items.firstIndex(of: selectedItem) {
|
|
356
|
+
selectedIndex = itemIndex
|
|
357
|
+
} else {
|
|
358
|
+
selectedIndex = max(0, min(items.count - 1, selectedIndex))
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private extension Array {
|
|
364
|
+
subscript(safe index: Int) -> Element? {
|
|
365
|
+
indices.contains(index) ? self[index] : nil
|
|
366
|
+
}
|
|
367
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
// MARK: - HUDTopBar
|
|
4
|
+
|
|
5
|
+
struct HUDTopBar: View {
|
|
6
|
+
@ObservedObject var state: HUDState
|
|
7
|
+
@ObservedObject private var handsOff = HandsOffSession.shared
|
|
8
|
+
@ObservedObject private var workspace = WorkspaceManager.shared
|
|
9
|
+
var onDismiss: () -> Void
|
|
10
|
+
|
|
11
|
+
var body: some View {
|
|
12
|
+
HStack(spacing: 0) {
|
|
13
|
+
// Logo
|
|
14
|
+
logo
|
|
15
|
+
.padding(.leading, 16)
|
|
16
|
+
|
|
17
|
+
// Voice status (when active)
|
|
18
|
+
if state.voiceActive {
|
|
19
|
+
voiceStatus
|
|
20
|
+
.padding(.leading, 12)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
Spacer()
|
|
24
|
+
|
|
25
|
+
// Layer strip (Hyprland-style)
|
|
26
|
+
if let layers = workspace.config?.layers, !layers.isEmpty {
|
|
27
|
+
layerStrip(layers)
|
|
28
|
+
.padding(.trailing, 12)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Quick actions
|
|
32
|
+
HStack(spacing: 6) {
|
|
33
|
+
quickAction(icon: "rectangle.3.group", label: "Map", shortcut: "⌃⌥⇧⌘1") {
|
|
34
|
+
onDismiss()
|
|
35
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
36
|
+
ScreenMapWindowController.shared.toggle()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
quickAction(icon: "magnifyingglass", label: "Search", shortcut: "⌃⌥⇧⌘5") {
|
|
41
|
+
onDismiss()
|
|
42
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
43
|
+
OmniSearchWindow.shared.toggle()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
quickAction(icon: "text.justify.left", label: "Palette", shortcut: "⇧⌘M") {
|
|
48
|
+
onDismiss()
|
|
49
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
50
|
+
CommandPaletteWindow.shared.toggle()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
.padding(.trailing, 16)
|
|
55
|
+
}
|
|
56
|
+
.frame(maxWidth: .infinity)
|
|
57
|
+
.frame(height: 44)
|
|
58
|
+
.background(Palette.bg)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// MARK: - Layer strip (Hyprland-style workspace bar)
|
|
62
|
+
|
|
63
|
+
private func layerStrip(_ layers: [Layer]) -> some View {
|
|
64
|
+
HStack(spacing: 3) {
|
|
65
|
+
ForEach(Array(layers.enumerated()), id: \.element.id) { idx, layer in
|
|
66
|
+
let isActive = idx == workspace.activeLayerIndex
|
|
67
|
+
|
|
68
|
+
Button {
|
|
69
|
+
workspace.focusLayer(index: idx)
|
|
70
|
+
} label: {
|
|
71
|
+
HStack(spacing: 4) {
|
|
72
|
+
Circle()
|
|
73
|
+
.fill(isActive ? Palette.running : Palette.textMuted.opacity(0.3))
|
|
74
|
+
.frame(width: 5, height: 5)
|
|
75
|
+
|
|
76
|
+
Text(layer.label)
|
|
77
|
+
.font(Typo.monoBold(9))
|
|
78
|
+
.foregroundColor(isActive ? Palette.text : Palette.textMuted)
|
|
79
|
+
.lineLimit(1)
|
|
80
|
+
}
|
|
81
|
+
.padding(.horizontal, 8)
|
|
82
|
+
.padding(.vertical, 4)
|
|
83
|
+
.background(
|
|
84
|
+
RoundedRectangle(cornerRadius: 4)
|
|
85
|
+
.fill(isActive ? Palette.surfaceHov : Palette.surface.opacity(0.5))
|
|
86
|
+
.overlay(
|
|
87
|
+
RoundedRectangle(cornerRadius: 4)
|
|
88
|
+
.strokeBorder(isActive ? Palette.running.opacity(0.4) : Palette.border.opacity(0.5), lineWidth: 0.5)
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
.buttonStyle(.plain)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// MARK: - Logo
|
|
98
|
+
|
|
99
|
+
private var logo: some View {
|
|
100
|
+
HStack(spacing: 7) {
|
|
101
|
+
latticesGrid
|
|
102
|
+
Text("lattices")
|
|
103
|
+
.font(Typo.monoBold(11))
|
|
104
|
+
.foregroundColor(Palette.textMuted.opacity(0.6))
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// 3×3 grid matching the menu bar icon — L-shape bright, rest dim
|
|
109
|
+
private var latticesGrid: some View {
|
|
110
|
+
let cellSize: CGFloat = 3
|
|
111
|
+
let gap: CGFloat = 1.5
|
|
112
|
+
let solidCells: Set<Int> = [0, 3, 6, 7, 8]
|
|
113
|
+
|
|
114
|
+
return Canvas { ctx, _ in
|
|
115
|
+
for row in 0..<3 {
|
|
116
|
+
for col in 0..<3 {
|
|
117
|
+
let idx = row * 3 + col
|
|
118
|
+
let x = CGFloat(col) * (cellSize + gap)
|
|
119
|
+
let y = CGFloat(row) * (cellSize + gap)
|
|
120
|
+
let rect = CGRect(x: x, y: y, width: cellSize, height: cellSize)
|
|
121
|
+
let opacity: Double = solidCells.contains(idx) ? 0.5 : 0.15
|
|
122
|
+
ctx.fill(
|
|
123
|
+
Path(roundedRect: rect, cornerRadius: 0.5),
|
|
124
|
+
with: .color(.white.opacity(opacity))
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
.frame(width: 3 * 3 + 2 * 1.5, height: 3 * 3 + 2 * 1.5)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// MARK: - Voice status
|
|
133
|
+
|
|
134
|
+
private var voiceStatus: some View {
|
|
135
|
+
HStack(spacing: 8) {
|
|
136
|
+
// Pulsing dot + state
|
|
137
|
+
Circle()
|
|
138
|
+
.fill(voiceColor)
|
|
139
|
+
.frame(width: 6, height: 6)
|
|
140
|
+
.overlay(
|
|
141
|
+
Circle()
|
|
142
|
+
.stroke(voiceColor.opacity(0.4), lineWidth: 1)
|
|
143
|
+
.scaleEffect(handsOff.state == .listening ? 2.0 : 1.0)
|
|
144
|
+
.opacity(handsOff.state == .listening ? 0 : 1)
|
|
145
|
+
.animation(
|
|
146
|
+
handsOff.state == .listening
|
|
147
|
+
? .easeOut(duration: 1.0).repeatForever(autoreverses: false)
|
|
148
|
+
: .default,
|
|
149
|
+
value: handsOff.state
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
Text(voiceLabel)
|
|
154
|
+
.font(Typo.monoBold(9))
|
|
155
|
+
.foregroundColor(voiceColor)
|
|
156
|
+
|
|
157
|
+
// Dialogue: last user message → last response
|
|
158
|
+
if let transcript = handsOff.lastTranscript {
|
|
159
|
+
Rectangle().fill(Palette.border).frame(width: 0.5, height: 16)
|
|
160
|
+
|
|
161
|
+
// What user said
|
|
162
|
+
HStack(spacing: 3) {
|
|
163
|
+
Text("you")
|
|
164
|
+
.font(Typo.monoBold(8))
|
|
165
|
+
.foregroundColor(Palette.textMuted)
|
|
166
|
+
Text(transcript)
|
|
167
|
+
.font(Typo.mono(9))
|
|
168
|
+
.foregroundColor(Palette.text)
|
|
169
|
+
.lineLimit(1)
|
|
170
|
+
}
|
|
171
|
+
.frame(maxWidth: 300, alignment: .leading)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if let response = handsOff.lastResponse {
|
|
175
|
+
// What Lattices said back
|
|
176
|
+
HStack(spacing: 3) {
|
|
177
|
+
Text("→")
|
|
178
|
+
.font(Typo.mono(9))
|
|
179
|
+
.foregroundColor(Palette.running)
|
|
180
|
+
Text(response)
|
|
181
|
+
.font(Typo.mono(9))
|
|
182
|
+
.foregroundColor(Palette.textMuted)
|
|
183
|
+
.lineLimit(1)
|
|
184
|
+
}
|
|
185
|
+
.frame(maxWidth: 350, alignment: .leading)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
.padding(.horizontal, 10)
|
|
189
|
+
.padding(.vertical, 4)
|
|
190
|
+
.background(
|
|
191
|
+
RoundedRectangle(cornerRadius: 5)
|
|
192
|
+
.fill(voiceColor.opacity(0.08))
|
|
193
|
+
.overlay(
|
|
194
|
+
RoundedRectangle(cornerRadius: 5)
|
|
195
|
+
.strokeBorder(voiceColor.opacity(0.2), lineWidth: 0.5)
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private var voiceColor: Color {
|
|
201
|
+
switch handsOff.state {
|
|
202
|
+
case .idle: return Palette.running
|
|
203
|
+
case .connecting: return Palette.detach
|
|
204
|
+
case .listening: return Palette.running
|
|
205
|
+
case .thinking: return Palette.detach
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private var voiceLabel: String {
|
|
210
|
+
switch handsOff.state {
|
|
211
|
+
case .idle: return "ready"
|
|
212
|
+
case .connecting: return "connecting"
|
|
213
|
+
case .listening: return "listening"
|
|
214
|
+
case .thinking: return "thinking"
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// MARK: - Quick action button
|
|
219
|
+
|
|
220
|
+
private func quickAction(icon: String, label: String, shortcut: String, action: @escaping () -> Void) -> some View {
|
|
221
|
+
Button(action: action) {
|
|
222
|
+
HStack(spacing: 5) {
|
|
223
|
+
Image(systemName: icon)
|
|
224
|
+
.font(.system(size: 10))
|
|
225
|
+
Text(label)
|
|
226
|
+
.font(Typo.mono(10))
|
|
227
|
+
}
|
|
228
|
+
.foregroundColor(Palette.textMuted)
|
|
229
|
+
.padding(.horizontal, 10)
|
|
230
|
+
.padding(.vertical, 5)
|
|
231
|
+
.background(
|
|
232
|
+
RoundedRectangle(cornerRadius: 5)
|
|
233
|
+
.fill(Palette.surface)
|
|
234
|
+
.overlay(
|
|
235
|
+
RoundedRectangle(cornerRadius: 5)
|
|
236
|
+
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
.buttonStyle(.plain)
|
|
241
|
+
.help("\(label) (\(shortcut))")
|
|
242
|
+
}
|
|
243
|
+
}
|