@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
@@ -69,39 +69,110 @@ final class OmniSearchState: ObservableObject {
69
69
  private var debounceTimer: AnyCancellable?
70
70
 
71
71
  init() {
72
- // Debounce search by 150ms
72
+ // Single-char queries fire immediately with a lightweight search;
73
+ // longer queries debounce 150ms and run the full search.
73
74
  debounceTimer = $query
74
- .debounce(for: .milliseconds(150), scheduler: RunLoop.main)
75
+ .removeDuplicates()
75
76
  .sink { [weak self] q in
77
+ guard let self else { return }
78
+ self.fullSearchTask?.cancel()
76
79
  if q.isEmpty {
77
- self?.results = []
78
- self?.refreshSummary()
80
+ self.results = []
81
+ self.refreshSummary()
82
+ } else if q.count == 1 {
83
+ self.quickSearch(q)
79
84
  } else {
80
- self?.search(q)
85
+ self.fullSearchTask = DispatchWorkItem { [weak self] in
86
+ self?.search(q)
87
+ }
88
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: self.fullSearchTask!)
81
89
  }
82
90
  }
83
91
 
84
92
  refreshSummary()
85
93
  }
86
94
 
87
- // MARK: - Search
95
+ private var fullSearchTask: DispatchWorkItem?
96
+
97
+ // MARK: - Quick search (first character — window index only, no terminal/OCR/projects)
98
+
99
+ private func quickSearch(_ query: String) {
100
+ let q = query.lowercased()
101
+ let desktop = DesktopModel.shared
102
+ var all: [OmniResult] = []
103
+
104
+ for entry in desktop.allWindows() {
105
+ var score = 0
106
+ if entry.title.lowercased().contains(q) { score += 3 }
107
+ if entry.app.lowercased().contains(q) { score += 2 }
108
+ if entry.latticesSession?.lowercased().contains(q) == true { score += 3 }
109
+ guard score > 0 else { continue }
110
+
111
+ let wid = entry.wid
112
+ let pid = entry.pid
113
+ all.append(OmniResult(
114
+ kind: .window,
115
+ title: entry.app,
116
+ subtitle: entry.title.isEmpty ? "Window \(wid)" : entry.title,
117
+ icon: "macwindow",
118
+ score: score
119
+ ) {
120
+ WindowTiler.focusWindow(wid: wid, pid: pid)
121
+ })
122
+ }
123
+
124
+ all.sort { $0.score > $1.score }
125
+ results = Array(all.prefix(12))
126
+ selectedIndex = 0
127
+ }
128
+
129
+ // MARK: - Search (delegates to unified lattices.search API)
88
130
 
89
131
  private func search(_ query: String) {
90
132
  let q = query.lowercased()
91
133
  var all: [OmniResult] = []
92
134
 
93
- // Windows
94
- let desktop = DesktopModel.shared
95
- for win in desktop.allWindows() {
96
- let score = scoreMatch(q, against: [win.app, win.title])
97
- if score > 0 {
98
- let wid = win.wid
99
- let pid = win.pid
135
+ // ── Daemon search: windows, terminals, OCR — single source of truth ──
136
+ // This is synchronous on the daemon's in-process API, not a network call.
137
+ if let json = try? LatticesApi.shared.dispatch(
138
+ method: "lattices.search",
139
+ params: .object(["query": .string(q)])
140
+ ), case .array(let hits) = json {
141
+ let desktop = DesktopModel.shared
142
+ for hit in hits {
143
+ guard let wid = hit["wid"]?.uint32Value else { continue }
144
+ let app = hit["app"]?.stringValue ?? ""
145
+ let title = hit["title"]?.stringValue ?? ""
146
+ let score = hit["score"]?.intValue ?? 0
147
+ let pid = desktop.windows[wid]?.pid ?? 0
148
+ let sources = (hit["matchSources"]?.arrayValue ?? []).compactMap(\.stringValue)
149
+
150
+ // Determine kind from match sources
151
+ let hasOcr = sources.contains("ocr")
152
+ let hasTerminal = !Set(sources).isDisjoint(with: ["cwd", "tab", "tmux", "process"])
153
+ let kind: OmniResultKind = hasOcr ? .ocrContent : hasTerminal ? .session : .window
154
+
155
+ let icon: String
156
+ let subtitle: String
157
+ switch kind {
158
+ case .ocrContent:
159
+ icon = "doc.text.magnifyingglass"
160
+ subtitle = hit["ocrSnippet"]?.stringValue ?? title
161
+ case .session:
162
+ icon = "terminal"
163
+ let tabs = hit["terminalTabs"]?.arrayValue ?? []
164
+ let cwds = tabs.compactMap { $0["cwd"]?.stringValue }
165
+ subtitle = cwds.first ?? title
166
+ default:
167
+ icon = "macwindow"
168
+ subtitle = title.isEmpty ? "Window \(wid)" : title
169
+ }
170
+
100
171
  all.append(OmniResult(
101
- kind: .window,
102
- title: win.app,
103
- subtitle: win.title.isEmpty ? "Window \(win.wid)" : win.title,
104
- icon: "macwindow",
172
+ kind: kind,
173
+ title: app,
174
+ subtitle: subtitle,
175
+ icon: icon,
105
176
  score: score
106
177
  ) {
107
178
  WindowTiler.focusWindow(wid: wid, pid: pid)
@@ -109,10 +180,9 @@ final class OmniSearchState: ObservableObject {
109
180
  }
110
181
  }
111
182
 
112
- // Projects
113
- let scanner = ProjectScanner.shared
114
- for project in scanner.projects {
115
- let score = scoreMatch(q, against: [project.name, project.path])
183
+ // ── Projects: local-only (not window-centric, so not in daemon search) ──
184
+ for project in ProjectScanner.shared.projects {
185
+ let score = scoreProjectMatch(q, name: project.name, path: project.path)
116
186
  if score > 0 {
117
187
  let proj = project
118
188
  all.append(OmniResult(
@@ -127,93 +197,20 @@ final class OmniSearchState: ObservableObject {
127
197
  }
128
198
  }
129
199
 
130
- // Tmux Sessions
131
- let tmux = TmuxModel.shared
132
- for session in tmux.sessions {
133
- let paneCommands = session.panes.map(\.currentCommand)
134
- let score = scoreMatch(q, against: [session.name] + paneCommands)
135
- if score > 0 {
136
- let name = session.name
137
- all.append(OmniResult(
138
- kind: .session,
139
- title: session.name,
140
- subtitle: "\(session.windowCount) windows, \(session.panes.count) panes\(session.attached ? " (attached)" : "")",
141
- icon: "terminal",
142
- score: score
143
- ) {
144
- let terminal = Preferences.shared.terminal
145
- terminal.focusOrAttach(session: name)
146
- })
147
- }
148
- }
149
-
150
- // Processes
151
- let processes = ProcessModel.shared
152
- for proc in processes.interesting {
153
- let score = scoreMatch(q, against: [proc.comm, proc.args, proc.cwd ?? ""])
154
- if score > 0 {
155
- all.append(OmniResult(
156
- kind: .process,
157
- title: proc.comm,
158
- subtitle: proc.cwd ?? proc.args,
159
- icon: "gearshape",
160
- score: score
161
- ) {
162
- // No direct action for processes — just informational
163
- })
164
- }
165
- }
166
-
167
- // OCR content
168
- let ocr = OcrModel.shared
169
- for (_, result) in ocr.results {
170
- let ocrScore = scoreOcr(q, fullText: result.fullText)
171
- if ocrScore > 0 {
172
- let wid = result.wid
173
- let pid = desktop.windows[wid]?.pid ?? 0
174
- // Find matching line for subtitle
175
- let matchLine = result.texts
176
- .first { $0.text.lowercased().contains(q) }?
177
- .text ?? String(result.fullText.prefix(80))
178
- all.append(OmniResult(
179
- kind: .ocrContent,
180
- title: "\(result.app) — \(result.title)",
181
- subtitle: matchLine,
182
- icon: "doc.text.magnifyingglass",
183
- score: ocrScore
184
- ) {
185
- WindowTiler.focusWindow(wid: wid, pid: pid)
186
- })
187
- }
188
- }
189
-
190
- // Sort by score descending
191
200
  all.sort { $0.score > $1.score }
192
-
193
201
  results = all
194
202
  selectedIndex = 0
195
203
  }
196
204
 
197
- // MARK: - Scoring
198
-
199
- private func scoreMatch(_ query: String, against fields: [String]) -> Int {
200
- var best = 0
201
- for field in fields {
202
- let lower = field.lowercased()
203
- if lower == query {
204
- best = max(best, 100) // exact
205
- } else if lower.hasPrefix(query) {
206
- best = max(best, 80) // prefix
207
- } else if lower.contains(query) {
208
- best = max(best, 60) // contains
209
- }
210
- }
211
- return best
212
- }
205
+ // MARK: - Project scoring (local — projects aren't windows)
213
206
 
214
- private func scoreOcr(_ query: String, fullText: String) -> Int {
215
- let lower = fullText.lowercased()
216
- if lower.contains(query) { return 40 }
207
+ private func scoreProjectMatch(_ query: String, name: String, path: String) -> Int {
208
+ let lowerName = name.lowercased()
209
+ let lowerPath = path.lowercased()
210
+ if lowerName == query { return 100 }
211
+ if lowerName.hasPrefix(query) { return 80 }
212
+ if lowerName.contains(query) { return 60 }
213
+ if lowerPath.contains(query) { return 40 }
217
214
  return 0
218
215
  }
219
216
 
@@ -0,0 +1,457 @@
1
+ import SwiftUI
2
+ import AppKit
3
+
4
+ // MARK: - Onboarding Flow
5
+
6
+ /// A step-by-step welcome screen shown on first launch.
7
+ /// Walks the user through granting Accessibility, Screen Recording,
8
+ /// choosing a project root, and optionally installing tmux.
9
+ struct OnboardingView: View {
10
+ @ObservedObject private var permChecker = PermissionChecker.shared
11
+ @ObservedObject private var prefs = Preferences.shared
12
+ @ObservedObject private var tmux = TmuxModel.shared
13
+ @State private var step: Step = .welcome
14
+ var onComplete: () -> Void
15
+
16
+ enum Step: Int, CaseIterable {
17
+ case welcome
18
+ case accessibility
19
+ case screenRecording
20
+ case projectRoot
21
+ case tmux
22
+ case done
23
+ }
24
+
25
+ var body: some View {
26
+ VStack(spacing: 0) {
27
+ // Progress dots
28
+ HStack(spacing: 6) {
29
+ ForEach(Step.allCases, id: \.rawValue) { s in
30
+ Circle()
31
+ .fill(s.rawValue <= step.rawValue ? Color.white : Color.white.opacity(0.20))
32
+ .frame(width: 6, height: 6)
33
+ }
34
+ }
35
+ .padding(.top, 28)
36
+ .padding(.bottom, 24)
37
+
38
+ // Step content
39
+ Group {
40
+ switch step {
41
+ case .welcome: welcomeStep
42
+ case .accessibility: accessibilityStep
43
+ case .screenRecording: screenRecordingStep
44
+ case .projectRoot: projectRootStep
45
+ case .tmux: tmuxStep
46
+ case .done: doneStep
47
+ }
48
+ }
49
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
50
+ .padding(.horizontal, 40)
51
+
52
+ Spacer(minLength: 0)
53
+
54
+ // Navigation
55
+ HStack {
56
+ if step != .welcome {
57
+ Button("Back") { withAnimation(.easeInOut(duration: 0.2)) { goBack() } }
58
+ .buttonStyle(.plain)
59
+ .font(Typo.mono(11))
60
+ .foregroundColor(Palette.textMuted)
61
+ }
62
+ Spacer()
63
+ if step == .done {
64
+ Button(action: { onComplete() }) {
65
+ Text("Get started")
66
+ .angularButton(Palette.running)
67
+ }
68
+ .buttonStyle(.plain)
69
+ } else {
70
+ Button(action: { withAnimation(.easeInOut(duration: 0.2)) { advance() } }) {
71
+ Text(nextLabel)
72
+ .angularButton(.white)
73
+ }
74
+ .buttonStyle(.plain)
75
+ }
76
+ }
77
+ .padding(.horizontal, 40)
78
+ .padding(.bottom, 28)
79
+ }
80
+ .frame(width: 480, height: 420)
81
+ .background(Palette.bg)
82
+ .preferredColorScheme(.dark)
83
+ }
84
+
85
+ // MARK: - Steps
86
+
87
+ private var welcomeStep: some View {
88
+ VStack(spacing: 16) {
89
+ latticesIcon
90
+ Text("Welcome to Lattices")
91
+ .font(Typo.title(18))
92
+ .foregroundColor(Palette.text)
93
+ Text("Workspace control plane for macOS.\nLet's get you set up in under a minute.")
94
+ .font(Typo.body(12))
95
+ .foregroundColor(Palette.textDim)
96
+ .multilineTextAlignment(.center)
97
+ .lineSpacing(3)
98
+ }
99
+ }
100
+
101
+ private var accessibilityStep: some View {
102
+ permissionStep(
103
+ icon: "hand.raised.fill",
104
+ title: "Accessibility",
105
+ description: "Lattices needs Accessibility access to read window titles, move and resize windows, and tile your workspace.",
106
+ granted: permChecker.accessibility,
107
+ action: { permChecker.requestAccessibility() }
108
+ )
109
+ }
110
+
111
+ private var screenRecordingStep: some View {
112
+ permissionStep(
113
+ icon: "rectangle.dashed.badge.record",
114
+ title: "Screen Recording",
115
+ description: "Allows Lattices to index on-screen text with OCR so you can search across all your windows.",
116
+ granted: permChecker.screenRecording,
117
+ action: { permChecker.requestScreenRecording() }
118
+ )
119
+ }
120
+
121
+ private var projectRootStep: some View {
122
+ VStack(spacing: 16) {
123
+ Image(systemName: "folder.fill")
124
+ .font(.system(size: 28))
125
+ .foregroundColor(.white.opacity(0.7))
126
+
127
+ Text("Project directory")
128
+ .font(Typo.title(16))
129
+ .foregroundColor(Palette.text)
130
+
131
+ Text("Where do your projects live? Lattices scans this folder to find workspaces.")
132
+ .font(Typo.body(12))
133
+ .foregroundColor(Palette.textDim)
134
+ .multilineTextAlignment(.center)
135
+ .lineSpacing(3)
136
+
137
+ HStack(spacing: 8) {
138
+ Text(prefs.scanRoot.isEmpty ? "Not set" : abbreviatePath(prefs.scanRoot))
139
+ .font(Typo.mono(11))
140
+ .foregroundColor(prefs.scanRoot.isEmpty ? Palette.textMuted : Palette.text)
141
+ .lineLimit(1)
142
+ .truncationMode(.middle)
143
+ .frame(maxWidth: .infinity, alignment: .leading)
144
+ .padding(.horizontal, 10)
145
+ .padding(.vertical, 8)
146
+ .background(
147
+ RoundedRectangle(cornerRadius: 5)
148
+ .fill(Palette.surface)
149
+ .overlay(
150
+ RoundedRectangle(cornerRadius: 5)
151
+ .strokeBorder(Palette.border, lineWidth: 0.5)
152
+ )
153
+ )
154
+
155
+ Button("Browse") {
156
+ let panel = NSOpenPanel()
157
+ panel.canChooseFiles = false
158
+ panel.canChooseDirectories = true
159
+ panel.allowsMultipleSelection = false
160
+ panel.directoryURL = URL(fileURLWithPath: prefs.scanRoot.isEmpty ? NSHomeDirectory() : prefs.scanRoot)
161
+ if panel.runModal() == .OK, let url = panel.url {
162
+ prefs.scanRoot = url.path
163
+ }
164
+ }
165
+ .buttonStyle(.plain)
166
+ .font(Typo.monoBold(10))
167
+ .foregroundColor(.white)
168
+ .padding(.horizontal, 10)
169
+ .padding(.vertical, 6)
170
+ .background(
171
+ RoundedRectangle(cornerRadius: 5)
172
+ .fill(Palette.surface)
173
+ .overlay(
174
+ RoundedRectangle(cornerRadius: 5)
175
+ .strokeBorder(Palette.borderLit, lineWidth: 0.5)
176
+ )
177
+ )
178
+ }
179
+
180
+ if !prefs.scanRoot.isEmpty {
181
+ HStack(spacing: 4) {
182
+ Image(systemName: "checkmark.circle.fill")
183
+ .font(.system(size: 10))
184
+ .foregroundColor(Palette.running)
185
+ Text(abbreviatePath(prefs.scanRoot))
186
+ .font(Typo.mono(10))
187
+ .foregroundColor(Palette.running)
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ private var tmuxStep: some View {
194
+ VStack(spacing: 16) {
195
+ Image(systemName: "terminal.fill")
196
+ .font(.system(size: 28))
197
+ .foregroundColor(.white.opacity(0.7))
198
+
199
+ Text("Terminal sessions")
200
+ .font(Typo.title(16))
201
+ .foregroundColor(Palette.text)
202
+
203
+ if tmux.isAvailable {
204
+ HStack(spacing: 6) {
205
+ Image(systemName: "checkmark.circle.fill")
206
+ .foregroundColor(Palette.running)
207
+ Text("tmux is installed")
208
+ .font(Typo.mono(12))
209
+ .foregroundColor(Palette.running)
210
+ }
211
+ Text("Lattices can manage tmux sessions, pane layouts, and terminal workspaces for you.")
212
+ .font(Typo.body(12))
213
+ .foregroundColor(Palette.textDim)
214
+ .multilineTextAlignment(.center)
215
+ .lineSpacing(3)
216
+ } else {
217
+ Text("tmux is optional but recommended. It enables managed terminal sessions with persistent pane layouts.")
218
+ .font(Typo.body(12))
219
+ .foregroundColor(Palette.textDim)
220
+ .multilineTextAlignment(.center)
221
+ .lineSpacing(3)
222
+
223
+ Button(action: {
224
+ let task = Process()
225
+ task.executableURL = URL(fileURLWithPath: "/bin/zsh")
226
+ task.arguments = ["-lc", "brew install tmux"]
227
+ task.standardOutput = FileHandle.nullDevice
228
+ task.standardError = FileHandle.nullDevice
229
+ try? task.run()
230
+ }) {
231
+ HStack(spacing: 6) {
232
+ Image(systemName: "arrow.down.circle")
233
+ .font(.system(size: 11))
234
+ Text("brew install tmux")
235
+ .font(Typo.monoBold(11))
236
+ }
237
+ .angularButton(.white, filled: false)
238
+ }
239
+ .buttonStyle(.plain)
240
+
241
+ Text("You can always install it later. Window tiling, search, and OCR work without tmux.")
242
+ .font(Typo.mono(10))
243
+ .foregroundColor(Palette.textMuted)
244
+ .multilineTextAlignment(.center)
245
+ .lineSpacing(2)
246
+ }
247
+ }
248
+ }
249
+
250
+ private var doneStep: some View {
251
+ VStack(spacing: 16) {
252
+ Image(systemName: "checkmark.circle.fill")
253
+ .font(.system(size: 36))
254
+ .foregroundColor(Palette.running)
255
+
256
+ Text("You're all set")
257
+ .font(Typo.title(18))
258
+ .foregroundColor(Palette.text)
259
+
260
+ VStack(alignment: .leading, spacing: 8) {
261
+ statusRow("Accessibility", granted: permChecker.accessibility)
262
+ statusRow("Screen Recording", granted: permChecker.screenRecording)
263
+ statusRow("Project root", granted: !prefs.scanRoot.isEmpty,
264
+ detail: prefs.scanRoot.isEmpty ? "not set" : abbreviatePath(prefs.scanRoot))
265
+ statusRow("tmux", granted: tmux.isAvailable,
266
+ detail: tmux.isAvailable ? "installed" : "skipped")
267
+ }
268
+ .padding(16)
269
+ .background(
270
+ RoundedRectangle(cornerRadius: 6)
271
+ .fill(Palette.surface)
272
+ .overlay(
273
+ RoundedRectangle(cornerRadius: 6)
274
+ .strokeBorder(Palette.border, lineWidth: 0.5)
275
+ )
276
+ )
277
+ }
278
+ }
279
+
280
+ // MARK: - Shared helpers
281
+
282
+ private func permissionStep(icon: String, title: String, description: String, granted: Bool, action: @escaping () -> Void) -> some View {
283
+ VStack(spacing: 16) {
284
+ Image(systemName: icon)
285
+ .font(.system(size: 28))
286
+ .foregroundColor(.white.opacity(0.7))
287
+
288
+ Text(title)
289
+ .font(Typo.title(16))
290
+ .foregroundColor(Palette.text)
291
+
292
+ Text(description)
293
+ .font(Typo.body(12))
294
+ .foregroundColor(Palette.textDim)
295
+ .multilineTextAlignment(.center)
296
+ .lineSpacing(3)
297
+
298
+ if granted {
299
+ HStack(spacing: 6) {
300
+ Image(systemName: "checkmark.circle.fill")
301
+ .foregroundColor(Palette.running)
302
+ Text("Granted")
303
+ .font(Typo.monoBold(11))
304
+ .foregroundColor(Palette.running)
305
+ }
306
+ } else {
307
+ Button(action: action) {
308
+ Text("Grant \(title)")
309
+ .angularButton(.white, filled: false)
310
+ }
311
+ .buttonStyle(.plain)
312
+
313
+ Text("macOS will ask you to toggle this on in System Settings.")
314
+ .font(Typo.mono(10))
315
+ .foregroundColor(Palette.textMuted)
316
+ .multilineTextAlignment(.center)
317
+ }
318
+ }
319
+ }
320
+
321
+ private func statusRow(_ label: String, granted: Bool, detail: String? = nil) -> some View {
322
+ HStack(spacing: 8) {
323
+ Image(systemName: granted ? "checkmark.circle.fill" : "circle")
324
+ .font(.system(size: 11))
325
+ .foregroundColor(granted ? Palette.running : Palette.detach)
326
+ Text(label)
327
+ .font(Typo.mono(11))
328
+ .foregroundColor(Palette.text)
329
+ Spacer()
330
+ if let detail {
331
+ Text(detail)
332
+ .font(Typo.mono(10))
333
+ .foregroundColor(granted ? Palette.textDim : Palette.detach)
334
+ } else {
335
+ Text(granted ? "granted" : "not set")
336
+ .font(Typo.mono(10))
337
+ .foregroundColor(granted ? Palette.running : Palette.detach)
338
+ }
339
+ }
340
+ }
341
+
342
+ private var latticesIcon: some View {
343
+ // 3x3 grid — L-shape pattern
344
+ let cells = [true, false, false, true, false, false, true, true, true]
345
+ let size: CGFloat = 40
346
+ let pad: CGFloat = 4
347
+ let gap: CGFloat = 2.5
348
+ let cell = (size - 2 * pad - 2 * gap) / 3
349
+ return Canvas { context, _ in
350
+ for (i, bright) in cells.enumerated() {
351
+ let row = i / 3
352
+ let col = i % 3
353
+ let rect = CGRect(
354
+ x: pad + CGFloat(col) * (cell + gap),
355
+ y: pad + CGFloat(row) * (cell + gap),
356
+ width: cell, height: cell
357
+ )
358
+ context.fill(
359
+ RoundedRectangle(cornerRadius: 2).path(in: rect),
360
+ with: .color(bright ? .white : .white.opacity(0.18))
361
+ )
362
+ }
363
+ }
364
+ .frame(width: size, height: size)
365
+ }
366
+
367
+ // MARK: - Navigation
368
+
369
+ private var nextLabel: String {
370
+ switch step {
371
+ case .accessibility where !permChecker.accessibility: return "Continue anyway"
372
+ case .screenRecording where !permChecker.screenRecording: return "Continue anyway"
373
+ case .projectRoot where prefs.scanRoot.isEmpty: return "Skip for now"
374
+ case .tmux where !tmux.isAvailable: return "Skip"
375
+ default: return "Continue"
376
+ }
377
+ }
378
+
379
+ private func advance() {
380
+ guard let next = Step(rawValue: step.rawValue + 1) else { return }
381
+ step = next
382
+ }
383
+
384
+ private func goBack() {
385
+ guard let prev = Step(rawValue: step.rawValue - 1) else { return }
386
+ step = prev
387
+ }
388
+
389
+ private func abbreviatePath(_ path: String) -> String {
390
+ let home = NSHomeDirectory()
391
+ if path.hasPrefix(home) {
392
+ return "~" + path.dropFirst(home.count)
393
+ }
394
+ return path
395
+ }
396
+ }
397
+
398
+ // MARK: - Window Controller
399
+
400
+ final class OnboardingWindowController {
401
+ static let shared = OnboardingWindowController()
402
+
403
+ private var window: NSWindow?
404
+ private static let completedKey = "onboarding.completed"
405
+
406
+ var hasCompleted: Bool {
407
+ UserDefaults.standard.bool(forKey: Self.completedKey)
408
+ }
409
+
410
+ /// Show the onboarding window if not yet completed.
411
+ /// Returns true if onboarding was shown.
412
+ @discardableResult
413
+ func showIfNeeded() -> Bool {
414
+ guard !hasCompleted else { return false }
415
+ show()
416
+ return true
417
+ }
418
+
419
+ func show() {
420
+ if let w = window {
421
+ w.makeKeyAndOrderFront(nil)
422
+ NSApp.activate(ignoringOtherApps: true)
423
+ return
424
+ }
425
+
426
+ let view = OnboardingView {
427
+ self.complete()
428
+ }
429
+
430
+ let w = AppWindowShell.makeWindow(
431
+ config: .init(
432
+ title: "Welcome to Lattices",
433
+ titleVisible: false,
434
+ initialSize: NSSize(width: 480, height: 420),
435
+ minSize: NSSize(width: 480, height: 420),
436
+ maxSize: NSSize(width: 480, height: 420),
437
+ miniaturizable: false
438
+ ),
439
+ rootView: view
440
+ )
441
+ w.styleMask.remove(.resizable)
442
+ AppWindowShell.positionCentered(w)
443
+ AppWindowShell.present(w)
444
+ self.window = w
445
+ }
446
+
447
+ private func complete() {
448
+ UserDefaults.standard.set(true, forKey: Self.completedKey)
449
+ window?.orderOut(nil)
450
+ window = nil
451
+ AppDelegate.updateActivationPolicy()
452
+ }
453
+
454
+ func reset() {
455
+ UserDefaults.standard.removeObject(forKey: Self.completedKey)
456
+ }
457
+ }