@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
|
@@ -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
|
|
|
@@ -113,7 +113,7 @@ class ProjectScanner: ObservableObject {
|
|
|
113
113
|
return (panes.count, labels, labels.joined(separator: " · "))
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
private static
|
|
116
|
+
private static var tmuxPath: String { TmuxQuery.resolvedPath ?? "/opt/homebrew/bin/tmux" }
|
|
117
117
|
|
|
118
118
|
private func isSessionRunning(_ name: String) -> Bool {
|
|
119
119
|
let task = Process()
|