@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.
- package/README.md +85 -9
- package/app/Package.swift +8 -1
- package/app/Sources/AdvisorLearningStore.swift +90 -0
- package/app/Sources/AgentSession.swift +377 -0
- package/app/Sources/AppDelegate.swift +44 -12
- package/app/Sources/AppShellView.swift +81 -8
- package/app/Sources/AudioProvider.swift +386 -0
- package/app/Sources/CheatSheetHUD.swift +261 -19
- package/app/Sources/DaemonProtocol.swift +13 -0
- package/app/Sources/DaemonServer.swift +8 -0
- package/app/Sources/DesktopModel.swift +164 -5
- package/app/Sources/DesktopModelTypes.swift +2 -0
- package/app/Sources/DiagnosticLog.swift +104 -2
- package/app/Sources/EventBus.swift +1 -0
- package/app/Sources/HUDBottomBar.swift +279 -0
- package/app/Sources/HUDController.swift +1158 -0
- package/app/Sources/HUDLeftBar.swift +849 -0
- package/app/Sources/HUDMinimap.swift +179 -0
- package/app/Sources/HUDRightBar.swift +774 -0
- package/app/Sources/HUDState.swift +367 -0
- package/app/Sources/HUDTopBar.swift +243 -0
- package/app/Sources/HandsOffSession.swift +733 -0
- package/app/Sources/HomeDashboardView.swift +125 -0
- package/app/Sources/HotkeyManager.swift +2 -0
- package/app/Sources/HotkeyStore.swift +45 -9
- package/app/Sources/IntentEngine.swift +925 -0
- package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
- package/app/Sources/Intents/DistributeIntent.swift +56 -0
- package/app/Sources/Intents/FocusIntent.swift +69 -0
- package/app/Sources/Intents/HelpIntent.swift +41 -0
- package/app/Sources/Intents/KillIntent.swift +47 -0
- package/app/Sources/Intents/LatticeIntent.swift +78 -0
- package/app/Sources/Intents/LaunchIntent.swift +67 -0
- package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
- package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
- package/app/Sources/Intents/ScanIntent.swift +52 -0
- package/app/Sources/Intents/SearchIntent.swift +190 -0
- package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
- package/app/Sources/Intents/TileIntent.swift +61 -0
- package/app/Sources/LatticesApi.swift +1235 -30
- package/app/Sources/LauncherHUD.swift +348 -0
- package/app/Sources/MainView.swift +147 -44
- package/app/Sources/OcrModel.swift +34 -1
- package/app/Sources/OmniSearchState.swift +99 -102
- package/app/Sources/OnboardingView.swift +457 -0
- package/app/Sources/PermissionChecker.swift +2 -12
- package/app/Sources/PiChatDock.swift +454 -0
- package/app/Sources/PiChatSession.swift +815 -0
- package/app/Sources/PiWorkspaceView.swift +364 -0
- package/app/Sources/PlacementSpec.swift +195 -0
- package/app/Sources/Preferences.swift +59 -0
- package/app/Sources/ProjectScanner.swift +1 -1
- package/app/Sources/ScreenMapState.swift +701 -55
- package/app/Sources/ScreenMapView.swift +843 -103
- package/app/Sources/ScreenMapWindowController.swift +22 -0
- package/app/Sources/SessionLayerStore.swift +285 -0
- package/app/Sources/SessionManager.swift +4 -1
- package/app/Sources/SettingsView.swift +186 -3
- package/app/Sources/Theme.swift +9 -8
- package/app/Sources/TmuxModel.swift +7 -0
- package/app/Sources/TmuxQuery.swift +27 -3
- package/app/Sources/VoiceChatView.swift +192 -0
- package/app/Sources/VoiceCommandWindow.swift +1594 -0
- package/app/Sources/VoiceIntentResolver.swift +671 -0
- package/app/Sources/VoxClient.swift +454 -0
- package/app/Sources/WindowTiler.swift +348 -87
- package/app/Sources/WorkspaceManager.swift +127 -18
- package/bin/client.ts +16 -0
- package/bin/{daemon-client.js → daemon-client.ts} +49 -30
- package/bin/handsoff-infer.ts +280 -0
- package/bin/handsoff-worker.ts +731 -0
- package/bin/{lattices-app.js → lattices-app.ts} +67 -32
- package/bin/lattices-dev +160 -0
- package/bin/{lattices.js → lattices.ts} +600 -137
- package/bin/project-twin.ts +645 -0
- package/docs/agent-execution-plan.md +562 -0
- package/docs/agents.md +142 -0
- package/docs/api.md +153 -34
- package/docs/app.md +29 -1
- package/docs/config.md +5 -1
- package/docs/handsoff-test-scenarios.md +84 -0
- package/docs/layers.md +20 -20
- package/docs/ocr.md +14 -5
- package/docs/overview.md +5 -1
- package/docs/presentation-execution-review.md +491 -0
- package/docs/prompts/hands-off-system.md +374 -0
- package/docs/prompts/hands-off-turn.md +30 -0
- package/docs/prompts/voice-advisor.md +31 -0
- package/docs/prompts/voice-fallback.md +23 -0
- package/docs/tiling-reference.md +167 -0
- package/docs/twins.md +138 -0
- package/docs/voice-command-protocol.md +278 -0
- package/docs/voice.md +219 -0
- package/package.json +21 -10
- 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
|
-
//
|
|
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
|
-
.
|
|
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
|
|
78
|
-
self
|
|
80
|
+
self.results = []
|
|
81
|
+
self.refreshSummary()
|
|
82
|
+
} else if q.count == 1 {
|
|
83
|
+
self.quickSearch(q)
|
|
79
84
|
} else {
|
|
80
|
-
self
|
|
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
|
-
|
|
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
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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:
|
|
102
|
-
title:
|
|
103
|
-
subtitle:
|
|
104
|
-
icon:
|
|
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
|
-
|
|
114
|
-
|
|
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: -
|
|
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
|
|
215
|
-
let
|
|
216
|
-
|
|
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
|
+
}
|