@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
@@ -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 let tmuxPath = "/opt/homebrew/bin/tmux"
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()