@lattices/cli 0.4.2 → 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 (70) 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/App.swift +10 -0
  7. package/app/Sources/AppDelegate.swift +90 -34
  8. package/app/Sources/AppShellView.swift +2 -0
  9. package/app/Sources/AppTypeClassifier.swift +36 -0
  10. package/app/Sources/AppUpdater.swift +92 -0
  11. package/app/Sources/CheatSheetHUD.swift +1 -0
  12. package/app/Sources/CliActionLauncher.swift +50 -0
  13. package/app/Sources/CommandModeView.swift +4 -24
  14. package/app/Sources/CompanionActivityLog.swift +70 -0
  15. package/app/Sources/CompanionKeyboardController.swift +141 -0
  16. package/app/Sources/DesktopModel.swift +4 -0
  17. package/app/Sources/HandsOffSession.swift +15 -4
  18. package/app/Sources/HomeDashboardView.swift +18 -10
  19. package/app/Sources/HotkeyStore.swift +8 -5
  20. package/app/Sources/IntentEngine.swift +7 -1
  21. package/app/Sources/LatticesApi.swift +125 -4
  22. package/app/Sources/LatticesCompanionBridgeServer.swift +438 -0
  23. package/app/Sources/LatticesCompanionCockpit.swift +555 -0
  24. package/app/Sources/LatticesCompanionSecurityCoordinator.swift +594 -0
  25. package/app/Sources/LatticesCompanionTrackpadController.swift +204 -0
  26. package/app/Sources/LatticesDeckHost.swift +1463 -0
  27. package/app/Sources/LatticesRuntime.swift +61 -0
  28. package/app/Sources/MainView.swift +351 -191
  29. package/app/Sources/MouseFinder.swift +335 -30
  30. package/app/Sources/MouseGestureConfig.swift +364 -0
  31. package/app/Sources/MouseGestureController.swift +1203 -0
  32. package/app/Sources/MouseInputDeviceStore.swift +98 -0
  33. package/app/Sources/MouseInputEventViewer.swift +272 -0
  34. package/app/Sources/MouseShortcutStore.swift +107 -0
  35. package/app/Sources/OmniSearchView.swift +136 -2
  36. package/app/Sources/OmniSearchWindow.swift +65 -5
  37. package/app/Sources/OnboardingView.swift +30 -16
  38. package/app/Sources/PaletteCommand.swift +26 -6
  39. package/app/Sources/PermissionChecker.swift +76 -2
  40. package/app/Sources/PiAuthNextStepCard.swift +148 -0
  41. package/app/Sources/PiAuthPromptCard.swift +90 -0
  42. package/app/Sources/PiChatDock.swift +137 -74
  43. package/app/Sources/PiChatSession.swift +608 -108
  44. package/app/Sources/PiInstallCallout.swift +86 -0
  45. package/app/Sources/PiProviderSetupCallout.swift +99 -0
  46. package/app/Sources/PiWorkspaceView.swift +174 -77
  47. package/app/Sources/Preferences.swift +78 -0
  48. package/app/Sources/ScreenMapState.swift +91 -31
  49. package/app/Sources/ScreenMapView.swift +510 -524
  50. package/app/Sources/ScreenMapWindowController.swift +12 -4
  51. package/app/Sources/SettingsView.swift +869 -152
  52. package/app/Sources/SystemTelemetryMonitor.swift +273 -0
  53. package/app/Sources/VoiceCommandWindow.swift +23 -2
  54. package/app/Sources/WindowDragSnapController.swift +628 -0
  55. package/app/Sources/WindowTiler.swift +328 -65
  56. package/app/Sources/WorkspaceManager.swift +288 -0
  57. package/bin/assistant-intelligence.ts +874 -0
  58. package/bin/handsoff-infer.ts +16 -209
  59. package/bin/handsoff-worker.ts +45 -258
  60. package/bin/lattices-app.ts +62 -0
  61. package/bin/lattices-dev +4 -0
  62. package/bin/lattices.ts +125 -14
  63. package/docs/agents.md +14 -0
  64. package/docs/api.md +55 -0
  65. package/docs/app.md +3 -0
  66. package/docs/companion-deck.md +180 -0
  67. package/docs/config.md +25 -0
  68. package/docs/tiling-reference.md +55 -0
  69. package/docs/voice-error-model.md +73 -0
  70. package/package.json +2 -1
@@ -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()
218
239
 
219
- // Bottom bar
220
- bottomBar
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
+ }
268
+
269
+ if !filteredOrphans.isEmpty {
270
+ orphanSection
271
+ .padding(.horizontal, 10)
272
+ .padding(.bottom, 8)
273
+ }
221
274
  }
222
275
  }
223
276
 
@@ -291,7 +344,7 @@ struct MainView: View {
291
344
  Spacer()
292
345
 
293
346
  Button("Help & shortcuts") {
294
- ScreenMapWindowController.shared.showPage(.docs)
347
+ SettingsWindowController.shared.show()
295
348
  }
296
349
  .buttonStyle(.plain)
297
350
  .font(Typo.mono(9))
@@ -302,31 +355,40 @@ struct MainView: View {
302
355
  .padding(.bottom, 6)
303
356
 
304
357
  ActionRow(
305
- label: "Command Palette",
306
- detail: "Launch, attach, and control projects",
307
- hotkeyTokens: hotkeyTokens(.palette),
308
- icon: "command",
309
- accentColor: Palette.running
310
- ) {
311
- CommandPaletteWindow.shared.toggle()
312
- }
313
- ActionRow(
314
- label: "Workspace",
315
- detail: "Screen map, inventory, and window context",
358
+ label: "Home",
359
+ detail: "Workspace overview and project launcher",
316
360
  hotkeyTokens: hotkeyTokens(.unifiedWindow),
317
- icon: "square.grid.2x2",
361
+ icon: "house",
318
362
  accentColor: Palette.text
319
363
  ) {
320
364
  ScreenMapWindowController.shared.showPage(.home)
321
365
  }
322
366
  ActionRow(
323
- label: "Assistant",
324
- detail: "Search now, or use voice when you need it",
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)
374
+ }
375
+ ActionRow(
376
+ label: "Search",
377
+ detail: "Windows, projects, sessions, processes, and OCR",
325
378
  hotkeyTokens: hotkeyTokens(.omniSearch),
326
379
  icon: "magnifyingglass",
327
380
  accentColor: AudioLayer.shared.isListening ? Palette.running : Palette.textDim
328
381
  ) {
329
- showAssistant()
382
+ ScreenMapWindowController.shared.showPage(.desktopInventory)
383
+ }
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()
330
392
  }
331
393
  }
332
394
  .padding(.vertical, 4)
@@ -340,9 +402,6 @@ struct MainView: View {
340
402
  }
341
403
 
342
404
  HStack(spacing: 4) {
343
- bottomBarButton(icon: "stethoscope", label: "Diagnostics") {
344
- DiagnosticWindow.shared.toggle()
345
- }
346
405
  if !permChecker.allGranted {
347
406
  Circle()
348
407
  .fill(Palette.detach)
@@ -399,15 +458,6 @@ struct MainView: View {
399
458
  }
400
459
  }
401
460
 
402
- private func showAssistant() {
403
- if AudioLayer.shared.isListening || VoiceCommandWindow.shared.isVisible {
404
- VoiceCommandWindow.shared.toggle()
405
- return
406
- }
407
-
408
- OmniSearchWindow.shared.show()
409
- }
410
-
411
461
  // MARK: - Empty state
412
462
 
413
463
  private var emptyState: some View {
@@ -420,12 +470,33 @@ struct MainView: View {
420
470
  .font(Typo.heading(14))
421
471
  .foregroundColor(Palette.textDim)
422
472
 
423
- 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.")
424
474
  .font(Typo.mono(11))
425
475
  .foregroundColor(Palette.textMuted)
426
476
  .multilineTextAlignment(.center)
427
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)
428
498
  }
499
+ .padding(.horizontal, 20)
429
500
  }
430
501
 
431
502
  // MARK: - Permission banner
@@ -451,11 +522,11 @@ struct MainView: View {
451
522
  permissionRow("Accessibility", granted: permChecker.accessibility) {
452
523
  permChecker.requestAccessibility()
453
524
  }
454
- permissionRow("Screen Recording", granted: permChecker.screenRecording) {
525
+ permissionRow("Screen Capture", granted: permChecker.screenRecording) {
455
526
  permChecker.requestScreenRecording()
456
527
  }
457
528
 
458
- Text("Click a row to request access.")
529
+ Text("Click a row to continue the permission flow in macOS.")
459
530
  .font(Typo.mono(9))
460
531
  .foregroundColor(Palette.textMuted)
461
532
  }
@@ -572,54 +643,6 @@ struct MainView: View {
572
643
  .disabled(granted)
573
644
  }
574
645
 
575
- // MARK: - Layer Bar
576
-
577
- private func layerBar(config: WorkspaceConfig) -> some View {
578
- HStack(spacing: 6) {
579
- ForEach(Array((config.layers ?? []).enumerated()), id: \.element.id) { i, layer in
580
- let isActive = i == workspace.activeLayerIndex
581
- let counts = workspace.layerRunningCount(index: i)
582
- Button {
583
- workspace.tileLayer(index: i)
584
- } label: {
585
- VStack(spacing: 2) {
586
- HStack(spacing: 5) {
587
- Circle()
588
- .fill(isActive ? Palette.running : Palette.textMuted.opacity(0.4))
589
- .frame(width: 6, height: 6)
590
- Text(layer.label)
591
- .font(Typo.mono(11))
592
- .foregroundColor(isActive ? Palette.text : Palette.textDim)
593
- if counts.total > 0 {
594
- Text("\(counts.running)/\(counts.total)")
595
- .font(Typo.mono(8))
596
- .foregroundColor(counts.running > 0 ? Palette.running : Palette.textMuted)
597
- }
598
- }
599
- Text("\u{2325}\(i + 1)")
600
- .font(Typo.mono(8))
601
- .foregroundColor(Palette.textMuted.opacity(0.6))
602
- }
603
- .padding(.horizontal, 10)
604
- .padding(.vertical, 5)
605
- .background(
606
- RoundedRectangle(cornerRadius: 5)
607
- .fill(isActive ? Palette.running.opacity(0.1) : Color.clear)
608
- )
609
- .overlay(
610
- RoundedRectangle(cornerRadius: 5)
611
- .strokeBorder(isActive ? Palette.running.opacity(0.3) : Palette.border, lineWidth: 0.5)
612
- )
613
- }
614
- .buttonStyle(.plain)
615
- .disabled(workspace.isSwitching)
616
- }
617
- Spacer()
618
- }
619
- .padding(.horizontal, 14)
620
- .padding(.bottom, 8)
621
- }
622
-
623
646
  // MARK: - Helpers
624
647
 
625
648
  private func headerButton(icon: String, action: @escaping () -> Void) -> some View {
@@ -631,4 +654,141 @@ struct MainView: View {
631
654
  }
632
655
  .buttonStyle(.plain)
633
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
+ }
634
794
  }