@lattices/cli 0.4.1 → 0.4.5

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 (71) hide show
  1. package/README.md +3 -0
  2. package/app/Info.plist +2 -2
  3. package/app/Lattices.app/Contents/Info.plist +2 -2
  4. package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/app/Package.swift +6 -0
  6. package/app/Sources/ActionRow.swift +43 -26
  7. package/app/Sources/App.swift +10 -0
  8. package/app/Sources/AppDelegate.swift +91 -30
  9. package/app/Sources/AppShellView.swift +2 -0
  10. package/app/Sources/AppTypeClassifier.swift +36 -0
  11. package/app/Sources/AppUpdater.swift +92 -0
  12. package/app/Sources/CheatSheetHUD.swift +1 -0
  13. package/app/Sources/CliActionLauncher.swift +50 -0
  14. package/app/Sources/CommandModeView.swift +4 -24
  15. package/app/Sources/CompanionActivityLog.swift +70 -0
  16. package/app/Sources/CompanionKeyboardController.swift +141 -0
  17. package/app/Sources/DesktopModel.swift +4 -0
  18. package/app/Sources/HandsOffSession.swift +53 -16
  19. package/app/Sources/HomeDashboardView.swift +18 -10
  20. package/app/Sources/HotkeyStore.swift +8 -5
  21. package/app/Sources/IntentEngine.swift +7 -1
  22. package/app/Sources/LatticesApi.swift +125 -4
  23. package/app/Sources/LatticesCompanionBridgeServer.swift +438 -0
  24. package/app/Sources/LatticesCompanionCockpit.swift +555 -0
  25. package/app/Sources/LatticesCompanionSecurityCoordinator.swift +594 -0
  26. package/app/Sources/LatticesCompanionTrackpadController.swift +204 -0
  27. package/app/Sources/LatticesDeckHost.swift +1463 -0
  28. package/app/Sources/LatticesRuntime.swift +61 -0
  29. package/app/Sources/MainView.swift +398 -186
  30. package/app/Sources/MouseFinder.swift +335 -30
  31. package/app/Sources/MouseGestureConfig.swift +364 -0
  32. package/app/Sources/MouseGestureController.swift +1203 -0
  33. package/app/Sources/MouseInputDeviceStore.swift +98 -0
  34. package/app/Sources/MouseInputEventViewer.swift +272 -0
  35. package/app/Sources/MouseShortcutStore.swift +107 -0
  36. package/app/Sources/OmniSearchView.swift +136 -2
  37. package/app/Sources/OmniSearchWindow.swift +65 -5
  38. package/app/Sources/OnboardingView.swift +30 -16
  39. package/app/Sources/PaletteCommand.swift +26 -6
  40. package/app/Sources/PermissionChecker.swift +76 -2
  41. package/app/Sources/PiAuthNextStepCard.swift +148 -0
  42. package/app/Sources/PiAuthPromptCard.swift +90 -0
  43. package/app/Sources/PiChatDock.swift +137 -74
  44. package/app/Sources/PiChatSession.swift +608 -108
  45. package/app/Sources/PiInstallCallout.swift +86 -0
  46. package/app/Sources/PiProviderSetupCallout.swift +99 -0
  47. package/app/Sources/PiWorkspaceView.swift +174 -77
  48. package/app/Sources/Preferences.swift +78 -0
  49. package/app/Sources/ScreenMapState.swift +91 -31
  50. package/app/Sources/ScreenMapView.swift +510 -524
  51. package/app/Sources/ScreenMapWindowController.swift +12 -4
  52. package/app/Sources/SettingsView.swift +869 -152
  53. package/app/Sources/SystemTelemetryMonitor.swift +273 -0
  54. package/app/Sources/VoiceCommandWindow.swift +23 -2
  55. package/app/Sources/WindowDragSnapController.swift +628 -0
  56. package/app/Sources/WindowTiler.swift +328 -65
  57. package/app/Sources/WorkspaceManager.swift +288 -0
  58. package/bin/assistant-intelligence.ts +874 -0
  59. package/bin/handsoff-infer.ts +16 -209
  60. package/bin/handsoff-worker.ts +45 -258
  61. package/bin/lattices-app.ts +65 -1
  62. package/bin/lattices-dev +4 -0
  63. package/bin/lattices.ts +125 -14
  64. package/docs/agents.md +14 -0
  65. package/docs/api.md +55 -0
  66. package/docs/app.md +3 -0
  67. package/docs/companion-deck.md +180 -0
  68. package/docs/config.md +25 -0
  69. package/docs/tiling-reference.md +55 -0
  70. package/docs/voice-error-model.md +73 -0
  71. package/package.json +4 -2
@@ -18,6 +18,12 @@ struct MainView: View {
18
18
  @State private var tmuxBannerDismissed = false
19
19
  @ObservedObject private var tmuxModel = TmuxModel.shared
20
20
  @State private var orphanSectionCollapsed = true
21
+ private let embeddedProjectColumns = Array(
22
+ repeating: GridItem(.flexible(minimum: 0, maximum: .infinity), spacing: 10, alignment: .top),
23
+ count: 3
24
+ )
25
+ private let embeddedProjectCardHeight: CGFloat = 94
26
+ private let embeddedProjectGridSpacing: CGFloat = 10
21
27
  private var filtered: [Project] {
22
28
  if searchText.isEmpty { return scanner.projects }
23
29
  return scanner.projects.filter {
@@ -34,6 +40,16 @@ struct MainView: View {
34
40
 
35
41
  private var needsSetup: Bool { prefs.scanRoot.isEmpty }
36
42
  private var runningCount: Int { scanner.projects.filter(\.isRunning).count }
43
+ private var hasVisibleGroups: Bool {
44
+ guard let groups = workspace.config?.groups else { return false }
45
+ return !groups.isEmpty && searchText.isEmpty
46
+ }
47
+ private var embeddedProjectGridHeight: CGFloat {
48
+ guard !filtered.isEmpty else { return 0 }
49
+ let rowCount = min(Int(ceil(Double(filtered.count) / 3.0)), 3)
50
+ return CGFloat(rowCount) * embeddedProjectCardHeight
51
+ + CGFloat(max(0, rowCount - 1)) * embeddedProjectGridSpacing
52
+ }
37
53
 
38
54
  var body: some View {
39
55
  VStack(spacing: 0) {
@@ -43,35 +59,42 @@ struct MainView: View {
43
59
  minWidth: layout == .popover ? 380 : 0,
44
60
  idealWidth: layout == .popover ? 380 : nil,
45
61
  maxWidth: .infinity,
46
- minHeight: layout == .popover ? 520 : 0,
47
- idealHeight: layout == .popover ? 560 : nil,
48
- maxHeight: .infinity
62
+ maxHeight: .infinity,
63
+ alignment: .top
49
64
  )
50
65
  .background(PanelBackground())
51
66
  .preferredColorScheme(.dark)
52
67
  .onAppear {
53
- let tTotal = DiagnosticLog.shared.startTimed("MainView.onAppear (total)")
54
68
  if needsSetup && !hasCheckedSetup {
55
69
  hasCheckedSetup = true
56
70
  SettingsWindowController.shared.show()
57
71
  }
58
- scanner.updateRoot(prefs.scanRoot)
72
+ runRefresh()
73
+ }
74
+ .onReceive(NotificationCenter.default.publisher(for: .latticesPopoverWillShow)) { _ in
75
+ guard layout == .popover else { return }
76
+ runRefresh()
77
+ }
78
+ }
59
79
 
60
- let tScan = DiagnosticLog.shared.startTimed("ProjectScanner.scan")
61
- scanner.scan()
62
- DiagnosticLog.shared.finish(tScan)
80
+ private func runRefresh() {
81
+ let tTotal = DiagnosticLog.shared.startTimed("MainView.refresh (total)")
82
+ scanner.updateRoot(prefs.scanRoot)
63
83
 
64
- let tInv = DiagnosticLog.shared.startTimed("InventoryManager.refresh")
65
- inventory.refresh()
66
- DiagnosticLog.shared.finish(tInv)
84
+ let tScan = DiagnosticLog.shared.startTimed("ProjectScanner.scan")
85
+ scanner.scan()
86
+ DiagnosticLog.shared.finish(tScan)
67
87
 
68
- let tPerm = DiagnosticLog.shared.startTimed("PermissionChecker.check")
69
- permChecker.check()
70
- DiagnosticLog.shared.finish(tPerm)
88
+ let tInv = DiagnosticLog.shared.startTimed("InventoryManager.refresh")
89
+ inventory.refresh()
90
+ DiagnosticLog.shared.finish(tInv)
71
91
 
72
- bannerDismissed = false
73
- DiagnosticLog.shared.finish(tTotal)
74
- }
92
+ let tPerm = DiagnosticLog.shared.startTimed("PermissionChecker.check")
93
+ permChecker.check()
94
+ DiagnosticLog.shared.finish(tPerm)
95
+
96
+ bannerDismissed = false
97
+ DiagnosticLog.shared.finish(tTotal)
75
98
  }
76
99
 
77
100
  private var mainContent: some View {
@@ -88,52 +111,51 @@ struct MainView: View {
88
111
  (NSApp.delegate as? AppDelegate)?.dismissPopover()
89
112
  ScreenMapWindowController.shared.showPage(.home)
90
113
  }
91
- headerButton(icon: "terminal") {
114
+ headerButton(icon: "rectangle.3.group") {
115
+ (NSApp.delegate as? AppDelegate)?.dismissPopover()
116
+ ScreenMapWindowController.shared.showPage(.screenMap)
117
+ }
118
+ headerButton(icon: "magnifyingglass") {
92
119
  (NSApp.delegate as? AppDelegate)?.dismissPopover()
93
- ScreenMapWindowController.shared.showPage(.pi)
120
+ ScreenMapWindowController.shared.showPage(.desktopInventory)
94
121
  }
95
- headerButton(icon: "arrow.up.left.and.arrow.down.right") {
122
+ headerButton(icon: "command") {
96
123
  (NSApp.delegate as? AppDelegate)?.dismissPopover()
97
- MainWindow.shared.show()
124
+ CommandPaletteWindow.shared.toggle()
98
125
  }
99
126
  headerButton(icon: "arrow.clockwise") { scanner.scan(); inventory.refresh() }
100
127
  }
101
128
  .padding(.horizontal, 18)
102
129
  .padding(.top, 18)
103
130
  .padding(.bottom, 12)
104
- }
105
-
106
- // Layer switcher
107
- if let config = workspace.config, let layers = config.layers, layers.count > 1 {
108
- layerBar(config: config)
109
- }
110
-
111
- // Search
112
- HStack(spacing: 8) {
113
- Image(systemName: "magnifyingglass")
114
- .foregroundColor(Palette.textMuted)
115
- .font(.system(size: 11))
116
- TextField("Search projects...", text: $searchText)
117
- .textFieldStyle(.plain)
118
- .font(Typo.body(13))
119
- .foregroundColor(Palette.text)
120
- if !searchText.isEmpty {
121
- Button { searchText = "" } label: {
122
- Image(systemName: "xmark.circle.fill")
123
- .foregroundColor(Palette.textMuted)
124
- .font(.system(size: 11))
131
+ } else {
132
+ // Search
133
+ HStack(spacing: 8) {
134
+ Image(systemName: "magnifyingglass")
135
+ .foregroundColor(Palette.textMuted)
136
+ .font(.system(size: 11))
137
+ TextField("Search projects...", text: $searchText)
138
+ .textFieldStyle(.plain)
139
+ .font(Typo.body(13))
140
+ .foregroundColor(Palette.text)
141
+ if !searchText.isEmpty {
142
+ Button { searchText = "" } label: {
143
+ Image(systemName: "xmark.circle.fill")
144
+ .foregroundColor(Palette.textMuted)
145
+ .font(.system(size: 11))
146
+ }
147
+ .buttonStyle(.plain)
125
148
  }
126
- .buttonStyle(.plain)
127
149
  }
150
+ .padding(.horizontal, 12)
151
+ .padding(.vertical, 8)
152
+ .background(
153
+ RoundedRectangle(cornerRadius: 4)
154
+ .fill(Palette.surface)
155
+ )
156
+ .padding(.horizontal, 14)
157
+ .padding(.bottom, 10)
128
158
  }
129
- .padding(.horizontal, 12)
130
- .padding(.vertical, 8)
131
- .background(
132
- RoundedRectangle(cornerRadius: 4)
133
- .fill(Palette.surface)
134
- )
135
- .padding(.horizontal, 14)
136
- .padding(.bottom, 10)
137
159
 
138
160
  // Permission banner
139
161
  if !permChecker.allGranted && !bannerDismissed {
@@ -145,79 +167,110 @@ struct MainView: View {
145
167
  tmuxBanner
146
168
  }
147
169
 
148
- Rectangle()
149
- .fill(Palette.border)
150
- .frame(height: 0.5)
170
+ if layout == .popover {
171
+ Rectangle()
172
+ .fill(Palette.border)
173
+ .frame(height: 0.5)
151
174
 
152
- // List
153
- if filtered.isEmpty && (workspace.config?.groups ?? []).isEmpty {
154
- Spacer()
155
- emptyState
156
- Spacer()
175
+ actionsSection
176
+
177
+ Rectangle()
178
+ .fill(Palette.border)
179
+ .frame(height: 0.5)
180
+
181
+ bottomBar
157
182
  } else {
158
- ScrollView {
159
- LazyVStack(spacing: 4) {
160
- // Tab groups section
161
- if let groups = workspace.config?.groups, !groups.isEmpty, searchText.isEmpty {
162
- ForEach(groups) { group in
163
- TabGroupRow(group: group, workspace: workspace)
164
- }
183
+ Rectangle()
184
+ .fill(Palette.border)
185
+ .frame(height: 0.5)
165
186
 
166
- if !filtered.isEmpty {
167
- Rectangle()
168
- .fill(Palette.border)
169
- .frame(height: 0.5)
170
- .padding(.vertical, 4)
171
- }
172
- }
187
+ if filtered.isEmpty && !hasVisibleGroups {
188
+ Spacer()
189
+ emptyState
190
+ Spacer()
191
+ } else {
192
+ embeddedProjectsSection
193
+ }
173
194
 
174
- // Projects
175
- ForEach(filtered) { project in
176
- ProjectRow(project: project) {
177
- SessionManager.launch(project: project)
178
- } onDetach: {
179
- SessionManager.detach(project: project)
180
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
181
- scanner.refreshStatus()
182
- }
183
- } onKill: {
184
- SessionManager.kill(project: project)
185
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
186
- scanner.refreshStatus()
187
- }
188
- } onSync: {
189
- SessionManager.sync(project: project)
190
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
191
- scanner.refreshStatus()
192
- }
193
- } onRestart: { paneName in
194
- SessionManager.restart(project: project, paneName: paneName)
195
- }
196
- }
195
+ Rectangle()
196
+ .fill(Palette.border)
197
+ .frame(height: 0.5)
197
198
 
198
- // Orphan sessions
199
- if !filteredOrphans.isEmpty {
200
- orphanSection
201
- }
199
+ // Actions footer
200
+ actionsSection
201
+
202
+ Rectangle()
203
+ .fill(Palette.border)
204
+ .frame(height: 0.5)
205
+
206
+ // Bottom bar
207
+ bottomBar
208
+ }
209
+ }
210
+ }
211
+
212
+ private var embeddedProjectsSection: some View {
213
+ VStack(spacing: 0) {
214
+ if hasVisibleGroups, let groups = workspace.config?.groups {
215
+ LazyVStack(spacing: 4) {
216
+ ForEach(groups) { group in
217
+ TabGroupRow(group: group, workspace: workspace)
202
218
  }
203
- .padding(.horizontal, 10)
204
- .padding(.vertical, 8)
205
219
  }
220
+ .padding(.horizontal, 10)
221
+ .padding(.top, 8)
222
+ .padding(.bottom, filtered.isEmpty ? 8 : 6)
206
223
  }
207
224
 
208
- Rectangle()
209
- .fill(Palette.border)
210
- .frame(height: 0.5)
225
+ if !filtered.isEmpty {
226
+ VStack(spacing: 0) {
227
+ HStack(spacing: 8) {
228
+ Text(searchText.isEmpty ? "Projects" : "Matches")
229
+ .font(Typo.monoBold(10))
230
+ .foregroundColor(Palette.textMuted)
211
231
 
212
- // Actions footer
213
- actionsSection
232
+ if searchText.isEmpty {
233
+ Text("\(runningCount) live")
234
+ .font(Typo.mono(9))
235
+ .foregroundColor(Palette.running.opacity(0.8))
236
+ }
214
237
 
215
- Rectangle()
216
- .fill(Palette.border)
217
- .frame(height: 0.5)
238
+ Spacer()
239
+
240
+ Text("\(filtered.count)")
241
+ .font(Typo.mono(9))
242
+ .foregroundColor(Palette.textMuted)
243
+ }
244
+ .padding(.horizontal, 14)
245
+ .padding(.top, 10)
246
+ .padding(.bottom, 8)
247
+
248
+ ScrollView(.vertical, showsIndicators: filtered.count > 9) {
249
+ LazyVGrid(columns: embeddedProjectColumns, spacing: embeddedProjectGridSpacing) {
250
+ ForEach(filtered) { project in
251
+ HomeProjectCard(
252
+ project: project,
253
+ onLaunch: { launchProject(project) },
254
+ onDetach: { detachProject(project) },
255
+ onKill: { killProject(project) },
256
+ onSync: { syncProject(project) },
257
+ onRestart: { paneName in restartProject(project, paneName: paneName) }
258
+ )
259
+ .frame(height: embeddedProjectCardHeight)
260
+ }
261
+ }
262
+ .padding(.horizontal, 14)
263
+ .padding(.bottom, 10)
264
+ }
265
+ .frame(maxHeight: embeddedProjectGridHeight)
266
+ }
267
+ }
218
268
 
219
- // Bottom bar
220
- bottomBar
269
+ if !filteredOrphans.isEmpty {
270
+ orphanSection
271
+ .padding(.horizontal, 10)
272
+ .padding(.bottom, 8)
273
+ }
221
274
  }
222
275
  }
223
276
 
@@ -283,27 +336,59 @@ struct MainView: View {
283
336
 
284
337
  private var actionsSection: some View {
285
338
  VStack(spacing: 0) {
286
- ActionRow(shortcut: "1", label: "Command Palette", hotkey: hotkeyLabel(.palette), icon: "command", accentColor: Palette.running) {
287
- CommandPaletteWindow.shared.toggle()
288
- }
289
- ActionRow(shortcut: "2", label: "Screen Map", hotkey: hotkeyLabel(.screenMap), icon: "rectangle.3.group") {
290
- ScreenMapWindowController.shared.toggle()
291
- }
292
- ActionRow(shortcut: "3", label: "Desktop Inventory", hotkey: hotkeyLabel(.desktopInventory), icon: "rectangle.split.2x1") {
293
- CommandModeWindow.shared.toggle()
339
+ HStack {
340
+ Text("Quick Actions")
341
+ .font(Typo.monoBold(10))
342
+ .foregroundColor(Palette.textMuted)
343
+
344
+ Spacer()
345
+
346
+ Button("Help & shortcuts") {
347
+ SettingsWindowController.shared.show()
348
+ }
349
+ .buttonStyle(.plain)
350
+ .font(Typo.mono(9))
351
+ .foregroundColor(Palette.textMuted)
294
352
  }
295
- ActionRow(shortcut: "4", label: "Window Bezel", hotkey: hotkeyLabel(.bezel), icon: "macwindow") {
296
- WindowBezel.showBezelForFrontmostWindow()
353
+ .padding(.horizontal, 14)
354
+ .padding(.top, 10)
355
+ .padding(.bottom, 6)
356
+
357
+ ActionRow(
358
+ label: "Home",
359
+ detail: "Workspace overview and project launcher",
360
+ hotkeyTokens: hotkeyTokens(.unifiedWindow),
361
+ icon: "house",
362
+ accentColor: Palette.text
363
+ ) {
364
+ ScreenMapWindowController.shared.showPage(.home)
297
365
  }
298
- ActionRow(shortcut: "5", label: "Cheat Sheet", hotkey: hotkeyLabel(.cheatSheet), icon: "keyboard") {
299
- CheatSheetHUD.shared.toggle()
366
+ ActionRow(
367
+ label: "Layout",
368
+ detail: "Arrange windows and layers",
369
+ hotkeyTokens: [],
370
+ icon: "rectangle.3.group",
371
+ accentColor: Palette.running
372
+ ) {
373
+ ScreenMapWindowController.shared.showPage(.screenMap)
300
374
  }
301
- ActionRow(shortcut: "6", label: "Omni Search", hotkey: hotkeyLabel(.omniSearch), icon: "magnifyingglass", accentColor: Palette.running) {
302
- OmniSearchWindow.shared.toggle()
375
+ ActionRow(
376
+ label: "Search",
377
+ detail: "Windows, projects, sessions, processes, and OCR",
378
+ hotkeyTokens: hotkeyTokens(.omniSearch),
379
+ icon: "magnifyingglass",
380
+ accentColor: AudioLayer.shared.isListening ? Palette.running : Palette.textDim
381
+ ) {
382
+ ScreenMapWindowController.shared.showPage(.desktopInventory)
303
383
  }
304
- ActionRow(shortcut: "7", label: "Voice Command", hotkey: hotkeyLabel(.voiceCommand), icon: "mic", accentColor: AudioLayer.shared.isListening ? Palette.running : Palette.textDim) {
305
- let audio = AudioLayer.shared
306
- if audio.isListening { audio.stopVoiceCommand() } else { audio.startVoiceCommand() }
384
+ ActionRow(
385
+ label: "Command Palette",
386
+ detail: "Launch, attach, and control projects",
387
+ hotkeyTokens: hotkeyTokens(.palette),
388
+ icon: "command",
389
+ accentColor: Palette.running
390
+ ) {
391
+ CommandPaletteWindow.shared.toggle()
307
392
  }
308
393
  }
309
394
  .padding(.vertical, 4)
@@ -317,9 +402,6 @@ struct MainView: View {
317
402
  }
318
403
 
319
404
  HStack(spacing: 4) {
320
- bottomBarButton(icon: "stethoscope", label: "Diagnostics") {
321
- DiagnosticWindow.shared.toggle()
322
- }
323
405
  if !permChecker.allGranted {
324
406
  Circle()
325
407
  .fill(Palette.detach)
@@ -351,9 +433,29 @@ struct MainView: View {
351
433
  .buttonStyle(.plain)
352
434
  }
353
435
 
354
- private func hotkeyLabel(_ action: HotkeyAction) -> String? {
355
- guard let binding = HotkeyStore.shared.bindings[action] else { return nil }
356
- return binding.displayParts.joined(separator: "")
436
+ private func hotkeyTokens(_ action: HotkeyAction) -> [String] {
437
+ guard let binding = HotkeyStore.shared.bindings[action],
438
+ let key = binding.displayParts.last else { return [] }
439
+
440
+ let modifiers = Set(binding.displayParts.dropLast())
441
+ if modifiers == Set(["Ctrl", "Option", "Shift", "Cmd"]) {
442
+ return ["Hyper", shortenHotkeyToken(key)]
443
+ }
444
+
445
+ return binding.displayParts.map(shortenHotkeyToken)
446
+ }
447
+
448
+ private func shortenHotkeyToken(_ token: String) -> String {
449
+ switch token {
450
+ case "Cmd": return "⌘"
451
+ case "Shift": return "⇧"
452
+ case "Option": return "⌥"
453
+ case "Ctrl": return "⌃"
454
+ case "Return": return "↩"
455
+ case "Escape": return "Esc"
456
+ case "Space": return "Space"
457
+ default: return token
458
+ }
357
459
  }
358
460
 
359
461
  // MARK: - Empty state
@@ -368,12 +470,33 @@ struct MainView: View {
368
470
  .font(Typo.heading(14))
369
471
  .foregroundColor(Palette.textDim)
370
472
 
371
- Text("Run lattices init in a project\nto add it here")
473
+ Text("Choose a repo and we’ll hand off to the CLI\nin your terminal.")
372
474
  .font(Typo.mono(11))
373
475
  .foregroundColor(Palette.textMuted)
374
476
  .multilineTextAlignment(.center)
375
477
  .lineSpacing(3)
478
+
479
+ HStack(spacing: 10) {
480
+ Button(action: CliActionLauncher.initializeProjectInTerminal) {
481
+ Text("Initialize Project")
482
+ .angularButton(Palette.running)
483
+ }
484
+ .buttonStyle(.plain)
485
+
486
+ Button(action: CliActionLauncher.launchProjectInTerminal) {
487
+ Text("Launch Project")
488
+ .angularButton(.white, filled: false)
489
+ }
490
+ .buttonStyle(.plain)
491
+ }
492
+
493
+ Text("Initialize runs lattices init && lattices in the folder you choose.")
494
+ .font(Typo.mono(9))
495
+ .foregroundColor(Palette.textMuted)
496
+ .multilineTextAlignment(.center)
497
+ .lineSpacing(2)
376
498
  }
499
+ .padding(.horizontal, 20)
377
500
  }
378
501
 
379
502
  // MARK: - Permission banner
@@ -399,11 +522,11 @@ struct MainView: View {
399
522
  permissionRow("Accessibility", granted: permChecker.accessibility) {
400
523
  permChecker.requestAccessibility()
401
524
  }
402
- permissionRow("Screen Recording", granted: permChecker.screenRecording) {
525
+ permissionRow("Screen Capture", granted: permChecker.screenRecording) {
403
526
  permChecker.requestScreenRecording()
404
527
  }
405
528
 
406
- Text("Click a row to request access.")
529
+ Text("Click a row to continue the permission flow in macOS.")
407
530
  .font(Typo.mono(9))
408
531
  .foregroundColor(Palette.textMuted)
409
532
  }
@@ -520,54 +643,6 @@ struct MainView: View {
520
643
  .disabled(granted)
521
644
  }
522
645
 
523
- // MARK: - Layer Bar
524
-
525
- private func layerBar(config: WorkspaceConfig) -> some View {
526
- HStack(spacing: 6) {
527
- ForEach(Array((config.layers ?? []).enumerated()), id: \.element.id) { i, layer in
528
- let isActive = i == workspace.activeLayerIndex
529
- let counts = workspace.layerRunningCount(index: i)
530
- Button {
531
- workspace.tileLayer(index: i)
532
- } label: {
533
- VStack(spacing: 2) {
534
- HStack(spacing: 5) {
535
- Circle()
536
- .fill(isActive ? Palette.running : Palette.textMuted.opacity(0.4))
537
- .frame(width: 6, height: 6)
538
- Text(layer.label)
539
- .font(Typo.mono(11))
540
- .foregroundColor(isActive ? Palette.text : Palette.textDim)
541
- if counts.total > 0 {
542
- Text("\(counts.running)/\(counts.total)")
543
- .font(Typo.mono(8))
544
- .foregroundColor(counts.running > 0 ? Palette.running : Palette.textMuted)
545
- }
546
- }
547
- Text("\u{2325}\(i + 1)")
548
- .font(Typo.mono(8))
549
- .foregroundColor(Palette.textMuted.opacity(0.6))
550
- }
551
- .padding(.horizontal, 10)
552
- .padding(.vertical, 5)
553
- .background(
554
- RoundedRectangle(cornerRadius: 5)
555
- .fill(isActive ? Palette.running.opacity(0.1) : Color.clear)
556
- )
557
- .overlay(
558
- RoundedRectangle(cornerRadius: 5)
559
- .strokeBorder(isActive ? Palette.running.opacity(0.3) : Palette.border, lineWidth: 0.5)
560
- )
561
- }
562
- .buttonStyle(.plain)
563
- .disabled(workspace.isSwitching)
564
- }
565
- Spacer()
566
- }
567
- .padding(.horizontal, 14)
568
- .padding(.bottom, 8)
569
- }
570
-
571
646
  // MARK: - Helpers
572
647
 
573
648
  private func headerButton(icon: String, action: @escaping () -> Void) -> some View {
@@ -579,4 +654,141 @@ struct MainView: View {
579
654
  }
580
655
  .buttonStyle(.plain)
581
656
  }
657
+
658
+ private func launchProject(_ project: Project) {
659
+ SessionManager.launch(project: project)
660
+ }
661
+
662
+ private func detachProject(_ project: Project) {
663
+ SessionManager.detach(project: project)
664
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
665
+ scanner.refreshStatus()
666
+ }
667
+ }
668
+
669
+ private func killProject(_ project: Project) {
670
+ SessionManager.kill(project: project)
671
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
672
+ scanner.refreshStatus()
673
+ }
674
+ }
675
+
676
+ private func syncProject(_ project: Project) {
677
+ SessionManager.sync(project: project)
678
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
679
+ scanner.refreshStatus()
680
+ }
681
+ }
682
+
683
+ private func restartProject(_ project: Project, paneName: String?) {
684
+ SessionManager.restart(project: project, paneName: paneName)
685
+ }
686
+ }
687
+
688
+ private struct HomeProjectCard: View {
689
+ let project: Project
690
+ let onLaunch: () -> Void
691
+ let onDetach: () -> Void
692
+ let onKill: () -> Void
693
+ let onSync: () -> Void
694
+ let onRestart: (String?) -> Void
695
+
696
+ @State private var isHovered = false
697
+
698
+ private var summaryText: String {
699
+ if !project.paneSummary.isEmpty { return project.paneSummary }
700
+ if let cmd = project.devCommand, !cmd.isEmpty { return cmd }
701
+ return project.hasConfig
702
+ ? "\(project.paneCount) pane\(project.paneCount == 1 ? "" : "s")"
703
+ : "No config"
704
+ }
705
+
706
+ var body: some View {
707
+ VStack(alignment: .leading, spacing: 10) {
708
+ HStack(alignment: .top, spacing: 8) {
709
+ Circle()
710
+ .fill(project.isRunning ? Palette.running : Palette.borderLit)
711
+ .frame(width: 7, height: 7)
712
+ .padding(.top, 4)
713
+
714
+ VStack(alignment: .leading, spacing: 4) {
715
+ Text(project.name)
716
+ .font(Typo.heading(13))
717
+ .foregroundColor(Palette.text)
718
+ .lineLimit(1)
719
+
720
+ Text(summaryText)
721
+ .font(Typo.mono(10))
722
+ .foregroundColor(Palette.textMuted)
723
+ .lineLimit(2)
724
+ .fixedSize(horizontal: false, vertical: true)
725
+ }
726
+
727
+ Spacer(minLength: 0)
728
+
729
+ if project.isRunning {
730
+ Text("LIVE")
731
+ .font(Typo.monoBold(8))
732
+ .foregroundColor(Palette.running)
733
+ .padding(.horizontal, 6)
734
+ .padding(.vertical, 3)
735
+ .background(
736
+ Capsule()
737
+ .fill(Palette.running.opacity(0.10))
738
+ )
739
+ }
740
+ }
741
+
742
+ Spacer(minLength: 0)
743
+
744
+ HStack(spacing: 6) {
745
+ if project.isRunning {
746
+ Button(action: onDetach) {
747
+ Image(systemName: "rectangle.portrait.and.arrow.right")
748
+ .font(.system(size: 10, weight: .medium))
749
+ .angularButton(Palette.detach, filled: false)
750
+ }
751
+ .buttonStyle(.plain)
752
+ }
753
+
754
+ Spacer(minLength: 0)
755
+
756
+ Button(action: onLaunch) {
757
+ Text(project.isRunning ? "Attach" : "Launch")
758
+ .angularButton(project.isRunning ? Palette.running : Palette.launch)
759
+ }
760
+ .buttonStyle(.plain)
761
+ }
762
+ }
763
+ .padding(12)
764
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
765
+ .glassCard(hovered: isHovered)
766
+ .contentShape(Rectangle())
767
+ .onHover { isHovered = $0 }
768
+ .contextMenu {
769
+ if project.isRunning {
770
+ Button("Attach") { onLaunch() }
771
+ Button("Detach") { onDetach() }
772
+ Button {
773
+ WindowTiler.navigateToWindow(
774
+ session: project.sessionName,
775
+ terminal: Preferences.shared.terminal
776
+ )
777
+ } label: {
778
+ Label("Go to Window", systemImage: "macwindow")
779
+ }
780
+ Divider()
781
+ Button("Sync Session") { onSync() }
782
+ Menu("Restart Pane") {
783
+ ForEach(project.paneNames, id: \.self) { name in
784
+ Button(name) { onRestart(name) }
785
+ }
786
+ }
787
+ Divider()
788
+ Button("Kill Session") { onKill() }
789
+ } else {
790
+ Button("Launch") { onLaunch() }
791
+ }
792
+ }
793
+ }
582
794
  }