@lattices/cli 0.3.0 → 0.4.0

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 (95) hide show
  1. package/README.md +85 -9
  2. package/app/Package.swift +8 -1
  3. package/app/Sources/AdvisorLearningStore.swift +90 -0
  4. package/app/Sources/AgentSession.swift +377 -0
  5. package/app/Sources/AppDelegate.swift +44 -12
  6. package/app/Sources/AppShellView.swift +81 -8
  7. package/app/Sources/AudioProvider.swift +386 -0
  8. package/app/Sources/CheatSheetHUD.swift +261 -19
  9. package/app/Sources/DaemonProtocol.swift +13 -0
  10. package/app/Sources/DaemonServer.swift +8 -0
  11. package/app/Sources/DesktopModel.swift +164 -5
  12. package/app/Sources/DesktopModelTypes.swift +2 -0
  13. package/app/Sources/DiagnosticLog.swift +104 -2
  14. package/app/Sources/EventBus.swift +1 -0
  15. package/app/Sources/HUDBottomBar.swift +279 -0
  16. package/app/Sources/HUDController.swift +1158 -0
  17. package/app/Sources/HUDLeftBar.swift +849 -0
  18. package/app/Sources/HUDMinimap.swift +179 -0
  19. package/app/Sources/HUDRightBar.swift +774 -0
  20. package/app/Sources/HUDState.swift +367 -0
  21. package/app/Sources/HUDTopBar.swift +243 -0
  22. package/app/Sources/HandsOffSession.swift +733 -0
  23. package/app/Sources/HomeDashboardView.swift +125 -0
  24. package/app/Sources/HotkeyManager.swift +2 -0
  25. package/app/Sources/HotkeyStore.swift +45 -9
  26. package/app/Sources/IntentEngine.swift +925 -0
  27. package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
  28. package/app/Sources/Intents/DistributeIntent.swift +56 -0
  29. package/app/Sources/Intents/FocusIntent.swift +69 -0
  30. package/app/Sources/Intents/HelpIntent.swift +41 -0
  31. package/app/Sources/Intents/KillIntent.swift +47 -0
  32. package/app/Sources/Intents/LatticeIntent.swift +78 -0
  33. package/app/Sources/Intents/LaunchIntent.swift +67 -0
  34. package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
  35. package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
  36. package/app/Sources/Intents/ScanIntent.swift +52 -0
  37. package/app/Sources/Intents/SearchIntent.swift +190 -0
  38. package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
  39. package/app/Sources/Intents/TileIntent.swift +61 -0
  40. package/app/Sources/LatticesApi.swift +1235 -30
  41. package/app/Sources/LauncherHUD.swift +348 -0
  42. package/app/Sources/MainView.swift +147 -44
  43. package/app/Sources/OcrModel.swift +34 -1
  44. package/app/Sources/OmniSearchState.swift +99 -102
  45. package/app/Sources/OnboardingView.swift +457 -0
  46. package/app/Sources/PermissionChecker.swift +2 -12
  47. package/app/Sources/PiChatDock.swift +454 -0
  48. package/app/Sources/PiChatSession.swift +815 -0
  49. package/app/Sources/PiWorkspaceView.swift +364 -0
  50. package/app/Sources/PlacementSpec.swift +195 -0
  51. package/app/Sources/Preferences.swift +59 -0
  52. package/app/Sources/ProjectScanner.swift +1 -1
  53. package/app/Sources/ScreenMapState.swift +701 -55
  54. package/app/Sources/ScreenMapView.swift +843 -103
  55. package/app/Sources/ScreenMapWindowController.swift +22 -0
  56. package/app/Sources/SessionLayerStore.swift +285 -0
  57. package/app/Sources/SessionManager.swift +4 -1
  58. package/app/Sources/SettingsView.swift +186 -3
  59. package/app/Sources/Theme.swift +9 -8
  60. package/app/Sources/TmuxModel.swift +7 -0
  61. package/app/Sources/TmuxQuery.swift +27 -3
  62. package/app/Sources/VoiceChatView.swift +192 -0
  63. package/app/Sources/VoiceCommandWindow.swift +1594 -0
  64. package/app/Sources/VoiceIntentResolver.swift +671 -0
  65. package/app/Sources/VoxClient.swift +454 -0
  66. package/app/Sources/WindowTiler.swift +348 -87
  67. package/app/Sources/WorkspaceManager.swift +127 -18
  68. package/bin/client.ts +16 -0
  69. package/bin/{daemon-client.js → daemon-client.ts} +49 -30
  70. package/bin/handsoff-infer.ts +280 -0
  71. package/bin/handsoff-worker.ts +731 -0
  72. package/bin/{lattices-app.js → lattices-app.ts} +67 -32
  73. package/bin/lattices-dev +160 -0
  74. package/bin/{lattices.js → lattices.ts} +600 -137
  75. package/bin/project-twin.ts +645 -0
  76. package/docs/agent-execution-plan.md +562 -0
  77. package/docs/agents.md +142 -0
  78. package/docs/api.md +153 -34
  79. package/docs/app.md +29 -1
  80. package/docs/config.md +5 -1
  81. package/docs/handsoff-test-scenarios.md +84 -0
  82. package/docs/layers.md +20 -20
  83. package/docs/ocr.md +14 -5
  84. package/docs/overview.md +5 -1
  85. package/docs/presentation-execution-review.md +491 -0
  86. package/docs/prompts/hands-off-system.md +374 -0
  87. package/docs/prompts/hands-off-turn.md +30 -0
  88. package/docs/prompts/voice-advisor.md +31 -0
  89. package/docs/prompts/voice-fallback.md +23 -0
  90. package/docs/tiling-reference.md +167 -0
  91. package/docs/twins.md +138 -0
  92. package/docs/voice-command-protocol.md +278 -0
  93. package/docs/voice.md +219 -0
  94. package/package.json +21 -10
  95. package/bin/client.js +0 -4
@@ -0,0 +1,849 @@
1
+ import SwiftUI
2
+
3
+ // MARK: - HUDLeftBar
4
+
5
+ struct HUDLeftBar: View {
6
+ @ObservedObject var state: HUDState
7
+ @ObservedObject private var scanner = ProjectScanner.shared
8
+ @ObservedObject private var desktop = DesktopModel.shared
9
+ @ObservedObject private var workspace = WorkspaceManager.shared
10
+ @FocusState private var searchFieldFocused: Bool
11
+ @State private var resizeStartWidth: CGFloat?
12
+ @State private var isSearchHovered: Bool = false
13
+ @State private var hoveredSectionKey: Int?
14
+ @State private var hoveredItemID: String?
15
+ @State private var hoveredLayerID: String?
16
+ @State private var previewClearWorkItem: DispatchWorkItem?
17
+ var onDismiss: () -> Void
18
+
19
+ // Section definitions: (number key, title, icon, items builder)
20
+ private struct SectionDef {
21
+ let key: Int // number-key jump
22
+ let title: String
23
+ let icon: String
24
+ let items: [HUDItem]
25
+ }
26
+
27
+ private var sections: [SectionDef] {
28
+ [
29
+ SectionDef(key: 1, title: "Projects", icon: "folder.fill", items: filteredProjects.map { .project($0) }),
30
+ SectionDef(key: 2, title: "Windows", icon: "macwindow", items: filteredWindows.map { .window($0) }),
31
+ ]
32
+ }
33
+
34
+ private var visibleItems: [HUDItem] {
35
+ sections
36
+ .filter { state.isSectionExpanded($0.key) }
37
+ .flatMap(\.items)
38
+ }
39
+
40
+ // MARK: - Filters
41
+
42
+ private var filteredProjects: [Project] {
43
+ let q = state.query.lowercased()
44
+ if q.isEmpty { return scanner.projects }
45
+ return scanner.projects.filter {
46
+ $0.name.lowercased().contains(q) ||
47
+ $0.paneSummary.lowercased().contains(q)
48
+ }
49
+ }
50
+
51
+ /// All desktop windows, sorted by z-order (front-to-back)
52
+ /// Filters out: Lattices itself, windows with no title, and windows whose title is just the app name
53
+ private var filteredWindows: [WindowEntry] {
54
+ let q = state.query.lowercased()
55
+ return desktop.allWindows()
56
+ .filter { $0.app != "Lattices" }
57
+ .filter { !$0.title.isEmpty }
58
+ .filter { $0.title != $0.app } // skip helper windows titled "Cursor", "Codex", etc.
59
+ .filter { q.isEmpty || $0.title.lowercased().contains(q) || $0.app.lowercased().contains(q) }
60
+ .sorted { lhs, rhs in
61
+ let lhsDate = desktop.lastInteractionDate(for: lhs.wid) ?? .distantPast
62
+ let rhsDate = desktop.lastInteractionDate(for: rhs.wid) ?? .distantPast
63
+ if lhsDate != rhsDate {
64
+ return lhsDate > rhsDate
65
+ }
66
+ return lhs.zIndex < rhs.zIndex
67
+ }
68
+ }
69
+
70
+ // MARK: - Body
71
+
72
+ var body: some View {
73
+ VStack(spacing: 0) {
74
+ searchBar
75
+
76
+ Rectangle().fill(Palette.border).frame(height: 0.5)
77
+
78
+ // Tile mode banner
79
+ if state.tileMode {
80
+ HStack(spacing: 8) {
81
+ Image(systemName: "rectangle.split.2x2")
82
+ .font(.system(size: 11, weight: .medium))
83
+ .foregroundColor(Palette.running)
84
+ Text("TILE MODE")
85
+ .font(.system(size: 10, weight: .semibold))
86
+ .foregroundColor(Palette.running)
87
+ Spacer()
88
+ Text("H/J/K/L to place · ⎋ done")
89
+ .font(.system(size: 10, weight: .medium, design: .monospaced))
90
+ .foregroundColor(Color.white.opacity(0.42))
91
+ }
92
+ .padding(.horizontal, 14)
93
+ .padding(.vertical, 8)
94
+ .background(Palette.running.opacity(0.08))
95
+
96
+ Rectangle().fill(Palette.running.opacity(0.3)).frame(height: 0.5)
97
+ }
98
+
99
+ // Scrollable sections
100
+ ScrollViewReader { proxy in
101
+ ScrollView {
102
+ VStack(alignment: .leading, spacing: 16) {
103
+ // Sections
104
+ ForEach(sections, id: \.key) { sec in
105
+ sectionView(sec, proxy: proxy)
106
+ }
107
+
108
+ // Layers (at bottom, scroll to find)
109
+ if let layers = workspace.config?.layers, !layers.isEmpty {
110
+ layersSection(layers)
111
+ }
112
+ }
113
+ .padding(.vertical, 10)
114
+ .padding(.horizontal, 10)
115
+ }
116
+ .onChange(of: state.selectedIndex) { _ in
117
+ let items = visibleItems
118
+ if let item = items[safe: state.selectedIndex] {
119
+ proxy.scrollTo(item.id, anchor: .center)
120
+ }
121
+ }
122
+ }
123
+
124
+ Rectangle().fill(Palette.border).frame(height: 0.5)
125
+
126
+ // Minimap (pinned at bottom, docked mode only)
127
+ if state.minimapMode == .docked {
128
+ minimapDocked
129
+ }
130
+
131
+ Rectangle().fill(Palette.border).frame(height: 0.5)
132
+
133
+ footer
134
+ }
135
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
136
+ .background(Palette.bgSidebar)
137
+ .overlay(alignment: .trailing) {
138
+ resizeHandle
139
+ }
140
+ .onAppear { syncState() }
141
+ .onChange(of: state.query) { _ in
142
+ state.selectedIndex = 0
143
+ syncState()
144
+ }
145
+ .onChange(of: state.expandedSections) { _ in
146
+ syncState()
147
+ }
148
+ .onChange(of: state.focus) { focus in
149
+ let shouldFocusSearch = focus == .search
150
+ guard searchFieldFocused != shouldFocusSearch else { return }
151
+ DispatchQueue.main.async {
152
+ searchFieldFocused = shouldFocusSearch
153
+ }
154
+ }
155
+ .onChange(of: searchFieldFocused) { isFocused in
156
+ if isFocused {
157
+ state.focus = .search
158
+ } else if state.focus == .search {
159
+ state.focus = .list
160
+ }
161
+ }
162
+ .onReceive(scanner.objectWillChange) { _ in
163
+ DispatchQueue.main.async { syncState() }
164
+ }
165
+ .onReceive(desktop.objectWillChange) { _ in
166
+ DispatchQueue.main.async { syncState() }
167
+ }
168
+ .task {
169
+ DispatchQueue.main.async {
170
+ searchFieldFocused = state.focus == .search
171
+ }
172
+ }
173
+ }
174
+
175
+ /// Push flat items + section offsets to state so HUDController's key handler can use them
176
+ private func syncState() {
177
+ state.syncAutoSectionDefaults(hasRunningProjects: scanner.projects.contains(where: \.isRunning))
178
+ let secs = sections
179
+ var flat = [HUDItem]()
180
+ var offsets = [Int: Int]()
181
+ for sec in secs {
182
+ guard state.isSectionExpanded(sec.key) else { continue }
183
+ if !sec.items.isEmpty {
184
+ offsets[sec.key] = flat.count
185
+ }
186
+ flat.append(contentsOf: sec.items)
187
+ }
188
+ state.flatItems = flat
189
+ state.sectionOffsets = offsets
190
+ state.reconcileSelection(with: flat)
191
+ }
192
+
193
+ private var resizeHandle: some View {
194
+ ZStack {
195
+ Color.clear
196
+ VStack(spacing: 4) {
197
+ Capsule()
198
+ .fill(Palette.borderLit.opacity(0.9))
199
+ .frame(width: 2, height: 28)
200
+ Capsule()
201
+ .fill(Palette.border.opacity(0.9))
202
+ .frame(width: 2, height: 18)
203
+ }
204
+ }
205
+ .frame(width: 10)
206
+ .contentShape(Rectangle())
207
+ .gesture(
208
+ DragGesture(minimumDistance: 0)
209
+ .onChanged { value in
210
+ if resizeStartWidth == nil {
211
+ resizeStartWidth = state.leftSidebarWidth
212
+ }
213
+ let base = resizeStartWidth ?? state.leftSidebarWidth
214
+ state.setLeftSidebarWidth(base + value.translation.width)
215
+ }
216
+ .onEnded { _ in
217
+ resizeStartWidth = nil
218
+ }
219
+ )
220
+ }
221
+
222
+ // MARK: - Search bar
223
+
224
+ private var searchBar: some View {
225
+ HStack(spacing: 10) {
226
+ Image(systemName: "magnifyingglass")
227
+ .font(.system(size: 13, weight: .medium))
228
+ .foregroundColor(state.focus == .search ? Palette.text : Palette.textMuted.opacity(0.85))
229
+
230
+ ZStack(alignment: .leading) {
231
+ if state.query.isEmpty {
232
+ Text(state.focus == .search ? "Type to search..." : "/ to search")
233
+ .font(.system(size: 13, weight: .regular))
234
+ .foregroundColor(Palette.textMuted)
235
+ .allowsHitTesting(false)
236
+ }
237
+ TextField("", text: $state.query)
238
+ .font(.system(size: 13, weight: .regular))
239
+ .foregroundColor(Palette.text)
240
+ .textFieldStyle(.plain)
241
+ .focused($searchFieldFocused)
242
+ .onTapGesture {
243
+ state.focus = .search
244
+ searchFieldFocused = true
245
+ }
246
+ .onSubmit { activateSelected() }
247
+ }
248
+ .frame(maxWidth: .infinity, alignment: .leading)
249
+
250
+ if !state.query.isEmpty {
251
+ Button {
252
+ state.query = ""
253
+ state.selectedIndex = 0
254
+ } label: {
255
+ Image(systemName: "xmark.circle.fill")
256
+ .font(.system(size: 12))
257
+ .foregroundColor(Palette.textMuted)
258
+ }
259
+ .buttonStyle(.plain)
260
+ }
261
+ }
262
+ .frame(maxWidth: .infinity, alignment: .leading)
263
+ .padding(.horizontal, 16)
264
+ .padding(.vertical, 12)
265
+ .background(searchBarBackground)
266
+ .contentShape(Rectangle())
267
+ .onTapGesture {
268
+ state.focus = .search
269
+ searchFieldFocused = true
270
+ }
271
+ .onHover { isHovering in
272
+ isSearchHovered = isHovering
273
+ }
274
+ }
275
+
276
+ // MARK: - Section
277
+
278
+ @ViewBuilder
279
+ private func sectionView(_ sec: SectionDef, proxy: ScrollViewProxy) -> some View {
280
+ if !sec.items.isEmpty {
281
+ let isExpanded = state.isSectionExpanded(sec.key)
282
+ let isHovered = hoveredSectionKey == sec.key
283
+ VStack(alignment: .leading, spacing: 4) {
284
+ Button {
285
+ state.toggleSection(sec.key)
286
+ } label: {
287
+ HStack(spacing: 8) {
288
+ Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
289
+ .font(.system(size: 8, weight: .bold))
290
+ .foregroundColor(Palette.textMuted)
291
+ .frame(width: 10, alignment: .center)
292
+
293
+ Image(systemName: sec.icon)
294
+ .font(.system(size: 10, weight: .medium))
295
+ .foregroundColor(Palette.textMuted)
296
+ Text(sec.title)
297
+ .font(.system(size: 11, weight: .semibold))
298
+ .foregroundColor(Palette.textMuted.opacity(0.9))
299
+ Text("\(sec.items.count)")
300
+ .font(.system(size: 10, weight: .medium))
301
+ .foregroundColor(Palette.textDim)
302
+ Spacer()
303
+ shortcutBadge("\(sec.key)")
304
+ }
305
+ .padding(.horizontal, 8)
306
+ .padding(.vertical, 4)
307
+ .background(
308
+ RoundedRectangle(cornerRadius: 6)
309
+ .fill(isHovered ? Palette.surface.opacity(0.65) : Color.clear)
310
+ )
311
+ .contentShape(Rectangle())
312
+ }
313
+ .buttonStyle(.plain)
314
+ .frame(maxWidth: .infinity, alignment: .leading)
315
+ .onHover { isHovering in
316
+ hoveredSectionKey = isHovering ? sec.key : (hoveredSectionKey == sec.key ? nil : hoveredSectionKey)
317
+ }
318
+
319
+ if isExpanded {
320
+ ForEach(sec.items) { item in
321
+ itemRow(item)
322
+ .id(item.id)
323
+ }
324
+ }
325
+ }
326
+ }
327
+ }
328
+
329
+ // MARK: - Item row
330
+
331
+ private func itemRow(_ item: HUDItem) -> some View {
332
+ let isSelected = state.selectedItem == item && state.focus == .list
333
+ let isMultiSelected = state.selectedItems.contains(item.id)
334
+ let isTiled = state.tileMode && {
335
+ if case .window(let w) = item { return state.tiledWindows.contains(w.wid) }
336
+ return false
337
+ }()
338
+ let subtitleText = subtitle(for: item)
339
+ let isHovered = hoveredItemID == item.id
340
+ let rowFill: Color = {
341
+ if isTiled { return Palette.running.opacity(0.12) }
342
+ if isMultiSelected { return Palette.surfaceHov.opacity(0.9) }
343
+ if isSelected { return Palette.surfaceHov }
344
+ if isHovered { return Palette.surface.opacity(0.92) }
345
+ if state.selectedItem == item { return Palette.surface.opacity(0.8) }
346
+ return Color.clear
347
+ }()
348
+ let rowStroke: Color = {
349
+ if isTiled { return Palette.running.opacity(0.45) }
350
+ if isMultiSelected { return Color.blue.opacity(0.25) }
351
+ if isSelected { return Palette.borderLit }
352
+ if isHovered { return Palette.border.opacity(0.9) }
353
+ return Color.clear
354
+ }()
355
+
356
+ return Button {
357
+ state.focus = .list
358
+ guard let idx = visibleItems.firstIndex(of: item) else { return }
359
+
360
+ let modifiers = NSEvent.modifierFlags.intersection([.shift, .command])
361
+ if modifiers.contains(.shift) {
362
+ state.selectRange(to: item, index: idx, in: visibleItems)
363
+ } else if modifiers.contains(.command) {
364
+ state.toggleSelection(item, index: idx, in: visibleItems)
365
+ } else {
366
+ state.selectSingle(item, index: idx)
367
+ state.pinInspector(item, source: "row")
368
+ }
369
+ } label: {
370
+ HStack(spacing: 10) {
371
+ statusDot(for: item)
372
+
373
+ VStack(alignment: .leading, spacing: 2) {
374
+ Text(item.displayName)
375
+ .font(.system(size: 13, weight: .semibold))
376
+ .foregroundColor(Palette.text)
377
+ .lineLimit(1)
378
+
379
+ if let subtitleText {
380
+ Text(subtitleText)
381
+ .font(.system(size: 11, weight: .regular))
382
+ .foregroundColor(Palette.textDim)
383
+ .lineLimit(1)
384
+ }
385
+ }
386
+
387
+ Spacer()
388
+ }
389
+ .padding(.horizontal, 10)
390
+ .padding(.vertical, 8)
391
+ .frame(maxWidth: .infinity, alignment: .leading)
392
+ .background(
393
+ RoundedRectangle(cornerRadius: 7)
394
+ .fill(rowFill)
395
+ .overlay(
396
+ RoundedRectangle(cornerRadius: 7)
397
+ .strokeBorder(rowStroke, lineWidth: 0.5)
398
+ )
399
+ )
400
+ .contentShape(Rectangle())
401
+ }
402
+ .buttonStyle(.plain)
403
+ .frame(maxWidth: .infinity, alignment: .leading)
404
+ .onHover { isHovering in
405
+ hoveredItemID = isHovering ? item.id : (hoveredItemID == item.id ? nil : hoveredItemID)
406
+ if isHovering {
407
+ previewClearWorkItem?.cancel()
408
+ state.hoveredPreviewItem = item
409
+ state.hoverPreviewAnchorScreenY = NSEvent.mouseLocation.y
410
+ prefetchPreview(for: item)
411
+ } else if state.hoveredPreviewItem == item {
412
+ let hoveredItemID = item.id
413
+ let clearWorkItem = DispatchWorkItem {
414
+ guard self.state.hoveredPreviewItem?.id == hoveredItemID,
415
+ !self.state.previewInteractionActive else { return }
416
+ self.state.hoveredPreviewItem = nil
417
+ self.state.hoverPreviewAnchorScreenY = nil
418
+ }
419
+ previewClearWorkItem = clearWorkItem
420
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: clearWorkItem)
421
+ }
422
+ }
423
+ }
424
+
425
+ private func statusDot(for item: HUDItem) -> some View {
426
+ Circle()
427
+ .fill(dotColor(for: item))
428
+ .frame(width: 7, height: 7)
429
+ }
430
+
431
+ private func dotColor(for item: HUDItem) -> Color {
432
+ switch item {
433
+ case .project(let p): return p.isRunning ? Palette.running : Palette.textMuted.opacity(0.3)
434
+ case .window: return Palette.textDim
435
+ }
436
+ }
437
+
438
+ private func subtitle(for item: HUDItem) -> String? {
439
+ switch item {
440
+ case .project(let p):
441
+ return p.paneSummary.isEmpty ? nil : p.paneSummary
442
+ case .window(let w):
443
+ return w.app
444
+ }
445
+ }
446
+
447
+ private func prefetchPreview(for item: HUDItem) {
448
+ switch item {
449
+ case .window(let window):
450
+ WindowPreviewStore.shared.load(window: window)
451
+ case .project(let project):
452
+ guard project.isRunning,
453
+ let window = desktop.windowForSession(project.sessionName) else { return }
454
+ WindowPreviewStore.shared.load(window: window)
455
+ }
456
+ }
457
+
458
+ // MARK: - Layers section (at bottom)
459
+
460
+ private func layersSection(_ layers: [Layer]) -> some View {
461
+ VStack(alignment: .leading, spacing: 4) {
462
+ HStack(spacing: 6) {
463
+ Image(systemName: "square.stack.3d.up")
464
+ .font(.system(size: 10, weight: .medium))
465
+ .foregroundColor(Palette.textMuted)
466
+ Text("Layers")
467
+ .font(.system(size: 11, weight: .semibold))
468
+ .foregroundColor(Palette.textMuted.opacity(0.9))
469
+ Text("\(layers.count)")
470
+ .font(.system(size: 10, weight: .medium))
471
+ .foregroundColor(Palette.textDim)
472
+ Spacer()
473
+ shortcutBadge("[ ]")
474
+ }
475
+ .padding(.horizontal, 8)
476
+ .padding(.vertical, 4)
477
+
478
+ ForEach(Array(layers.enumerated()), id: \.element.id) { idx, layer in
479
+ let isActive = idx == workspace.activeLayerIndex
480
+ let counts = workspace.layerRunningCount(index: idx)
481
+ let isHovered = hoveredLayerID == layer.id
482
+
483
+ Button {
484
+ workspace.focusLayer(index: idx)
485
+ } label: {
486
+ HStack(spacing: 10) {
487
+ Circle()
488
+ .fill(isActive ? Palette.running : Palette.textMuted.opacity(0.2))
489
+ .frame(width: 7, height: 7)
490
+
491
+ VStack(alignment: .leading, spacing: 2) {
492
+ Text(layer.label)
493
+ .font(.system(size: 13, weight: .semibold))
494
+ .foregroundColor(isActive ? Palette.text : Palette.textMuted)
495
+ .lineLimit(1)
496
+
497
+ Text("\(counts.running)/\(counts.total) projects")
498
+ .font(.system(size: 11, weight: .regular))
499
+ .foregroundColor(Palette.textDim)
500
+ }
501
+
502
+ Spacer()
503
+
504
+ Text("\(idx + 1)")
505
+ .font(.system(size: 10, weight: .semibold, design: .monospaced))
506
+ .foregroundColor(isActive ? Palette.text : Palette.textDim)
507
+ .frame(width: 18, height: 18)
508
+ .background(
509
+ RoundedRectangle(cornerRadius: 4)
510
+ .fill(isActive ? Palette.running.opacity(0.15) : Palette.surface)
511
+ .overlay(
512
+ RoundedRectangle(cornerRadius: 4)
513
+ .strokeBorder(isActive ? Palette.running.opacity(0.3) : Palette.border, lineWidth: 0.5)
514
+ )
515
+ )
516
+ }
517
+ .padding(.horizontal, 10)
518
+ .padding(.vertical, 8)
519
+ .frame(maxWidth: .infinity, alignment: .leading)
520
+ .background(
521
+ RoundedRectangle(cornerRadius: 7)
522
+ .fill(isActive ? Palette.running.opacity(0.06) : (isHovered ? Palette.surface.opacity(0.75) : Color.clear))
523
+ .overlay(
524
+ RoundedRectangle(cornerRadius: 7)
525
+ .strokeBorder(isActive ? Palette.running.opacity(0.2) : (isHovered ? Palette.border : Color.clear), lineWidth: 0.5)
526
+ )
527
+ )
528
+ .contentShape(Rectangle())
529
+ }
530
+ .buttonStyle(.plain)
531
+ .frame(maxWidth: .infinity, alignment: .leading)
532
+ .onHover { isHovering in
533
+ hoveredLayerID = isHovering ? layer.id : (hoveredLayerID == layer.id ? nil : hoveredLayerID)
534
+ }
535
+ }
536
+ }
537
+ }
538
+
539
+ // MARK: - Docked minimap
540
+
541
+ private var minimapDocked: some View {
542
+ let screens = NSScreen.screens
543
+ let screen: NSScreen? = screens.isEmpty ? nil : screens.first
544
+ let mapWidth: CGFloat = 300 // full sidebar width minus padding
545
+ let mapHeight: CGFloat = 140
546
+
547
+ return VStack(alignment: .leading, spacing: 0) {
548
+ // Header
549
+ HStack(spacing: 0) {
550
+ Image(systemName: "map")
551
+ .font(.system(size: 9, weight: .medium))
552
+ .foregroundColor(Palette.textMuted)
553
+ Text("Map")
554
+ .font(.system(size: 10, weight: .semibold))
555
+ .foregroundColor(Palette.textMuted.opacity(0.9))
556
+ .padding(.leading, 4)
557
+
558
+ Spacer()
559
+
560
+ // Expand out to canvas
561
+ Button {
562
+ state.minimapMode = .expanded
563
+ } label: {
564
+ Image(systemName: "arrow.up.left.and.arrow.down.right")
565
+ .font(.system(size: 8, weight: .bold))
566
+ .foregroundColor(Palette.textMuted)
567
+ .frame(width: 18, height: 18)
568
+ .background(
569
+ RoundedRectangle(cornerRadius: 3)
570
+ .fill(Palette.surface)
571
+ )
572
+ }
573
+ .buttonStyle(.plain)
574
+ .help("Expand map (M)")
575
+
576
+ // Hide
577
+ Button {
578
+ state.minimapMode = .hidden
579
+ } label: {
580
+ Image(systemName: "chevron.down")
581
+ .font(.system(size: 8, weight: .bold))
582
+ .foregroundColor(Palette.textMuted)
583
+ .frame(width: 18, height: 18)
584
+ .background(
585
+ RoundedRectangle(cornerRadius: 3)
586
+ .fill(Palette.surface)
587
+ )
588
+ }
589
+ .buttonStyle(.plain)
590
+ .help("Hide map (M)")
591
+ }
592
+ .padding(.horizontal, 10)
593
+ .padding(.vertical, 6)
594
+
595
+ // Map canvas
596
+ if let screen {
597
+ let sw = screen.frame.width
598
+ let sh = screen.frame.height
599
+ let scaleX = mapWidth / sw
600
+ let scaleY = mapHeight / sh
601
+ let scale = min(scaleX, scaleY)
602
+ let drawW = sw * scale
603
+ let drawH = sh * scale
604
+ let offsetX = (mapWidth - drawW) / 2
605
+ let offsetY = (mapHeight - drawH) / 2
606
+ let origin = screenCGOrigin(screen)
607
+ let wins = windowsOnScreen(0)
608
+
609
+ ZStack(alignment: .topLeading) {
610
+ // Screen background
611
+ RoundedRectangle(cornerRadius: 4)
612
+ .fill(Palette.surface.opacity(0.4))
613
+ .frame(width: drawW, height: drawH)
614
+ .offset(x: offsetX, y: offsetY)
615
+
616
+ // Windows (back-to-front)
617
+ ForEach(wins.reversed()) { win in
618
+ let rx = (CGFloat(win.frame.x) - origin.x) * scale + offsetX
619
+ let ry = (CGFloat(win.frame.y) - origin.y) * scale + offsetY
620
+ let rw = CGFloat(win.frame.w) * scale
621
+ let rh = CGFloat(win.frame.h) * scale
622
+ let isSelected = state.selectedItem == .window(win)
623
+
624
+ RoundedRectangle(cornerRadius: 1.5)
625
+ .fill(appColor(win.app).opacity(isSelected ? 0.5 : 0.15))
626
+ .overlay(
627
+ RoundedRectangle(cornerRadius: 1.5)
628
+ .strokeBorder(
629
+ isSelected ? Palette.running : appColor(win.app).opacity(0.35),
630
+ lineWidth: isSelected ? 1.5 : 0.5
631
+ )
632
+ )
633
+ .overlay(
634
+ Group {
635
+ if rw > 24 && rh > 14 {
636
+ Text(String(win.app.prefix(1)))
637
+ .font(.system(size: max(6, min(9, rh * 0.35)), weight: .semibold, design: .monospaced))
638
+ .foregroundColor(appColor(win.app).opacity(isSelected ? 1.0 : 0.5))
639
+ }
640
+ }
641
+ )
642
+ .frame(width: max(rw, 3), height: max(rh, 2))
643
+ .offset(x: rx, y: ry)
644
+ .onTapGesture {
645
+ state.focus = .list
646
+ if let flatIdx = visibleItems.firstIndex(of: .window(win)) {
647
+ state.selectSingle(.window(win), index: flatIdx)
648
+ state.pinnedItem = .window(win)
649
+ state.hoveredPreviewItem = nil
650
+ }
651
+ }
652
+ }
653
+ }
654
+ .frame(width: mapWidth, height: mapHeight)
655
+ .clipShape(RoundedRectangle(cornerRadius: 4))
656
+ .padding(.horizontal, 10)
657
+ .padding(.bottom, 8)
658
+ }
659
+ }
660
+ }
661
+
662
+ // MARK: - Minimap helpers
663
+
664
+ private func screenCGOrigin(_ screen: NSScreen) -> (x: CGFloat, y: CGFloat) {
665
+ let primaryH = NSScreen.screens.first?.frame.height ?? 900
666
+ return (screen.frame.origin.x, primaryH - screen.frame.origin.y - screen.frame.height)
667
+ }
668
+
669
+ private func windowsOnScreen(_ screenIdx: Int) -> [WindowEntry] {
670
+ let screens = NSScreen.screens
671
+ guard screenIdx < screens.count else { return [] }
672
+ let screen = screens[screenIdx]
673
+ let origin = screenCGOrigin(screen)
674
+ let sw = Double(screen.frame.width)
675
+ let sh = Double(screen.frame.height)
676
+
677
+ return desktop.allWindows().filter { win in
678
+ let cx = win.frame.x + win.frame.w / 2
679
+ let cy = win.frame.y + win.frame.h / 2
680
+ return cx >= Double(origin.x) && cx < Double(origin.x) + sw &&
681
+ cy >= Double(origin.y) && cy < Double(origin.y) + sh &&
682
+ win.app != "Lattices"
683
+ }
684
+ }
685
+
686
+ private func appColor(_ app: String) -> Color {
687
+ if ["iTerm2", "Terminal", "WezTerm", "Alacritty", "kitty"].contains(app) {
688
+ return Palette.running
689
+ }
690
+ if ["Google Chrome", "Safari", "Arc", "Firefox", "Brave Browser"].contains(app) {
691
+ return Color.blue
692
+ }
693
+ if ["Xcode", "Visual Studio Code", "Cursor", "Zed"].contains(app) {
694
+ return Color.purple
695
+ }
696
+ if app.localizedCaseInsensitiveContains("Claude") || app.localizedCaseInsensitiveContains("Codex") {
697
+ return Color.orange
698
+ }
699
+ return Palette.textMuted
700
+ }
701
+
702
+ // MARK: - Footer
703
+
704
+ private var footer: some View {
705
+ let selectedIDs = state.effectiveSelectionIDs
706
+ let selectionCount = state.multiSelectionCount
707
+ let selectedProjects = visibleItems.compactMap { item -> Project? in
708
+ guard selectedIDs.contains(item.id), case .project(let project) = item else { return nil }
709
+ return project
710
+ }
711
+ let selectedWindows = visibleItems.compactMap { item -> WindowEntry? in
712
+ guard selectedIDs.contains(item.id), case .window(let window) = item else { return nil }
713
+ return window
714
+ }
715
+
716
+ return HStack(spacing: 10) {
717
+ if selectionCount > 1 {
718
+ Text("\(selectionCount) selected")
719
+ .font(.system(size: 10, weight: .semibold))
720
+ .foregroundColor(Color.white.opacity(0.86))
721
+ if !selectedWindows.isEmpty || !selectedProjects.isEmpty {
722
+ keyBadge("T", label: "Tile")
723
+ }
724
+ if !selectedProjects.isEmpty {
725
+ keyBadge("D", label: "Detach")
726
+ } else if selectedWindows.count > 1 {
727
+ keyBadge("D", label: "Distrib")
728
+ }
729
+ }
730
+ keyBadge("⇧↕", label: "Range")
731
+ keyBadge("⌘", label: "Multi")
732
+ keyBadge("⇥", label: "Focus")
733
+ keyBadge("↵", label: "Go")
734
+ keyBadge("[ ]", label: "Layer")
735
+
736
+ Spacer()
737
+
738
+ if state.minimapMode != .docked {
739
+ Button {
740
+ state.minimapMode = .docked
741
+ } label: {
742
+ HStack(spacing: 3) {
743
+ Image(systemName: "map")
744
+ .font(.system(size: 8))
745
+ Text("M")
746
+ .font(.system(size: 9, weight: .semibold, design: .monospaced))
747
+ }
748
+ .foregroundColor(state.minimapMode == .expanded ? Palette.running : Palette.textMuted)
749
+ .padding(.horizontal, 6)
750
+ .padding(.vertical, 3)
751
+ .background(
752
+ RoundedRectangle(cornerRadius: 3)
753
+ .fill(Palette.surface)
754
+ .overlay(
755
+ RoundedRectangle(cornerRadius: 3)
756
+ .strokeBorder(state.minimapMode == .expanded ? Palette.running.opacity(0.3) : Palette.border, lineWidth: 0.5)
757
+ )
758
+ )
759
+ }
760
+ .buttonStyle(.plain)
761
+ .help("Dock map (M)")
762
+ }
763
+ }
764
+ .padding(.horizontal, 14)
765
+ .padding(.vertical, 7)
766
+ }
767
+
768
+ private func keyBadge(_ key: String, label: String) -> some View {
769
+ HStack(spacing: 3) {
770
+ Text(key)
771
+ .font(.system(size: 9, weight: .semibold, design: .monospaced))
772
+ .foregroundColor(Palette.text)
773
+ .padding(.horizontal, 4)
774
+ .padding(.vertical, 2)
775
+ .background(
776
+ RoundedRectangle(cornerRadius: 3)
777
+ .fill(Palette.surface)
778
+ .overlay(
779
+ RoundedRectangle(cornerRadius: 3)
780
+ .strokeBorder(Palette.border, lineWidth: 0.5)
781
+ )
782
+ )
783
+ Text(label)
784
+ .font(.system(size: 10, weight: .medium))
785
+ .foregroundColor(Palette.textMuted)
786
+ }
787
+ }
788
+
789
+ private func shortcutBadge(_ key: String) -> some View {
790
+ Text(key)
791
+ .font(.system(size: 9, weight: .semibold, design: .monospaced))
792
+ .foregroundColor(Palette.textMuted)
793
+ .padding(.horizontal, 6)
794
+ .padding(.vertical, 3)
795
+ .background(
796
+ RoundedRectangle(cornerRadius: 4)
797
+ .fill(Palette.surface)
798
+ .overlay(
799
+ RoundedRectangle(cornerRadius: 4)
800
+ .strokeBorder(Palette.border, lineWidth: 0.5)
801
+ )
802
+ )
803
+ }
804
+
805
+ private var searchBarBackground: some View {
806
+ RoundedRectangle(cornerRadius: 8)
807
+ .fill(
808
+ state.focus == .search
809
+ ? Palette.surface.opacity(0.6)
810
+ : (isSearchHovered ? Palette.surface.opacity(0.3) : Color.clear)
811
+ )
812
+ .overlay(
813
+ RoundedRectangle(cornerRadius: 8)
814
+ .strokeBorder(
815
+ state.focus == .search
816
+ ? Palette.borderLit
817
+ : (isSearchHovered ? Palette.border.opacity(0.85) : Color.clear),
818
+ lineWidth: 0.5
819
+ )
820
+ )
821
+ }
822
+
823
+ // MARK: - Actions
824
+
825
+ private func activateSelected() {
826
+ guard let item = state.selectedItem else { return }
827
+ activate(item)
828
+ }
829
+
830
+ private func activate(_ item: HUDItem) {
831
+ switch item {
832
+ case .project(let p):
833
+ SessionManager.launch(project: p)
834
+ HandsOffSession.shared.playCachedCue(p.isRunning ? "Focused." : "Done.")
835
+ case .window(let w):
836
+ _ = WindowTiler.focusWindow(wid: w.wid, pid: w.pid)
837
+ HandsOffSession.shared.playCachedCue("Focused.")
838
+ }
839
+ onDismiss()
840
+ }
841
+ }
842
+
843
+ // MARK: - Safe array subscript
844
+
845
+ private extension Array {
846
+ subscript(safe index: Int) -> Element? {
847
+ indices.contains(index) ? self[index] : nil
848
+ }
849
+ }