@lattices/cli 0.4.14 → 0.6.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 (181) hide show
  1. package/README.md +5 -7
  2. package/apps/mac/Info.plist +4 -4
  3. package/apps/mac/Lattices.app/Contents/Info.plist +4 -12
  4. package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/bin/lattices-app.ts +110 -17
  6. package/bin/lattices-build +125 -0
  7. package/bin/lattices-dev +89 -16
  8. package/bin/lattices.ts +977 -16
  9. package/docs/agents.md +81 -4
  10. package/docs/ai-chat-ux-review.md +416 -0
  11. package/docs/api.md +135 -3
  12. package/docs/app.md +30 -8
  13. package/docs/config.md +4 -0
  14. package/docs/mouse-gestures.md +60 -1
  15. package/docs/proposals/LAT-004-interactive-overlay-actors.md +1 -1
  16. package/docs/proposals/LAT-005-action-runtime-product-spine.md +914 -0
  17. package/docs/proposals/LAT-006-mira-in-lattices.md +553 -0
  18. package/docs/proposals/LAT-007-unified-app-shell.md +128 -0
  19. package/docs/reference/dewey.config.ts +2 -2
  20. package/docs/release.md +171 -0
  21. package/docs/repo-structure.md +5 -5
  22. package/docs/voice.md +11 -27
  23. package/package.json +11 -10
  24. package/apps/mac/Package.swift +0 -27
  25. package/apps/mac/Sources/AppShell/App.swift +0 -26
  26. package/apps/mac/Sources/AppShell/AppActivationCoordinator.swift +0 -27
  27. package/apps/mac/Sources/AppShell/AppDelegate.swift +0 -189
  28. package/apps/mac/Sources/AppShell/AppServicesBootstrap.swift +0 -25
  29. package/apps/mac/Sources/AppShell/AppShellView.swift +0 -171
  30. package/apps/mac/Sources/AppShell/AppUpdater.swift +0 -305
  31. package/apps/mac/Sources/AppShell/CliActionLauncher.swift +0 -50
  32. package/apps/mac/Sources/AppShell/HomeDashboardView.swift +0 -133
  33. package/apps/mac/Sources/AppShell/HotkeyBootstrap.swift +0 -87
  34. package/apps/mac/Sources/AppShell/KeyRecorderView.swift +0 -210
  35. package/apps/mac/Sources/AppShell/LatticesRuntime.swift +0 -104
  36. package/apps/mac/Sources/AppShell/MainView.swift +0 -847
  37. package/apps/mac/Sources/AppShell/MainWindow.swift +0 -83
  38. package/apps/mac/Sources/AppShell/MenuBarController.swift +0 -177
  39. package/apps/mac/Sources/AppShell/OnboardingView.swift +0 -483
  40. package/apps/mac/Sources/AppShell/PermissionsAssistantView.swift +0 -366
  41. package/apps/mac/Sources/AppShell/PermissionsAssistantWindow.swift +0 -70
  42. package/apps/mac/Sources/AppShell/Preferences.swift +0 -297
  43. package/apps/mac/Sources/AppShell/SettingsView.swift +0 -3163
  44. package/apps/mac/Sources/AppShell/SettingsWindow.swift +0 -34
  45. package/apps/mac/Sources/AppShell/WorkspaceInspectorPresenter.swift +0 -13
  46. package/apps/mac/Sources/Core/Actions/HotkeyManager.swift +0 -256
  47. package/apps/mac/Sources/Core/Actions/HotkeyStore.swift +0 -399
  48. package/apps/mac/Sources/Core/Actions/IntentEngine.swift +0 -988
  49. package/apps/mac/Sources/Core/Actions/IntentSchema.swift +0 -94
  50. package/apps/mac/Sources/Core/Actions/Intents/CreateLayerIntent.swift +0 -54
  51. package/apps/mac/Sources/Core/Actions/Intents/DistributeIntent.swift +0 -56
  52. package/apps/mac/Sources/Core/Actions/Intents/FocusIntent.swift +0 -69
  53. package/apps/mac/Sources/Core/Actions/Intents/HelpIntent.swift +0 -41
  54. package/apps/mac/Sources/Core/Actions/Intents/KillIntent.swift +0 -47
  55. package/apps/mac/Sources/Core/Actions/Intents/LatticeIntent.swift +0 -53
  56. package/apps/mac/Sources/Core/Actions/Intents/LaunchIntent.swift +0 -67
  57. package/apps/mac/Sources/Core/Actions/Intents/ListSessionsIntent.swift +0 -32
  58. package/apps/mac/Sources/Core/Actions/Intents/ListWindowsIntent.swift +0 -30
  59. package/apps/mac/Sources/Core/Actions/Intents/ScanIntent.swift +0 -52
  60. package/apps/mac/Sources/Core/Actions/Intents/SearchIntent.swift +0 -190
  61. package/apps/mac/Sources/Core/Actions/Intents/SwitchLayerIntent.swift +0 -50
  62. package/apps/mac/Sources/Core/Actions/Intents/TileIntent.swift +0 -61
  63. package/apps/mac/Sources/Core/Actions/PaletteCommand.swift +0 -439
  64. package/apps/mac/Sources/Core/Actions/VoiceIntentResolver.swift +0 -713
  65. package/apps/mac/Sources/Core/Companion/CompanionActivityLog.swift +0 -70
  66. package/apps/mac/Sources/Core/Companion/CompanionKeyboardController.swift +0 -141
  67. package/apps/mac/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +0 -454
  68. package/apps/mac/Sources/Core/Companion/LatticesCompanionCockpit.swift +0 -555
  69. package/apps/mac/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +0 -629
  70. package/apps/mac/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +0 -204
  71. package/apps/mac/Sources/Core/Companion/LatticesDeckHost.swift +0 -1463
  72. package/apps/mac/Sources/Core/Daemon/DaemonProtocol.swift +0 -114
  73. package/apps/mac/Sources/Core/Daemon/DaemonServer.swift +0 -427
  74. package/apps/mac/Sources/Core/Daemon/LatticesApi.swift +0 -2965
  75. package/apps/mac/Sources/Core/Desktop/AccessibilityTextExtractor.swift +0 -111
  76. package/apps/mac/Sources/Core/Desktop/AppTypeClassifier.swift +0 -106
  77. package/apps/mac/Sources/Core/Desktop/DesktopModel.swift +0 -331
  78. package/apps/mac/Sources/Core/Desktop/DesktopModelTypes.swift +0 -73
  79. package/apps/mac/Sources/Core/Desktop/InventoryManager.swift +0 -35
  80. package/apps/mac/Sources/Core/Desktop/InventoryPath.swift +0 -43
  81. package/apps/mac/Sources/Core/Desktop/MouseFinder.swift +0 -527
  82. package/apps/mac/Sources/Core/Desktop/OcrModel.swift +0 -467
  83. package/apps/mac/Sources/Core/Desktop/OcrStore.swift +0 -329
  84. package/apps/mac/Sources/Core/Desktop/PlacementSpec.swift +0 -195
  85. package/apps/mac/Sources/Core/Desktop/SessionWindowLocator.swift +0 -139
  86. package/apps/mac/Sources/Core/Desktop/TilePickerView.swift +0 -209
  87. package/apps/mac/Sources/Core/Desktop/WindowCapture.swift +0 -33
  88. package/apps/mac/Sources/Core/Desktop/WindowDragSnapController.swift +0 -429
  89. package/apps/mac/Sources/Core/Desktop/WindowPreviewCard.swift +0 -100
  90. package/apps/mac/Sources/Core/Desktop/WindowPreviewStore.swift +0 -112
  91. package/apps/mac/Sources/Core/Desktop/WindowSelectionStore.swift +0 -76
  92. package/apps/mac/Sources/Core/Desktop/WindowTiler.swift +0 -2222
  93. package/apps/mac/Sources/Core/Input/EventTapBreaker.swift +0 -124
  94. package/apps/mac/Sources/Core/Input/EventTapThread.swift +0 -54
  95. package/apps/mac/Sources/Core/Input/InputCaptureResetCenter.swift +0 -20
  96. package/apps/mac/Sources/Core/Input/KeyboardRemapConfig.swift +0 -69
  97. package/apps/mac/Sources/Core/Input/KeyboardRemapController.swift +0 -346
  98. package/apps/mac/Sources/Core/Input/KeyboardRemapStore.swift +0 -141
  99. package/apps/mac/Sources/Core/Input/MouseGestureConfig.swift +0 -499
  100. package/apps/mac/Sources/Core/Input/MouseGestureController.swift +0 -2583
  101. package/apps/mac/Sources/Core/Input/MouseInputDeviceStore.swift +0 -98
  102. package/apps/mac/Sources/Core/Input/MouseInputEventViewer.swift +0 -272
  103. package/apps/mac/Sources/Core/Input/MouseShortcutStore.swift +0 -170
  104. package/apps/mac/Sources/Core/Input/SecureEventInputMonitor.swift +0 -39
  105. package/apps/mac/Sources/Core/Input/ShapeRecognizer.swift +0 -624
  106. package/apps/mac/Sources/Core/Input/TapBudgetMeter.swift +0 -56
  107. package/apps/mac/Sources/Core/Overlays/AppWindowShell.swift +0 -63
  108. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeState.swift +0 -1566
  109. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeView.swift +0 -1927
  110. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeWindow.swift +0 -196
  111. package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteView.swift +0 -307
  112. package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +0 -67
  113. package/apps/mac/Sources/Core/Overlays/HUD/CheatSheetHUD.swift +0 -576
  114. package/apps/mac/Sources/Core/Overlays/HUD/HUDBottomBar.swift +0 -279
  115. package/apps/mac/Sources/Core/Overlays/HUD/HUDController.swift +0 -1158
  116. package/apps/mac/Sources/Core/Overlays/HUD/HUDLeftBar.swift +0 -849
  117. package/apps/mac/Sources/Core/Overlays/HUD/HUDMinimap.swift +0 -179
  118. package/apps/mac/Sources/Core/Overlays/HUD/HUDRightBar.swift +0 -596
  119. package/apps/mac/Sources/Core/Overlays/HUD/HUDState.swift +0 -367
  120. package/apps/mac/Sources/Core/Overlays/HUD/HUDTopBar.swift +0 -243
  121. package/apps/mac/Sources/Core/Overlays/HUD/LauncherHUD.swift +0 -334
  122. package/apps/mac/Sources/Core/Overlays/HUD/LayerBezel.swift +0 -203
  123. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchState.swift +0 -280
  124. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchView.swift +0 -422
  125. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchWindow.swift +0 -94
  126. package/apps/mac/Sources/Core/Overlays/OverlayPanelShell.swift +0 -241
  127. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapState.swift +0 -3135
  128. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapView.swift +0 -3977
  129. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapWindowController.swift +0 -119
  130. package/apps/mac/Sources/Core/Overlays/ScreenOverlayCanvasController.swift +0 -1217
  131. package/apps/mac/Sources/Core/Overlays/Voice/VoiceCommandWindow.swift +0 -1575
  132. package/apps/mac/Sources/Core/Pi/PiAuthNextStepCard.swift +0 -148
  133. package/apps/mac/Sources/Core/Pi/PiAuthPromptCard.swift +0 -90
  134. package/apps/mac/Sources/Core/Pi/PiChatDock.swift +0 -564
  135. package/apps/mac/Sources/Core/Pi/PiChatSession.swift +0 -1948
  136. package/apps/mac/Sources/Core/Pi/PiInstallCallout.swift +0 -86
  137. package/apps/mac/Sources/Core/Pi/PiProviderSetupCallout.swift +0 -99
  138. package/apps/mac/Sources/Core/Pi/PiWorkspaceView.swift +0 -510
  139. package/apps/mac/Sources/Core/System/Capability.swift +0 -79
  140. package/apps/mac/Sources/Core/System/DiagnosticLog.swift +0 -373
  141. package/apps/mac/Sources/Core/System/EventBus.swift +0 -31
  142. package/apps/mac/Sources/Core/System/PermissionChecker.swift +0 -224
  143. package/apps/mac/Sources/Core/System/ProcessModel.swift +0 -199
  144. package/apps/mac/Sources/Core/System/ProcessQuery.swift +0 -151
  145. package/apps/mac/Sources/Core/System/SystemTelemetryMonitor.swift +0 -273
  146. package/apps/mac/Sources/Core/Voice/AdvisorLearningStore.swift +0 -90
  147. package/apps/mac/Sources/Core/Voice/AgentSession.swift +0 -377
  148. package/apps/mac/Sources/Core/Voice/AudioProvider.swift +0 -555
  149. package/apps/mac/Sources/Core/Voice/HandsOffSession.swift +0 -839
  150. package/apps/mac/Sources/Core/Voice/VoiceChatView.swift +0 -192
  151. package/apps/mac/Sources/Core/Voice/VoxClient.swift +0 -454
  152. package/apps/mac/Sources/Core/Workspace/Project.swift +0 -28
  153. package/apps/mac/Sources/Core/Workspace/ProjectScanner.swift +0 -141
  154. package/apps/mac/Sources/Core/Workspace/SessionLayerStore.swift +0 -285
  155. package/apps/mac/Sources/Core/Workspace/SessionManager.swift +0 -75
  156. package/apps/mac/Sources/Core/Workspace/Terminal/Terminal.swift +0 -259
  157. package/apps/mac/Sources/Core/Workspace/Terminal/TerminalQuery.swift +0 -156
  158. package/apps/mac/Sources/Core/Workspace/Terminal/TerminalSynthesizer.swift +0 -200
  159. package/apps/mac/Sources/Core/Workspace/Tmux/TmuxModel.swift +0 -60
  160. package/apps/mac/Sources/Core/Workspace/Tmux/TmuxQuery.swift +0 -105
  161. package/apps/mac/Sources/Core/Workspace/WorkspaceManager.swift +0 -1027
  162. package/apps/mac/Sources/UI/ActionRow.swift +0 -78
  163. package/apps/mac/Sources/UI/OrphanRow.swift +0 -129
  164. package/apps/mac/Sources/UI/ProjectRow.swift +0 -368
  165. package/apps/mac/Sources/UI/TabGroupRow.swift +0 -178
  166. package/apps/mac/Sources/UI/Theme.swift +0 -164
  167. package/apps/mac/Tests/StageDragTests.swift +0 -333
  168. package/apps/mac/Tests/StageJoinTests.swift +0 -313
  169. package/apps/mac/Tests/StageManagerTests.swift +0 -280
  170. package/apps/mac/Tests/StageTileTests.swift +0 -353
  171. package/swift/Package.swift +0 -20
  172. package/swift/Sources/DeckKit/DeckAction.swift +0 -51
  173. package/swift/Sources/DeckKit/DeckBridgeSecurity.swift +0 -152
  174. package/swift/Sources/DeckKit/DeckCockpit.swift +0 -82
  175. package/swift/Sources/DeckKit/DeckHost.swift +0 -7
  176. package/swift/Sources/DeckKit/DeckManifest.swift +0 -145
  177. package/swift/Sources/DeckKit/DeckRuntimeSnapshot.swift +0 -533
  178. package/swift/Sources/DeckKit/DeckTrackpad.swift +0 -63
  179. package/swift/Sources/DeckKit/DeckValue.swift +0 -93
  180. package/swift/Sources/DeckKit/DeckVoiceError.swift +0 -88
  181. package/swift/Tests/DeckKitTests/DeckKitTests.swift +0 -286
@@ -1,1948 +0,0 @@
1
- import AppKit
2
- import Foundation
3
-
4
- struct PiChatMessage: Identifiable, Equatable {
5
- enum Role {
6
- case system
7
- case user
8
- case assistant
9
- }
10
-
11
- let id = UUID()
12
- let role: Role
13
- let text: String
14
- let timestamp: Date
15
- }
16
-
17
- struct PiAuthPrompt: Equatable {
18
- let message: String
19
- let placeholder: String?
20
- let allowEmpty: Bool
21
- }
22
-
23
- struct PiProvider: Identifiable, Equatable {
24
- enum AuthMode {
25
- case apiKey
26
- case oauth
27
- }
28
-
29
- let id: String
30
- let name: String
31
- let authMode: AuthMode
32
- let tokenLabel: String
33
- let tokenPlaceholder: String
34
- let helpText: String
35
-
36
- static let supported: [PiProvider] = [
37
- PiProvider(
38
- id: "github-copilot",
39
- name: "GitHub Copilot",
40
- authMode: .oauth,
41
- tokenLabel: "OAuth",
42
- tokenPlaceholder: "",
43
- helpText: "Uses device-code login. Personal access tokens are not accepted on this path."
44
- ),
45
- PiProvider(
46
- id: "openai-codex",
47
- name: "OpenAI Codex",
48
- authMode: .oauth,
49
- tokenLabel: "OAuth",
50
- tokenPlaceholder: "",
51
- helpText: "Uses browser login for ChatGPT Plus/Pro Codex access."
52
- ),
53
- PiProvider(
54
- id: "openai",
55
- name: "OpenAI",
56
- authMode: .apiKey,
57
- tokenLabel: "API key",
58
- tokenPlaceholder: "sk-...",
59
- helpText: "Stores an OpenAI API key for this app and the provider runtime to reuse."
60
- ),
61
- PiProvider(
62
- id: "anthropic",
63
- name: "Anthropic",
64
- authMode: .apiKey,
65
- tokenLabel: "API key",
66
- tokenPlaceholder: "sk-ant-...",
67
- helpText: "Stores an Anthropic API key for provider-backed chat."
68
- ),
69
- PiProvider(
70
- id: "google",
71
- name: "Google Gemini",
72
- authMode: .apiKey,
73
- tokenLabel: "API key",
74
- tokenPlaceholder: "AIza...",
75
- helpText: "Stores a Gemini API key for provider-backed chat."
76
- ),
77
- PiProvider(
78
- id: "openrouter",
79
- name: "OpenRouter",
80
- authMode: .apiKey,
81
- tokenLabel: "API key",
82
- tokenPlaceholder: "sk-or-...",
83
- helpText: "Stores an OpenRouter API key for provider-backed chat."
84
- ),
85
- PiProvider(
86
- id: "groq",
87
- name: "Groq",
88
- authMode: .apiKey,
89
- tokenLabel: "API key",
90
- tokenPlaceholder: "gsk_...",
91
- helpText: "Stores a Groq API key for provider-backed chat."
92
- ),
93
- PiProvider(
94
- id: "xai",
95
- name: "xAI",
96
- authMode: .apiKey,
97
- tokenLabel: "API key",
98
- tokenPlaceholder: "xai-...",
99
- helpText: "Stores an xAI API key for provider-backed chat."
100
- ),
101
- PiProvider(
102
- id: "mistral",
103
- name: "Mistral",
104
- authMode: .apiKey,
105
- tokenLabel: "API key",
106
- tokenPlaceholder: "",
107
- helpText: "Stores a Mistral API key for provider-backed chat."
108
- ),
109
- PiProvider(
110
- id: "minimax",
111
- name: "MiniMax",
112
- authMode: .apiKey,
113
- tokenLabel: "API key",
114
- tokenPlaceholder: "",
115
- helpText: "Stores a MiniMax API key for provider-backed chat."
116
- ),
117
- ]
118
-
119
- static func provider(id: String) -> PiProvider {
120
- supported.first(where: { $0.id == id }) ?? supported[0]
121
- }
122
- }
123
-
124
- final class PiChatSession: ObservableObject {
125
- static let shared = PiChatSession()
126
- private static let installCommand = "npm install -g @mariozechner/pi-coding-agent@latest"
127
-
128
- @Published private(set) var messages: [PiChatMessage] = [
129
- PiChatMessage(
130
- role: .system,
131
- text: "Assistant ready. This is a lightweight in-app conversation surface, not a full terminal.",
132
- timestamp: Date()
133
- )
134
- ]
135
- @Published var draft: String = ""
136
- @Published var isVisible: Bool = false
137
- @Published private(set) var isSending: Bool = false
138
- @Published private(set) var statusText: String = "idle"
139
- @Published var dockHeight: CGFloat = 230 {
140
- didSet {
141
- dockHeight = Self.clampDockHeight(dockHeight)
142
- UserDefaults.standard.set(dockHeight, forKey: Self.dockHeightDefaultsKey)
143
- }
144
- }
145
- @Published var isAuthPanelVisible: Bool = false
146
- @Published var authProviderID: String = "openai-codex" {
147
- didSet {
148
- guard oldValue != authProviderID else { return }
149
- if isAuthenticating {
150
- cancelAuthFlow(silently: true)
151
- }
152
- UserDefaults.standard.set(authProviderID, forKey: Self.selectedProviderDefaultsKey)
153
- authToken = ""
154
- isEditingStoredCredential = false
155
- authPromptInput = ""
156
- pendingAuthPrompt = nil
157
- authNoticeText = nil
158
- authErrorText = nil
159
- latestAuthURL = nil
160
- latestAuthInstructions = nil
161
- authVerificationCodeCopied = false
162
- lastCopiedAuthVerificationCode = nil
163
- prepareForDisplay()
164
- }
165
- }
166
- @Published var authToken: String = ""
167
- @Published var isEditingStoredCredential: Bool = false
168
- @Published var authPromptInput: String = ""
169
- @Published private(set) var isAuthenticating: Bool = false
170
- @Published private(set) var authenticatingProviderID: String?
171
- @Published private(set) var pendingAuthPrompt: PiAuthPrompt?
172
- @Published private(set) var authNoticeText: String?
173
- @Published private(set) var authErrorText: String?
174
- @Published private(set) var storedCredentialKinds: [String: String] = [:]
175
- @Published private(set) var piBinaryPath: String?
176
- @Published private(set) var latestAuthURL: URL?
177
- @Published private(set) var latestAuthInstructions: String?
178
- @Published private(set) var authVerificationCodeCopied: Bool = false
179
-
180
- private let queue = DispatchQueue(label: "pi-chat-session", qos: .userInitiated)
181
- private let sessionFileURL: URL
182
- private let voiceAdvisorSessionFileURL: URL
183
- private let voiceResolverSessionFileURL: URL
184
- private let authFileURL: URL
185
- private var authProcess: Process?
186
- private var authProcessIdentifier: Int32?
187
- private var authInputHandle: FileHandle?
188
- private var authStdoutPipe: Pipe?
189
- private var authStderrPipe: Pipe?
190
- private var authStdoutBuffer: String = ""
191
- private var authStderrBuffer: String = ""
192
- private var nodeBinaryPath: String?
193
- private var lastCopiedAuthVerificationCode: String?
194
-
195
- private static let selectedProviderDefaultsKey = "PiChatSelectedProvider"
196
- private static let dockHeightDefaultsKey = "PiChatDockHeight"
197
-
198
- private init() {
199
- let fm = FileManager.default
200
- let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
201
- ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support")
202
- let dir = base.appendingPathComponent("Lattices/pi-chat", isDirectory: true)
203
- try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
204
- sessionFileURL = dir.appendingPathComponent("session.jsonl")
205
- voiceAdvisorSessionFileURL = dir.appendingPathComponent("voice-advisor.jsonl")
206
- voiceResolverSessionFileURL = dir.appendingPathComponent("voice-resolver.jsonl")
207
- authFileURL = Self.piAgentDirURL().appendingPathComponent("auth.json")
208
-
209
- if let savedProvider = UserDefaults.standard.string(forKey: Self.selectedProviderDefaultsKey),
210
- PiProvider.supported.contains(where: { $0.id == savedProvider }) {
211
- authProviderID = savedProvider
212
- }
213
- let savedDockHeight = UserDefaults.standard.double(forKey: Self.dockHeightDefaultsKey)
214
- if savedDockHeight > 0 {
215
- dockHeight = Self.clampDockHeight(savedDockHeight)
216
- }
217
-
218
- reloadAuthState()
219
- refreshBinaryAvailability()
220
- cleanupLingeringAuthHelpers()
221
- }
222
-
223
- var hasPiBinary: Bool {
224
- piBinaryPath != nil
225
- }
226
-
227
- var isProviderInferenceReady: Bool {
228
- hasPiBinary && !needsProviderSetup
229
- }
230
-
231
- var piInstallCommand: String {
232
- Self.installCommand
233
- }
234
-
235
- var providerOptions: [PiProvider] {
236
- PiProvider.supported
237
- }
238
-
239
- var currentProvider: PiProvider {
240
- PiProvider.provider(id: authProviderID)
241
- }
242
-
243
- var authenticatingProvider: PiProvider? {
244
- guard let authenticatingProviderID else { return nil }
245
- return PiProvider.provider(id: authenticatingProviderID)
246
- }
247
-
248
- var needsProviderSetup: Bool {
249
- hasPiBinary && !hasSelectedCredential
250
- }
251
-
252
- var hasConversationHistory: Bool {
253
- messages.contains { $0.role != .system }
254
- }
255
-
256
- var selectedCredentialSummary: String {
257
- guard let kind = storedCredentialKinds[authProviderID] else { return "not authenticated" }
258
- return kind == "oauth" ? "oauth saved" : "token saved"
259
- }
260
-
261
- var hasSelectedCredential: Bool {
262
- storedCredentialKinds[authProviderID] != nil
263
- }
264
-
265
- var authVerificationCode: String? {
266
- guard let latestAuthInstructions else { return nil }
267
- let prefix = "Enter code:"
268
- guard let range = latestAuthInstructions.range(of: prefix, options: [.caseInsensitive]) else { return nil }
269
- let value = latestAuthInstructions[range.upperBound...]
270
- .trimmingCharacters(in: .whitespacesAndNewlines)
271
- return value.isEmpty ? nil : value
272
- }
273
-
274
- var authStepLabel: String {
275
- if pendingAuthPrompt != nil || latestAuthURL == nil {
276
- return "STEP 1"
277
- }
278
- return "STEP 2"
279
- }
280
-
281
- var authStepTitle: String {
282
- if pendingAuthPrompt != nil {
283
- return "Answer one quick question"
284
- }
285
- if latestAuthURL == nil {
286
- return "Opening your sign-in page"
287
- }
288
- if authVerificationCode != nil {
289
- return authVerificationCodeCopied
290
- ? "Paste the copied code in your browser"
291
- : "Copy the code, then paste it in your browser"
292
- }
293
- return "Finish sign-in in your browser"
294
- }
295
-
296
- var authStepDescription: String {
297
- if let prompt = pendingAuthPrompt {
298
- return prompt.message
299
- }
300
- if latestAuthURL == nil {
301
- return "Stay here for a second while the sign-in page is prepared."
302
- }
303
- if authVerificationCode != nil {
304
- return authVerificationCodeCopied
305
- ? "The code is already on your clipboard. Switch to the browser page and paste it."
306
- : "Use the code below on the browser page, or copy it here first."
307
- }
308
- return "Your browser sign-in page is ready. Finish the provider flow there."
309
- }
310
-
311
- var authStepShortText: String {
312
- if pendingAuthPrompt != nil {
313
- return "Answer one quick question"
314
- }
315
- if latestAuthURL == nil {
316
- return "Opening browser sign-in"
317
- }
318
- if authVerificationCode != nil {
319
- return authVerificationCodeCopied ? "Paste the copied code" : "Copy the code and paste it"
320
- }
321
- return "Finish sign-in in browser"
322
- }
323
-
324
- var setupStatusSummary: String {
325
- if !hasPiBinary {
326
- return "Install the provider runtime to enable provider chat"
327
- }
328
- if isAuthenticating {
329
- return authStepShortText
330
- }
331
- if needsProviderSetup {
332
- return "Next: connect \(currentProvider.name)"
333
- }
334
- return currentProvider.name
335
- }
336
-
337
- var canSubmitAuthPrompt: Bool {
338
- guard let prompt = pendingAuthPrompt else { return false }
339
- let value = authPromptInput.trimmingCharacters(in: .whitespacesAndNewlines)
340
- return prompt.allowEmpty || !value.isEmpty
341
- }
342
-
343
- func toggleVisibility() {
344
- isVisible.toggle()
345
- }
346
-
347
- func toggleAuthPanel() {
348
- if needsProviderSetup || isAuthenticating {
349
- isAuthPanelVisible = true
350
- dockHeight = max(dockHeight, 300)
351
- return
352
- }
353
- isAuthPanelVisible.toggle()
354
- if isAuthPanelVisible {
355
- dockHeight = max(dockHeight, 300)
356
- }
357
- }
358
-
359
- func clearConversation() {
360
- try? FileManager.default.removeItem(at: sessionFileURL)
361
- messages = []
362
- prepareForDisplay()
363
- }
364
-
365
- func prepareForDisplay() {
366
- reconcileAuthState()
367
- refreshBinaryAvailability()
368
-
369
- if isAuthenticating {
370
- isAuthPanelVisible = true
371
- statusText = "connecting..."
372
- } else if needsProviderSetup {
373
- statusText = "setup ai"
374
- } else if hasPiBinary && (statusText == "setup ai" || statusText == "missing pi") {
375
- statusText = "idle"
376
- }
377
-
378
- syncStructuredWelcomeMessage()
379
- }
380
-
381
- func refreshBinaryAvailability() {
382
- piBinaryPath = resolvePiPath()
383
- nodeBinaryPath = resolveNodePath()
384
-
385
- if piBinaryPath == nil {
386
- if statusText == "idle" || statusText == "missing pi" {
387
- statusText = "missing pi"
388
- }
389
- } else if !hasSelectedCredential {
390
- if statusText == "idle" || statusText == "setup ai" {
391
- statusText = "setup ai"
392
- }
393
- } else if statusText == "missing pi" {
394
- statusText = "idle"
395
- }
396
- }
397
-
398
- func copyPiInstallCommand() {
399
- NSPasteboard.general.clearContents()
400
- NSPasteboard.general.setString(piInstallCommand, forType: .string)
401
- appendSystemMessage("Copied the provider runtime install command to the clipboard.")
402
- }
403
-
404
- func installPiInTerminal() {
405
- Preferences.shared.terminal.launch(command: piInstallCommand, in: NSHomeDirectory())
406
- appendSystemMessage("Opened \(Preferences.shared.terminal.rawValue) and started the provider runtime install.")
407
- }
408
-
409
- func sendDraft() {
410
- let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
411
- guard !text.isEmpty else { return }
412
- draft = ""
413
- send(text)
414
- }
415
-
416
- func send(_ text: String) {
417
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
418
- guard !trimmed.isEmpty else { return }
419
- guard !isSending else { return }
420
-
421
- messages.append(PiChatMessage(role: .user, text: trimmed, timestamp: Date()))
422
-
423
- if let localResponse = handleLocalSettingsCommand(trimmed) {
424
- messages.append(PiChatMessage(role: .assistant, text: localResponse, timestamp: Date()))
425
- statusText = hasPiBinary ? (needsProviderSetup ? "setup ai" : "idle") : "missing pi"
426
- return
427
- }
428
-
429
- refreshBinaryAvailability()
430
-
431
- guard let piPath = piBinaryPath else {
432
- prepareForDisplay()
433
- statusText = "missing pi"
434
- return
435
- }
436
-
437
- guard !needsProviderSetup else {
438
- prepareForDisplay()
439
- isAuthPanelVisible = true
440
- dockHeight = max(dockHeight, 300)
441
- return
442
- }
443
-
444
- let provider = currentProvider
445
- isSending = true
446
- statusText = "thinking..."
447
- let prompt = providerPrompt(for: trimmed)
448
-
449
- queue.async { [weak self] in
450
- guard let self else { return }
451
-
452
- let proc = Process()
453
- proc.executableURL = URL(fileURLWithPath: piPath)
454
- proc.arguments = [
455
- "--provider", provider.id,
456
- "-p",
457
- "--session", self.sessionFileURL.path,
458
- prompt,
459
- ]
460
-
461
- var env = ProcessInfo.processInfo.environment
462
- env.removeValue(forKey: "CLAUDECODE")
463
- if provider.id == "github-copilot", self.storedCredentialKinds[provider.id] == nil {
464
- env.removeValue(forKey: "COPILOT_GITHUB_TOKEN")
465
- }
466
- Self.sanitizeEnvironment(&env, for: provider.id, hasStoredCredential: self.storedCredentialKinds[provider.id] != nil)
467
- proc.environment = env
468
-
469
- let outPipe = Pipe()
470
- let errPipe = Pipe()
471
- proc.standardOutput = outPipe
472
- proc.standardError = errPipe
473
-
474
- let stdout: String
475
- let stderr: String
476
- let exitCode: Int32
477
-
478
- do {
479
- try proc.run()
480
- proc.waitUntilExit()
481
- stdout = String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
482
- .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
483
- stderr = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
484
- .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
485
- exitCode = proc.terminationStatus
486
- } catch {
487
- DispatchQueue.main.async {
488
- self.isSending = false
489
- self.statusText = "launch failed"
490
- self.appendSystemMessage("Failed to launch the provider runtime: \(error.localizedDescription)")
491
- }
492
- return
493
- }
494
-
495
- DispatchQueue.main.async {
496
- self.isSending = false
497
-
498
- if exitCode == 0, !stdout.isEmpty {
499
- self.statusText = "idle"
500
- self.messages.append(PiChatMessage(
501
- role: .assistant,
502
- text: stdout,
503
- timestamp: Date()
504
- ))
505
- return
506
- }
507
-
508
- let message = !stderr.isEmpty ? stderr : (stdout.isEmpty ? "The provider runtime returned no output." : stdout)
509
- if let friendly = self.friendlyAuthFailureMessage(for: message) {
510
- self.statusText = "setup ai"
511
- self.authErrorText = friendly
512
- self.isAuthPanelVisible = true
513
- self.syncStructuredWelcomeMessage()
514
- return
515
- }
516
- self.statusText = "error"
517
- self.appendSystemMessage(message)
518
- if Self.looksLikeAuthError(message) {
519
- self.isAuthPanelVisible = true
520
- }
521
- }
522
- }
523
- }
524
-
525
- func askVoiceAdvisor(transcript: String, matched: String, callback: @escaping (AgentResponse?) -> Void) {
526
- runProviderInference(
527
- prompt: voiceAdvisorPrompt(transcript: transcript, matched: matched),
528
- sessionURL: voiceAdvisorSessionFileURL,
529
- label: "voice advisor"
530
- ) { output in
531
- guard let output, !output.isEmpty else {
532
- callback(nil)
533
- return
534
- }
535
- callback(AgentResponse.parse(text: output))
536
- }
537
- }
538
-
539
- func answerVoiceQuestion(_ transcript: String, callback: @escaping (AgentResponse?) -> Void) {
540
- runProviderInference(
541
- prompt: voiceQuestionPrompt(transcript: transcript),
542
- sessionURL: voiceAdvisorSessionFileURL,
543
- label: "voice question"
544
- ) { output in
545
- guard let output, !output.isEmpty else {
546
- callback(nil)
547
- return
548
- }
549
- callback(AgentResponse(commentary: output, suggestion: nil, raw: output))
550
- }
551
- }
552
-
553
- func resolveVoiceIntent(transcript: String, callback: @escaping (ResolvedIntent?) -> Void) {
554
- runProviderInference(
555
- prompt: voiceResolverPrompt(transcript: transcript),
556
- sessionURL: voiceResolverSessionFileURL,
557
- label: "voice resolver"
558
- ) { output in
559
- guard let output,
560
- let jsonStr = Self.extractJSON(from: output),
561
- let data = jsonStr.data(using: .utf8),
562
- let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
563
- let intent = json["intent"] as? String,
564
- intent != "unknown" else {
565
- callback(nil)
566
- return
567
- }
568
-
569
- var slots: [String: JSON] = [:]
570
- if let rawSlots = json["slots"] as? [String: Any] {
571
- for (key, value) in rawSlots {
572
- if let value = value as? String {
573
- slots[key] = .string(value)
574
- } else if let value = value as? Int {
575
- slots[key] = .int(value)
576
- } else if let value = value as? Bool {
577
- slots[key] = .bool(value)
578
- }
579
- }
580
- }
581
- callback(ResolvedIntent(intent: intent, slots: slots))
582
- }
583
- }
584
-
585
- private func runProviderInference(
586
- prompt: String,
587
- sessionURL: URL,
588
- label: String,
589
- callback: @escaping (String?) -> Void
590
- ) {
591
- refreshBinaryAvailability()
592
-
593
- guard let piPath = piBinaryPath else {
594
- DiagnosticLog.shared.info("Assistant inference[\(label)]: provider runtime not installed")
595
- callback(nil)
596
- return
597
- }
598
- guard !needsProviderSetup else {
599
- DiagnosticLog.shared.info("Assistant inference[\(label)]: selected provider needs credentials")
600
- callback(nil)
601
- return
602
- }
603
-
604
- let provider = currentProvider
605
- let hasStoredCredential = storedCredentialKinds[provider.id] != nil
606
-
607
- queue.async {
608
- let timer = DiagnosticLog.shared.startTimed("Assistant inference[\(label)] via \(provider.name)")
609
-
610
- let proc = Process()
611
- proc.executableURL = URL(fileURLWithPath: piPath)
612
- proc.arguments = [
613
- "--provider", provider.id,
614
- "-p",
615
- "--session", sessionURL.path,
616
- prompt,
617
- ]
618
-
619
- var env = ProcessInfo.processInfo.environment
620
- env.removeValue(forKey: "CLAUDECODE")
621
- Self.sanitizeEnvironment(&env, for: provider.id, hasStoredCredential: hasStoredCredential)
622
- proc.environment = env
623
-
624
- let outPipe = Pipe()
625
- let errPipe = Pipe()
626
- proc.standardOutput = outPipe
627
- proc.standardError = errPipe
628
-
629
- do {
630
- try proc.run()
631
- proc.waitUntilExit()
632
- } catch {
633
- DiagnosticLog.shared.warn("Assistant inference[\(label)]: launch failed — \(error)")
634
- DiagnosticLog.shared.finish(timer)
635
- DispatchQueue.main.async { callback(nil) }
636
- return
637
- }
638
-
639
- DiagnosticLog.shared.finish(timer)
640
- let stdout = String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
641
- .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
642
- let stderr = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
643
- .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
644
-
645
- if !stderr.isEmpty {
646
- DiagnosticLog.shared.info("Assistant inference[\(label)] stderr: \(stderr.prefix(200))")
647
- }
648
-
649
- guard proc.terminationStatus == 0, !stdout.isEmpty else {
650
- DiagnosticLog.shared.info("Assistant inference[\(label)]: empty/error response")
651
- DispatchQueue.main.async { callback(nil) }
652
- return
653
- }
654
-
655
- DispatchQueue.main.async { callback(stdout) }
656
- }
657
- }
658
-
659
- func saveSelectedToken() {
660
- let token = authToken.trimmingCharacters(in: .whitespacesAndNewlines)
661
- guard !token.isEmpty else {
662
- authErrorText = "Enter a token before saving."
663
- return
664
- }
665
-
666
- do {
667
- try mutateAuthFile { auth in
668
- auth[authProviderID] = [
669
- "type": "api_key",
670
- "key": token,
671
- ]
672
- }
673
- authToken = ""
674
- isEditingStoredCredential = false
675
- authNoticeText = "Saved \(currentProvider.tokenLabel.lowercased()) for \(currentProvider.name)."
676
- authErrorText = nil
677
- reloadAuthState()
678
- appendSystemMessage("Saved \(currentProvider.name) credentials.")
679
- isAuthPanelVisible = false
680
- prepareForDisplay()
681
- } catch {
682
- authErrorText = "Failed to save token: \(error.localizedDescription)"
683
- }
684
- }
685
-
686
- func removeSelectedCredential() {
687
- do {
688
- try mutateAuthFile { auth in
689
- auth.removeValue(forKey: authProviderID)
690
- }
691
- authNoticeText = "Removed saved credentials for \(currentProvider.name)."
692
- authErrorText = nil
693
- isEditingStoredCredential = true
694
- reloadAuthState()
695
- appendSystemMessage("Removed saved \(currentProvider.name) credentials.")
696
- prepareForDisplay()
697
- } catch {
698
- authErrorText = "Failed to remove credentials: \(error.localizedDescription)"
699
- }
700
- }
701
-
702
- func startSelectedAuthFlow() {
703
- if currentProvider.authMode == .apiKey {
704
- saveSelectedToken()
705
- return
706
- }
707
-
708
- startOAuthLogin(for: currentProvider)
709
- }
710
-
711
- func beginReplacingSelectedCredential() {
712
- authToken = ""
713
- authErrorText = nil
714
- authNoticeText = nil
715
- isEditingStoredCredential = true
716
- }
717
-
718
- func cancelReplacingSelectedCredential() {
719
- authToken = ""
720
- authErrorText = nil
721
- isEditingStoredCredential = false
722
- }
723
-
724
- func submitAuthPrompt() {
725
- guard let prompt = pendingAuthPrompt else { return }
726
- let value = authPromptInput.trimmingCharacters(in: .whitespacesAndNewlines)
727
- guard prompt.allowEmpty || !value.isEmpty else { return }
728
- submitAuthPromptValue(value)
729
- }
730
-
731
- private func submitAuthPromptValue(_ value: String) {
732
- guard let handle = authInputHandle else {
733
- authErrorText = "The auth input pipe is no longer available."
734
- return
735
- }
736
-
737
- let line = value + "\n"
738
- if let data = line.data(using: .utf8) {
739
- do {
740
- try handle.write(contentsOf: data)
741
- authPromptInput = ""
742
- pendingAuthPrompt = nil
743
- } catch {
744
- authErrorText = "Failed to send auth input: \(error.localizedDescription)"
745
- }
746
- }
747
- }
748
-
749
- func reopenLatestAuthURL() {
750
- guard let latestAuthURL else {
751
- authNoticeText = "Still preparing the browser sign-in link..."
752
- return
753
- }
754
-
755
- autoCopyAuthVerificationCodeIfNeeded()
756
- NSWorkspace.shared.open(latestAuthURL)
757
- authNoticeText = authVerificationCode != nil
758
- ? "Reopened the sign-in page. Paste the copied code there."
759
- : "Reopened \(authenticatingProvider?.name ?? currentProvider.name) sign-in in your browser."
760
- }
761
-
762
- func copyAuthVerificationCode() {
763
- copyAuthVerificationCode(silently: false)
764
- }
765
-
766
- private func copyAuthVerificationCode(silently: Bool) {
767
- guard let authVerificationCode else {
768
- authNoticeText = "No sign-in code is ready yet."
769
- return
770
- }
771
-
772
- NSPasteboard.general.clearContents()
773
- NSPasteboard.general.setString(authVerificationCode, forType: .string)
774
- authVerificationCodeCopied = true
775
- lastCopiedAuthVerificationCode = authVerificationCode
776
- if !silently {
777
- authNoticeText = "Copied the sign-in code. Paste it into the browser page."
778
- }
779
- }
780
-
781
- private func autoCopyAuthVerificationCodeIfNeeded() {
782
- guard let authVerificationCode else { return }
783
- guard !authVerificationCodeCopied || lastCopiedAuthVerificationCode != authVerificationCode else { return }
784
- copyAuthVerificationCode(silently: true)
785
- }
786
-
787
- func cancelAuthFlow(silently: Bool = false) {
788
- let process = authProcess
789
- cleanupAuthProcess()
790
- terminateProcess(process, escalateAfter: 0.8)
791
- isAuthenticating = false
792
- statusText = hasPiBinary && !hasSelectedCredential ? "setup ai" : "idle"
793
- if !silently {
794
- authNoticeText = "Cancelled auth flow."
795
- }
796
- }
797
-
798
- private func startOAuthLogin(for provider: PiProvider) {
799
- reconcileAuthState()
800
- cleanupLingeringAuthHelpers()
801
-
802
- if isAuthenticating {
803
- cancelAuthFlow(silently: true)
804
- }
805
-
806
- refreshBinaryAvailability()
807
-
808
- guard hasPiBinary else {
809
- authErrorText = "Install the provider runtime before starting auth."
810
- return
811
- }
812
-
813
- guard let nodePath = nodeBinaryPath else {
814
- authErrorText = "Node.js is required for OAuth login."
815
- return
816
- }
817
-
818
- guard let oauthModuleURL = resolveOAuthModuleURL() else {
819
- authErrorText = "Couldn't locate the OAuth module next to the installed provider runtime."
820
- return
821
- }
822
-
823
- let proc = Process()
824
- proc.executableURL = URL(fileURLWithPath: nodePath)
825
- proc.arguments = [
826
- "--input-type=module",
827
- "--eval",
828
- Self.oauthDriverScript,
829
- provider.id,
830
- oauthModuleURL.absoluteString,
831
- ]
832
-
833
- let stdinPipe = Pipe()
834
- let stdoutPipe = Pipe()
835
- let stderrPipe = Pipe()
836
- proc.standardInput = stdinPipe
837
- proc.standardOutput = stdoutPipe
838
- proc.standardError = stderrPipe
839
-
840
- authStdoutBuffer = ""
841
- authStderrBuffer = ""
842
- authPromptInput = ""
843
- pendingAuthPrompt = nil
844
- latestAuthURL = nil
845
- latestAuthInstructions = nil
846
- authVerificationCodeCopied = false
847
- lastCopiedAuthVerificationCode = nil
848
- authNoticeText = "Preparing \(provider.name) sign-in..."
849
- authErrorText = nil
850
- isAuthenticating = true
851
- authenticatingProviderID = provider.id
852
- statusText = "connecting..."
853
-
854
- stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
855
- let data = handle.availableData
856
- guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
857
- self?.handleAuthStdout(text)
858
- }
859
-
860
- stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
861
- let data = handle.availableData
862
- guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
863
- self?.handleAuthStderr(text)
864
- }
865
-
866
- proc.terminationHandler = { [weak self] process in
867
- DispatchQueue.main.async {
868
- self?.handleAuthProcessExit(processID: process.processIdentifier, status: process.terminationStatus)
869
- }
870
- }
871
-
872
- do {
873
- try proc.run()
874
- authProcess = proc
875
- authProcessIdentifier = proc.processIdentifier
876
- authInputHandle = stdinPipe.fileHandleForWriting
877
- authStdoutPipe = stdoutPipe
878
- authStderrPipe = stderrPipe
879
- recordAuthHelperProcess(proc.processIdentifier)
880
- appendSystemMessage("Started \(provider.name) auth flow.")
881
- } catch {
882
- cleanupAuthProcess()
883
- isAuthenticating = false
884
- statusText = hasPiBinary && !hasSelectedCredential ? "setup ai" : "idle"
885
- authErrorText = "Failed to launch auth flow: \(error.localizedDescription)"
886
- }
887
- }
888
-
889
- private func handleAuthStdout(_ text: String) {
890
- DispatchQueue.main.async {
891
- self.authStdoutBuffer.append(text)
892
- self.consumeBufferedAuthLines(buffer: &self.authStdoutBuffer, handler: self.handleAuthEventLine(_:))
893
- }
894
- }
895
-
896
- private func handleAuthStderr(_ text: String) {
897
- DispatchQueue.main.async {
898
- self.authStderrBuffer.append(text)
899
- self.consumeBufferedAuthLines(buffer: &self.authStderrBuffer) { line in
900
- guard !line.isEmpty else { return }
901
- self.authNoticeText = line
902
- }
903
- }
904
- }
905
-
906
- private func consumeBufferedAuthLines(buffer: inout String, handler: (String) -> Void) {
907
- while let range = buffer.range(of: "\n") {
908
- let line = String(buffer[..<range.lowerBound])
909
- buffer.removeSubrange(buffer.startIndex...range.lowerBound)
910
- handler(line.trimmingCharacters(in: .whitespacesAndNewlines))
911
- }
912
- }
913
-
914
- private func handleAuthEventLine(_ line: String) {
915
- guard !line.isEmpty else { return }
916
- guard let data = line.data(using: .utf8),
917
- let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
918
- let type = json["type"] as? String else {
919
- authNoticeText = line
920
- return
921
- }
922
-
923
- switch type {
924
- case "prompt":
925
- let prompt = PiAuthPrompt(
926
- message: json["message"] as? String ?? "Continue",
927
- placeholder: json["placeholder"] as? String,
928
- allowEmpty: json["allowEmpty"] as? Bool ?? false
929
- )
930
- pendingAuthPrompt = prompt
931
- authNoticeText = prompt.message
932
- if shouldAutoSubmitPrompt(prompt) {
933
- authNoticeText = "Using github.com. If you need GitHub Enterprise, cancel and enter your domain instead."
934
- submitAuthPromptValue("")
935
- }
936
-
937
- case "auth":
938
- let urlString = json["url"] as? String ?? ""
939
- let instructions = json["instructions"] as? String
940
- latestAuthURL = URL(string: urlString)
941
- latestAuthInstructions = instructions
942
- if authVerificationCode != lastCopiedAuthVerificationCode {
943
- authVerificationCodeCopied = false
944
- }
945
- autoCopyAuthVerificationCodeIfNeeded()
946
- authNoticeText = authVerificationCode != nil
947
- ? "The sign-in code is copied. Paste it into the browser page."
948
- : "Your browser sign-in page is ready."
949
- if let url = latestAuthURL {
950
- NSWorkspace.shared.open(url)
951
- }
952
- if authVerificationCode != nil {
953
- appendSystemMessage("Auth is ready. The sign-in code is copied, and you can reopen the browser page here if needed.")
954
- } else if let instructions, !instructions.isEmpty {
955
- appendSystemMessage("Auth: \(instructions) If nothing opened, use OPEN AGAIN.")
956
- } else {
957
- appendSystemMessage("Auth is ready in your browser. If nothing opened, use OPEN AGAIN.")
958
- }
959
-
960
- case "progress":
961
- authNoticeText = json["message"] as? String ?? "Working..."
962
-
963
- case "success":
964
- guard var credentials = json["credentials"] as? [String: Any] else {
965
- authErrorText = "Auth completed but returned no credentials."
966
- return
967
- }
968
- let providerID = authenticatingProviderID ?? authProviderID
969
- let provider = PiProvider.provider(id: providerID)
970
- credentials["type"] = "oauth"
971
- do {
972
- try mutateAuthFile { auth in
973
- auth[providerID] = credentials
974
- }
975
- reloadAuthState()
976
- authNoticeText = "Saved OAuth credentials for \(provider.name)."
977
- authErrorText = nil
978
- appendSystemMessage("Saved \(provider.name) OAuth credentials.")
979
- isAuthPanelVisible = false
980
- prepareForDisplay()
981
- } catch {
982
- authErrorText = "Failed to save OAuth credentials: \(error.localizedDescription)"
983
- }
984
-
985
- case "error":
986
- let message = json["message"] as? String ?? "Unknown auth error."
987
- authErrorText = message
988
- appendSystemMessage("Auth failed: \(message)")
989
-
990
- default:
991
- authNoticeText = line
992
- }
993
- }
994
-
995
- private func handleAuthProcessExit(processID: Int32, status: Int32) {
996
- guard authProcessIdentifier == processID else { return }
997
-
998
- let hadExplicitError = authErrorText != nil
999
- cleanupAuthProcess()
1000
- isAuthenticating = false
1001
- pendingAuthPrompt = nil
1002
-
1003
- if status == 0 {
1004
- if !hadExplicitError {
1005
- authNoticeText = authNoticeText ?? "Auth flow finished."
1006
- }
1007
- } else if !hadExplicitError {
1008
- authErrorText = "Auth flow exited with status \(status)."
1009
- }
1010
-
1011
- if status == 0, hasSelectedCredential {
1012
- statusText = "idle"
1013
- } else if hasPiBinary && !hasSelectedCredential {
1014
- statusText = "setup ai"
1015
- }
1016
- }
1017
-
1018
- private func cleanupAuthProcess() {
1019
- authProcess?.terminationHandler = nil
1020
- authStdoutPipe?.fileHandleForReading.readabilityHandler = nil
1021
- authStderrPipe?.fileHandleForReading.readabilityHandler = nil
1022
- try? authInputHandle?.close()
1023
- authInputHandle = nil
1024
- authStdoutPipe = nil
1025
- authStderrPipe = nil
1026
- authStdoutBuffer = ""
1027
- authStderrBuffer = ""
1028
- latestAuthURL = nil
1029
- latestAuthInstructions = nil
1030
- authVerificationCodeCopied = false
1031
- lastCopiedAuthVerificationCode = nil
1032
- authProcess = nil
1033
- authProcessIdentifier = nil
1034
- authenticatingProviderID = nil
1035
- clearRecordedAuthHelperProcess()
1036
- }
1037
-
1038
- private func appendSystemMessage(_ text: String) {
1039
- messages.append(PiChatMessage(role: .system, text: text, timestamp: Date()))
1040
- }
1041
-
1042
- private func syncStructuredWelcomeMessage() {
1043
- guard !hasConversationHistory else { return }
1044
- messages = [
1045
- PiChatMessage(
1046
- role: .system,
1047
- text: structuredWelcomeMessage(),
1048
- timestamp: Date()
1049
- )
1050
- ]
1051
- }
1052
-
1053
- private func structuredWelcomeMessage() -> String {
1054
- if !hasPiBinary {
1055
- return """
1056
- Welcome to the Workspace Assistant.
1057
-
1058
- I can manage app settings here. Install the provider runtime to unlock provider-backed chat for longer planning and coding prompts.
1059
-
1060
- Install command:
1061
- \(piInstallCommand)
1062
- """
1063
- }
1064
-
1065
- if isAuthenticating {
1066
- return """
1067
- Welcome to the Workspace Assistant.
1068
-
1069
- \(authStepTitle)
1070
-
1071
- \(authStepDescription)
1072
- """
1073
- }
1074
-
1075
- if needsProviderSetup {
1076
- return """
1077
- Welcome to the Workspace Assistant.
1078
-
1079
- Next step: connect \(currentProvider.name).
1080
-
1081
- Open Settings with the gear icon, choose a provider, and save its API key to unlock provider-backed chat.
1082
- """
1083
- }
1084
-
1085
- return """
1086
- Welcome to the Workspace Assistant.
1087
-
1088
- You're connected with \(currentProvider.name). I can manage Lattices settings locally, or use the provider for code help, planning, debugging, and second opinions.
1089
- """
1090
- }
1091
-
1092
- private func handleLocalSettingsCommand(_ text: String) -> String? {
1093
- let lower = text.lowercased()
1094
- let prefs = Preferences.shared
1095
-
1096
- if lower.contains("open settings") || lower.contains("show settings") {
1097
- SettingsWindowController.shared.show()
1098
- return "Opened Settings."
1099
- }
1100
-
1101
- if lower.contains("scan root") || lower.contains("project root") || lower.contains("project scan") {
1102
- if let root = extractPathValue(from: text) {
1103
- prefs.scanRoot = root
1104
- ProjectScanner.shared.updateRoot(root)
1105
- ProjectScanner.shared.scan()
1106
- return "Set project scan root to \(root) and started a rescan."
1107
- }
1108
- return nil
1109
- }
1110
-
1111
- if lower.contains("terminal") {
1112
- if let terminal = parseTerminal(from: lower) {
1113
- guard terminal.isInstalled else {
1114
- return "\(terminal.rawValue) is not installed, so I left the terminal set to \(prefs.terminal.rawValue)."
1115
- }
1116
- prefs.terminal = terminal
1117
- return "Set terminal to \(terminal.rawValue)."
1118
- }
1119
- return nil
1120
- }
1121
-
1122
- if lower.contains("detach mode") || lower.contains("interaction mode") || lower.contains("learning mode") || lower.contains("auto mode") {
1123
- if lower.contains("auto") {
1124
- prefs.mode = .auto
1125
- return "Set detach mode to Auto."
1126
- }
1127
- if lower.contains("learning") {
1128
- prefs.mode = .learning
1129
- return "Set detach mode to Learning."
1130
- }
1131
- return nil
1132
- }
1133
-
1134
- if lower.contains("drag") && lower.contains("snap") {
1135
- if let enabled = parseBooleanIntent(from: lower) {
1136
- prefs.dragSnapEnabled = enabled
1137
- return "\(enabled ? "Enabled" : "Disabled") drag-to-snap."
1138
- }
1139
- return nil
1140
- }
1141
-
1142
- if lower.contains("mouse") && (lower.contains("gesture") || lower.contains("shortcut")) {
1143
- if let enabled = parseBooleanIntent(from: lower) {
1144
- prefs.mouseGesturesEnabled = enabled
1145
- return "\(enabled ? "Enabled" : "Disabled") mouse gestures."
1146
- }
1147
- return nil
1148
- }
1149
-
1150
- if lower.contains("companion") && lower.contains("bridge") {
1151
- if let enabled = parseBooleanIntent(from: lower) {
1152
- prefs.companionBridgeEnabled = enabled
1153
- return "\(enabled ? "Enabled" : "Disabled") the companion bridge."
1154
- }
1155
- return nil
1156
- }
1157
-
1158
- if lower.contains("companion") && lower.contains("trackpad") {
1159
- if let enabled = parseBooleanIntent(from: lower) {
1160
- prefs.companionTrackpadEnabled = enabled
1161
- return "\(enabled ? "Enabled" : "Disabled") companion trackpad."
1162
- }
1163
- return nil
1164
- }
1165
-
1166
- if lower.contains("ocr") || lower.contains("screen text") || lower.contains("text recognition") {
1167
- if lower.contains("accuracy") {
1168
- if lower.contains("fast") {
1169
- prefs.ocrAccuracy = "fast"
1170
- return "Set OCR accuracy to Fast."
1171
- }
1172
- if lower.contains("accurate") {
1173
- prefs.ocrAccuracy = "accurate"
1174
- return "Set OCR accuracy to Accurate."
1175
- }
1176
- return nil
1177
- }
1178
-
1179
- if let enabled = parseBooleanIntent(from: lower) {
1180
- OcrModel.shared.setEnabled(enabled)
1181
- return "\(enabled ? "Enabled" : "Disabled") screen text recognition."
1182
- }
1183
- return nil
1184
- }
1185
-
1186
- if lower.contains("advisor") || lower.contains("voice advisor") {
1187
- return nil
1188
- }
1189
-
1190
- if lower.contains("open assistant settings") || lower.contains("show assistant settings") {
1191
- SettingsWindowController.shared.showAssistant()
1192
- return "Opened Assistant settings."
1193
- }
1194
-
1195
- return nil
1196
- }
1197
-
1198
- private func providerPrompt(for userText: String) -> String {
1199
- """
1200
- You are the Workspace Assistant, the in-app assistant for Lattices.
1201
-
1202
- Use the structured context as ground truth. Answer naturally and concretely. For informational questions, explain what is currently configured and what the available choices mean. For setting changes, describe what should change; the host app is responsible for applying supported changes.
1203
-
1204
- Structured context:
1205
- \(assistantKnowledgeBrief())
1206
-
1207
- User request:
1208
- \(userText)
1209
- """
1210
- }
1211
-
1212
- private func voiceAdvisorPrompt(transcript: String, matched: String) -> String {
1213
- """
1214
- You are the same Workspace Assistant used by Lattices chat, responding through the voice command surface.
1215
-
1216
- Use the shared structured context below as ground truth. The voice surface needs terse commentary and optional next actions, not a chatty answer.
1217
-
1218
- Structured context:
1219
- \(assistantKnowledgeBrief())
1220
-
1221
- Voice transcript:
1222
- "\(transcript)"
1223
-
1224
- Local match already handled:
1225
- \(matched)
1226
-
1227
- Respond with ONLY a JSON object:
1228
- {"commentary": "short observation or null", "suggestion": {"label": "button text", "intent": "intent_name", "slots": {"key": "value"}} or null}
1229
-
1230
- Rules:
1231
- - commentary: 1 sentence max. null if the matched command fully covers the request.
1232
- - suggestion: a follow-up action. null if none needed.
1233
- - Never suggest what was already executed.
1234
- - Suggestions MUST include all required slots.
1235
- - Be terse and useful.
1236
- """
1237
- }
1238
-
1239
- private func voiceQuestionPrompt(transcript: String) -> String {
1240
- """
1241
- You are the same Workspace Assistant used by Lattices chat, responding through the voice surface.
1242
-
1243
- This is an informational question, not necessarily a command. Use the shared structured context below, answer naturally, and include concrete current settings when relevant. Keep it short enough for voice, but do not give a clipped yes/no answer.
1244
-
1245
- Structured context:
1246
- \(assistantKnowledgeBrief())
1247
-
1248
- User said:
1249
- "\(transcript)"
1250
- """
1251
- }
1252
-
1253
- private func voiceResolverPrompt(transcript: String) -> String {
1254
- let windowList = DesktopModel.shared.windows.values
1255
- .prefix(20)
1256
- .map { "\($0.app): \($0.title)" }
1257
- .joined(separator: "\n")
1258
-
1259
- var intentList = ""
1260
- if case .array(let intents) = PhraseMatcher.shared.catalog() {
1261
- intentList = intents.compactMap { intent -> String? in
1262
- guard let name = intent["intent"]?.stringValue else { return nil }
1263
- var slotNames: [String] = []
1264
- if case .array(let slots) = intent["slots"] {
1265
- slotNames = slots.compactMap { $0["name"]?.stringValue }
1266
- }
1267
- return slotNames.isEmpty ? name : "\(name)(\(slotNames.joined(separator: ",")))"
1268
- }.joined(separator: ", ")
1269
- }
1270
-
1271
- return """
1272
- You are the same Workspace Assistant used by Lattices chat, resolving a spoken command into one executable Lattices intent.
1273
-
1274
- Structured context:
1275
- \(assistantKnowledgeBrief())
1276
-
1277
- Voice transcript, possibly with transcription errors:
1278
- "\(transcript)"
1279
-
1280
- Available intents:
1281
- \(intentList)
1282
-
1283
- Current windows:
1284
- \(windowList)
1285
-
1286
- Return ONLY a JSON object like:
1287
- {"intent":"search","slots":{"query":"dewey"},"reasoning":"user wants to find dewey windows"}
1288
-
1289
- Rules:
1290
- - Use intent "unknown" if the request cannot be mapped confidently.
1291
- - Include all required slots.
1292
- - For search, extract the key term.
1293
- - Use app/window names from the current windows list when targeting windows.
1294
- """
1295
- }
1296
-
1297
- private func assistantKnowledgeBrief() -> String {
1298
- assistantContextJSON()
1299
- }
1300
-
1301
- private func assistantContextJSON() -> String {
1302
- let payload = assistantContextPayload()
1303
- guard JSONSerialization.isValidJSONObject(payload),
1304
- let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]),
1305
- let text = String(data: data, encoding: .utf8) else {
1306
- return #"{"error":"context unavailable"}"#
1307
- }
1308
- return text
1309
- }
1310
-
1311
- private func assistantContextPayload() -> [String: Any] {
1312
- let prefs = Preferences.shared
1313
- MouseShortcutStore.shared.reloadIfNeeded()
1314
-
1315
- return [
1316
- "assistant": [
1317
- "name": "Workspace Assistant",
1318
- "selectedProvider": [
1319
- "id": authProviderID,
1320
- "name": currentProvider.name,
1321
- "credential": selectedCredentialSummary,
1322
- ],
1323
- "providerRuntime": [
1324
- "binary": (piBinaryPath as Any?) ?? NSNull(),
1325
- "node": (nodeBinaryPath as Any?) ?? NSNull(),
1326
- "authFile": authFileURL.path,
1327
- "chatSession": sessionFileURL.path,
1328
- ],
1329
- ],
1330
- "currentSettings": [
1331
- "terminal": prefs.terminal.rawValue,
1332
- "detachMode": prefs.mode.rawValue,
1333
- "scanRoot": prefs.scanRoot.isEmpty ? NSNull() : prefs.scanRoot,
1334
- "dragToSnap": prefs.dragSnapEnabled,
1335
- "companionBridge": prefs.companionBridgeEnabled,
1336
- "companionTrackpad": prefs.companionTrackpadEnabled,
1337
- "ocr": [
1338
- "enabled": prefs.ocrEnabled,
1339
- "accuracy": prefs.ocrAccuracy,
1340
- "quickIntervalSeconds": prefs.ocrQuickInterval,
1341
- "deepIntervalSeconds": prefs.ocrDeepInterval,
1342
- "quickWindowLimit": prefs.ocrQuickLimit,
1343
- "deepWindowLimit": prefs.ocrDeepLimit,
1344
- "deepScanBudget": prefs.ocrDeepBudget,
1345
- ],
1346
- "mouseShortcuts": mouseShortcutContextPayload(),
1347
- ],
1348
- "settingsCatalog": [
1349
- [
1350
- "id": "terminal",
1351
- "type": "enum",
1352
- "choices": Terminal.allCases.map(\.rawValue),
1353
- "installedChoices": Terminal.installed.map(\.rawValue),
1354
- "description": "Terminal app used when Lattices launches workspaces.",
1355
- ],
1356
- [
1357
- "id": "detachMode",
1358
- "type": "enum",
1359
- "choices": ["learning", "auto"],
1360
- "description": "Learning mode shows tmux hints; auto mode stays quieter.",
1361
- ],
1362
- [
1363
- "id": "scanRoot",
1364
- "type": "path",
1365
- "description": "Root directory scanned for projects containing .lattices.json.",
1366
- ],
1367
- [
1368
- "id": "dragToSnap",
1369
- "type": "boolean",
1370
- "description": "Enables drag-to-snap window zones.",
1371
- ],
1372
- [
1373
- "id": "mouseShortcuts",
1374
- "type": "boolean-plus-json-rules",
1375
- "description": "Middle-click and drag gesture shortcuts controlled by mouseGestures.enabled plus ~/.lattices/mouse-shortcuts.json.",
1376
- ],
1377
- [
1378
- "id": "ocr",
1379
- "type": "object",
1380
- "description": "Screen text recognition settings, including enablement, cadence, and accuracy.",
1381
- ],
1382
- [
1383
- "id": "assistantProvider",
1384
- "type": "enum-plus-api-key",
1385
- "choices": ["openai", "groq", "openrouter", "minimax"],
1386
- "description": "Provider-backed inference for chat and voice.",
1387
- ],
1388
- ],
1389
- "settingsFiles": [
1390
- "workspace": "\(NSHomeDirectory())/.lattices/workspace.json",
1391
- "mouseShortcuts": MouseShortcutStore.shared.configURL.path,
1392
- "snapZones": "\(NSHomeDirectory())/.lattices/snap-zones.json",
1393
- "ocrDatabase": "\(NSHomeDirectory())/.lattices/ocr.db",
1394
- "diagnostics": "\(NSHomeDirectory())/.lattices/lattices.log",
1395
- ],
1396
- "cliCommands": [
1397
- "lattices",
1398
- "lattices init",
1399
- "lattices sync",
1400
- "lattices restart [pane]",
1401
- "lattices tile <position>",
1402
- "lattices group [id]",
1403
- "lattices layer [name|index]",
1404
- "lattices windows --json",
1405
- "lattices search <query>",
1406
- "lattices app restart",
1407
- ],
1408
- "runtimeSnapshot": [
1409
- "installedTerminals": Terminal.installed.map(\.rawValue),
1410
- "discoveredProjectCount": ProjectScanner.shared.projects.count,
1411
- ],
1412
- ]
1413
- }
1414
-
1415
- private func mouseShortcutContextPayload() -> [String: Any] {
1416
- let prefs = Preferences.shared
1417
- let store = MouseShortcutStore.shared
1418
- store.reloadIfNeeded()
1419
-
1420
- return [
1421
- "enabled": prefs.mouseGesturesEnabled,
1422
- "configFile": store.configURL.path,
1423
- "tuning": [
1424
- "dragThresholdPx": Double(store.tuning.dragThreshold),
1425
- "holdTolerancePx": Double(store.tuning.holdTolerance),
1426
- "axisBias": Double(store.tuning.axisBias),
1427
- ],
1428
- "activeMappings": store.enabledRules.map { rule in
1429
- [
1430
- "id": rule.id,
1431
- "trigger": rule.trigger.displayLabel,
1432
- "action": rule.action.label,
1433
- "summary": rule.summary,
1434
- ]
1435
- },
1436
- ]
1437
- }
1438
-
1439
- private func settingsSummary() -> String {
1440
- let prefs = Preferences.shared
1441
- return """
1442
- Current settings:
1443
- Terminal: \(prefs.terminal.rawValue)
1444
- Detach mode: \(prefs.mode.rawValue)
1445
- Scan root: \(prefs.scanRoot.isEmpty ? "not set" : prefs.scanRoot)
1446
- Drag-to-snap: \(prefs.dragSnapEnabled ? "on" : "off")
1447
- Mouse gestures: \(prefs.mouseGesturesEnabled ? "on" : "off")
1448
- Companion bridge: \(prefs.companionBridgeEnabled ? "on" : "off")
1449
- Companion trackpad: \(prefs.companionTrackpadEnabled ? "on" : "off")
1450
- OCR: \(prefs.ocrEnabled ? "on" : "off"), \(prefs.ocrAccuracy)
1451
- Voice assistant: same provider as chat, \(currentProvider.name)
1452
-
1453
- \(mouseShortcutSummary())
1454
- """
1455
- }
1456
-
1457
- private func mouseShortcutSummary() -> String {
1458
- let prefs = Preferences.shared
1459
- let store = MouseShortcutStore.shared
1460
- store.reloadIfNeeded()
1461
- let mappings = store.summaryLines
1462
- let mappingText = mappings.isEmpty
1463
- ? "- No active mouse shortcut mappings."
1464
- : mappings.map { "- \($0)" }.joined(separator: "\n")
1465
-
1466
- return """
1467
- Mouse shortcuts:
1468
- - Middle-click shortcuts are \(prefs.mouseGesturesEnabled ? "enabled" : "disabled").
1469
- - Config file: \(store.configURL.path)
1470
- - Drag threshold: \(Int(store.tuning.dragThreshold)) px; hold tolerance: \(Int(store.tuning.holdTolerance)) px; axis bias: \(String(format: "%.1f", Double(store.tuning.axisBias))).
1471
- Active mappings:
1472
- \(mappingText)
1473
- """
1474
- }
1475
-
1476
- private static func extractJSON(from text: String) -> String? {
1477
- let cleaned = text
1478
- .replacingOccurrences(of: "```json", with: "")
1479
- .replacingOccurrences(of: "```", with: "")
1480
- .trimmingCharacters(in: .whitespacesAndNewlines)
1481
- guard let start = cleaned.firstIndex(of: "{"),
1482
- let end = cleaned.lastIndex(of: "}") else { return nil }
1483
- return String(cleaned[start...end])
1484
- }
1485
-
1486
- private func settingsHelpText() -> String {
1487
- """
1488
- I can manage Lattices settings from chat. Try:
1489
- - set terminal to Ghostty
1490
- - set scan root to ~/dev
1491
- - turn OCR off
1492
- - set OCR accuracy to fast
1493
- - enable drag snap
1494
- - disable mouse gestures
1495
- - set detach mode to auto
1496
- - open assistant settings
1497
- - open settings
1498
- """
1499
- }
1500
-
1501
- private func parseTerminal(from lower: String) -> Terminal? {
1502
- let aliases: [(Terminal, [String])] = [
1503
- (.iterm2, ["iterm2", "iterm"]),
1504
- (.warp, ["warp"]),
1505
- (.ghostty, ["ghostty"]),
1506
- (.kitty, ["kitty"]),
1507
- (.alacritty, ["alacritty"]),
1508
- (.terminal, ["terminal.app", "apple terminal", "terminal"]),
1509
- ]
1510
-
1511
- return aliases.first { _, names in
1512
- names.contains { lower.contains($0) }
1513
- }?.0
1514
- }
1515
-
1516
- private func parseBooleanIntent(from lower: String) -> Bool? {
1517
- let offTokens = ["turn off", "disable", "disabled", "off", "false", "stop"]
1518
- if offTokens.contains(where: lower.contains) {
1519
- return false
1520
- }
1521
-
1522
- let onTokens = ["turn on", "enable", "enabled", "on", "true", "start"]
1523
- if onTokens.contains(where: lower.contains) {
1524
- return true
1525
- }
1526
-
1527
- return nil
1528
- }
1529
-
1530
- private func extractPathValue(from text: String) -> String? {
1531
- let markers = [" to ", " at ", " root "]
1532
- let lower = text.lowercased()
1533
-
1534
- for marker in markers {
1535
- guard let range = lower.range(of: marker) else { continue }
1536
- let raw = text[range.upperBound...]
1537
- .trimmingCharacters(in: .whitespacesAndNewlines)
1538
- .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`"))
1539
- guard !raw.isEmpty else { continue }
1540
- return (raw as NSString).expandingTildeInPath
1541
- }
1542
-
1543
- return nil
1544
- }
1545
-
1546
- private func extractFirstNumber(from text: String) -> Double? {
1547
- let pattern = #"(?<![A-Za-z0-9_])\$?([0-9]+(?:\.[0-9]+)?)"#
1548
- guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
1549
- let nsRange = NSRange(text.startIndex..<text.endIndex, in: text)
1550
- guard let match = regex.firstMatch(in: text, range: nsRange),
1551
- match.numberOfRanges > 1,
1552
- let range = Range(match.range(at: 1), in: text) else {
1553
- return nil
1554
- }
1555
- return Double(text[range])
1556
- }
1557
-
1558
- private func friendlyAuthFailureMessage(for message: String) -> String? {
1559
- let lowercased = message.lowercased()
1560
- let authHints = [
1561
- "use /login",
1562
- "set an api key environment variable",
1563
- "authentication",
1564
- "unauthorized",
1565
- "api key",
1566
- "oauth",
1567
- "token",
1568
- ]
1569
-
1570
- guard authHints.contains(where: lowercased.contains) else { return nil }
1571
-
1572
- if currentProvider.authMode == .oauth {
1573
- return "This provider is not connected yet. Open Settings with the gear icon, connect \(currentProvider.name), then come back and send your first prompt."
1574
- }
1575
-
1576
- return "This provider still needs an API key. Open Settings with the gear icon, save your \(currentProvider.tokenLabel.lowercased()), and then try again."
1577
- }
1578
-
1579
- private func shouldAutoSubmitPrompt(_ prompt: PiAuthPrompt) -> Bool {
1580
- guard authenticatingProviderID == "github-copilot" else { return false }
1581
- guard prompt.allowEmpty else { return false }
1582
-
1583
- let message = prompt.message.lowercased()
1584
- return message.contains("github enterprise url")
1585
- || message.contains("github enterprise")
1586
- || message.contains("blank for github.com")
1587
- }
1588
-
1589
- private func reloadAuthState() {
1590
- let auth = loadAuthFile()
1591
- var kinds: [String: String] = [:]
1592
-
1593
- for (providerID, rawValue) in auth {
1594
- guard let record = rawValue as? [String: Any],
1595
- let type = record["type"] as? String else { continue }
1596
- kinds[providerID] = type
1597
- }
1598
-
1599
- storedCredentialKinds = kinds
1600
- }
1601
-
1602
- private func reconcileAuthState() {
1603
- guard isAuthenticating else { return }
1604
-
1605
- if let authProcess, authProcess.isRunning {
1606
- return
1607
- }
1608
-
1609
- cleanupAuthProcess()
1610
- isAuthenticating = false
1611
- pendingAuthPrompt = nil
1612
- if hasPiBinary && !hasSelectedCredential {
1613
- statusText = "setup ai"
1614
- }
1615
- }
1616
-
1617
- private func loadAuthFile() -> [String: Any] {
1618
- guard let data = try? Data(contentsOf: authFileURL), !data.isEmpty else { return [:] }
1619
- guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] }
1620
- return json
1621
- }
1622
-
1623
- private func mutateAuthFile(_ mutate: (inout [String: Any]) -> Void) throws {
1624
- let fm = FileManager.default
1625
- let dir = authFileURL.deletingLastPathComponent()
1626
- try fm.createDirectory(at: dir, withIntermediateDirectories: true)
1627
-
1628
- var auth = loadAuthFile()
1629
- mutate(&auth)
1630
-
1631
- let data = try JSONSerialization.data(withJSONObject: auth, options: [.prettyPrinted, .sortedKeys])
1632
- try data.write(to: authFileURL, options: .atomic)
1633
- try fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: dir.path)
1634
- try fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: authFileURL.path)
1635
- }
1636
-
1637
- private func resolvePiPath() -> String? {
1638
- resolveCommandPath(
1639
- named: "pi",
1640
- candidates: [
1641
- "/opt/homebrew/bin/pi",
1642
- "/usr/local/bin/pi",
1643
- NSHomeDirectory() + "/.local/bin/pi",
1644
- NSHomeDirectory() + "/.bun/bin/pi",
1645
- ]
1646
- )
1647
- }
1648
-
1649
- private func resolveNodePath() -> String? {
1650
- resolveCommandPath(
1651
- named: "node",
1652
- candidates: [
1653
- "/opt/homebrew/bin/node",
1654
- "/usr/local/bin/node",
1655
- "/usr/bin/node",
1656
- NSHomeDirectory() + "/.local/bin/node",
1657
- ]
1658
- )
1659
- }
1660
-
1661
- private func resolveCommandPath(named command: String, candidates: [String]) -> String? {
1662
- var orderedCandidates: [String] = []
1663
- var seen: Set<String> = []
1664
-
1665
- for rawPath in candidates + managedInstallCandidates(for: command) {
1666
- let path = (rawPath as NSString).expandingTildeInPath
1667
- guard !path.isEmpty else { continue }
1668
- guard seen.insert(path).inserted else { continue }
1669
- orderedCandidates.append(path)
1670
- }
1671
-
1672
- for path in orderedCandidates where FileManager.default.isExecutableFile(atPath: path) {
1673
- return path
1674
- }
1675
-
1676
- let lookups = [
1677
- ProcessQuery.shell(["/usr/bin/which", command]),
1678
- ProcessQuery.shell(["/bin/sh", "-lc", "command -v \(command) 2>/dev/null"]),
1679
- ProcessQuery.shell(["/bin/zsh", "-lc", "command -v \(command) 2>/dev/null"]),
1680
- ]
1681
-
1682
- for output in lookups {
1683
- let path = output.trimmingCharacters(in: .whitespacesAndNewlines)
1684
- if !path.isEmpty, FileManager.default.isExecutableFile(atPath: path) {
1685
- return path
1686
- }
1687
- }
1688
-
1689
- return nil
1690
- }
1691
-
1692
- private func managedInstallCandidates(for command: String) -> [String] {
1693
- let home = NSHomeDirectory()
1694
- var candidates = [
1695
- "\(home)/.bun/bin/\(command)",
1696
- "\(home)/.npm-global/bin/\(command)",
1697
- "\(home)/Library/pnpm/\(command)",
1698
- "\(home)/.local/share/mise/shims/\(command)",
1699
- ]
1700
-
1701
- let fnmRoot = URL(fileURLWithPath: home, isDirectory: true)
1702
- .appendingPathComponent(".local", isDirectory: true)
1703
- .appendingPathComponent("share", isDirectory: true)
1704
- .appendingPathComponent("fnm", isDirectory: true)
1705
- .appendingPathComponent("node-versions", isDirectory: true)
1706
-
1707
- if let installs = try? FileManager.default.contentsOfDirectory(
1708
- at: fnmRoot,
1709
- includingPropertiesForKeys: nil,
1710
- options: [.skipsHiddenFiles]
1711
- ) {
1712
- let sortedInstalls = installs.sorted {
1713
- $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedDescending
1714
- }
1715
-
1716
- for install in sortedInstalls {
1717
- candidates.append(
1718
- install
1719
- .appendingPathComponent("installation", isDirectory: true)
1720
- .appendingPathComponent("bin", isDirectory: true)
1721
- .appendingPathComponent(command)
1722
- .path
1723
- )
1724
- }
1725
- }
1726
-
1727
- return candidates
1728
- }
1729
-
1730
- private func resolveOAuthModuleURL() -> URL? {
1731
- guard let packageRoot = resolvePiPackageRoot() else { return nil }
1732
- let moduleURL = packageRoot
1733
- .appendingPathComponent("node_modules")
1734
- .appendingPathComponent("@mariozechner")
1735
- .appendingPathComponent("pi-ai")
1736
- .appendingPathComponent("dist")
1737
- .appendingPathComponent("utils")
1738
- .appendingPathComponent("oauth")
1739
- .appendingPathComponent("index.js")
1740
- return FileManager.default.fileExists(atPath: moduleURL.path) ? moduleURL : nil
1741
- }
1742
-
1743
- private func resolvePiPackageRoot() -> URL? {
1744
- guard let piPath = resolvePiPath() else { return nil }
1745
- let resolved = URL(fileURLWithPath: piPath).resolvingSymlinksInPath()
1746
- guard resolved.lastPathComponent == "cli.js",
1747
- resolved.deletingLastPathComponent().lastPathComponent == "dist" else { return nil }
1748
- return resolved.deletingLastPathComponent().deletingLastPathComponent()
1749
- }
1750
-
1751
- private func recordAuthHelperProcess(_ pid: Int32) {
1752
- let payload: [String: Any] = [
1753
- "pid": Int(pid),
1754
- "recordedAt": Date().timeIntervalSince1970,
1755
- ]
1756
- guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) else {
1757
- return
1758
- }
1759
- try? data.write(to: authRuntimeURL, options: .atomic)
1760
- }
1761
-
1762
- private func clearRecordedAuthHelperProcess() {
1763
- try? FileManager.default.removeItem(at: authRuntimeURL)
1764
- }
1765
-
1766
- private func cleanupLingeringAuthHelpers() {
1767
- let fm = FileManager.default
1768
-
1769
- if let data = try? Data(contentsOf: authRuntimeURL),
1770
- let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1771
- let pid = json["pid"] as? Int {
1772
- terminateRecordedAuthHelper(pid)
1773
- try? fm.removeItem(at: authRuntimeURL)
1774
- }
1775
-
1776
- let currentPID = Int(ProcessInfo.processInfo.processIdentifier)
1777
- for entry in ProcessQuery.snapshot().values where Self.looksLikePiOAuthHelper(entry.args) {
1778
- guard entry.pid != currentPID else { continue }
1779
- guard authProcess?.processIdentifier != Int32(entry.pid) else { continue }
1780
- terminateRecordedAuthHelper(entry.pid)
1781
- }
1782
- }
1783
-
1784
- private func terminateRecordedAuthHelper(_ pid: Int) {
1785
- guard pid > 1 else { return }
1786
- guard kill(Int32(pid), 0) == 0 else { return }
1787
-
1788
- let args = ProcessQuery.shell(["/bin/ps", "-p", "\(pid)", "-o", "args="])
1789
- guard Self.looksLikePiOAuthHelper(args) else { return }
1790
-
1791
- _ = kill(Int32(pid), SIGTERM)
1792
- let deadline = Date().addingTimeInterval(1.0)
1793
- while Date() < deadline {
1794
- if kill(Int32(pid), 0) != 0 {
1795
- return
1796
- }
1797
- usleep(100_000)
1798
- }
1799
-
1800
- _ = kill(Int32(pid), SIGKILL)
1801
- }
1802
-
1803
- private func terminateProcess(_ process: Process?, escalateAfter delay: TimeInterval) {
1804
- guard let process else { return }
1805
- let pid = process.processIdentifier
1806
- process.terminate()
1807
-
1808
- let deadline = Date().addingTimeInterval(delay)
1809
- while Date() < deadline {
1810
- if kill(pid, 0) != 0 {
1811
- return
1812
- }
1813
- usleep(100_000)
1814
- }
1815
-
1816
- _ = kill(pid, SIGKILL)
1817
- }
1818
-
1819
- private static func piAgentDirURL() -> URL {
1820
- if let override = ProcessInfo.processInfo.environment["PI_CODING_AGENT_DIR"],
1821
- !override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
1822
- return URL(fileURLWithPath: override, isDirectory: true)
1823
- }
1824
-
1825
- return URL(fileURLWithPath: NSHomeDirectory())
1826
- .appendingPathComponent(".pi", isDirectory: true)
1827
- .appendingPathComponent("agent", isDirectory: true)
1828
- }
1829
-
1830
- private static func looksLikeAuthError(_ message: String) -> Bool {
1831
- let lowercased = message.lowercased()
1832
- return lowercased.contains("api key")
1833
- || lowercased.contains("oauth")
1834
- || lowercased.contains("token")
1835
- || lowercased.contains("authentication")
1836
- || lowercased.contains("unauthorized")
1837
- || lowercased.contains("bad request")
1838
- }
1839
-
1840
- private static func looksLikePiOAuthHelper(_ args: String) -> Bool {
1841
- args.contains("node:readline")
1842
- && args.contains("getOAuthProvider")
1843
- && args.contains("oauthModuleUrl")
1844
- }
1845
-
1846
- private static func clampDockHeight(_ height: CGFloat) -> CGFloat {
1847
- min(max(height, 170), 520)
1848
- }
1849
-
1850
- private static func sanitizeEnvironment(_ env: inout [String: String], for providerID: String, hasStoredCredential: Bool) {
1851
- let providerEnvVars: [String: [String]] = [
1852
- "github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
1853
- "anthropic": ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"],
1854
- "openai": ["OPENAI_API_KEY"],
1855
- "google": ["GEMINI_API_KEY"],
1856
- "groq": ["GROQ_API_KEY"],
1857
- "xai": ["XAI_API_KEY"],
1858
- "openrouter": ["OPENROUTER_API_KEY"],
1859
- "mistral": ["MISTRAL_API_KEY"],
1860
- "minimax": ["MINIMAX_API_KEY"],
1861
- "openai-codex": [],
1862
- ]
1863
-
1864
- for (id, keys) in providerEnvVars where id != providerID {
1865
- for key in keys {
1866
- env.removeValue(forKey: key)
1867
- }
1868
- }
1869
-
1870
- if providerID == "github-copilot", !hasStoredCredential {
1871
- env.removeValue(forKey: "COPILOT_GITHUB_TOKEN")
1872
- env.removeValue(forKey: "GH_TOKEN")
1873
- env.removeValue(forKey: "GITHUB_TOKEN")
1874
- }
1875
- }
1876
-
1877
- private static let oauthDriverScript = #"""
1878
- import readline from 'node:readline';
1879
-
1880
- const providerId = process.argv[1];
1881
- const oauthModuleUrl = process.argv[2];
1882
- const { getOAuthProvider } = await import(oauthModuleUrl);
1883
-
1884
- const provider = getOAuthProvider(providerId);
1885
- if (!provider) {
1886
- process.stdout.write(JSON.stringify({ type: 'error', message: `Unknown OAuth provider: ${providerId}` }) + '\n');
1887
- process.exit(1);
1888
- }
1889
-
1890
- const rl = readline.createInterface({
1891
- input: process.stdin,
1892
- output: process.stderr,
1893
- terminal: false,
1894
- });
1895
-
1896
- function emit(event) {
1897
- process.stdout.write(JSON.stringify(event) + '\n');
1898
- }
1899
-
1900
- function readLine() {
1901
- return new Promise((resolve) => {
1902
- rl.once('line', (line) => resolve(line));
1903
- });
1904
- }
1905
-
1906
- try {
1907
- const credentials = await provider.login({
1908
- onAuth: (info) => emit({
1909
- type: 'auth',
1910
- url: info.url,
1911
- instructions: info.instructions ?? null,
1912
- }),
1913
- onPrompt: async (prompt) => {
1914
- emit({
1915
- type: 'prompt',
1916
- message: prompt.message,
1917
- placeholder: prompt.placeholder ?? null,
1918
- allowEmpty: Boolean(prompt.allowEmpty),
1919
- });
1920
- const input = await readLine();
1921
- return typeof input === 'string' ? input : '';
1922
- },
1923
- onProgress: (message) => emit({
1924
- type: 'progress',
1925
- message,
1926
- }),
1927
- });
1928
-
1929
- emit({
1930
- type: 'success',
1931
- credentials,
1932
- });
1933
- rl.close();
1934
- process.exit(0);
1935
- } catch (error) {
1936
- emit({
1937
- type: 'error',
1938
- message: error instanceof Error ? error.message : String(error),
1939
- });
1940
- rl.close();
1941
- process.exit(1);
1942
- }
1943
- """#
1944
-
1945
- private var authRuntimeURL: URL {
1946
- sessionFileURL.deletingLastPathComponent().appendingPathComponent("oauth-runtime.json")
1947
- }
1948
- }