@lattices/cli 0.3.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 (74) hide show
  1. package/README.md +155 -0
  2. package/app/Lattices.app/Contents/Info.plist +24 -0
  3. package/app/Package.swift +13 -0
  4. package/app/Sources/AccessibilityTextExtractor.swift +111 -0
  5. package/app/Sources/ActionRow.swift +61 -0
  6. package/app/Sources/App.swift +10 -0
  7. package/app/Sources/AppDelegate.swift +242 -0
  8. package/app/Sources/AppShellView.swift +62 -0
  9. package/app/Sources/AppTypeClassifier.swift +70 -0
  10. package/app/Sources/AppWindowShell.swift +63 -0
  11. package/app/Sources/CheatSheetHUD.swift +332 -0
  12. package/app/Sources/CommandModeState.swift +1362 -0
  13. package/app/Sources/CommandModeView.swift +1405 -0
  14. package/app/Sources/CommandModeWindow.swift +192 -0
  15. package/app/Sources/CommandPaletteView.swift +307 -0
  16. package/app/Sources/CommandPaletteWindow.swift +134 -0
  17. package/app/Sources/DaemonProtocol.swift +101 -0
  18. package/app/Sources/DaemonServer.swift +414 -0
  19. package/app/Sources/DesktopModel.swift +149 -0
  20. package/app/Sources/DesktopModelTypes.swift +71 -0
  21. package/app/Sources/DiagnosticLog.swift +271 -0
  22. package/app/Sources/EventBus.swift +30 -0
  23. package/app/Sources/HotkeyManager.swift +254 -0
  24. package/app/Sources/HotkeyStore.swift +338 -0
  25. package/app/Sources/InventoryManager.swift +35 -0
  26. package/app/Sources/InventoryPath.swift +43 -0
  27. package/app/Sources/KeyRecorderView.swift +210 -0
  28. package/app/Sources/LatticesApi.swift +1234 -0
  29. package/app/Sources/LayerBezel.swift +203 -0
  30. package/app/Sources/MainView.swift +479 -0
  31. package/app/Sources/MainWindow.swift +83 -0
  32. package/app/Sources/OcrModel.swift +430 -0
  33. package/app/Sources/OcrStore.swift +329 -0
  34. package/app/Sources/OmniSearchState.swift +283 -0
  35. package/app/Sources/OmniSearchView.swift +288 -0
  36. package/app/Sources/OmniSearchWindow.swift +105 -0
  37. package/app/Sources/OrphanRow.swift +129 -0
  38. package/app/Sources/PaletteCommand.swift +419 -0
  39. package/app/Sources/PermissionChecker.swift +125 -0
  40. package/app/Sources/Preferences.swift +99 -0
  41. package/app/Sources/ProcessModel.swift +199 -0
  42. package/app/Sources/ProcessQuery.swift +151 -0
  43. package/app/Sources/Project.swift +28 -0
  44. package/app/Sources/ProjectRow.swift +368 -0
  45. package/app/Sources/ProjectScanner.swift +128 -0
  46. package/app/Sources/ScreenMapState.swift +2387 -0
  47. package/app/Sources/ScreenMapView.swift +2820 -0
  48. package/app/Sources/ScreenMapWindowController.swift +89 -0
  49. package/app/Sources/SessionManager.swift +72 -0
  50. package/app/Sources/SettingsView.swift +1064 -0
  51. package/app/Sources/SettingsWindow.swift +20 -0
  52. package/app/Sources/TabGroupRow.swift +178 -0
  53. package/app/Sources/Terminal.swift +259 -0
  54. package/app/Sources/TerminalQuery.swift +156 -0
  55. package/app/Sources/TerminalSynthesizer.swift +200 -0
  56. package/app/Sources/Theme.swift +163 -0
  57. package/app/Sources/TilePickerView.swift +209 -0
  58. package/app/Sources/TmuxModel.swift +53 -0
  59. package/app/Sources/TmuxQuery.swift +81 -0
  60. package/app/Sources/WindowTiler.swift +1778 -0
  61. package/app/Sources/WorkspaceManager.swift +575 -0
  62. package/bin/client.js +4 -0
  63. package/bin/daemon-client.js +187 -0
  64. package/bin/lattices-app.js +221 -0
  65. package/bin/lattices.js +1551 -0
  66. package/docs/api.md +924 -0
  67. package/docs/app.md +297 -0
  68. package/docs/concepts.md +135 -0
  69. package/docs/config.md +245 -0
  70. package/docs/layers.md +410 -0
  71. package/docs/ocr.md +185 -0
  72. package/docs/overview.md +94 -0
  73. package/docs/quickstart.md +75 -0
  74. package/package.json +42 -0
@@ -0,0 +1,203 @@
1
+ import AppKit
2
+ import SwiftUI
3
+
4
+ // MARK: - Layer Switch HUD
5
+
6
+ /// A notch-style pill that briefly shows the active layer name when switching.
7
+ final class LayerBezel {
8
+ static let shared = LayerBezel()
9
+
10
+ private var panel: NSPanel?
11
+ private var dismissTimer: Timer?
12
+ /// Cached pill width per layer count — stable once computed for a workspace
13
+ private var cachedWidth: CGFloat?
14
+ private var cachedLayerSignature: String?
15
+
16
+ /// Show the layer bezel for a given layer label and index.
17
+ func show(label: String, index: Int, total: Int, allLabels: [String]) {
18
+ dismissTimer?.invalidate()
19
+
20
+ guard let screen = NSScreen.main ?? NSScreen.screens.first else { return }
21
+ let screenFrame = screen.frame
22
+
23
+ let pillWidth = stableWidth(for: allLabels, total: total)
24
+ let pillHeight: CGFloat = 64
25
+
26
+ // Position: centered on screen, upper third
27
+ let x = screenFrame.origin.x + (screenFrame.width - pillWidth) / 2
28
+ let y = screenFrame.origin.y + screenFrame.height * 0.65
29
+
30
+ let pillFrame = NSRect(x: x, y: y, width: pillWidth, height: pillHeight)
31
+
32
+ let view = LayerBezelView(label: label, index: index, total: total)
33
+ let hostingView = NSHostingView(rootView: view)
34
+
35
+ if panel == nil {
36
+ let p = NSPanel(
37
+ contentRect: pillFrame,
38
+ styleMask: [.borderless, .nonactivatingPanel],
39
+ backing: .buffered,
40
+ defer: false
41
+ )
42
+ p.isOpaque = false
43
+ p.backgroundColor = .clear
44
+ p.level = .statusBar
45
+ p.hasShadow = false
46
+ p.hidesOnDeactivate = false
47
+ p.isReleasedWhenClosed = false
48
+ p.isMovable = false
49
+ p.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
50
+ p.ignoresMouseEvents = true
51
+ panel = p
52
+ }
53
+
54
+ guard let p = panel else { return }
55
+
56
+ p.contentView = hostingView
57
+ p.setFrame(pillFrame, display: false)
58
+ p.alphaValue = 0
59
+ p.orderFrontRegardless()
60
+
61
+ NSAnimationContext.runAnimationGroup { ctx in
62
+ ctx.duration = 0.15
63
+ p.animator().alphaValue = 1.0
64
+ }
65
+
66
+ dismissTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self] _ in
67
+ self?.dismiss()
68
+ }
69
+ }
70
+
71
+ func dismiss() {
72
+ guard let p = panel, p.isVisible else { return }
73
+ NSAnimationContext.runAnimationGroup({ ctx in
74
+ ctx.duration = 0.3
75
+ p.animator().alphaValue = 0
76
+ }, completionHandler: {
77
+ p.orderOut(nil)
78
+ })
79
+ }
80
+
81
+ /// Invalidate cached width (call when workspace config changes)
82
+ func invalidateCache() {
83
+ cachedWidth = nil
84
+ cachedLayerSignature = nil
85
+ }
86
+
87
+ // MARK: - Width Heuristics
88
+
89
+ /// Compute a stable pill width based on the longest layer label.
90
+ /// Cached so the pill never resizes between switches within the same workspace.
91
+ private func stableWidth(for allLabels: [String], total: Int) -> CGFloat {
92
+ let signature = allLabels.joined(separator: "|") + ":\(total)"
93
+ if let cached = cachedWidth, cachedLayerSignature == signature {
94
+ return cached
95
+ }
96
+
97
+ // Measure the widest label using the actual font
98
+ let font = NSFont(name: "NewYork-RegularItalic", size: 24)
99
+ ?? NSFont.systemFont(ofSize: 24, weight: .medium)
100
+ let attrs: [NSAttributedString.Key: Any] = [.font: font]
101
+
102
+ var maxTextWidth: CGFloat = 0
103
+ for label in allLabels {
104
+ let size = (label as NSString).size(withAttributes: attrs)
105
+ maxTextWidth = max(maxTextWidth, ceil(size.width))
106
+ }
107
+
108
+ // dots width: 7px per dot + 5px spacing
109
+ let dotsWidth = CGFloat(total) * 7 + CGFloat(max(0, total - 1)) * 5
110
+ // divider + spacing
111
+ let dividerWidth: CGFloat = 1 + 14 * 2
112
+ // horizontal padding
113
+ let hPadding: CGFloat = 36 * 2
114
+
115
+ let contentWidth = dotsWidth + dividerWidth + maxTextWidth + hPadding
116
+
117
+ // Minimum 360, round up to nearest 20 for visual stability
118
+ let rawWidth = max(360, contentWidth)
119
+ let width = ceil(rawWidth / 20) * 20
120
+
121
+ cachedWidth = width
122
+ cachedLayerSignature = signature
123
+ return width
124
+ }
125
+ }
126
+
127
+ // MARK: - Bezel View
128
+
129
+ struct LayerBezelView: View {
130
+ let label: String
131
+ let index: Int
132
+ let total: Int
133
+
134
+ private var layerFont: Font {
135
+ // New York Italic — Apple's serif font
136
+ if let descriptor = NSFontDescriptor(fontAttributes: [
137
+ .family: "New York",
138
+ .traits: [NSFontDescriptor.TraitKey.symbolic: NSFontDescriptor.SymbolicTraits.italic.rawValue]
139
+ ]).withDesign(.serif) {
140
+ return Font(NSFont(descriptor: descriptor, size: 24) ?? .systemFont(ofSize: 24))
141
+ }
142
+ return .system(size: 24, weight: .medium, design: .serif).italic()
143
+ }
144
+
145
+ var body: some View {
146
+ HStack(spacing: 14) {
147
+ // Layer index dots
148
+ HStack(spacing: 5) {
149
+ ForEach(0..<total, id: \.self) { i in
150
+ Circle()
151
+ .fill(i == index ? Color.white : Color.white.opacity(0.25))
152
+ .frame(width: 7, height: 7)
153
+ }
154
+ }
155
+
156
+ // Divider
157
+ Rectangle()
158
+ .fill(Color.white.opacity(0.15))
159
+ .frame(width: 1, height: 20)
160
+
161
+ // Layer name
162
+ Text(label)
163
+ .font(layerFont)
164
+ .foregroundStyle(
165
+ .linearGradient(
166
+ colors: [.white, .white.opacity(0.85)],
167
+ startPoint: .leading,
168
+ endPoint: .trailing
169
+ )
170
+ )
171
+ }
172
+ .padding(.horizontal, 36)
173
+ .padding(.vertical, 16)
174
+ .frame(maxWidth: .infinity)
175
+ .background(
176
+ RoundedRectangle(cornerRadius: 28, style: .continuous)
177
+ .fill(Color.black)
178
+ )
179
+ .overlay(
180
+ RoundedRectangle(cornerRadius: 28, style: .continuous)
181
+ .strokeBorder(
182
+ .linearGradient(
183
+ colors: [.white.opacity(0.9), .white.opacity(0.22)],
184
+ startPoint: .top,
185
+ endPoint: .bottom
186
+ ),
187
+ lineWidth: 0.5
188
+ )
189
+ )
190
+ // Inner glow — top edge highlight
191
+ .overlay(
192
+ RoundedRectangle(cornerRadius: 28, style: .continuous)
193
+ .fill(
194
+ .linearGradient(
195
+ colors: [.white.opacity(0.08), .clear],
196
+ startPoint: .top,
197
+ endPoint: .center
198
+ )
199
+ )
200
+ .allowsHitTesting(false)
201
+ )
202
+ }
203
+ }
@@ -0,0 +1,479 @@
1
+ import SwiftUI
2
+
3
+ struct MainView: View {
4
+ @ObservedObject var scanner: ProjectScanner
5
+ @StateObject private var prefs = Preferences.shared
6
+ @StateObject private var permChecker = PermissionChecker.shared
7
+ @ObservedObject private var workspace = WorkspaceManager.shared
8
+ @StateObject private var inventory = InventoryManager.shared
9
+ @State private var searchText = ""
10
+ @State private var hasCheckedSetup = false
11
+ @State private var bannerDismissed = false
12
+ @State private var orphanSectionCollapsed = true
13
+ private var filtered: [Project] {
14
+ if searchText.isEmpty { return scanner.projects }
15
+ return scanner.projects.filter {
16
+ $0.name.localizedCaseInsensitiveContains(searchText)
17
+ }
18
+ }
19
+
20
+ private var filteredOrphans: [TmuxSession] {
21
+ if searchText.isEmpty { return inventory.orphans }
22
+ return inventory.orphans.filter {
23
+ $0.name.localizedCaseInsensitiveContains(searchText)
24
+ }
25
+ }
26
+
27
+ private var needsSetup: Bool { prefs.scanRoot.isEmpty }
28
+ private var runningCount: Int { scanner.projects.filter(\.isRunning).count }
29
+
30
+ var body: some View {
31
+ VStack(spacing: 0) {
32
+ mainContent
33
+ }
34
+ .frame(minWidth: 380, idealWidth: 380, maxWidth: 600, minHeight: 460, idealHeight: 460, maxHeight: .infinity)
35
+ .background(PanelBackground())
36
+ .preferredColorScheme(.dark)
37
+ .onAppear {
38
+ let tTotal = DiagnosticLog.shared.startTimed("MainView.onAppear (total)")
39
+ if needsSetup && !hasCheckedSetup {
40
+ hasCheckedSetup = true
41
+ SettingsWindowController.shared.show()
42
+ }
43
+ scanner.updateRoot(prefs.scanRoot)
44
+
45
+ let tScan = DiagnosticLog.shared.startTimed("ProjectScanner.scan")
46
+ scanner.scan()
47
+ DiagnosticLog.shared.finish(tScan)
48
+
49
+ let tInv = DiagnosticLog.shared.startTimed("InventoryManager.refresh")
50
+ inventory.refresh()
51
+ DiagnosticLog.shared.finish(tInv)
52
+
53
+ let tPerm = DiagnosticLog.shared.startTimed("PermissionChecker.check")
54
+ permChecker.check()
55
+ DiagnosticLog.shared.finish(tPerm)
56
+
57
+ bannerDismissed = false
58
+ DiagnosticLog.shared.finish(tTotal)
59
+ }
60
+ }
61
+
62
+ private var mainContent: some View {
63
+ VStack(spacing: 0) {
64
+ // Title bar
65
+ HStack {
66
+ Text("lattices")
67
+ .font(Typo.title())
68
+ .foregroundColor(Palette.text)
69
+
70
+ if runningCount > 0 || !inventory.orphans.isEmpty {
71
+ let total = runningCount + inventory.orphans.count
72
+ Text("\(total) session\(total == 1 ? "" : "s")")
73
+ .font(Typo.mono(10))
74
+ .foregroundColor(Palette.running)
75
+ .padding(.leading, 4)
76
+ } else {
77
+ Text("None")
78
+ .font(Typo.mono(10))
79
+ .foregroundColor(Palette.textMuted)
80
+ .padding(.leading, 4)
81
+ }
82
+
83
+ Spacer()
84
+
85
+ headerButton(icon: "arrow.up.left.and.arrow.down.right") {
86
+ (NSApp.delegate as? AppDelegate)?.dismissPopover()
87
+ MainWindow.shared.show()
88
+ }
89
+ headerButton(icon: "arrow.clockwise") { scanner.scan(); inventory.refresh() }
90
+ }
91
+ .padding(.horizontal, 18)
92
+ .padding(.top, 14)
93
+ .padding(.bottom, 10)
94
+
95
+ // Layer switcher
96
+ if let config = workspace.config, let layers = config.layers, layers.count > 1 {
97
+ layerBar(config: config)
98
+ }
99
+
100
+ // Search
101
+ HStack(spacing: 8) {
102
+ Image(systemName: "magnifyingglass")
103
+ .foregroundColor(Palette.textMuted)
104
+ .font(.system(size: 11))
105
+ TextField("Search projects...", text: $searchText)
106
+ .textFieldStyle(.plain)
107
+ .font(Typo.body(13))
108
+ .foregroundColor(Palette.text)
109
+ if !searchText.isEmpty {
110
+ Button { searchText = "" } label: {
111
+ Image(systemName: "xmark.circle.fill")
112
+ .foregroundColor(Palette.textMuted)
113
+ .font(.system(size: 11))
114
+ }
115
+ .buttonStyle(.plain)
116
+ }
117
+ }
118
+ .padding(.horizontal, 12)
119
+ .padding(.vertical, 8)
120
+ .background(
121
+ RoundedRectangle(cornerRadius: 4)
122
+ .fill(Palette.surface)
123
+ )
124
+ .padding(.horizontal, 14)
125
+ .padding(.bottom, 10)
126
+
127
+ // Permission banner
128
+ if !permChecker.allGranted && !bannerDismissed {
129
+ permissionBanner
130
+ }
131
+
132
+ Rectangle()
133
+ .fill(Palette.border)
134
+ .frame(height: 0.5)
135
+
136
+ // List
137
+ if filtered.isEmpty && (workspace.config?.groups ?? []).isEmpty {
138
+ Spacer()
139
+ emptyState
140
+ Spacer()
141
+ } else {
142
+ ScrollView {
143
+ LazyVStack(spacing: 4) {
144
+ // Tab groups section
145
+ if let groups = workspace.config?.groups, !groups.isEmpty, searchText.isEmpty {
146
+ ForEach(groups) { group in
147
+ TabGroupRow(group: group, workspace: workspace)
148
+ }
149
+
150
+ if !filtered.isEmpty {
151
+ Rectangle()
152
+ .fill(Palette.border)
153
+ .frame(height: 0.5)
154
+ .padding(.vertical, 4)
155
+ }
156
+ }
157
+
158
+ // Projects
159
+ ForEach(filtered) { project in
160
+ ProjectRow(project: project) {
161
+ SessionManager.launch(project: project)
162
+ } onDetach: {
163
+ SessionManager.detach(project: project)
164
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
165
+ scanner.refreshStatus()
166
+ }
167
+ } onKill: {
168
+ SessionManager.kill(project: project)
169
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
170
+ scanner.refreshStatus()
171
+ }
172
+ } onSync: {
173
+ SessionManager.sync(project: project)
174
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
175
+ scanner.refreshStatus()
176
+ }
177
+ } onRestart: { paneName in
178
+ SessionManager.restart(project: project, paneName: paneName)
179
+ }
180
+ }
181
+
182
+ // Orphan sessions
183
+ if !filteredOrphans.isEmpty {
184
+ orphanSection
185
+ }
186
+ }
187
+ .padding(.horizontal, 10)
188
+ .padding(.vertical, 8)
189
+ }
190
+ }
191
+
192
+ Rectangle()
193
+ .fill(Palette.border)
194
+ .frame(height: 0.5)
195
+
196
+ // Actions footer
197
+ actionsSection
198
+ }
199
+ }
200
+
201
+ // MARK: - Orphan section
202
+
203
+ private var orphanSection: some View {
204
+ VStack(spacing: 4) {
205
+ Rectangle()
206
+ .fill(Palette.border)
207
+ .frame(height: 0.5)
208
+ .padding(.vertical, 4)
209
+
210
+ // Section header
211
+ Button {
212
+ withAnimation(.easeOut(duration: 0.15)) { orphanSectionCollapsed.toggle() }
213
+ } label: {
214
+ HStack(spacing: 6) {
215
+ Image(systemName: orphanSectionCollapsed ? "chevron.right" : "chevron.down")
216
+ .font(.system(size: 9, weight: .semibold))
217
+ .foregroundColor(Palette.textMuted)
218
+
219
+ Text("Unmanaged Sessions")
220
+ .font(Typo.caption(10))
221
+ .foregroundColor(Palette.textMuted)
222
+
223
+ Text("\(filteredOrphans.count)")
224
+ .font(Typo.mono(9))
225
+ .foregroundColor(Palette.detach)
226
+ .padding(.horizontal, 5)
227
+ .padding(.vertical, 1)
228
+ .background(
229
+ RoundedRectangle(cornerRadius: 3)
230
+ .fill(Palette.detach.opacity(0.12))
231
+ )
232
+
233
+ Spacer()
234
+ }
235
+ }
236
+ .buttonStyle(.plain)
237
+ .padding(.horizontal, 4)
238
+
239
+ if !orphanSectionCollapsed {
240
+ ForEach(filteredOrphans) { session in
241
+ OrphanRow(
242
+ session: session,
243
+ onAttach: {
244
+ let terminal = Preferences.shared.terminal
245
+ terminal.focusOrAttach(session: session.name)
246
+ },
247
+ onKill: {
248
+ SessionManager.killByName(session.name)
249
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
250
+ inventory.refresh()
251
+ }
252
+ }
253
+ )
254
+ }
255
+ }
256
+ }
257
+ }
258
+
259
+ // MARK: - Actions footer
260
+
261
+ private var actionsSection: some View {
262
+ VStack(spacing: 0) {
263
+ ActionRow(shortcut: "1", label: "Command Palette", hotkey: hotkeyLabel(.palette), icon: "command", accentColor: Palette.running) {
264
+ CommandPaletteWindow.shared.toggle()
265
+ }
266
+ ActionRow(shortcut: "2", label: "Screen Map", hotkey: hotkeyLabel(.screenMap), icon: "rectangle.3.group") {
267
+ ScreenMapWindowController.shared.toggle()
268
+ }
269
+ ActionRow(shortcut: "3", label: "Desktop Inventory", hotkey: hotkeyLabel(.desktopInventory), icon: "rectangle.split.2x1") {
270
+ CommandModeWindow.shared.toggle()
271
+ }
272
+ ActionRow(shortcut: "4", label: "Window Bezel", hotkey: hotkeyLabel(.bezel), icon: "macwindow") {
273
+ WindowBezel.showBezelForFrontmostWindow()
274
+ }
275
+ ActionRow(shortcut: "5", label: "Cheat Sheet", hotkey: hotkeyLabel(.cheatSheet), icon: "keyboard") {
276
+ CheatSheetHUD.shared.toggle()
277
+ }
278
+ ActionRow(shortcut: "6", label: "Omni Search", hotkey: hotkeyLabel(.omniSearch), icon: "magnifyingglass", accentColor: Palette.running) {
279
+ OmniSearchWindow.shared.toggle()
280
+ }
281
+
282
+ Rectangle()
283
+ .fill(Palette.border)
284
+ .frame(height: 0.5)
285
+ .padding(.horizontal, 10)
286
+
287
+ ActionRow(shortcut: "S", label: "Settings", icon: "gearshape") {
288
+ SettingsWindowController.shared.show()
289
+ }
290
+ HStack(spacing: 0) {
291
+ ActionRow(shortcut: "D", label: "Diagnostics", icon: "stethoscope") {
292
+ DiagnosticWindow.shared.toggle()
293
+ }
294
+ if !permChecker.allGranted {
295
+ Circle()
296
+ .fill(Palette.detach)
297
+ .frame(width: 6, height: 6)
298
+ .padding(.trailing, 14)
299
+ }
300
+ }
301
+
302
+ Rectangle()
303
+ .fill(Palette.border)
304
+ .frame(height: 0.5)
305
+ .padding(.horizontal, 10)
306
+
307
+ ActionRow(shortcut: "Q", label: "Quit", icon: "power", accentColor: Palette.kill) {
308
+ NSApp.terminate(nil)
309
+ }
310
+ }
311
+ .padding(.vertical, 4)
312
+ .background(Palette.surface.opacity(0.4))
313
+ }
314
+
315
+ private func hotkeyLabel(_ action: HotkeyAction) -> String? {
316
+ guard let binding = HotkeyStore.shared.bindings[action] else { return nil }
317
+ return binding.displayParts.joined(separator: "")
318
+ }
319
+
320
+ // MARK: - Empty state
321
+
322
+ private var emptyState: some View {
323
+ VStack(spacing: 14) {
324
+ Image(systemName: "terminal")
325
+ .font(.system(size: 28, weight: .light))
326
+ .foregroundColor(Palette.textMuted)
327
+
328
+ Text("No projects yet")
329
+ .font(Typo.heading(14))
330
+ .foregroundColor(Palette.textDim)
331
+
332
+ Text("Run lattices init in a project\nto add it here")
333
+ .font(Typo.mono(11))
334
+ .foregroundColor(Palette.textMuted)
335
+ .multilineTextAlignment(.center)
336
+ .lineSpacing(3)
337
+ }
338
+ }
339
+
340
+ // MARK: - Permission banner
341
+
342
+ private var permissionBanner: some View {
343
+ VStack(alignment: .leading, spacing: 6) {
344
+ HStack {
345
+ Image(systemName: "exclamationmark.triangle.fill")
346
+ .font(.system(size: 10))
347
+ .foregroundColor(Palette.detach)
348
+ Text("PERMISSIONS NEEDED")
349
+ .font(Typo.monoBold(10))
350
+ .foregroundColor(Palette.detach)
351
+ Spacer()
352
+ Button { bannerDismissed = true } label: {
353
+ Image(systemName: "xmark")
354
+ .font(.system(size: 8, weight: .bold))
355
+ .foregroundColor(Palette.textMuted)
356
+ }
357
+ .buttonStyle(.plain)
358
+ }
359
+
360
+ permissionRow("Accessibility", granted: permChecker.accessibility) {
361
+ permChecker.requestAccessibility()
362
+ }
363
+ permissionRow("Screen Recording", granted: permChecker.screenRecording) {
364
+ permChecker.requestScreenRecording()
365
+ }
366
+
367
+ Text("Click a row to request access.")
368
+ .font(Typo.mono(9))
369
+ .foregroundColor(Palette.textMuted)
370
+ }
371
+ .padding(12)
372
+ .background(
373
+ RoundedRectangle(cornerRadius: 5)
374
+ .fill(Palette.detach.opacity(0.08))
375
+ .overlay(
376
+ RoundedRectangle(cornerRadius: 5)
377
+ .strokeBorder(Palette.detach.opacity(0.20), lineWidth: 0.5)
378
+ )
379
+ )
380
+ .padding(.horizontal, 14)
381
+ .padding(.bottom, 10)
382
+ }
383
+
384
+ private func permissionRow(_ name: String, granted: Bool, open: @escaping () -> Void) -> some View {
385
+ Button(action: { if !granted { open() } }) {
386
+ HStack(spacing: 6) {
387
+ Image(systemName: granted ? "checkmark.circle.fill" : "circle")
388
+ .font(.system(size: 10))
389
+ .foregroundColor(granted ? Palette.running : Palette.detach)
390
+ Text(name)
391
+ .font(Typo.mono(10))
392
+ .foregroundColor(Palette.text)
393
+ Spacer()
394
+ if granted {
395
+ Text("granted")
396
+ .font(Typo.mono(9))
397
+ .foregroundColor(Palette.running)
398
+ } else {
399
+ HStack(spacing: 4) {
400
+ Text("not set")
401
+ .font(Typo.mono(9))
402
+ .foregroundColor(Palette.detach)
403
+ Image(systemName: "arrow.up.forward.square")
404
+ .font(.system(size: 9))
405
+ .foregroundColor(Palette.detach)
406
+ }
407
+ }
408
+ }
409
+ .padding(.vertical, 4)
410
+ .padding(.horizontal, 8)
411
+ .background(
412
+ RoundedRectangle(cornerRadius: 4)
413
+ .fill(granted ? Color.clear : Palette.detach.opacity(0.06))
414
+ )
415
+ }
416
+ .buttonStyle(.plain)
417
+ .disabled(granted)
418
+ }
419
+
420
+ // MARK: - Layer Bar
421
+
422
+ private func layerBar(config: WorkspaceConfig) -> some View {
423
+ HStack(spacing: 6) {
424
+ ForEach(Array((config.layers ?? []).enumerated()), id: \.element.id) { i, layer in
425
+ let isActive = i == workspace.activeLayerIndex
426
+ let counts = workspace.layerRunningCount(index: i)
427
+ Button {
428
+ workspace.tileLayer(index: i)
429
+ } label: {
430
+ VStack(spacing: 2) {
431
+ HStack(spacing: 5) {
432
+ Circle()
433
+ .fill(isActive ? Palette.running : Palette.textMuted.opacity(0.4))
434
+ .frame(width: 6, height: 6)
435
+ Text(layer.label)
436
+ .font(Typo.mono(11))
437
+ .foregroundColor(isActive ? Palette.text : Palette.textDim)
438
+ if counts.total > 0 {
439
+ Text("\(counts.running)/\(counts.total)")
440
+ .font(Typo.mono(8))
441
+ .foregroundColor(counts.running > 0 ? Palette.running : Palette.textMuted)
442
+ }
443
+ }
444
+ Text("\u{2325}\(i + 1)")
445
+ .font(Typo.mono(8))
446
+ .foregroundColor(Palette.textMuted.opacity(0.6))
447
+ }
448
+ .padding(.horizontal, 10)
449
+ .padding(.vertical, 5)
450
+ .background(
451
+ RoundedRectangle(cornerRadius: 5)
452
+ .fill(isActive ? Palette.running.opacity(0.1) : Color.clear)
453
+ )
454
+ .overlay(
455
+ RoundedRectangle(cornerRadius: 5)
456
+ .strokeBorder(isActive ? Palette.running.opacity(0.3) : Palette.border, lineWidth: 0.5)
457
+ )
458
+ }
459
+ .buttonStyle(.plain)
460
+ .disabled(workspace.isSwitching)
461
+ }
462
+ Spacer()
463
+ }
464
+ .padding(.horizontal, 14)
465
+ .padding(.bottom, 8)
466
+ }
467
+
468
+ // MARK: - Helpers
469
+
470
+ private func headerButton(icon: String, action: @escaping () -> Void) -> some View {
471
+ Button(action: action) {
472
+ Image(systemName: icon)
473
+ .font(.system(size: 12, weight: .medium))
474
+ .foregroundColor(Palette.textDim)
475
+ .frame(width: 28, height: 28)
476
+ }
477
+ .buttonStyle(.plain)
478
+ }
479
+ }