@lattices/cli 0.4.1 → 0.4.5

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 (71) hide show
  1. package/README.md +3 -0
  2. package/app/Info.plist +2 -2
  3. package/app/Lattices.app/Contents/Info.plist +2 -2
  4. package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/app/Package.swift +6 -0
  6. package/app/Sources/ActionRow.swift +43 -26
  7. package/app/Sources/App.swift +10 -0
  8. package/app/Sources/AppDelegate.swift +91 -30
  9. package/app/Sources/AppShellView.swift +2 -0
  10. package/app/Sources/AppTypeClassifier.swift +36 -0
  11. package/app/Sources/AppUpdater.swift +92 -0
  12. package/app/Sources/CheatSheetHUD.swift +1 -0
  13. package/app/Sources/CliActionLauncher.swift +50 -0
  14. package/app/Sources/CommandModeView.swift +4 -24
  15. package/app/Sources/CompanionActivityLog.swift +70 -0
  16. package/app/Sources/CompanionKeyboardController.swift +141 -0
  17. package/app/Sources/DesktopModel.swift +4 -0
  18. package/app/Sources/HandsOffSession.swift +53 -16
  19. package/app/Sources/HomeDashboardView.swift +18 -10
  20. package/app/Sources/HotkeyStore.swift +8 -5
  21. package/app/Sources/IntentEngine.swift +7 -1
  22. package/app/Sources/LatticesApi.swift +125 -4
  23. package/app/Sources/LatticesCompanionBridgeServer.swift +438 -0
  24. package/app/Sources/LatticesCompanionCockpit.swift +555 -0
  25. package/app/Sources/LatticesCompanionSecurityCoordinator.swift +594 -0
  26. package/app/Sources/LatticesCompanionTrackpadController.swift +204 -0
  27. package/app/Sources/LatticesDeckHost.swift +1463 -0
  28. package/app/Sources/LatticesRuntime.swift +61 -0
  29. package/app/Sources/MainView.swift +398 -186
  30. package/app/Sources/MouseFinder.swift +335 -30
  31. package/app/Sources/MouseGestureConfig.swift +364 -0
  32. package/app/Sources/MouseGestureController.swift +1203 -0
  33. package/app/Sources/MouseInputDeviceStore.swift +98 -0
  34. package/app/Sources/MouseInputEventViewer.swift +272 -0
  35. package/app/Sources/MouseShortcutStore.swift +107 -0
  36. package/app/Sources/OmniSearchView.swift +136 -2
  37. package/app/Sources/OmniSearchWindow.swift +65 -5
  38. package/app/Sources/OnboardingView.swift +30 -16
  39. package/app/Sources/PaletteCommand.swift +26 -6
  40. package/app/Sources/PermissionChecker.swift +76 -2
  41. package/app/Sources/PiAuthNextStepCard.swift +148 -0
  42. package/app/Sources/PiAuthPromptCard.swift +90 -0
  43. package/app/Sources/PiChatDock.swift +137 -74
  44. package/app/Sources/PiChatSession.swift +608 -108
  45. package/app/Sources/PiInstallCallout.swift +86 -0
  46. package/app/Sources/PiProviderSetupCallout.swift +99 -0
  47. package/app/Sources/PiWorkspaceView.swift +174 -77
  48. package/app/Sources/Preferences.swift +78 -0
  49. package/app/Sources/ScreenMapState.swift +91 -31
  50. package/app/Sources/ScreenMapView.swift +510 -524
  51. package/app/Sources/ScreenMapWindowController.swift +12 -4
  52. package/app/Sources/SettingsView.swift +869 -152
  53. package/app/Sources/SystemTelemetryMonitor.swift +273 -0
  54. package/app/Sources/VoiceCommandWindow.swift +23 -2
  55. package/app/Sources/WindowDragSnapController.swift +628 -0
  56. package/app/Sources/WindowTiler.swift +328 -65
  57. package/app/Sources/WorkspaceManager.swift +288 -0
  58. package/bin/assistant-intelligence.ts +874 -0
  59. package/bin/handsoff-infer.ts +16 -209
  60. package/bin/handsoff-worker.ts +45 -258
  61. package/bin/lattices-app.ts +65 -1
  62. package/bin/lattices-dev +4 -0
  63. package/bin/lattices.ts +125 -14
  64. package/docs/agents.md +14 -0
  65. package/docs/api.md +55 -0
  66. package/docs/app.md +3 -0
  67. package/docs/companion-deck.md +180 -0
  68. package/docs/config.md +25 -0
  69. package/docs/tiling-reference.md +55 -0
  70. package/docs/voice-error-model.md +73 -0
  71. package/package.json +4 -2
@@ -0,0 +1,86 @@
1
+ import SwiftUI
2
+
3
+ struct PiInstallCallout: View {
4
+ @ObservedObject var session: PiChatSession
5
+ let compact: Bool
6
+
7
+ var body: some View {
8
+ VStack(alignment: .leading, spacing: compact ? 10 : 12) {
9
+ HStack(spacing: 8) {
10
+ Circle()
11
+ .fill(Palette.kill)
12
+ .frame(width: compact ? 6 : 7, height: compact ? 6 : 7)
13
+
14
+ Text("PI REQUIRED")
15
+ .font(Typo.geistMonoBold(compact ? 9 : 10))
16
+ .foregroundColor(Palette.kill.opacity(0.95))
17
+
18
+ Text("assistant unavailable")
19
+ .font(Typo.mono(compact ? 9 : 10))
20
+ .foregroundColor(Palette.textMuted)
21
+ }
22
+
23
+ Text("Install the official Pi coding agent CLI to use the in-app assistant. Lattices can copy the command or run it in \(Preferences.shared.terminal.rawValue).")
24
+ .font(Typo.mono(compact ? 10 : 11))
25
+ .foregroundColor(Palette.textDim)
26
+ .fixedSize(horizontal: false, vertical: true)
27
+
28
+ Text(session.piInstallCommand)
29
+ .font(Typo.mono(compact ? 10 : 11))
30
+ .foregroundColor(Palette.text)
31
+ .textSelection(.enabled)
32
+ .frame(maxWidth: .infinity, alignment: .leading)
33
+ .padding(.horizontal, compact ? 10 : 12)
34
+ .padding(.vertical, compact ? 8 : 10)
35
+ .background(
36
+ RoundedRectangle(cornerRadius: compact ? 6 : 8)
37
+ .fill(Color.black.opacity(0.35))
38
+ .overlay(
39
+ RoundedRectangle(cornerRadius: compact ? 6 : 8)
40
+ .strokeBorder(Palette.border, lineWidth: 0.5)
41
+ )
42
+ )
43
+
44
+ HStack(spacing: 8) {
45
+ actionButton(compact ? "COPY" : "COPY CMD", tint: Palette.running) {
46
+ session.copyPiInstallCommand()
47
+ }
48
+
49
+ actionButton(compact ? "INSTALL" : "INSTALL IN TERMINAL", tint: Palette.detach) {
50
+ session.installPiInTerminal()
51
+ }
52
+
53
+ actionButton("REFRESH", tint: Palette.textMuted) {
54
+ session.refreshBinaryAvailability()
55
+ }
56
+ }
57
+ }
58
+ .padding(.horizontal, compact ? 10 : 16)
59
+ .padding(.vertical, compact ? 10 : 14)
60
+ .background(
61
+ RoundedRectangle(cornerRadius: compact ? 6 : 8)
62
+ .fill(Palette.kill.opacity(0.06))
63
+ .overlay(
64
+ RoundedRectangle(cornerRadius: compact ? 6 : 8)
65
+ .strokeBorder(Palette.kill.opacity(0.22), lineWidth: 0.5)
66
+ )
67
+ )
68
+ }
69
+
70
+ private func actionButton(_ label: String, tint: Color, action: @escaping () -> Void) -> some View {
71
+ Button(label, action: action)
72
+ .buttonStyle(.plain)
73
+ .font(Typo.geistMonoBold(compact ? 9 : 10))
74
+ .foregroundColor(tint)
75
+ .padding(.horizontal, compact ? 8 : 10)
76
+ .padding(.vertical, compact ? 5 : 6)
77
+ .background(
78
+ Capsule()
79
+ .fill(Color.white.opacity(0.03))
80
+ .overlay(
81
+ Capsule()
82
+ .strokeBorder(Palette.border, lineWidth: 0.5)
83
+ )
84
+ )
85
+ }
86
+ }
@@ -0,0 +1,99 @@
1
+ import SwiftUI
2
+
3
+ struct PiProviderSetupCallout: View {
4
+ @ObservedObject var session: PiChatSession
5
+ let compact: Bool
6
+
7
+ var body: some View {
8
+ VStack(alignment: .leading, spacing: compact ? 10 : 12) {
9
+ HStack(spacing: 8) {
10
+ Circle()
11
+ .fill(Palette.detach)
12
+ .frame(width: compact ? 6 : 7, height: compact ? 6 : 7)
13
+
14
+ Text("SET UP YOUR AI")
15
+ .font(Typo.geistMonoBold(compact ? 9 : 10))
16
+ .foregroundColor(Palette.detach.opacity(0.95))
17
+ }
18
+
19
+ Text(session.isAuthenticating
20
+ ? "Finish the setup above. As soon as that one step is done, the chat box unlocks."
21
+ : "Next step: connect \(session.currentProvider.name). You only have to do this once.")
22
+ .font(Typo.mono(compact ? 10 : 11))
23
+ .foregroundColor(Palette.textDim)
24
+ .fixedSize(horizontal: false, vertical: true)
25
+
26
+ HStack(spacing: 8) {
27
+ capsuleLabel(session.currentProvider.name.uppercased(), tint: Palette.text)
28
+ capsuleLabel(session.currentProvider.authMode == .oauth ? "SIGN IN" : "API KEY", tint: Palette.running)
29
+ }
30
+
31
+ if session.isAuthenticating {
32
+ PiAuthNextStepCard(session: session, compact: compact)
33
+ } else {
34
+ Text(session.currentProvider.authMode == .oauth
35
+ ? "The setup panel above is already open, so you can connect right now."
36
+ : "Paste your key in the setup panel above, save it once, and you are done.")
37
+ .font(Typo.mono(compact ? 9 : 10))
38
+ .foregroundColor(Palette.textMuted)
39
+ }
40
+
41
+ if session.currentProvider.authMode == .oauth && !session.isAuthenticating {
42
+ primaryActionButton(
43
+ "CONNECT \(session.currentProvider.name.uppercased())",
44
+ tint: Palette.running
45
+ ) {
46
+ session.startSelectedAuthFlow()
47
+ }
48
+ }
49
+ }
50
+ .padding(.horizontal, compact ? 10 : 16)
51
+ .padding(.vertical, compact ? 10 : 14)
52
+ .background(
53
+ RoundedRectangle(cornerRadius: compact ? 6 : 8)
54
+ .fill(Palette.detach.opacity(0.06))
55
+ .overlay(
56
+ RoundedRectangle(cornerRadius: compact ? 6 : 8)
57
+ .strokeBorder(Palette.detach.opacity(0.22), lineWidth: 0.5)
58
+ )
59
+ )
60
+ }
61
+
62
+ private func primaryActionButton(_ label: String, tint: Color, disabled: Bool = false, action: @escaping () -> Void) -> some View {
63
+ Button(action: action) {
64
+ Text(label)
65
+ .font(Typo.geistMonoBold(compact ? 10 : 11))
66
+ .foregroundColor(disabled ? Palette.textMuted : tint)
67
+ .frame(maxWidth: .infinity)
68
+ .padding(.horizontal, compact ? 10 : 12)
69
+ .padding(.vertical, compact ? 8 : 10)
70
+ .background(
71
+ RoundedRectangle(cornerRadius: compact ? 6 : 8)
72
+ .fill(tint.opacity(disabled ? 0.05 : 0.12))
73
+ .overlay(
74
+ RoundedRectangle(cornerRadius: compact ? 6 : 8)
75
+ .strokeBorder((disabled ? Palette.border : tint.opacity(0.35)), lineWidth: 0.5)
76
+ )
77
+ )
78
+ }
79
+ .buttonStyle(.plain)
80
+ .opacity(disabled ? 0.65 : 1)
81
+ .disabled(disabled)
82
+ }
83
+
84
+ private func capsuleLabel(_ text: String, tint: Color) -> some View {
85
+ Text(text)
86
+ .font(Typo.geistMonoBold(compact ? 9 : 10))
87
+ .foregroundColor(tint.opacity(0.95))
88
+ .padding(.horizontal, compact ? 7 : 8)
89
+ .padding(.vertical, compact ? 4 : 5)
90
+ .background(
91
+ Capsule()
92
+ .fill(tint.opacity(0.10))
93
+ .overlay(
94
+ Capsule()
95
+ .strokeBorder(tint.opacity(0.28), lineWidth: 0.5)
96
+ )
97
+ )
98
+ }
99
+ }
@@ -19,7 +19,7 @@ struct PiWorkspaceView: View {
19
19
  .fill(Palette.border)
20
20
  .frame(height: 0.5)
21
21
 
22
- if session.isAuthPanelVisible {
22
+ if session.hasPiBinary && session.isAuthPanelVisible {
23
23
  authPanel
24
24
 
25
25
  Rectangle()
@@ -33,12 +33,31 @@ struct PiWorkspaceView: View {
33
33
  .fill(Palette.border)
34
34
  .frame(height: 0.5)
35
35
 
36
- composer
36
+ if session.hasPiBinary && !session.needsProviderSetup {
37
+ composer
38
+ } else if session.needsProviderSetup {
39
+ if session.isAuthPanelVisible {
40
+ setupLockedPanel
41
+ } else {
42
+ PiProviderSetupCallout(session: session, compact: false)
43
+ .padding(.horizontal, 16)
44
+ .padding(.vertical, 14)
45
+ .background(Palette.surface.opacity(0.22))
46
+ }
47
+ } else {
48
+ PiInstallCallout(session: session, compact: false)
49
+ .padding(.horizontal, 16)
50
+ .padding(.vertical, 14)
51
+ .background(Palette.surface.opacity(0.22))
52
+ }
37
53
  }
38
54
  .background(Palette.bg)
39
55
  .onAppear {
40
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
41
- composerFocused = true
56
+ session.prepareForDisplay()
57
+ if session.hasPiBinary && !session.needsProviderSetup {
58
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
59
+ composerFocused = true
60
+ }
42
61
  }
43
62
  }
44
63
  }
@@ -51,14 +70,27 @@ struct PiWorkspaceView: View {
51
70
  .fill(session.hasPiBinary ? Palette.running : Palette.kill)
52
71
  .frame(width: 7, height: 7)
53
72
 
54
- Text("PI WORKSPACE")
73
+ Text("WORKSPACE CHAT")
55
74
  .font(Typo.geistMonoBold(11))
56
75
  .foregroundColor(Palette.text)
57
76
 
58
- capsuleLabel(session.statusText.uppercased(), tint: session.isSending ? Palette.detach : Palette.running)
77
+ capsuleLabel(
78
+ session.statusText.uppercased(),
79
+ tint: session.statusText == "missing pi"
80
+ ? Palette.kill
81
+ : ((session.statusText == "setup ai" || session.statusText == "connecting...")
82
+ ? Palette.detach
83
+ : (session.isSending ? Palette.detach : Palette.running))
84
+ )
59
85
  }
60
86
 
61
- Text("Full conversation surface for longer prompts, auth, and provider switching.")
87
+ Text(session.hasPiBinary
88
+ ? (session.isAuthenticating
89
+ ? session.authStepDescription
90
+ : (session.needsProviderSetup
91
+ ? "Next step: connect a provider to unlock chat."
92
+ : "Full conversation surface for longer prompts, auth, and provider switching."))
93
+ : "Install Pi to unlock longer prompts, provider auth, and the full in-app assistant surface.")
62
94
  .font(Typo.mono(10))
63
95
  .foregroundColor(Palette.textDim)
64
96
  }
@@ -68,12 +100,16 @@ struct PiWorkspaceView: View {
68
100
  HStack(spacing: 6) {
69
101
  capsuleLabel(session.currentProvider.name.uppercased(), tint: Palette.textDim)
70
102
 
71
- actionChip(session.isAuthPanelVisible ? "AUTH -" : "AUTH +") {
72
- session.toggleAuthPanel()
103
+ if session.hasPiBinary && !session.needsProviderSetup {
104
+ actionChip(session.isAuthPanelVisible ? "AUTH -" : "AUTH +") {
105
+ session.toggleAuthPanel()
106
+ }
73
107
  }
74
108
 
75
- actionChip("RESET") {
76
- session.clearConversation()
109
+ if session.hasConversationHistory {
110
+ actionChip("RESET") {
111
+ session.clearConversation()
112
+ }
77
113
  }
78
114
  }
79
115
  }
@@ -83,99 +119,113 @@ struct PiWorkspaceView: View {
83
119
 
84
120
  private var authPanel: some View {
85
121
  VStack(alignment: .leading, spacing: 12) {
86
- HStack(spacing: 8) {
87
- Text("provider")
88
- .font(Typo.geistMonoBold(9))
89
- .foregroundColor(Palette.textMuted)
122
+ if session.isAuthenticating {
123
+ VStack(alignment: .leading, spacing: 4) {
124
+ Text("Finish Setup")
125
+ .font(Typo.geistMonoBold(11))
126
+ .foregroundColor(Palette.text)
90
127
 
91
- Picker("Provider", selection: $session.authProviderID) {
92
- ForEach(session.providerOptions) { provider in
93
- Text(provider.name).tag(provider.id)
94
- }
128
+ Text("Ignore the rest for a second and just do the next step below.")
129
+ .font(Typo.mono(10))
130
+ .foregroundColor(Palette.textDim)
131
+ .fixedSize(horizontal: false, vertical: true)
95
132
  }
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
133
 
113
- if session.currentProvider.authMode == .apiKey {
114
134
  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
- }
135
+ capsuleLabel(session.currentProvider.name.uppercased(), tint: Palette.text)
136
+ capsuleLabel("IN PROGRESS", tint: Palette.detach)
137
+ Spacer()
138
+ }
123
139
 
124
- actionChip("SAVE") {
125
- session.saveSelectedToken()
126
- }
140
+ if let prompt = session.pendingAuthPrompt {
141
+ PiAuthPromptCard(session: session, prompt: prompt, compact: false, focus: $authFieldFocused)
142
+ } else {
143
+ PiAuthNextStepCard(session: session, compact: false)
144
+ }
145
+ } else {
146
+ if session.needsProviderSetup {
147
+ VStack(alignment: .leading, spacing: 4) {
148
+ Text("Set Up Your AI")
149
+ .font(Typo.geistMonoBold(11))
150
+ .foregroundColor(Palette.text)
127
151
 
128
- if session.hasSelectedCredential {
129
- actionChip("CLEAR") {
130
- session.removeSelectedCredential()
131
- }
152
+ Text("Choose a provider, connect it once, and the chat box unlocks automatically.")
153
+ .font(Typo.mono(10))
154
+ .foregroundColor(Palette.textDim)
155
+ .fixedSize(horizontal: false, vertical: true)
132
156
  }
133
157
  }
134
- .padding(.horizontal, 12)
135
- .padding(.vertical, 10)
136
- .background(authCardBackground(tint: Palette.running))
137
- } else {
158
+
138
159
  HStack(spacing: 8) {
139
- actionChip(session.isAuthenticating ? "CANCEL" : "LOGIN") {
140
- if session.isAuthenticating {
141
- session.cancelAuthFlow()
142
- } else {
143
- session.startSelectedAuthFlow()
144
- }
145
- }
160
+ Text(session.needsProviderSetup ? "choose provider" : "provider")
161
+ .font(Typo.geistMonoBold(9))
162
+ .foregroundColor(Palette.textMuted)
146
163
 
147
- if session.hasSelectedCredential {
148
- actionChip("CLEAR") {
149
- session.removeSelectedCredential()
164
+ Picker("Provider", selection: $session.authProviderID) {
165
+ ForEach(session.providerOptions) { provider in
166
+ Text(provider.name).tag(provider.id)
150
167
  }
151
168
  }
169
+ .labelsHidden()
170
+ .pickerStyle(.menu)
171
+ .font(Typo.mono(10))
172
+
173
+ Spacer()
174
+
175
+ capsuleLabel(
176
+ session.currentProvider.authMode == .oauth ? "OAUTH" : "TOKEN",
177
+ tint: session.currentProvider.authMode == .oauth ? Palette.detach : Palette.running
178
+ )
152
179
  }
153
- .padding(.horizontal, 12)
154
- .padding(.vertical, 10)
155
- .background(authCardBackground(tint: session.isAuthenticating ? Palette.detach : Palette.running))
156
180
 
157
- if let prompt = session.pendingAuthPrompt {
181
+ Text(session.currentProvider.helpText)
182
+ .font(Typo.mono(10))
183
+ .foregroundColor(Palette.textDim)
184
+ .fixedSize(horizontal: false, vertical: true)
185
+
186
+ if session.currentProvider.authMode == .apiKey {
158
187
  HStack(spacing: 8) {
159
- TextField(prompt.placeholder ?? prompt.message, text: $session.authPromptInput)
188
+ SecureField(session.currentProvider.tokenPlaceholder, text: $session.authToken)
160
189
  .textFieldStyle(.plain)
161
190
  .font(Typo.mono(11))
162
191
  .foregroundColor(Palette.text)
163
192
  .focused($authFieldFocused)
164
193
  .onSubmit {
165
- session.submitAuthPrompt()
194
+ session.saveSelectedToken()
195
+ }
196
+
197
+ actionChip("SAVE KEY") {
198
+ session.saveSelectedToken()
199
+ }
200
+
201
+ if session.hasSelectedCredential {
202
+ actionChip("CLEAR") {
203
+ session.removeSelectedCredential()
166
204
  }
205
+ }
206
+ }
207
+ .padding(.horizontal, 12)
208
+ .padding(.vertical, 10)
209
+ .background(authCardBackground(tint: Palette.running))
210
+ } else {
211
+ HStack(spacing: 8) {
212
+ actionChip("CONNECT") {
213
+ session.startSelectedAuthFlow()
214
+ }
167
215
 
168
- actionChip("CONTINUE") {
169
- session.submitAuthPrompt()
216
+ if session.hasSelectedCredential {
217
+ actionChip("CLEAR") {
218
+ session.removeSelectedCredential()
219
+ }
170
220
  }
171
221
  }
172
222
  .padding(.horizontal, 12)
173
223
  .padding(.vertical, 10)
174
- .background(authCardBackground(tint: Palette.detach))
224
+ .background(authCardBackground(tint: Palette.running))
175
225
  }
176
226
  }
177
227
 
178
- if let notice = session.authNoticeText, !notice.isEmpty {
228
+ if !session.isAuthenticating, let notice = session.authNoticeText, !notice.isEmpty {
179
229
  Text(notice)
180
230
  .font(Typo.mono(9))
181
231
  .foregroundColor(Palette.textDim)
@@ -192,6 +242,43 @@ struct PiWorkspaceView: View {
192
242
  .padding(.horizontal, 16)
193
243
  .padding(.vertical, 14)
194
244
  .background(Palette.surface.opacity(0.35))
245
+ .onAppear {
246
+ focusAuthFieldIfNeeded()
247
+ }
248
+ .onChange(of: session.authProviderID) { _ in
249
+ focusAuthFieldIfNeeded()
250
+ }
251
+ .onChange(of: session.pendingAuthPrompt?.message) { prompt in
252
+ if prompt != nil {
253
+ focusAuthFieldIfNeeded()
254
+ }
255
+ }
256
+ }
257
+
258
+ private var setupLockedPanel: some View {
259
+ HStack(alignment: .center, spacing: 10) {
260
+ Circle()
261
+ .fill(Palette.detach)
262
+ .frame(width: 7, height: 7)
263
+
264
+ VStack(alignment: .leading, spacing: 3) {
265
+ Text("SETUP IN PROGRESS")
266
+ .font(Typo.geistMonoBold(10))
267
+ .foregroundColor(Palette.text)
268
+
269
+ Text(session.isAuthenticating
270
+ ? "Stay with the setup panel above for now. The chat box unlocks as soon as you finish that step."
271
+ : "Finish the setup panel above to unlock the chat box.")
272
+ .font(Typo.mono(10))
273
+ .foregroundColor(Palette.textDim)
274
+ .fixedSize(horizontal: false, vertical: true)
275
+ }
276
+
277
+ Spacer()
278
+ }
279
+ .padding(.horizontal, 16)
280
+ .padding(.vertical, 14)
281
+ .background(Palette.surface.opacity(0.22))
195
282
  }
196
283
 
197
284
  private var transcript: some View {
@@ -312,6 +399,14 @@ struct PiWorkspaceView: View {
312
399
  }
313
400
  }
314
401
 
402
+ private func focusAuthFieldIfNeeded() {
403
+ if session.currentProvider.authMode == .apiKey || session.pendingAuthPrompt != nil {
404
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
405
+ authFieldFocused = true
406
+ }
407
+ }
408
+ }
409
+
315
410
  private func roleColor(for role: PiChatMessage.Role) -> Color {
316
411
  switch role {
317
412
  case .system: return Palette.detach
@@ -336,11 +431,11 @@ struct PiWorkspaceView: View {
336
431
  )
337
432
  }
338
433
 
339
- private func actionChip(_ label: String, action: @escaping () -> Void) -> some View {
434
+ private func actionChip(_ label: String, tint: Color = Palette.textMuted, disabled: Bool = false, action: @escaping () -> Void) -> some View {
340
435
  Button(label, action: action)
341
436
  .buttonStyle(.plain)
342
437
  .font(Typo.geistMonoBold(9))
343
- .foregroundColor(Palette.textMuted)
438
+ .foregroundColor(disabled ? Palette.textMuted : tint)
344
439
  .padding(.horizontal, 8)
345
440
  .padding(.vertical, 5)
346
441
  .background(
@@ -351,6 +446,8 @@ struct PiWorkspaceView: View {
351
446
  .strokeBorder(Palette.border, lineWidth: 0.5)
352
447
  )
353
448
  )
449
+ .opacity(disabled ? 0.65 : 1)
450
+ .disabled(disabled)
354
451
  }
355
452
 
356
453
  private func authCardBackground(tint: Color) -> some View {
@@ -1,3 +1,4 @@
1
+ import DeckKit
1
2
  import Foundation
2
3
 
3
4
  enum InteractionMode: String {
@@ -8,6 +9,11 @@ enum InteractionMode: String {
8
9
  class Preferences: ObservableObject {
9
10
  static let shared = Preferences()
10
11
 
12
+ private enum CompanionDefaultsKey {
13
+ static let trackpadEnabled = "companion.trackpad.enabled"
14
+ static let cockpitLayout = "companion.cockpit.layout"
15
+ }
16
+
11
17
  @Published var terminal: Terminal {
12
18
  didSet { UserDefaults.standard.set(terminal.rawValue, forKey: "terminal") }
13
19
  }
@@ -20,6 +26,22 @@ class Preferences: ObservableObject {
20
26
  didSet { UserDefaults.standard.set(mode.rawValue, forKey: "mode") }
21
27
  }
22
28
 
29
+ @Published var dragSnapEnabled: Bool {
30
+ didSet { UserDefaults.standard.set(dragSnapEnabled, forKey: "windowSnap.enabled") }
31
+ }
32
+
33
+ @Published var companionTrackpadEnabled: Bool {
34
+ didSet { UserDefaults.standard.set(companionTrackpadEnabled, forKey: CompanionDefaultsKey.trackpadEnabled) }
35
+ }
36
+
37
+ @Published var companionCockpitLayout: LatticesCompanionCockpitLayout {
38
+ didSet { persistCompanionCockpitLayout() }
39
+ }
40
+
41
+ @Published var mouseGesturesEnabled: Bool {
42
+ didSet { UserDefaults.standard.set(mouseGesturesEnabled, forKey: "mouseGestures.enabled") }
43
+ }
44
+
23
45
  // MARK: - AI / Claude
24
46
 
25
47
  @Published var claudePath: String {
@@ -128,6 +150,25 @@ class Preferences: ObservableObject {
128
150
  self.mode = .learning
129
151
  }
130
152
 
153
+ if UserDefaults.standard.object(forKey: "windowSnap.enabled") != nil {
154
+ self.dragSnapEnabled = UserDefaults.standard.bool(forKey: "windowSnap.enabled")
155
+ } else {
156
+ self.dragSnapEnabled = true
157
+ }
158
+
159
+ if UserDefaults.standard.object(forKey: CompanionDefaultsKey.trackpadEnabled) != nil {
160
+ self.companionTrackpadEnabled = UserDefaults.standard.bool(forKey: CompanionDefaultsKey.trackpadEnabled)
161
+ } else {
162
+ self.companionTrackpadEnabled = true
163
+ }
164
+
165
+ self.companionCockpitLayout = Self.loadCompanionCockpitLayout()
166
+
167
+ if UserDefaults.standard.object(forKey: "mouseGestures.enabled") != nil {
168
+ self.mouseGesturesEnabled = UserDefaults.standard.bool(forKey: "mouseGestures.enabled")
169
+ } else {
170
+ self.mouseGesturesEnabled = false
171
+ }
131
172
  // AI / Claude
132
173
  self.claudePath = UserDefaults.standard.string(forKey: "claude.path") ?? ""
133
174
  self.advisorModel = UserDefaults.standard.string(forKey: "claude.advisorModel") ?? "haiku"
@@ -155,4 +196,41 @@ class Preferences: ObservableObject {
155
196
  let savedAcc = UserDefaults.standard.string(forKey: "ocr.accuracy") ?? "accurate"
156
197
  self.ocrAccuracy = savedAcc
157
198
  }
199
+
200
+ func updateCompanionCockpitSlot(
201
+ pageID: String,
202
+ index: Int,
203
+ shortcutID: String
204
+ ) {
205
+ var normalized = LatticesCompanionCockpitCatalog.normalized(companionCockpitLayout)
206
+ guard let pageIndex = normalized.pages.firstIndex(where: { $0.id == pageID }),
207
+ normalized.pages[pageIndex].slotIDs.indices.contains(index) else {
208
+ return
209
+ }
210
+ normalized.pages[pageIndex].slotIDs[index] = shortcutID
211
+ companionCockpitLayout = normalized
212
+ }
213
+
214
+ func resetCompanionCockpitLayout() {
215
+ companionCockpitLayout = LatticesCompanionCockpitCatalog.defaultLayout
216
+ }
217
+
218
+ private static func loadCompanionCockpitLayout() -> LatticesCompanionCockpitLayout {
219
+ guard let data = UserDefaults.standard.data(forKey: CompanionDefaultsKey.cockpitLayout),
220
+ let decoded = try? JSONDecoder().decode(LatticesCompanionCockpitLayout.self, from: data) else {
221
+ return LatticesCompanionCockpitCatalog.defaultLayout
222
+ }
223
+ return LatticesCompanionCockpitCatalog.normalized(decoded)
224
+ }
225
+
226
+ private func persistCompanionCockpitLayout() {
227
+ let normalized = LatticesCompanionCockpitCatalog.normalized(companionCockpitLayout)
228
+ if normalized != companionCockpitLayout {
229
+ companionCockpitLayout = normalized
230
+ return
231
+ }
232
+
233
+ guard let data = try? JSONEncoder().encode(normalized) else { return }
234
+ UserDefaults.standard.set(data, forKey: CompanionDefaultsKey.cockpitLayout)
235
+ }
158
236
  }