@lattices/cli 0.3.0 → 0.4.1
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/Info.plist +30 -0
- package/app/Lattices.app/Contents/Info.plist +8 -2
- package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/app/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
- package/app/Lattices.app/Contents/Resources/tap.wav +0 -0
- package/app/Lattices.app/Contents/_CodeSignature/CodeResources +139 -0
- package/app/Lattices.entitlements +15 -0
- package/app/Package.swift +8 -1
- package/app/Resources/tap.wav +0 -0
- package/app/Sources/AdvisorLearningStore.swift +90 -0
- package/app/Sources/AgentSession.swift +377 -0
- package/app/Sources/AppDelegate.swift +45 -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 +189 -6
- 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 +802 -0
- package/app/Sources/HomeDashboardView.swift +125 -0
- package/app/Sources/HotkeyManager.swift +2 -0
- package/app/Sources/HotkeyStore.swift +49 -9
- package/app/Sources/IntentEngine.swift +962 -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 +1275 -30
- package/app/Sources/LauncherHUD.swift +348 -0
- package/app/Sources/MainView.swift +147 -44
- package/app/Sources/MouseFinder.swift +222 -0
- 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 +58 -45
- 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/app/Tests/StageDragTests.swift +333 -0
- package/app/Tests/StageJoinTests.swift +313 -0
- package/app/Tests/StageManagerTests.swift +280 -0
- package/app/Tests/StageTileTests.swift +353 -0
- package/assets/AppIcon.icns +0 -0
- 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 +740 -0
- package/bin/lattices-app.ts +338 -0
- package/bin/lattices-dev +208 -0
- package/bin/{lattices.js → lattices.ts} +777 -140
- package/bin/project-twin.ts +645 -0
- package/docs/agent-execution-plan.md +562 -0
- package/docs/agent-layer-guide.md +207 -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 +29 -11
- package/bin/client.js +0 -4
- package/bin/lattices-app.js +0 -221
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
struct PiWorkspaceView: View {
|
|
4
|
+
@StateObject private var session = PiChatSession.shared
|
|
5
|
+
@FocusState private var composerFocused: Bool
|
|
6
|
+
@FocusState private var authFieldFocused: Bool
|
|
7
|
+
|
|
8
|
+
private static let timeFormatter: DateFormatter = {
|
|
9
|
+
let formatter = DateFormatter()
|
|
10
|
+
formatter.dateFormat = "HH:mm"
|
|
11
|
+
return formatter
|
|
12
|
+
}()
|
|
13
|
+
|
|
14
|
+
var body: some View {
|
|
15
|
+
VStack(spacing: 0) {
|
|
16
|
+
header
|
|
17
|
+
|
|
18
|
+
Rectangle()
|
|
19
|
+
.fill(Palette.border)
|
|
20
|
+
.frame(height: 0.5)
|
|
21
|
+
|
|
22
|
+
if session.isAuthPanelVisible {
|
|
23
|
+
authPanel
|
|
24
|
+
|
|
25
|
+
Rectangle()
|
|
26
|
+
.fill(Palette.border)
|
|
27
|
+
.frame(height: 0.5)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
transcript
|
|
31
|
+
|
|
32
|
+
Rectangle()
|
|
33
|
+
.fill(Palette.border)
|
|
34
|
+
.frame(height: 0.5)
|
|
35
|
+
|
|
36
|
+
composer
|
|
37
|
+
}
|
|
38
|
+
.background(Palette.bg)
|
|
39
|
+
.onAppear {
|
|
40
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
41
|
+
composerFocused = true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private var header: some View {
|
|
47
|
+
HStack(spacing: 10) {
|
|
48
|
+
VStack(alignment: .leading, spacing: 6) {
|
|
49
|
+
HStack(spacing: 8) {
|
|
50
|
+
Circle()
|
|
51
|
+
.fill(session.hasPiBinary ? Palette.running : Palette.kill)
|
|
52
|
+
.frame(width: 7, height: 7)
|
|
53
|
+
|
|
54
|
+
Text("PI WORKSPACE")
|
|
55
|
+
.font(Typo.geistMonoBold(11))
|
|
56
|
+
.foregroundColor(Palette.text)
|
|
57
|
+
|
|
58
|
+
capsuleLabel(session.statusText.uppercased(), tint: session.isSending ? Palette.detach : Palette.running)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
Text("Full conversation surface for longer prompts, auth, and provider switching.")
|
|
62
|
+
.font(Typo.mono(10))
|
|
63
|
+
.foregroundColor(Palette.textDim)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
Spacer()
|
|
67
|
+
|
|
68
|
+
HStack(spacing: 6) {
|
|
69
|
+
capsuleLabel(session.currentProvider.name.uppercased(), tint: Palette.textDim)
|
|
70
|
+
|
|
71
|
+
actionChip(session.isAuthPanelVisible ? "AUTH -" : "AUTH +") {
|
|
72
|
+
session.toggleAuthPanel()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
actionChip("RESET") {
|
|
76
|
+
session.clearConversation()
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
.padding(.horizontal, 16)
|
|
81
|
+
.padding(.vertical, 14)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private var authPanel: some View {
|
|
85
|
+
VStack(alignment: .leading, spacing: 12) {
|
|
86
|
+
HStack(spacing: 8) {
|
|
87
|
+
Text("provider")
|
|
88
|
+
.font(Typo.geistMonoBold(9))
|
|
89
|
+
.foregroundColor(Palette.textMuted)
|
|
90
|
+
|
|
91
|
+
Picker("Provider", selection: $session.authProviderID) {
|
|
92
|
+
ForEach(session.providerOptions) { provider in
|
|
93
|
+
Text(provider.name).tag(provider.id)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
.labelsHidden()
|
|
97
|
+
.pickerStyle(.menu)
|
|
98
|
+
.font(Typo.mono(10))
|
|
99
|
+
|
|
100
|
+
Spacer()
|
|
101
|
+
|
|
102
|
+
capsuleLabel(
|
|
103
|
+
session.currentProvider.authMode == .oauth ? "OAUTH" : "TOKEN",
|
|
104
|
+
tint: session.currentProvider.authMode == .oauth ? Palette.detach : Palette.running
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
Text(session.currentProvider.helpText)
|
|
109
|
+
.font(Typo.mono(10))
|
|
110
|
+
.foregroundColor(Palette.textDim)
|
|
111
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
112
|
+
|
|
113
|
+
if session.currentProvider.authMode == .apiKey {
|
|
114
|
+
HStack(spacing: 8) {
|
|
115
|
+
SecureField(session.currentProvider.tokenPlaceholder, text: $session.authToken)
|
|
116
|
+
.textFieldStyle(.plain)
|
|
117
|
+
.font(Typo.mono(11))
|
|
118
|
+
.foregroundColor(Palette.text)
|
|
119
|
+
.focused($authFieldFocused)
|
|
120
|
+
.onSubmit {
|
|
121
|
+
session.saveSelectedToken()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
actionChip("SAVE") {
|
|
125
|
+
session.saveSelectedToken()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if session.hasSelectedCredential {
|
|
129
|
+
actionChip("CLEAR") {
|
|
130
|
+
session.removeSelectedCredential()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
.padding(.horizontal, 12)
|
|
135
|
+
.padding(.vertical, 10)
|
|
136
|
+
.background(authCardBackground(tint: Palette.running))
|
|
137
|
+
} else {
|
|
138
|
+
HStack(spacing: 8) {
|
|
139
|
+
actionChip(session.isAuthenticating ? "CANCEL" : "LOGIN") {
|
|
140
|
+
if session.isAuthenticating {
|
|
141
|
+
session.cancelAuthFlow()
|
|
142
|
+
} else {
|
|
143
|
+
session.startSelectedAuthFlow()
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if session.hasSelectedCredential {
|
|
148
|
+
actionChip("CLEAR") {
|
|
149
|
+
session.removeSelectedCredential()
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
.padding(.horizontal, 12)
|
|
154
|
+
.padding(.vertical, 10)
|
|
155
|
+
.background(authCardBackground(tint: session.isAuthenticating ? Palette.detach : Palette.running))
|
|
156
|
+
|
|
157
|
+
if let prompt = session.pendingAuthPrompt {
|
|
158
|
+
HStack(spacing: 8) {
|
|
159
|
+
TextField(prompt.placeholder ?? prompt.message, text: $session.authPromptInput)
|
|
160
|
+
.textFieldStyle(.plain)
|
|
161
|
+
.font(Typo.mono(11))
|
|
162
|
+
.foregroundColor(Palette.text)
|
|
163
|
+
.focused($authFieldFocused)
|
|
164
|
+
.onSubmit {
|
|
165
|
+
session.submitAuthPrompt()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
actionChip("CONTINUE") {
|
|
169
|
+
session.submitAuthPrompt()
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
.padding(.horizontal, 12)
|
|
173
|
+
.padding(.vertical, 10)
|
|
174
|
+
.background(authCardBackground(tint: Palette.detach))
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if let notice = session.authNoticeText, !notice.isEmpty {
|
|
179
|
+
Text(notice)
|
|
180
|
+
.font(Typo.mono(9))
|
|
181
|
+
.foregroundColor(Palette.textDim)
|
|
182
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if let error = session.authErrorText, !error.isEmpty {
|
|
186
|
+
Text(error)
|
|
187
|
+
.font(Typo.mono(9))
|
|
188
|
+
.foregroundColor(Palette.kill)
|
|
189
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
.padding(.horizontal, 16)
|
|
193
|
+
.padding(.vertical, 14)
|
|
194
|
+
.background(Palette.surface.opacity(0.35))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private var transcript: some View {
|
|
198
|
+
ScrollViewReader { proxy in
|
|
199
|
+
ScrollView(.vertical, showsIndicators: true) {
|
|
200
|
+
LazyVStack(alignment: .leading, spacing: 10) {
|
|
201
|
+
ForEach(session.messages) { message in
|
|
202
|
+
row(message)
|
|
203
|
+
.id(message.id)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
.padding(.horizontal, 16)
|
|
207
|
+
.padding(.vertical, 16)
|
|
208
|
+
}
|
|
209
|
+
.onAppear {
|
|
210
|
+
if let last = session.messages.last?.id {
|
|
211
|
+
proxy.scrollTo(last, anchor: .bottom)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
.onChange(of: session.messages.count) { _ in
|
|
215
|
+
if let last = session.messages.last?.id {
|
|
216
|
+
withAnimation(.easeOut(duration: 0.15)) {
|
|
217
|
+
proxy.scrollTo(last, anchor: .bottom)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private func row(_ message: PiChatMessage) -> some View {
|
|
225
|
+
HStack(alignment: .top, spacing: 12) {
|
|
226
|
+
VStack(alignment: .leading, spacing: 4) {
|
|
227
|
+
capsuleLabel(roleLabel(for: message.role).uppercased(), tint: roleColor(for: message.role))
|
|
228
|
+
Text(Self.timeFormatter.string(from: message.timestamp))
|
|
229
|
+
.font(Typo.mono(8))
|
|
230
|
+
.foregroundColor(Palette.textMuted)
|
|
231
|
+
}
|
|
232
|
+
.frame(width: 62, alignment: .leading)
|
|
233
|
+
|
|
234
|
+
Text(message.text)
|
|
235
|
+
.font(Typo.mono(12))
|
|
236
|
+
.foregroundColor(Palette.text)
|
|
237
|
+
.textSelection(.enabled)
|
|
238
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
239
|
+
}
|
|
240
|
+
.padding(.horizontal, 12)
|
|
241
|
+
.padding(.vertical, 10)
|
|
242
|
+
.background(
|
|
243
|
+
RoundedRectangle(cornerRadius: 8)
|
|
244
|
+
.fill(roleColor(for: message.role).opacity(message.role == .assistant ? 0.10 : 0.06))
|
|
245
|
+
.overlay(
|
|
246
|
+
RoundedRectangle(cornerRadius: 8)
|
|
247
|
+
.strokeBorder(roleColor(for: message.role).opacity(0.22), lineWidth: 0.5)
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private var composer: some View {
|
|
253
|
+
VStack(spacing: 10) {
|
|
254
|
+
HStack(spacing: 10) {
|
|
255
|
+
Text(">")
|
|
256
|
+
.font(Typo.geistMonoBold(12))
|
|
257
|
+
.foregroundColor(Palette.running)
|
|
258
|
+
|
|
259
|
+
TextField("Ask Pi something heavier...", text: $session.draft, axis: .vertical)
|
|
260
|
+
.textFieldStyle(.plain)
|
|
261
|
+
.font(Typo.mono(12))
|
|
262
|
+
.foregroundColor(Palette.text)
|
|
263
|
+
.lineLimit(1...6)
|
|
264
|
+
.focused($composerFocused)
|
|
265
|
+
.onSubmit {
|
|
266
|
+
session.sendDraft()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
actionChip(session.isSending ? "..." : "SEND") {
|
|
270
|
+
session.sendDraft()
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
.padding(.horizontal, 12)
|
|
274
|
+
.padding(.vertical, 10)
|
|
275
|
+
.background(
|
|
276
|
+
RoundedRectangle(cornerRadius: 8)
|
|
277
|
+
.fill(Color.black.opacity(0.28))
|
|
278
|
+
.overlay(
|
|
279
|
+
RoundedRectangle(cornerRadius: 8)
|
|
280
|
+
.strokeBorder(Palette.running.opacity(0.18), lineWidth: 0.5)
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
HStack {
|
|
285
|
+
HStack(spacing: 6) {
|
|
286
|
+
Circle()
|
|
287
|
+
.fill(session.hasPiBinary ? Palette.running : Palette.kill)
|
|
288
|
+
.frame(width: 6, height: 6)
|
|
289
|
+
|
|
290
|
+
Text(session.currentProvider.name)
|
|
291
|
+
.font(Typo.mono(10))
|
|
292
|
+
.foregroundColor(Palette.textMuted)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
Spacer()
|
|
296
|
+
|
|
297
|
+
Text("Return to send")
|
|
298
|
+
.font(Typo.mono(9))
|
|
299
|
+
.foregroundColor(Palette.textMuted)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
.padding(.horizontal, 16)
|
|
303
|
+
.padding(.vertical, 14)
|
|
304
|
+
.background(Palette.surface.opacity(0.22))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private func roleLabel(for role: PiChatMessage.Role) -> String {
|
|
308
|
+
switch role {
|
|
309
|
+
case .system: return "system"
|
|
310
|
+
case .user: return "you"
|
|
311
|
+
case .assistant: return "pi"
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private func roleColor(for role: PiChatMessage.Role) -> Color {
|
|
316
|
+
switch role {
|
|
317
|
+
case .system: return Palette.detach
|
|
318
|
+
case .user: return Palette.textDim
|
|
319
|
+
case .assistant: return Palette.running
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private func capsuleLabel(_ text: String, tint: Color) -> some View {
|
|
324
|
+
Text(text)
|
|
325
|
+
.font(Typo.geistMonoBold(9))
|
|
326
|
+
.foregroundColor(tint.opacity(0.95))
|
|
327
|
+
.padding(.horizontal, 7)
|
|
328
|
+
.padding(.vertical, 4)
|
|
329
|
+
.background(
|
|
330
|
+
Capsule()
|
|
331
|
+
.fill(tint.opacity(0.10))
|
|
332
|
+
.overlay(
|
|
333
|
+
Capsule()
|
|
334
|
+
.strokeBorder(tint.opacity(0.28), lineWidth: 0.5)
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private func actionChip(_ label: String, action: @escaping () -> Void) -> some View {
|
|
340
|
+
Button(label, action: action)
|
|
341
|
+
.buttonStyle(.plain)
|
|
342
|
+
.font(Typo.geistMonoBold(9))
|
|
343
|
+
.foregroundColor(Palette.textMuted)
|
|
344
|
+
.padding(.horizontal, 8)
|
|
345
|
+
.padding(.vertical, 5)
|
|
346
|
+
.background(
|
|
347
|
+
Capsule()
|
|
348
|
+
.fill(Color.white.opacity(0.03))
|
|
349
|
+
.overlay(
|
|
350
|
+
Capsule()
|
|
351
|
+
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private func authCardBackground(tint: Color) -> some View {
|
|
357
|
+
RoundedRectangle(cornerRadius: 8)
|
|
358
|
+
.fill(tint.opacity(0.06))
|
|
359
|
+
.overlay(
|
|
360
|
+
RoundedRectangle(cornerRadius: 8)
|
|
361
|
+
.strokeBorder(tint.opacity(0.22), lineWidth: 0.5)
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import CoreGraphics
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
struct GridPlacement: Equatable {
|
|
5
|
+
let columns: Int
|
|
6
|
+
let rows: Int
|
|
7
|
+
let column: Int
|
|
8
|
+
let row: Int
|
|
9
|
+
|
|
10
|
+
init?(columns: Int, rows: Int, column: Int, row: Int) {
|
|
11
|
+
guard columns > 0, rows > 0,
|
|
12
|
+
column >= 0, column < columns,
|
|
13
|
+
row >= 0, row < rows else { return nil }
|
|
14
|
+
self.columns = columns
|
|
15
|
+
self.rows = rows
|
|
16
|
+
self.column = column
|
|
17
|
+
self.row = row
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static func parse(_ str: String) -> GridPlacement? {
|
|
21
|
+
let parts = str.split(separator: ":")
|
|
22
|
+
guard parts.count == 3, parts[0] == "grid" else { return nil }
|
|
23
|
+
let dims = parts[1].split(separator: "x")
|
|
24
|
+
let coords = parts[2].split(separator: ",")
|
|
25
|
+
guard dims.count == 2, coords.count == 2,
|
|
26
|
+
let columns = Int(dims[0]), let rows = Int(dims[1]),
|
|
27
|
+
let column = Int(coords[0]), let row = Int(coords[1]) else {
|
|
28
|
+
return nil
|
|
29
|
+
}
|
|
30
|
+
return GridPlacement(columns: columns, rows: rows, column: column, row: row)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
var fractions: (CGFloat, CGFloat, CGFloat, CGFloat) {
|
|
34
|
+
let w = 1.0 / CGFloat(columns)
|
|
35
|
+
let h = 1.0 / CGFloat(rows)
|
|
36
|
+
return (CGFloat(column) * w, CGFloat(row) * h, w, h)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
var wireValue: String {
|
|
40
|
+
"grid:\(columns)x\(rows):\(column),\(row)"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
struct FractionalPlacement: Equatable {
|
|
45
|
+
let x: CGFloat
|
|
46
|
+
let y: CGFloat
|
|
47
|
+
let w: CGFloat
|
|
48
|
+
let h: CGFloat
|
|
49
|
+
|
|
50
|
+
init?(x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat) {
|
|
51
|
+
guard x >= 0, y >= 0, w > 0, h > 0,
|
|
52
|
+
x + w <= 1.0001, y + h <= 1.0001 else { return nil }
|
|
53
|
+
self.x = x
|
|
54
|
+
self.y = y
|
|
55
|
+
self.w = w
|
|
56
|
+
self.h = h
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
var fractions: (CGFloat, CGFloat, CGFloat, CGFloat) {
|
|
60
|
+
(x, y, w, h)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
enum PlacementSpec: Equatable {
|
|
65
|
+
case tile(TilePosition)
|
|
66
|
+
case grid(GridPlacement)
|
|
67
|
+
case fractions(FractionalPlacement)
|
|
68
|
+
|
|
69
|
+
init?(string: String) {
|
|
70
|
+
let normalized = PlacementSpec.normalize(string)
|
|
71
|
+
if let position = TilePosition(rawValue: normalized) {
|
|
72
|
+
self = .tile(position)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
if let alias = PlacementSpec.aliases[normalized], let position = TilePosition(rawValue: alias) {
|
|
76
|
+
self = .tile(position)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if let grid = GridPlacement.parse(normalized) {
|
|
80
|
+
self = .grid(grid)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
return nil
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
init?(json: JSON?) {
|
|
87
|
+
guard let json else { return nil }
|
|
88
|
+
|
|
89
|
+
if let string = json.stringValue {
|
|
90
|
+
self.init(string: string)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
guard case .object(let obj) = json,
|
|
95
|
+
let kind = obj["kind"]?.stringValue?.lowercased() else {
|
|
96
|
+
return nil
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
switch kind {
|
|
100
|
+
case "tile", "named", "position":
|
|
101
|
+
guard let value = obj["value"]?.stringValue else { return nil }
|
|
102
|
+
self.init(string: value)
|
|
103
|
+
case "grid":
|
|
104
|
+
guard let columns = obj["columns"]?.intValue,
|
|
105
|
+
let rows = obj["rows"]?.intValue,
|
|
106
|
+
let column = obj["column"]?.intValue,
|
|
107
|
+
let row = obj["row"]?.intValue,
|
|
108
|
+
let grid = GridPlacement(columns: columns, rows: rows, column: column, row: row) else {
|
|
109
|
+
return nil
|
|
110
|
+
}
|
|
111
|
+
self = .grid(grid)
|
|
112
|
+
case "fractions":
|
|
113
|
+
guard let x = obj["x"]?.numericDouble,
|
|
114
|
+
let y = obj["y"]?.numericDouble,
|
|
115
|
+
let w = obj["w"]?.numericDouble,
|
|
116
|
+
let h = obj["h"]?.numericDouble,
|
|
117
|
+
let placement = FractionalPlacement(
|
|
118
|
+
x: CGFloat(x),
|
|
119
|
+
y: CGFloat(y),
|
|
120
|
+
w: CGFloat(w),
|
|
121
|
+
h: CGFloat(h)
|
|
122
|
+
) else {
|
|
123
|
+
return nil
|
|
124
|
+
}
|
|
125
|
+
self = .fractions(placement)
|
|
126
|
+
default:
|
|
127
|
+
return nil
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
var fractions: (CGFloat, CGFloat, CGFloat, CGFloat) {
|
|
132
|
+
switch self {
|
|
133
|
+
case .tile(let position):
|
|
134
|
+
return position.rect
|
|
135
|
+
case .grid(let grid):
|
|
136
|
+
return grid.fractions
|
|
137
|
+
case .fractions(let placement):
|
|
138
|
+
return placement.fractions
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
var wireValue: String {
|
|
143
|
+
switch self {
|
|
144
|
+
case .tile(let position):
|
|
145
|
+
return position.rawValue
|
|
146
|
+
case .grid(let grid):
|
|
147
|
+
return grid.wireValue
|
|
148
|
+
case .fractions(let placement):
|
|
149
|
+
return "fractions:\(placement.x),\(placement.y),\(placement.w),\(placement.h)"
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
var jsonValue: JSON {
|
|
154
|
+
switch self {
|
|
155
|
+
case .tile(let position):
|
|
156
|
+
return .object([
|
|
157
|
+
"kind": .string("tile"),
|
|
158
|
+
"value": .string(position.rawValue),
|
|
159
|
+
])
|
|
160
|
+
case .grid(let grid):
|
|
161
|
+
return .object([
|
|
162
|
+
"kind": .string("grid"),
|
|
163
|
+
"columns": .int(grid.columns),
|
|
164
|
+
"rows": .int(grid.rows),
|
|
165
|
+
"column": .int(grid.column),
|
|
166
|
+
"row": .int(grid.row),
|
|
167
|
+
])
|
|
168
|
+
case .fractions(let placement):
|
|
169
|
+
return .object([
|
|
170
|
+
"kind": .string("fractions"),
|
|
171
|
+
"x": .double(Double(placement.x)),
|
|
172
|
+
"y": .double(Double(placement.y)),
|
|
173
|
+
"w": .double(Double(placement.w)),
|
|
174
|
+
"h": .double(Double(placement.h)),
|
|
175
|
+
])
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private static func normalize(_ string: String) -> String {
|
|
180
|
+
string
|
|
181
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
182
|
+
.lowercased()
|
|
183
|
+
.replacingOccurrences(of: "_", with: "-")
|
|
184
|
+
.replacingOccurrences(of: " ", with: "-")
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private static let aliases: [String: String] = [
|
|
188
|
+
"upper-third": "top-third",
|
|
189
|
+
"lower-third": "bottom-third",
|
|
190
|
+
"left-quarter": "left-quarter",
|
|
191
|
+
"right-quarter": "right-quarter",
|
|
192
|
+
"top-quarter": "top-quarter",
|
|
193
|
+
"bottom-quarter": "bottom-quarter",
|
|
194
|
+
]
|
|
195
|
+
}
|
|
@@ -20,6 +20,59 @@ class Preferences: ObservableObject {
|
|
|
20
20
|
didSet { UserDefaults.standard.set(mode.rawValue, forKey: "mode") }
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// MARK: - AI / Claude
|
|
24
|
+
|
|
25
|
+
@Published var claudePath: String {
|
|
26
|
+
didSet { UserDefaults.standard.set(claudePath, forKey: "claude.path") }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Published var advisorModel: String {
|
|
30
|
+
didSet { UserDefaults.standard.set(advisorModel, forKey: "claude.advisorModel") }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@Published var advisorBudgetUSD: Double {
|
|
34
|
+
didSet { UserDefaults.standard.set(advisorBudgetUSD, forKey: "claude.advisorBudget") }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Resolve claude CLI path: saved preference → well-known locations → `which`
|
|
38
|
+
static func resolveClaudePath() -> String? {
|
|
39
|
+
let saved = shared.claudePath
|
|
40
|
+
if !saved.isEmpty, FileManager.default.isExecutableFile(atPath: saved) {
|
|
41
|
+
return saved
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let candidates = [
|
|
45
|
+
"\(NSHomeDirectory())/.local/bin/claude",
|
|
46
|
+
"/usr/local/bin/claude",
|
|
47
|
+
"/opt/homebrew/bin/claude",
|
|
48
|
+
]
|
|
49
|
+
for path in candidates {
|
|
50
|
+
if FileManager.default.isExecutableFile(atPath: path) {
|
|
51
|
+
// Save for next time
|
|
52
|
+
DispatchQueue.main.async { shared.claudePath = path }
|
|
53
|
+
return path
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Last resort: `which claude`
|
|
58
|
+
let proc = Process()
|
|
59
|
+
proc.executableURL = URL(fileURLWithPath: "/bin/sh")
|
|
60
|
+
proc.arguments = ["-c", "which claude 2>/dev/null"]
|
|
61
|
+
let pipe = Pipe()
|
|
62
|
+
proc.standardOutput = pipe
|
|
63
|
+
proc.standardError = FileHandle.nullDevice
|
|
64
|
+
try? proc.run()
|
|
65
|
+
proc.waitUntilExit()
|
|
66
|
+
let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
|
|
67
|
+
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
68
|
+
if !output.isEmpty, FileManager.default.isExecutableFile(atPath: output) {
|
|
69
|
+
DispatchQueue.main.async { shared.claudePath = output }
|
|
70
|
+
return output
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return nil
|
|
74
|
+
}
|
|
75
|
+
|
|
23
76
|
// MARK: - Search & OCR
|
|
24
77
|
|
|
25
78
|
@Published var ocrEnabled: Bool {
|
|
@@ -75,6 +128,12 @@ class Preferences: ObservableObject {
|
|
|
75
128
|
self.mode = .learning
|
|
76
129
|
}
|
|
77
130
|
|
|
131
|
+
// AI / Claude
|
|
132
|
+
self.claudePath = UserDefaults.standard.string(forKey: "claude.path") ?? ""
|
|
133
|
+
self.advisorModel = UserDefaults.standard.string(forKey: "claude.advisorModel") ?? "haiku"
|
|
134
|
+
let savedBudgetUSD = UserDefaults.standard.double(forKey: "claude.advisorBudget")
|
|
135
|
+
self.advisorBudgetUSD = savedBudgetUSD > 0 ? savedBudgetUSD : 0.50
|
|
136
|
+
|
|
78
137
|
// Search & OCR
|
|
79
138
|
self.ocrEnabled = !UserDefaults.standard.bool(forKey: "ocr.disabled")
|
|
80
139
|
|