@lattices/cli 0.4.14 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -7
- package/apps/mac/Info.plist +2 -2
- package/apps/mac/Lattices.app/Contents/Info.plist +4 -12
- package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/bin/lattices-app.ts +110 -17
- package/bin/lattices-build +125 -0
- package/bin/lattices-dev +89 -16
- package/bin/lattices.ts +977 -16
- package/docs/agents.md +81 -4
- package/docs/ai-chat-ux-review.md +416 -0
- package/docs/api.md +135 -3
- package/docs/app.md +30 -8
- package/docs/config.md +4 -0
- package/docs/mouse-gestures.md +60 -1
- package/docs/proposals/LAT-004-interactive-overlay-actors.md +1 -1
- package/docs/proposals/LAT-005-action-runtime-product-spine.md +914 -0
- package/docs/proposals/LAT-006-mira-in-lattices.md +553 -0
- package/docs/reference/dewey.config.ts +2 -2
- package/docs/release.md +171 -0
- package/docs/repo-structure.md +4 -5
- package/docs/voice.md +11 -27
- package/package.json +9 -10
- package/apps/mac/Package.swift +0 -27
- package/apps/mac/Sources/AppShell/App.swift +0 -26
- package/apps/mac/Sources/AppShell/AppActivationCoordinator.swift +0 -27
- package/apps/mac/Sources/AppShell/AppDelegate.swift +0 -189
- package/apps/mac/Sources/AppShell/AppServicesBootstrap.swift +0 -25
- package/apps/mac/Sources/AppShell/AppShellView.swift +0 -171
- package/apps/mac/Sources/AppShell/AppUpdater.swift +0 -305
- package/apps/mac/Sources/AppShell/CliActionLauncher.swift +0 -50
- package/apps/mac/Sources/AppShell/HomeDashboardView.swift +0 -133
- package/apps/mac/Sources/AppShell/HotkeyBootstrap.swift +0 -87
- package/apps/mac/Sources/AppShell/KeyRecorderView.swift +0 -210
- package/apps/mac/Sources/AppShell/LatticesRuntime.swift +0 -104
- package/apps/mac/Sources/AppShell/MainView.swift +0 -847
- package/apps/mac/Sources/AppShell/MainWindow.swift +0 -83
- package/apps/mac/Sources/AppShell/MenuBarController.swift +0 -177
- package/apps/mac/Sources/AppShell/OnboardingView.swift +0 -483
- package/apps/mac/Sources/AppShell/PermissionsAssistantView.swift +0 -366
- package/apps/mac/Sources/AppShell/PermissionsAssistantWindow.swift +0 -70
- package/apps/mac/Sources/AppShell/Preferences.swift +0 -297
- package/apps/mac/Sources/AppShell/SettingsView.swift +0 -3163
- package/apps/mac/Sources/AppShell/SettingsWindow.swift +0 -34
- package/apps/mac/Sources/AppShell/WorkspaceInspectorPresenter.swift +0 -13
- package/apps/mac/Sources/Core/Actions/HotkeyManager.swift +0 -256
- package/apps/mac/Sources/Core/Actions/HotkeyStore.swift +0 -399
- package/apps/mac/Sources/Core/Actions/IntentEngine.swift +0 -988
- package/apps/mac/Sources/Core/Actions/IntentSchema.swift +0 -94
- package/apps/mac/Sources/Core/Actions/Intents/CreateLayerIntent.swift +0 -54
- package/apps/mac/Sources/Core/Actions/Intents/DistributeIntent.swift +0 -56
- package/apps/mac/Sources/Core/Actions/Intents/FocusIntent.swift +0 -69
- package/apps/mac/Sources/Core/Actions/Intents/HelpIntent.swift +0 -41
- package/apps/mac/Sources/Core/Actions/Intents/KillIntent.swift +0 -47
- package/apps/mac/Sources/Core/Actions/Intents/LatticeIntent.swift +0 -53
- package/apps/mac/Sources/Core/Actions/Intents/LaunchIntent.swift +0 -67
- package/apps/mac/Sources/Core/Actions/Intents/ListSessionsIntent.swift +0 -32
- package/apps/mac/Sources/Core/Actions/Intents/ListWindowsIntent.swift +0 -30
- package/apps/mac/Sources/Core/Actions/Intents/ScanIntent.swift +0 -52
- package/apps/mac/Sources/Core/Actions/Intents/SearchIntent.swift +0 -190
- package/apps/mac/Sources/Core/Actions/Intents/SwitchLayerIntent.swift +0 -50
- package/apps/mac/Sources/Core/Actions/Intents/TileIntent.swift +0 -61
- package/apps/mac/Sources/Core/Actions/PaletteCommand.swift +0 -439
- package/apps/mac/Sources/Core/Actions/VoiceIntentResolver.swift +0 -713
- package/apps/mac/Sources/Core/Companion/CompanionActivityLog.swift +0 -70
- package/apps/mac/Sources/Core/Companion/CompanionKeyboardController.swift +0 -141
- package/apps/mac/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +0 -454
- package/apps/mac/Sources/Core/Companion/LatticesCompanionCockpit.swift +0 -555
- package/apps/mac/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +0 -629
- package/apps/mac/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +0 -204
- package/apps/mac/Sources/Core/Companion/LatticesDeckHost.swift +0 -1463
- package/apps/mac/Sources/Core/Daemon/DaemonProtocol.swift +0 -114
- package/apps/mac/Sources/Core/Daemon/DaemonServer.swift +0 -427
- package/apps/mac/Sources/Core/Daemon/LatticesApi.swift +0 -2965
- package/apps/mac/Sources/Core/Desktop/AccessibilityTextExtractor.swift +0 -111
- package/apps/mac/Sources/Core/Desktop/AppTypeClassifier.swift +0 -106
- package/apps/mac/Sources/Core/Desktop/DesktopModel.swift +0 -331
- package/apps/mac/Sources/Core/Desktop/DesktopModelTypes.swift +0 -73
- package/apps/mac/Sources/Core/Desktop/InventoryManager.swift +0 -35
- package/apps/mac/Sources/Core/Desktop/InventoryPath.swift +0 -43
- package/apps/mac/Sources/Core/Desktop/MouseFinder.swift +0 -527
- package/apps/mac/Sources/Core/Desktop/OcrModel.swift +0 -467
- package/apps/mac/Sources/Core/Desktop/OcrStore.swift +0 -329
- package/apps/mac/Sources/Core/Desktop/PlacementSpec.swift +0 -195
- package/apps/mac/Sources/Core/Desktop/SessionWindowLocator.swift +0 -139
- package/apps/mac/Sources/Core/Desktop/TilePickerView.swift +0 -209
- package/apps/mac/Sources/Core/Desktop/WindowCapture.swift +0 -33
- package/apps/mac/Sources/Core/Desktop/WindowDragSnapController.swift +0 -429
- package/apps/mac/Sources/Core/Desktop/WindowPreviewCard.swift +0 -100
- package/apps/mac/Sources/Core/Desktop/WindowPreviewStore.swift +0 -112
- package/apps/mac/Sources/Core/Desktop/WindowSelectionStore.swift +0 -76
- package/apps/mac/Sources/Core/Desktop/WindowTiler.swift +0 -2222
- package/apps/mac/Sources/Core/Input/EventTapBreaker.swift +0 -124
- package/apps/mac/Sources/Core/Input/EventTapThread.swift +0 -54
- package/apps/mac/Sources/Core/Input/InputCaptureResetCenter.swift +0 -20
- package/apps/mac/Sources/Core/Input/KeyboardRemapConfig.swift +0 -69
- package/apps/mac/Sources/Core/Input/KeyboardRemapController.swift +0 -346
- package/apps/mac/Sources/Core/Input/KeyboardRemapStore.swift +0 -141
- package/apps/mac/Sources/Core/Input/MouseGestureConfig.swift +0 -499
- package/apps/mac/Sources/Core/Input/MouseGestureController.swift +0 -2583
- package/apps/mac/Sources/Core/Input/MouseInputDeviceStore.swift +0 -98
- package/apps/mac/Sources/Core/Input/MouseInputEventViewer.swift +0 -272
- package/apps/mac/Sources/Core/Input/MouseShortcutStore.swift +0 -170
- package/apps/mac/Sources/Core/Input/SecureEventInputMonitor.swift +0 -39
- package/apps/mac/Sources/Core/Input/ShapeRecognizer.swift +0 -624
- package/apps/mac/Sources/Core/Input/TapBudgetMeter.swift +0 -56
- package/apps/mac/Sources/Core/Overlays/AppWindowShell.swift +0 -63
- package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeState.swift +0 -1566
- package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeView.swift +0 -1927
- package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeWindow.swift +0 -196
- package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteView.swift +0 -307
- package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +0 -67
- package/apps/mac/Sources/Core/Overlays/HUD/CheatSheetHUD.swift +0 -576
- package/apps/mac/Sources/Core/Overlays/HUD/HUDBottomBar.swift +0 -279
- package/apps/mac/Sources/Core/Overlays/HUD/HUDController.swift +0 -1158
- package/apps/mac/Sources/Core/Overlays/HUD/HUDLeftBar.swift +0 -849
- package/apps/mac/Sources/Core/Overlays/HUD/HUDMinimap.swift +0 -179
- package/apps/mac/Sources/Core/Overlays/HUD/HUDRightBar.swift +0 -596
- package/apps/mac/Sources/Core/Overlays/HUD/HUDState.swift +0 -367
- package/apps/mac/Sources/Core/Overlays/HUD/HUDTopBar.swift +0 -243
- package/apps/mac/Sources/Core/Overlays/HUD/LauncherHUD.swift +0 -334
- package/apps/mac/Sources/Core/Overlays/HUD/LayerBezel.swift +0 -203
- package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchState.swift +0 -280
- package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchView.swift +0 -422
- package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchWindow.swift +0 -94
- package/apps/mac/Sources/Core/Overlays/OverlayPanelShell.swift +0 -241
- package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapState.swift +0 -3135
- package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapView.swift +0 -3977
- package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapWindowController.swift +0 -119
- package/apps/mac/Sources/Core/Overlays/ScreenOverlayCanvasController.swift +0 -1217
- package/apps/mac/Sources/Core/Overlays/Voice/VoiceCommandWindow.swift +0 -1575
- package/apps/mac/Sources/Core/Pi/PiAuthNextStepCard.swift +0 -148
- package/apps/mac/Sources/Core/Pi/PiAuthPromptCard.swift +0 -90
- package/apps/mac/Sources/Core/Pi/PiChatDock.swift +0 -564
- package/apps/mac/Sources/Core/Pi/PiChatSession.swift +0 -1948
- package/apps/mac/Sources/Core/Pi/PiInstallCallout.swift +0 -86
- package/apps/mac/Sources/Core/Pi/PiProviderSetupCallout.swift +0 -99
- package/apps/mac/Sources/Core/Pi/PiWorkspaceView.swift +0 -510
- package/apps/mac/Sources/Core/System/Capability.swift +0 -79
- package/apps/mac/Sources/Core/System/DiagnosticLog.swift +0 -373
- package/apps/mac/Sources/Core/System/EventBus.swift +0 -31
- package/apps/mac/Sources/Core/System/PermissionChecker.swift +0 -224
- package/apps/mac/Sources/Core/System/ProcessModel.swift +0 -199
- package/apps/mac/Sources/Core/System/ProcessQuery.swift +0 -151
- package/apps/mac/Sources/Core/System/SystemTelemetryMonitor.swift +0 -273
- package/apps/mac/Sources/Core/Voice/AdvisorLearningStore.swift +0 -90
- package/apps/mac/Sources/Core/Voice/AgentSession.swift +0 -377
- package/apps/mac/Sources/Core/Voice/AudioProvider.swift +0 -555
- package/apps/mac/Sources/Core/Voice/HandsOffSession.swift +0 -839
- package/apps/mac/Sources/Core/Voice/VoiceChatView.swift +0 -192
- package/apps/mac/Sources/Core/Voice/VoxClient.swift +0 -454
- package/apps/mac/Sources/Core/Workspace/Project.swift +0 -28
- package/apps/mac/Sources/Core/Workspace/ProjectScanner.swift +0 -141
- package/apps/mac/Sources/Core/Workspace/SessionLayerStore.swift +0 -285
- package/apps/mac/Sources/Core/Workspace/SessionManager.swift +0 -75
- package/apps/mac/Sources/Core/Workspace/Terminal/Terminal.swift +0 -259
- package/apps/mac/Sources/Core/Workspace/Terminal/TerminalQuery.swift +0 -156
- package/apps/mac/Sources/Core/Workspace/Terminal/TerminalSynthesizer.swift +0 -200
- package/apps/mac/Sources/Core/Workspace/Tmux/TmuxModel.swift +0 -60
- package/apps/mac/Sources/Core/Workspace/Tmux/TmuxQuery.swift +0 -105
- package/apps/mac/Sources/Core/Workspace/WorkspaceManager.swift +0 -1027
- package/apps/mac/Sources/UI/ActionRow.swift +0 -78
- package/apps/mac/Sources/UI/OrphanRow.swift +0 -129
- package/apps/mac/Sources/UI/ProjectRow.swift +0 -368
- package/apps/mac/Sources/UI/TabGroupRow.swift +0 -178
- package/apps/mac/Sources/UI/Theme.swift +0 -164
- package/apps/mac/Tests/StageDragTests.swift +0 -333
- package/apps/mac/Tests/StageJoinTests.swift +0 -313
- package/apps/mac/Tests/StageManagerTests.swift +0 -280
- package/apps/mac/Tests/StageTileTests.swift +0 -353
- package/swift/Package.swift +0 -20
- package/swift/Sources/DeckKit/DeckAction.swift +0 -51
- package/swift/Sources/DeckKit/DeckBridgeSecurity.swift +0 -152
- package/swift/Sources/DeckKit/DeckCockpit.swift +0 -82
- package/swift/Sources/DeckKit/DeckHost.swift +0 -7
- package/swift/Sources/DeckKit/DeckManifest.swift +0 -145
- package/swift/Sources/DeckKit/DeckRuntimeSnapshot.swift +0 -533
- package/swift/Sources/DeckKit/DeckTrackpad.swift +0 -63
- package/swift/Sources/DeckKit/DeckValue.swift +0 -93
- package/swift/Sources/DeckKit/DeckVoiceError.swift +0 -88
- 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
|
-
}
|