@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,1575 +0,0 @@
1
- import AppKit
2
- import Combine
3
- import SwiftUI
4
-
5
- // MARK: - Window Controller
6
-
7
- final class VoiceCommandWindow {
8
- static let shared = VoiceCommandWindow()
9
-
10
- private(set) var panel: OverlayPanel?
11
- private var keyMonitor: Any?
12
- private var state: VoiceCommandState?
13
-
14
- var isVisible: Bool { panel?.isVisible ?? false }
15
-
16
- func toggle() {
17
- if isVisible {
18
- dismiss()
19
- return
20
- }
21
- show()
22
- }
23
-
24
- func show() {
25
- // If panel exists but is hidden, just re-show it
26
- if let p = panel, state != nil {
27
- p.alphaValue = 0
28
- p.orderFrontRegardless()
29
- NSAnimationContext.runAnimationGroup { ctx in
30
- ctx.duration = 0.15
31
- p.animator().alphaValue = 1.0
32
- }
33
- installMonitors()
34
- // Push-to-talk: user holds Option to start, no auto-listen
35
- return
36
- }
37
-
38
- let voiceState = VoiceCommandState()
39
- state = voiceState
40
-
41
- let view = VoiceCommandView(state: voiceState) { [weak self] in
42
- self?.dismiss()
43
- }
44
- .preferredColorScheme(.dark)
45
-
46
- let mouseLocation = NSEvent.mouseLocation
47
- let screen = NSScreen.screens.first(where: { $0.frame.contains(mouseLocation) })
48
- ?? NSScreen.main
49
- ?? NSScreen.screens.first!
50
- let visible = screen.visibleFrame
51
- let panelWidth: CGFloat = min(900, visible.width - 80)
52
- let panelHeight: CGFloat = min(560, visible.height - 80)
53
-
54
- let p = OverlayPanelShell.makePanel(
55
- config: .init(
56
- size: NSSize(width: panelWidth, height: panelHeight),
57
- styleMask: [.titled, .nonactivatingPanel],
58
- titleVisible: .hidden,
59
- titlebarAppearsTransparent: true,
60
- background: .clear,
61
- hidesOnDeactivate: false,
62
- isMovableByWindowBackground: true,
63
- activatesOnMouseDown: true,
64
- onKeyDown: { [weak self] event in self?.handleKey(event) },
65
- onFlagsChanged: { [weak self] event in self?.handleFlags(event) }
66
- ),
67
- rootView: view
68
- )
69
- OverlayPanelShell.position(p, placement: .topCenter(margin: 40))
70
-
71
- p.alphaValue = 0
72
- OverlayPanelShell.present(p, activate: true, makeKey: true, orderFrontRegardless: true)
73
-
74
- NSAnimationContext.runAnimationGroup { ctx in
75
- ctx.duration = 0.15
76
- p.animator().alphaValue = 1.0
77
- }
78
-
79
- self.panel = p
80
- installMonitors()
81
-
82
- // Auto-start listening immediately
83
- voiceState.startListening()
84
- }
85
-
86
- func dismiss() {
87
- guard let p = panel else { return }
88
- removeMonitors()
89
-
90
- // Cancel any in-progress listening or processing
91
- state?.cancelProcessing()
92
-
93
- // Hide panel but keep state — Hyper+3 will bring it back
94
- NSAnimationContext.runAnimationGroup({ ctx in
95
- ctx.duration = 0.15
96
- p.animator().alphaValue = 0
97
- }) {
98
- p.orderOut(nil)
99
- }
100
- }
101
-
102
- private func handleKey(_ event: NSEvent) {
103
- guard let state else { return }
104
-
105
- switch event.keyCode {
106
- case 53: // Escape
107
- if state.phase == .listening {
108
- state.cancelListening()
109
- state.armed = false
110
- } else {
111
- dismiss()
112
- }
113
-
114
- case 48: // Tab — toggle armed
115
- state.toggleArmed()
116
-
117
- default:
118
- break
119
- }
120
- }
121
-
122
- /// Push-to-talk: hold Option to record, release to stop. Only when panel is focused.
123
- private func handleFlags(_ event: NSEvent) {
124
- guard let state else { return }
125
- let optionDown = event.modifierFlags.contains(.option)
126
-
127
- if optionDown {
128
- // Option pressed — start recording
129
- if state.armed, state.phase == .idle || state.phase == .result {
130
- state.startListening()
131
- }
132
- } else {
133
- // Option released — stop recording
134
- if state.phase == .listening {
135
- state.stopListening()
136
- }
137
- }
138
- }
139
-
140
- private var focusObservers: [NSObjectProtocol] = []
141
-
142
- private var flagsMonitor: Any?
143
-
144
- private func installMonitors() {
145
- // Global monitor: Escape/Tab only (no recording keys globally)
146
- keyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in
147
- self?.handleKey(event)
148
- }
149
-
150
- // Local flagsChanged monitor: push-to-talk with Option key (only when panel is focused)
151
- flagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
152
- // Only handle when our panel is the key window
153
- guard let self, event.window === self.panel else { return event }
154
- self.handleFlags(event)
155
- return event
156
- }
157
-
158
- // Focus/blur: cancel recording if window loses focus
159
- let nc = NotificationCenter.default
160
- focusObservers.append(
161
- nc.addObserver(forName: NSWindow.didResignKeyNotification, object: panel, queue: .main) { [weak self] _ in
162
- guard let self, let state = self.state else { return }
163
- if state.phase == .listening {
164
- state.cancelListening()
165
- }
166
- }
167
- )
168
- }
169
-
170
- private func removeMonitors() {
171
- if let m = keyMonitor { NSEvent.removeMonitor(m); keyMonitor = nil }
172
- if let m = flagsMonitor { NSEvent.removeMonitor(m); flagsMonitor = nil }
173
- for obs in focusObservers { NotificationCenter.default.removeObserver(obs) }
174
- focusObservers.removeAll()
175
- }
176
- }
177
-
178
- // MARK: - Transcript Entry
179
-
180
- struct ResultItem: Identifiable {
181
- let id = UUID()
182
- let wid: UInt32
183
- let app: String
184
- let title: String
185
- }
186
-
187
- struct TranscriptEntry: Identifiable {
188
- let id = UUID()
189
- let timestamp: Date
190
- let text: String
191
- let intent: String?
192
- let slots: [String: String]
193
- let result: String?
194
- let resultItems: [ResultItem]
195
- let logLines: [String]
196
- }
197
-
198
- // MARK: - State
199
-
200
- final class VoiceCommandState: ObservableObject {
201
- enum Phase: Equatable {
202
- case idle
203
- case connecting
204
- case listening
205
- case transcribing
206
- case result
207
- }
208
-
209
- @Published var phase: Phase = .idle
210
- @Published var armed: Bool = true // When armed, Space controls the mic
211
- @Published var partialText: String = ""
212
-
213
- // Current command
214
- @Published var finalText: String = ""
215
- @Published var intentName: String?
216
- @Published var intentSlots: [String: String] = [:]
217
- @Published var executionResult: String?
218
- @Published var resultItems: [ResultItem] = []
219
- @Published var resultSummary: String = ""
220
-
221
- // Agent advisor response
222
- @Published var agentResponse: AgentResponse?
223
-
224
- // Listening timer
225
- @Published var listenStartTime: Date = Date()
226
-
227
- // History — all transcripts this session
228
- @Published var history: [TranscriptEntry] = []
229
-
230
- // Diagnostic log
231
- @Published var logLines: [String] = []
232
-
233
- private var logSnapshot = 0
234
- private var logObserver: AnyCancellable?
235
- private var cancelled = false
236
-
237
- func startListening() {
238
- let client = VoxClient.shared
239
-
240
- if client.connectionState == .connected {
241
- beginListening()
242
- } else {
243
- phase = .connecting
244
- client.connect()
245
- waitForConnection(attempts: 0)
246
- }
247
- }
248
-
249
- private func waitForConnection(attempts: Int) {
250
- let client = VoxClient.shared
251
- if client.connectionState == .connected {
252
- beginListening()
253
- } else if attempts < 20 {
254
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
255
- self?.waitForConnection(attempts: attempts + 1)
256
- }
257
- } else {
258
- appendLog("Connection to Vox failed after 2s")
259
- phase = .idle
260
- }
261
- }
262
-
263
- private func beginListening() {
264
- cancelled = false
265
- phase = .listening
266
- listenStartTime = Date()
267
- partialText = ""
268
- finalText = ""
269
- intentName = nil
270
- intentSlots = [:]
271
- executionResult = nil
272
- resultItems = []
273
- resultSummary = ""
274
- agentResponse = nil
275
- // Snapshot log position and observe changes reactively (no polling race)
276
- logSnapshot = DiagnosticLog.shared.entries.count
277
- logLines = []
278
- logObserver = DiagnosticLog.shared.$entries
279
- .receive(on: RunLoop.main)
280
- .sink { [weak self] entries in
281
- guard let self else { return }
282
- let start = min(self.logSnapshot, entries.count)
283
- let newLines = entries.suffix(from: start).map { $0.message }
284
- if !newLines.isEmpty {
285
- self.logLines = newLines
286
- }
287
- }
288
- AudioLayer.shared.startVoiceCommand()
289
- }
290
-
291
- func stopListening() {
292
- phase = .transcribing
293
- AudioLayer.shared.stopVoiceCommand()
294
- observeResult()
295
- }
296
-
297
- func cancelListening() {
298
- cancelled = true
299
- phase = .idle
300
- AudioLayer.shared.stopVoiceCommand()
301
- appendLog("Cancelled")
302
- }
303
-
304
- /// Cancel any in-progress processing (polling loops will check this flag).
305
- func cancelProcessing() {
306
- cancelled = true
307
- if phase == .listening || phase == .transcribing || phase == .connecting {
308
- AudioLayer.shared.stopVoiceCommand()
309
- appendLog("Processing cancelled")
310
- phase = .idle
311
- }
312
- }
313
-
314
- func toggleArmed() {
315
- if phase == .listening {
316
- // Stop listening when disarming
317
- cancelListening()
318
- }
319
- armed.toggle()
320
- }
321
-
322
- func toggleListening() {
323
- switch phase {
324
- case .listening:
325
- stopListening()
326
- case .idle, .result:
327
- startListening()
328
- default:
329
- break
330
- }
331
- }
332
-
333
- func appendLog(_ msg: String) {
334
- DiagnosticLog.shared.info(msg)
335
- }
336
-
337
- private func syncLogs() {
338
- // Logs are now updated reactively via logObserver.
339
- // This is kept as a manual trigger for the final commit.
340
- let entries = DiagnosticLog.shared.entries
341
- let start = min(logSnapshot, entries.count)
342
- let newLines = entries.suffix(from: start).map { $0.message }
343
- logLines = newLines
344
- }
345
-
346
- private func pollForAdvisor() {
347
- let audio = AudioLayer.shared
348
- var checks = 0
349
-
350
- func poll() {
351
- if let resp = audio.agentResponse {
352
- self.agentResponse = resp
353
- return
354
- }
355
- checks += 1
356
- if checks < 60 { // Up to 12 seconds
357
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { poll() }
358
- }
359
- }
360
-
361
- // Only poll if we don't already have a response
362
- if agentResponse == nil {
363
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { poll() }
364
- }
365
- }
366
-
367
- func restoreFromHistory(_ entry: TranscriptEntry) {
368
- finalText = entry.text
369
- intentName = entry.intent
370
- intentSlots = entry.slots
371
- executionResult = entry.result
372
- resultItems = entry.resultItems
373
- resultSummary = entry.resultItems.isEmpty ? "" : "\(entry.resultItems.count) result\(entry.resultItems.count == 1 ? "" : "s")"
374
- logLines = entry.logLines
375
- agentResponse = nil
376
- phase = .result
377
- }
378
-
379
- private func commitToHistory() {
380
- guard !finalText.isEmpty else { return }
381
- let entry = TranscriptEntry(
382
- timestamp: Date(),
383
- text: finalText,
384
- intent: intentName,
385
- slots: intentSlots,
386
- result: executionResult,
387
- resultItems: resultItems,
388
- logLines: logLines
389
- )
390
- history.append(entry)
391
- // logLines are NOT reset here — they stay visible until the next command starts
392
- }
393
-
394
- private func observeResult() {
395
- let audio = AudioLayer.shared
396
- var checks = 0
397
- let maxChecks = 75 // 15 seconds at 0.2s intervals
398
-
399
- func syncState() {
400
- // Sync transcript immediately
401
- if let transcript = audio.lastTranscript, !transcript.isEmpty {
402
- self.finalText = transcript
403
- }
404
-
405
- // Sync intent/slots as they become available
406
- if let intent = audio.matchedIntent {
407
- self.intentName = intent
408
- self.intentSlots = audio.matchedSlots
409
- }
410
-
411
- // Sync agent advisor response
412
- if let resp = audio.agentResponse {
413
- self.agentResponse = resp
414
- }
415
- }
416
-
417
- func poll() {
418
- // Bail if cancelled (e.g. user dismissed or started a new command)
419
- guard !self.cancelled else { return }
420
-
421
- checks += 1
422
- syncState()
423
-
424
- let result = audio.executionResult
425
-
426
- // Terminal errors — log them, go to idle (not a separate error phase)
427
- if result == "No speech detected" {
428
- appendLog("No speech detected")
429
- self.phase = .idle
430
- return
431
- }
432
- if result == "Transcription failed" {
433
- appendLog("Transcription failed")
434
- self.phase = .idle
435
- return
436
- }
437
- if let result, result.hasPrefix("Mic in use") {
438
- appendLog(result)
439
- self.phase = .idle
440
- return
441
- }
442
-
443
- // Still working
444
- let stillWorking = result == nil
445
- || result == "Transcribing..."
446
- || result == "thinking..."
447
- || result == "searching..."
448
-
449
- if stillWorking {
450
- if let result { self.executionResult = result }
451
- self.phase = .transcribing
452
- if checks < maxChecks {
453
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { poll() }
454
- } else {
455
- appendLog("Timed out waiting for result")
456
- self.phase = .idle
457
- }
458
- return
459
- }
460
-
461
- // Grace period for transcript
462
- if self.finalText.isEmpty && checks < 25 {
463
- self.phase = .transcribing
464
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { poll() }
465
- return
466
- }
467
-
468
- // Final result
469
- self.executionResult = result
470
- if let data = audio.executionData {
471
- switch data {
472
- case .array(let items):
473
- self.resultItems = items.compactMap { item in
474
- guard let wid = item["wid"]?.intValue,
475
- let app = item["app"]?.stringValue,
476
- let title = item["title"]?.stringValue else { return nil }
477
- return ResultItem(wid: UInt32(wid), app: app, title: title)
478
- }
479
- self.resultSummary = "\(items.count) result\(items.count == 1 ? "" : "s")"
480
- case .object(let obj):
481
- self.resultItems = []
482
- self.resultSummary = obj.map { "\($0.key): \($0.value)" }.joined(separator: ", ")
483
- default:
484
- self.resultItems = []
485
- self.resultSummary = "\(data)"
486
- }
487
- } else {
488
- self.resultItems = []
489
- self.resultSummary = ""
490
- }
491
-
492
- syncLogs() // Final sync before committing
493
- commitToHistory()
494
- self.phase = .result
495
-
496
- // Keep polling for agent advisor response (arrives later)
497
- self.pollForAdvisor()
498
- }
499
-
500
- // Sync immediately (no delay for transcript), then start polling
501
- syncState()
502
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { poll() }
503
- }
504
- }
505
-
506
- // MARK: - View
507
-
508
- struct VoiceCommandView: View {
509
- @ObservedObject var state: VoiceCommandState
510
- @ObservedObject private var activeSelection = WindowSelectionStore.shared
511
- let onDismiss: () -> Void
512
-
513
- private let docsURL = "https://lattices.dev/docs/voice"
514
-
515
- @State private var historyColumnWidth: CGFloat?
516
- @State private var logColumnWidth: CGFloat?
517
-
518
- var body: some View {
519
- VStack(spacing: 0) {
520
- // Mic bar
521
- micBar
522
- Rectangle().fill(Palette.border).frame(height: 0.5)
523
-
524
- // Three-column layout — all widths computed explicitly
525
- GeometryReader { geo in
526
- let histW = historyColumnWidth ?? geo.size.width * 0.20
527
- let logW = logColumnWidth ?? geo.size.width * 0.28
528
- let dividerW: CGFloat = 1
529
- let voiceW = geo.size.width - histW - logW - (dividerW * 2)
530
-
531
- HStack(spacing: 0) {
532
- // HISTORY column
533
- VStack(spacing: 0) {
534
- Text("HISTORY")
535
- .font(Typo.geistMonoBold(9))
536
- .foregroundColor(Palette.textMuted)
537
- .tracking(1)
538
- .padding(.leading, 16)
539
- .frame(maxWidth: .infinity, alignment: .leading)
540
- .padding(.vertical, 8)
541
- Rectangle().fill(Palette.border).frame(height: 0.5)
542
- transcriptHistoryBody
543
- .frame(width: histW, height: geo.size.height - 30)
544
- }
545
- .frame(width: histW, height: geo.size.height)
546
-
547
- // Left divider — full height
548
- columnDivider(
549
- width: $historyColumnWidth,
550
- defaultWidth: geo.size.width * 0.20,
551
- min: 100, max: geo.size.width * 0.35
552
- )
553
- .frame(height: geo.size.height)
554
-
555
- // VOICE COMMAND column — explicit width
556
- VStack(spacing: 0) {
557
- Text("VOICE COMMAND")
558
- .font(Typo.geistMonoBold(9))
559
- .foregroundColor(Palette.textMuted)
560
- .tracking(1)
561
- .padding(.leading, 16)
562
- .frame(maxWidth: .infinity, alignment: .leading)
563
- .padding(.vertical, 8)
564
- Rectangle().fill(Palette.border).frame(height: 0.5)
565
- voiceCommandBody
566
- .frame(width: voiceW, height: geo.size.height - 30, alignment: .topLeading)
567
- }
568
- .frame(width: voiceW, height: geo.size.height)
569
-
570
- // Right divider — full height
571
- columnDivider(
572
- width: $logColumnWidth,
573
- defaultWidth: geo.size.width * 0.28,
574
- min: 140, max: geo.size.width * 0.40,
575
- inverted: true
576
- )
577
- .frame(height: geo.size.height)
578
-
579
- // LOG + AI column (split vertically)
580
- VStack(spacing: 0) {
581
- logHeader
582
- .frame(width: logW, alignment: .leading)
583
- Rectangle().fill(Palette.border).frame(height: 0.5)
584
- logBody
585
- .frame(width: logW, height: (geo.size.height - 30) * 0.55)
586
- Rectangle().fill(Palette.border).frame(height: 0.5)
587
- aiCorner
588
- .frame(width: logW, height: (geo.size.height - 30) * 0.45)
589
- }
590
- .frame(width: logW, height: geo.size.height)
591
- }
592
- .frame(width: geo.size.width, height: geo.size.height)
593
- }
594
-
595
- Rectangle().fill(Palette.border).frame(height: 0.5)
596
-
597
- // Footer
598
- footerBar
599
- }
600
- .background(
601
- RoundedRectangle(cornerRadius: 12)
602
- .fill(Palette.bg)
603
- .overlay(
604
- RoundedRectangle(cornerRadius: 12)
605
- .strokeBorder(Palette.borderLit, lineWidth: 0.5)
606
- )
607
- )
608
- .clipShape(RoundedRectangle(cornerRadius: 12))
609
- }
610
-
611
- // MARK: - Mic Bar
612
-
613
- private var micBar: some View {
614
- HStack(spacing: 0) {
615
- // Mic button
616
- Button(action: { state.toggleListening() }) {
617
- HStack(spacing: 8) {
618
- Image(systemName: state.phase == .listening ? "mic.fill" : state.armed ? "mic" : "mic.slash")
619
- .font(.system(size: 13, weight: .medium))
620
- .foregroundColor(state.phase == .listening ? .white : state.armed ? Palette.textMuted : Palette.textMuted.opacity(0.4))
621
-
622
- if state.phase == .listening {
623
- WaveBar()
624
- ListeningTimer(startTime: state.listenStartTime)
625
- } else {
626
- statusLabel
627
- }
628
- }
629
- .padding(.horizontal, 14)
630
- .frame(maxHeight: .infinity)
631
- }
632
- .buttonStyle(.plain)
633
-
634
- Spacer()
635
- }
636
- .frame(height: 36)
637
- .background(Color.black)
638
- }
639
-
640
- private var statusLabel: some View {
641
- Group {
642
- switch state.phase {
643
- case .idle:
644
- if state.armed {
645
- Text("ready — hold ⌥ to speak")
646
- .foregroundColor(Palette.textMuted)
647
- } else {
648
- Text("paused — Tab to activate")
649
- .foregroundColor(Palette.textMuted.opacity(0.5))
650
- }
651
- case .connecting:
652
- Text("connecting...")
653
- .foregroundColor(Palette.detach)
654
- case .listening:
655
- ListeningTimer(startTime: state.listenStartTime)
656
- case .transcribing:
657
- if let r = state.executionResult, r == "thinking..." || r == "searching..." {
658
- Text(r)
659
- .foregroundColor(Palette.detach)
660
- } else {
661
- Text("processing...")
662
- .foregroundColor(Palette.textDim)
663
- }
664
- case .result:
665
- Text("done")
666
- .foregroundColor(Palette.textMuted)
667
- }
668
- }
669
- .font(Typo.geistMono(10))
670
- }
671
-
672
- // MARK: - Transcript History (left pane)
673
-
674
- private var transcriptHistoryBody: some View {
675
- Group {
676
- if !state.history.isEmpty {
677
- ScrollViewReader { proxy in
678
- ScrollView {
679
- LazyVStack(alignment: .leading, spacing: 0) {
680
- ForEach(state.history) { entry in
681
- historyRow(entry)
682
- .id(entry.id)
683
- Rectangle().fill(Palette.border).frame(height: 0.5)
684
- }
685
- }
686
- }
687
- .onChange(of: state.history.count) { _ in
688
- if let last = state.history.last {
689
- withAnimation(.easeOut(duration: 0.2)) {
690
- proxy.scrollTo(last.id, anchor: .bottom)
691
- }
692
- }
693
- }
694
- }
695
- } else {
696
- Color.clear
697
- }
698
- }
699
- }
700
-
701
- @State private var expandedEntries: Set<UUID> = []
702
-
703
- private func historyRow(_ entry: TranscriptEntry) -> some View {
704
- let isExpanded = expandedEntries.contains(entry.id)
705
-
706
- return VStack(alignment: .leading, spacing: 4) {
707
- // Always visible: compact row
708
- HStack(alignment: .center, spacing: 6) {
709
- Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
710
- .font(.system(size: 7))
711
- .foregroundColor(Palette.textMuted)
712
- .frame(width: 8)
713
-
714
- Text(entry.timestamp, style: .time)
715
- .font(Typo.geistMono(9))
716
- .foregroundColor(Palette.textMuted)
717
-
718
- if let intent = entry.intent {
719
- Text(intent)
720
- .font(Typo.geistMonoBold(9))
721
- .foregroundColor(Palette.running)
722
- } else {
723
- Text(entry.text)
724
- .font(Typo.geistMono(9))
725
- .foregroundColor(Palette.text)
726
- .lineLimit(1)
727
- }
728
-
729
- Spacer()
730
-
731
- if !entry.resultItems.isEmpty {
732
- Text("\(entry.resultItems.count)")
733
- .font(Typo.geistMono(8))
734
- .foregroundColor(Palette.textMuted)
735
- .padding(.horizontal, 4)
736
- .padding(.vertical, 1)
737
- .background(
738
- RoundedRectangle(cornerRadius: 3)
739
- .fill(Palette.surface)
740
- )
741
- }
742
- }
743
-
744
- // Expanded: full details
745
- if isExpanded {
746
- VStack(alignment: .leading, spacing: 4) {
747
- // Transcript
748
- Text(entry.text)
749
- .font(Typo.geistMono(11))
750
- .foregroundColor(Palette.text)
751
- .lineLimit(3)
752
- .padding(.leading, 14)
753
-
754
- // Intent + slots
755
- if let intent = entry.intent {
756
- HStack(spacing: 4) {
757
- Text(intent)
758
- .font(Typo.geistMonoBold(9))
759
- .foregroundColor(Palette.running)
760
- .padding(.horizontal, 5)
761
- .padding(.vertical, 1)
762
- .background(
763
- RoundedRectangle(cornerRadius: 3)
764
- .fill(Palette.running.opacity(0.1))
765
- )
766
-
767
- if !entry.slots.isEmpty {
768
- let slotText = entry.slots.map { "\($0.key)=\($0.value)" }.joined(separator: " ")
769
- Text(slotText)
770
- .font(Typo.geistMono(9))
771
- .foregroundColor(Palette.textDim)
772
- }
773
- }
774
- .padding(.leading, 14)
775
- }
776
-
777
- // Result items
778
- if !entry.resultItems.isEmpty {
779
- VStack(alignment: .leading, spacing: 2) {
780
- ForEach(Array(entry.resultItems.prefix(5).enumerated()), id: \.1.id) { idx, item in
781
- ResultRow(index: idx, item: item, onFocus: focusWindow, onTile: tileWindow)
782
- }
783
- if entry.resultItems.count > 5 {
784
- Text("+ \(entry.resultItems.count - 5) more")
785
- .font(Typo.geistMono(9))
786
- .foregroundColor(Palette.textMuted)
787
- }
788
- }
789
- .padding(.leading, 14)
790
- } else if let result = entry.result, result != "ok" {
791
- Text(result)
792
- .font(Typo.geistMono(9))
793
- .foregroundColor(Palette.detach)
794
- .padding(.leading, 14)
795
- }
796
-
797
- // Log lines
798
- if !entry.logLines.isEmpty {
799
- VStack(alignment: .leading, spacing: 1) {
800
- ForEach(Array(entry.logLines.enumerated()), id: \.offset) { _, line in
801
- Text(line)
802
- .font(.system(size: 8, design: .monospaced))
803
- .foregroundColor(Palette.textMuted.opacity(0.7))
804
- .lineLimit(1)
805
- }
806
- }
807
- .padding(.leading, 14)
808
- .padding(.top, 2)
809
- }
810
- }
811
- }
812
- }
813
- .padding(.horizontal, 14)
814
- .padding(.vertical, isExpanded ? 10 : 6)
815
- .background(isExpanded ? Palette.surface.opacity(0.3) : Color.clear)
816
- .contentShape(Rectangle())
817
- .onTapGesture {
818
- withAnimation(.easeInOut(duration: 0.15)) {
819
- if isExpanded {
820
- expandedEntries.remove(entry.id)
821
- } else {
822
- expandedEntries.insert(entry.id)
823
- }
824
- }
825
- }
826
- }
827
-
828
- // MARK: - Voice Command (center pane)
829
-
830
- private var voiceCommandBody: some View {
831
- ScrollView(.vertical) {
832
- VStack(alignment: .leading, spacing: 14) {
833
- // Zero-height spacer forces VStack to fill ScrollView width
834
- Color.clear.frame(maxWidth: .infinity, maxHeight: 0)
835
- if activeSelection.isActive {
836
- commandSection("selection") {
837
- VStack(alignment: .leading, spacing: 6) {
838
- HStack(spacing: 6) {
839
- Text("\(activeSelection.count) window\(activeSelection.count == 1 ? "" : "s")")
840
- .font(Typo.geistMonoBold(11))
841
- .foregroundColor(Palette.running)
842
- if let source = activeSelection.sourceLabel {
843
- Text(source)
844
- .font(Typo.geistMono(10))
845
- .foregroundColor(Palette.textMuted)
846
- }
847
- }
848
- Text(activeSelection.summary(maxItems: 4))
849
- .font(Typo.geistMono(11))
850
- .foregroundColor(Palette.textDim)
851
- .lineLimit(3)
852
- Text("Try: grid that in the bottom half")
853
- .font(Typo.geistMono(10))
854
- .foregroundColor(Palette.textMuted)
855
- }
856
- }
857
- }
858
-
859
- // Partial transcript (while listening)
860
- if state.phase == .listening, !state.partialText.isEmpty {
861
- commandSection("hearing...") {
862
- Text(state.partialText)
863
- .font(Typo.geistMono(13))
864
- .foregroundColor(Palette.textDim)
865
- }
866
- }
867
-
868
- // What was heard
869
- if !state.finalText.isEmpty {
870
- commandSection("heard") {
871
- Text(state.finalText)
872
- .font(Typo.geistMono(13))
873
- .foregroundColor(Palette.text)
874
- .textSelection(.enabled)
875
- }
876
- }
877
-
878
- // Matched intent + slots
879
- if let intent = state.intentName {
880
- commandSection("intent") {
881
- HStack(spacing: 6) {
882
- Text(intent)
883
- .font(Typo.geistMonoBold(11))
884
- .foregroundColor(Palette.running)
885
- .padding(.horizontal, 6)
886
- .padding(.vertical, 2)
887
- .background(
888
- RoundedRectangle(cornerRadius: 4)
889
- .fill(Palette.running.opacity(0.1))
890
- .overlay(
891
- RoundedRectangle(cornerRadius: 4)
892
- .strokeBorder(Palette.running.opacity(0.2), lineWidth: 0.5)
893
- )
894
- )
895
-
896
- if !state.intentSlots.isEmpty {
897
- ForEach(Array(state.intentSlots.keys.sorted()), id: \.self) { key in
898
- if let val = state.intentSlots[key] {
899
- Text("\(key): \(val)")
900
- .font(Typo.geistMono(10))
901
- .foregroundColor(Palette.detach)
902
- .padding(.horizontal, 5)
903
- .padding(.vertical, 1)
904
- .background(
905
- RoundedRectangle(cornerRadius: 3)
906
- .fill(Palette.detach.opacity(0.08))
907
- )
908
- }
909
- }
910
- }
911
- }
912
- }
913
- }
914
-
915
- // Results
916
- if !state.resultItems.isEmpty {
917
- commandSection("\(state.resultItems.count) match\(state.resultItems.count == 1 ? "" : "es")") {
918
- VStack(alignment: .leading, spacing: 2) {
919
- ForEach(Array(state.resultItems.prefix(25).enumerated()), id: \.1.id) { idx, item in
920
- ResultRow(index: idx, item: item, onFocus: focusWindow, onTile: tileWindow)
921
- }
922
- if state.resultItems.count > 25 {
923
- Text("+ \(state.resultItems.count - 25) more")
924
- .font(Typo.geistMono(9))
925
- .foregroundColor(Palette.textMuted)
926
- }
927
- }
928
- }
929
- } else if !state.resultSummary.isEmpty {
930
- commandSection("result") {
931
- Text(state.resultSummary)
932
- .font(Typo.geistMono(11))
933
- .foregroundColor(Palette.text)
934
- }
935
- } else if state.executionResult == "ok" {
936
- commandSection("result") {
937
- Text("done")
938
- .font(Typo.geistMono(11))
939
- .foregroundColor(Palette.running)
940
- }
941
- }
942
-
943
- // Advisor now lives in the AI corner (bottom-right)
944
- }
945
- .padding(16)
946
- .frame(maxWidth: .infinity, alignment: .leading)
947
- }
948
- }
949
-
950
- private func copyAIResponse() {
951
- guard let response = state.agentResponse else { return }
952
- var text = ""
953
- if let commentary = response.commentary { text += commentary }
954
- if let suggestion = response.suggestion {
955
- if !text.isEmpty { text += "\n" }
956
- text += "\(suggestion.label) → \(suggestion.intent)"
957
- if !suggestion.slots.isEmpty {
958
- text += " " + suggestion.slots.map { "\($0.key)=\($0.value)" }.joined(separator: ", ")
959
- }
960
- }
961
- NSPasteboard.general.clearContents()
962
- NSPasteboard.general.setString(text, forType: .string)
963
- }
964
-
965
- private func manuallyAskAdvisor() {
966
- let transcript = state.finalText
967
- let matched = state.intentName ?? "none"
968
- let slots = state.intentSlots.map { "\($0.key)=\($0.value)" }.joined(separator: ", ")
969
- let matchStr = slots.isEmpty ? matched : "\(matched)(\(slots))"
970
-
971
- let assistant = PiChatSession.shared
972
- guard assistant.isProviderInferenceReady else {
973
- state.appendLog("Assistant provider not ready")
974
- return
975
- }
976
-
977
- state.appendLog("Asking Assistant...")
978
- assistant.askVoiceAdvisor(transcript: transcript, matched: matchStr) { [weak state] response in
979
- guard let state = state, let response = response else { return }
980
- state.agentResponse = response
981
- }
982
- }
983
-
984
- private func executeSuggestion(_ suggestion: AgentResponse.AgentSuggestion) {
985
- var slotsDict = suggestion.slots
986
-
987
- // If the intent needs a query slot and the assistant did not include one,
988
- // try to extract it from the label or fall back to the original query
989
- if suggestion.intent == "search" && slotsDict["query"] == nil {
990
- // Try extracting from label: "Deep search Vox" → "Vox"
991
- let label = suggestion.label
992
- let prefixes = ["Deep search ", "Search ", "Find ", "deep search ", "search ", "find "]
993
- var extracted: String?
994
- for prefix in prefixes {
995
- if label.hasPrefix(prefix) {
996
- extracted = String(label.dropFirst(prefix.count))
997
- break
998
- }
999
- }
1000
- // Fall back to the original query slot from the local match
1001
- let query = extracted ?? state.intentSlots["query"] ?? state.finalText
1002
- slotsDict["query"] = query
1003
- DiagnosticLog.shared.info("Advisor: inferred query='\(query)' for search suggestion")
1004
- }
1005
-
1006
- let slots: [String: JSON] = slotsDict.reduce(into: [:]) { dict, pair in
1007
- dict[pair.key] = .string(pair.value)
1008
- }
1009
- let match = IntentMatch(
1010
- intentName: suggestion.intent,
1011
- slots: slots,
1012
- confidence: 0.9,
1013
- matchedPhrase: "advisor-suggestion"
1014
- )
1015
- do {
1016
- let result = try PhraseMatcher.shared.execute(match)
1017
- state.appendLog("Advisor: executed \(suggestion.intent) → ok")
1018
- DiagnosticLog.shared.info("Advisor suggestion executed: \(suggestion.intent) → \(result)")
1019
-
1020
- // Capture the learning signal: advisor saved us, user engaged
1021
- AdvisorLearningStore.shared.record(
1022
- transcript: state.finalText,
1023
- localIntent: state.intentName,
1024
- localSlots: state.intentSlots,
1025
- localResultCount: state.resultItems.count,
1026
- advisorIntent: suggestion.intent,
1027
- advisorSlots: suggestion.slots,
1028
- advisorLabel: suggestion.label
1029
- )
1030
- } catch {
1031
- state.appendLog("Advisor: \(suggestion.intent) failed — \(error.localizedDescription)")
1032
- }
1033
- }
1034
-
1035
- // MARK: - Log (right pane)
1036
-
1037
- private var logHeader: some View {
1038
- HStack(spacing: 6) {
1039
- Text("LOG")
1040
- .font(Typo.geistMonoBold(9))
1041
- .foregroundColor(Palette.textMuted)
1042
- .tracking(1)
1043
- Spacer()
1044
- if !DiagnosticLog.shared.entries.isEmpty {
1045
- Button(action: {
1046
- let fmt = DateFormatter()
1047
- fmt.dateFormat = "HH:mm:ss.SSS"
1048
- let text = DiagnosticLog.shared.entries.map { entry in
1049
- "\(fmt.string(from: entry.time)) \(entry.icon) \(entry.message)"
1050
- }.joined(separator: "\n")
1051
- NSPasteboard.general.clearContents()
1052
- NSPasteboard.general.setString(text, forType: .string)
1053
- }) {
1054
- Text("copy")
1055
- .font(Typo.geistMono(9))
1056
- .foregroundColor(Palette.textMuted)
1057
- }
1058
- .buttonStyle(.plain)
1059
- }
1060
- Button(action: {
1061
- DiagnosticWindow.shared.toggle()
1062
- }) {
1063
- Image(systemName: "arrow.up.right.square")
1064
- .font(.system(size: 9))
1065
- .foregroundColor(Palette.textMuted)
1066
- }
1067
- .buttonStyle(.plain)
1068
- }
1069
- .padding(.horizontal, 14)
1070
- .padding(.vertical, 8)
1071
- }
1072
-
1073
- @StateObject private var diagnosticLog = DiagnosticLog.shared
1074
-
1075
- /// Rolling window: only show the tail of the log
1076
- private var visibleLogEntries: [DiagnosticLog.Entry] {
1077
- let entries = diagnosticLog.entries
1078
- let tail = 12
1079
- if entries.count <= tail { return entries }
1080
- return Array(entries.suffix(tail))
1081
- }
1082
-
1083
- private var logBody: some View {
1084
- ScrollViewReader { proxy in
1085
- ScrollView {
1086
- LazyVStack(alignment: .leading, spacing: 0) {
1087
- ForEach(visibleLogEntries) { entry in
1088
- HStack(spacing: 3) {
1089
- Text(entry.icon)
1090
- .font(.system(size: 8, design: .monospaced))
1091
- .foregroundColor(logColor(entry.level))
1092
- .frame(width: 8)
1093
- Text(entry.message)
1094
- .font(.system(size: 9, design: .monospaced))
1095
- .foregroundColor(logColor(entry.level))
1096
- .lineLimit(1)
1097
- .truncationMode(.tail)
1098
- }
1099
- .frame(maxWidth: .infinity, alignment: .leading)
1100
- .padding(.vertical, 1)
1101
- .id(entry.id)
1102
- }
1103
- }
1104
- .padding(.horizontal, 10)
1105
- .padding(.vertical, 4)
1106
- }
1107
- .onChange(of: diagnosticLog.entries.count) { _ in
1108
- if let last = visibleLogEntries.last {
1109
- proxy.scrollTo(last.id, anchor: .bottom)
1110
- }
1111
- }
1112
- }
1113
- }
1114
-
1115
- private func logColor(_ level: DiagnosticLog.Entry.Level) -> Color {
1116
- switch level {
1117
- case .info: return Palette.textDim
1118
- case .success: return Palette.running
1119
- case .warning: return Palette.detach
1120
- case .error: return Palette.kill
1121
- }
1122
- }
1123
-
1124
- // MARK: - AI Corner (bottom-right)
1125
-
1126
- @ObservedObject private var assistantSession = PiChatSession.shared
1127
-
1128
- private var aiCorner: some View {
1129
- VStack(spacing: 0) {
1130
- // Header
1131
- HStack(spacing: 6) {
1132
- Image(systemName: "sparkles")
1133
- .font(.system(size: 8, weight: .medium))
1134
- .foregroundColor(Palette.running)
1135
- Text("AI")
1136
- .font(Typo.geistMonoBold(9))
1137
- .foregroundColor(Palette.textMuted)
1138
- .tracking(1)
1139
- Spacer()
1140
-
1141
- Text(assistantSession.currentProvider.name)
1142
- .font(Typo.geistMono(8))
1143
- .foregroundColor(Palette.textMuted.opacity(0.65))
1144
-
1145
- if state.agentResponse != nil {
1146
- Button(action: { copyAIResponse() }) {
1147
- Text("copy")
1148
- .font(Typo.geistMono(9))
1149
- .foregroundColor(Palette.textMuted)
1150
- }
1151
- .buttonStyle(.plain)
1152
- }
1153
-
1154
- if assistantSession.isProviderInferenceReady {
1155
- Circle()
1156
- .fill(Palette.running.opacity(0.6))
1157
- .frame(width: 4, height: 4)
1158
- }
1159
- }
1160
- .padding(.horizontal, 14)
1161
- .padding(.vertical, 8)
1162
- Rectangle().fill(Palette.border).frame(height: 0.5)
1163
-
1164
- // Content
1165
- ScrollView {
1166
- VStack(alignment: .leading, spacing: 8) {
1167
- if let agent = state.agentResponse {
1168
- // Commentary
1169
- if let commentary = agent.commentary {
1170
- Text(commentary)
1171
- .font(Typo.geistMono(10))
1172
- .foregroundColor(Palette.text)
1173
- .fixedSize(horizontal: false, vertical: true)
1174
- }
1175
-
1176
- // Suggestion button
1177
- if let suggestion = agent.suggestion {
1178
- Button(action: { executeSuggestion(suggestion) }) {
1179
- HStack(spacing: 5) {
1180
- Text(suggestion.label)
1181
- .font(Typo.geistMonoBold(9))
1182
- .foregroundColor(Palette.text)
1183
- Image(systemName: "arrow.right")
1184
- .font(.system(size: 8, weight: .medium))
1185
- .foregroundColor(Palette.running)
1186
- }
1187
- .padding(.horizontal, 10)
1188
- .padding(.vertical, 5)
1189
- .background(
1190
- RoundedRectangle(cornerRadius: 4)
1191
- .fill(Palette.running.opacity(0.08))
1192
- .overlay(
1193
- RoundedRectangle(cornerRadius: 4)
1194
- .strokeBorder(Palette.running.opacity(0.2), lineWidth: 0.5)
1195
- )
1196
- )
1197
- }
1198
- .buttonStyle(.plain)
1199
- }
1200
- } else if state.phase == .transcribing {
1201
- HStack(spacing: 6) {
1202
- ProgressView()
1203
- .controlSize(.mini)
1204
- .scaleEffect(0.6)
1205
- Text("thinking...")
1206
- .font(Typo.geistMono(9))
1207
- .foregroundColor(Palette.textMuted)
1208
- }
1209
- } else if state.phase == .result, !state.finalText.isEmpty {
1210
- // No AI response yet — offer to ask
1211
- HStack(spacing: 6) {
1212
- Text("no AI needed")
1213
- .font(Typo.geistMono(9))
1214
- .foregroundColor(Palette.textMuted)
1215
- Button(action: { manuallyAskAdvisor() }) {
1216
- Text("ask AI")
1217
- .font(Typo.geistMonoBold(9))
1218
- .foregroundColor(Palette.text)
1219
- .padding(.horizontal, 8)
1220
- .padding(.vertical, 3)
1221
- .background(
1222
- RoundedRectangle(cornerRadius: 3)
1223
- .fill(Palette.surface)
1224
- .overlay(
1225
- RoundedRectangle(cornerRadius: 3)
1226
- .strokeBorder(Palette.border, lineWidth: 0.5)
1227
- )
1228
- )
1229
- }
1230
- .buttonStyle(.plain)
1231
- }
1232
- } else {
1233
- Text("ready")
1234
- .font(Typo.geistMono(9))
1235
- .foregroundColor(Palette.textMuted.opacity(0.5))
1236
- }
1237
- }
1238
- .padding(.horizontal, 14)
1239
- .padding(.vertical, 8)
1240
- .frame(maxWidth: .infinity, alignment: .leading)
1241
- }
1242
- }
1243
- }
1244
-
1245
- // MARK: - Section Helper
1246
-
1247
- private func commandSection<Content: View>(_ label: String, @ViewBuilder content: () -> Content) -> some View {
1248
- VStack(alignment: .leading, spacing: 8) {
1249
- Text(label)
1250
- .font(Typo.geistMono(9))
1251
- .foregroundColor(Palette.textDim)
1252
- content()
1253
- }
1254
- .padding(12)
1255
- .frame(maxWidth: .infinity, alignment: .leading)
1256
- .background(
1257
- RoundedRectangle(cornerRadius: 6)
1258
- .fill(Palette.surface.opacity(0.4))
1259
- .overlay(
1260
- RoundedRectangle(cornerRadius: 6)
1261
- .strokeBorder(Palette.borderLit, lineWidth: 0.5)
1262
- )
1263
- )
1264
- }
1265
-
1266
- // MARK: - Footer
1267
-
1268
- private var footerBar: some View {
1269
- HStack(spacing: 12) {
1270
- footerHint("ESC", "Dismiss", dimmed: false)
1271
- footerHint("Tab", state.armed ? "Pause" : "Activate", dimmed: false)
1272
- if state.phase == .listening {
1273
- footerHint("⌥", "Release to stop", dimmed: false)
1274
- } else {
1275
- footerHint("⌥", "Hold to speak", dimmed: !state.armed || state.phase == .result)
1276
- }
1277
-
1278
- Spacer()
1279
-
1280
- Text("find · show · open · tile · kill · scan")
1281
- .font(Typo.geistMono(9))
1282
- .foregroundColor(Palette.textDim)
1283
- }
1284
- .padding(.horizontal, 14)
1285
- .padding(.vertical, 6)
1286
- .background(Palette.surface.opacity(0.6))
1287
- }
1288
-
1289
- private func footerHint(_ key: String, _ label: String, dimmed: Bool = false) -> some View {
1290
- HStack(spacing: 4) {
1291
- Text(key)
1292
- .font(Typo.geistMonoBold(9))
1293
- .foregroundColor(dimmed ? Palette.textMuted.opacity(0.3) : Palette.text)
1294
- .padding(.horizontal, 5)
1295
- .padding(.vertical, 2)
1296
- .background(
1297
- RoundedRectangle(cornerRadius: 2)
1298
- .fill(Palette.surface.opacity(dimmed ? 0.3 : 1))
1299
- .overlay(
1300
- RoundedRectangle(cornerRadius: 2)
1301
- .strokeBorder(Palette.border.opacity(dimmed ? 0.3 : 1), lineWidth: 0.5)
1302
- )
1303
- )
1304
- Text(label)
1305
- .font(Typo.caption(9))
1306
- .foregroundColor(dimmed ? Palette.textMuted.opacity(0.3) : Palette.textMuted)
1307
- }
1308
- }
1309
-
1310
- // MARK: - Resizable Column Divider
1311
-
1312
- private func columnDivider(
1313
- width: Binding<CGFloat?>,
1314
- defaultWidth: CGFloat,
1315
- min minW: CGFloat,
1316
- max maxW: CGFloat,
1317
- inverted: Bool = false
1318
- ) -> some View {
1319
- DragDivider(
1320
- width: width,
1321
- defaultWidth: defaultWidth,
1322
- minWidth: minW,
1323
- maxWidth: maxW,
1324
- inverted: inverted
1325
- )
1326
- }
1327
-
1328
- private func focusWindow(wid: UInt32) {
1329
- guard let entry = DesktopModel.shared.windows[wid] else { return }
1330
- DispatchQueue.main.async {
1331
- WindowTiler.focusWindow(wid: wid, pid: entry.pid)
1332
- WindowTiler.highlightWindowById(wid: wid)
1333
- }
1334
- }
1335
-
1336
- private func tileWindow(wid: UInt32, position: String) {
1337
- guard let entry = DesktopModel.shared.windows[wid],
1338
- let placement = PlacementSpec(string: position) else { return }
1339
- DispatchQueue.main.async {
1340
- WindowTiler.focusWindow(wid: wid, pid: entry.pid)
1341
- WindowTiler.tileWindowById(wid: wid, pid: entry.pid, to: placement)
1342
- WindowTiler.highlightWindowById(wid: wid)
1343
- }
1344
- }
1345
-
1346
- }
1347
-
1348
- // MARK: - Result Row (hover actions)
1349
-
1350
- // MARK: - Wave Bar Animation
1351
-
1352
- struct WaveBar: View {
1353
- @State private var animating = false
1354
- private let barCount = 4
1355
- private let barWidth: CGFloat = 2
1356
- private let barSpacing: CGFloat = 1.5
1357
-
1358
- var body: some View {
1359
- HStack(spacing: barSpacing) {
1360
- ForEach(0..<barCount, id: \.self) { i in
1361
- RoundedRectangle(cornerRadius: 1)
1362
- .fill(Palette.text.opacity(0.7))
1363
- .frame(width: barWidth, height: animating ? barHeight(for: i) : 3)
1364
- .animation(
1365
- .easeInOut(duration: duration(for: i))
1366
- .repeatForever(autoreverses: true)
1367
- .delay(Double(i) * 0.1),
1368
- value: animating
1369
- )
1370
- }
1371
- }
1372
- .frame(height: 12)
1373
- .onAppear { animating = true }
1374
- .onDisappear { animating = false }
1375
- }
1376
-
1377
- private func barHeight(for index: Int) -> CGFloat {
1378
- [10, 6, 12, 8][index % 4]
1379
- }
1380
-
1381
- private func duration(for index: Int) -> Double {
1382
- [0.4, 0.35, 0.45, 0.3][index % 4]
1383
- }
1384
- }
1385
-
1386
- // MARK: - Listening Timer
1387
-
1388
- struct ListeningTimer: View {
1389
- let startTime: Date
1390
- @State private var elapsed: TimeInterval = 0
1391
- private let timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
1392
-
1393
- var body: some View {
1394
- Text(formatTime(elapsed))
1395
- .font(Typo.geistMono(10))
1396
- .foregroundColor(Palette.text.opacity(0.7))
1397
- .monospacedDigit()
1398
- .onReceive(timer) { _ in
1399
- elapsed = Date().timeIntervalSince(startTime)
1400
- }
1401
- }
1402
-
1403
- private func formatTime(_ t: TimeInterval) -> String {
1404
- let secs = Int(t)
1405
- let tenths = Int((t - Double(secs)) * 10)
1406
- return String(format: "%d.%d", secs, tenths)
1407
- }
1408
- }
1409
-
1410
- // MARK: - Result Row
1411
-
1412
- struct ResultRow: View {
1413
- let index: Int
1414
- let item: ResultItem
1415
- let onFocus: (UInt32) -> Void
1416
- let onTile: (UInt32, String) -> Void
1417
-
1418
- @State private var isHovered = false
1419
-
1420
- var body: some View {
1421
- HStack(spacing: 8) {
1422
- Text("\(index + 1)")
1423
- .font(Typo.geistMono(9))
1424
- .foregroundColor(Palette.textMuted)
1425
- .frame(width: 14, alignment: .leading)
1426
- Text(item.app)
1427
- .font(Typo.geistMonoBold(10))
1428
- .foregroundColor(Palette.textDim)
1429
- .frame(minWidth: 60, alignment: .leading)
1430
- Text(item.title.isEmpty ? "(untitled)" : item.title)
1431
- .font(Typo.geistMono(10))
1432
- .foregroundColor(Palette.text)
1433
- .lineLimit(1)
1434
- .truncationMode(.tail)
1435
-
1436
- Spacer()
1437
-
1438
- if isHovered {
1439
- HStack(spacing: 4) {
1440
- actionButton("Focus", systemImage: "eye") {
1441
- onFocus(item.wid)
1442
- }
1443
- actionButton("Tile Left", systemImage: "rectangle.lefthalf.filled") {
1444
- onTile(item.wid, "left")
1445
- }
1446
- actionButton("Tile Right", systemImage: "rectangle.righthalf.filled") {
1447
- onTile(item.wid, "right")
1448
- }
1449
- actionButton("Maximize", systemImage: "rectangle.fill") {
1450
- onTile(item.wid, "maximize")
1451
- }
1452
- actionButton("Inspect in Map", systemImage: "map") {
1453
- ScreenMapWindowController.shared.showWindow(wid: item.wid)
1454
- }
1455
- }
1456
- .transition(.opacity.combined(with: .move(edge: .trailing)))
1457
- }
1458
- }
1459
- .padding(.vertical, 5)
1460
- .padding(.horizontal, 8)
1461
- .background(
1462
- RoundedRectangle(cornerRadius: 4)
1463
- .fill(isHovered ? Palette.surface : Color.clear)
1464
- )
1465
- .contentShape(Rectangle())
1466
- .onHover { hovering in
1467
- withAnimation(.easeInOut(duration: 0.12)) {
1468
- isHovered = hovering
1469
- }
1470
- }
1471
- .onTapGesture {
1472
- onFocus(item.wid)
1473
- }
1474
- }
1475
-
1476
- private func actionButton(_ label: String, systemImage: String, action: @escaping () -> Void) -> some View {
1477
- Button(action: action) {
1478
- Image(systemName: systemImage)
1479
- .font(.system(size: 9))
1480
- .foregroundColor(Palette.text)
1481
- .frame(width: 22, height: 18)
1482
- .background(
1483
- RoundedRectangle(cornerRadius: 3)
1484
- .fill(Palette.bg)
1485
- .overlay(
1486
- RoundedRectangle(cornerRadius: 3)
1487
- .strokeBorder(Palette.border, lineWidth: 0.5)
1488
- )
1489
- )
1490
- }
1491
- .buttonStyle(.plain)
1492
- .help(label)
1493
- }
1494
- }
1495
-
1496
- // MARK: - Drag Divider (NSView-backed to prevent window drag)
1497
-
1498
- struct DragDivider: NSViewRepresentable {
1499
- @Binding var width: CGFloat?
1500
- let defaultWidth: CGFloat
1501
- let minWidth: CGFloat
1502
- let maxWidth: CGFloat
1503
- var inverted: Bool = false
1504
-
1505
- func makeNSView(context: Context) -> DragDividerNSView {
1506
- let view = DragDividerNSView()
1507
- view.onDrag = { delta in
1508
- let current = width ?? defaultWidth
1509
- let d = inverted ? -delta : delta
1510
- width = Swift.max(minWidth, Swift.min(maxWidth, current + d))
1511
- }
1512
- return view
1513
- }
1514
-
1515
- func updateNSView(_ nsView: DragDividerNSView, context: Context) {
1516
- nsView.onDrag = { delta in
1517
- let current = width ?? defaultWidth
1518
- let d = inverted ? -delta : delta
1519
- width = Swift.max(minWidth, Swift.min(maxWidth, current + d))
1520
- }
1521
- }
1522
- }
1523
-
1524
- final class DragDividerNSView: NSView {
1525
- var onDrag: ((CGFloat) -> Void)?
1526
- private var lastX: CGFloat = 0
1527
- private var trackingArea: NSTrackingArea?
1528
-
1529
- override var intrinsicContentSize: NSSize {
1530
- NSSize(width: 1, height: NSView.noIntrinsicMetric)
1531
- }
1532
-
1533
- override func updateTrackingAreas() {
1534
- super.updateTrackingAreas()
1535
- if let t = trackingArea { removeTrackingArea(t) }
1536
- let area = NSTrackingArea(
1537
- rect: bounds.insetBy(dx: -3, dy: 0),
1538
- options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
1539
- owner: self
1540
- )
1541
- addTrackingArea(area)
1542
- trackingArea = area
1543
- }
1544
-
1545
- override func draw(_ dirtyRect: NSRect) {
1546
- let lineX = bounds.midX
1547
- NSColor.white.withAlphaComponent(0.22).setFill()
1548
- NSRect(x: lineX - 0.25, y: 0, width: 0.5, height: bounds.height).fill()
1549
- }
1550
-
1551
- override func mouseEntered(with event: NSEvent) {
1552
- NSCursor.resizeLeftRight.push()
1553
- }
1554
-
1555
- override func mouseExited(with event: NSEvent) {
1556
- NSCursor.pop()
1557
- }
1558
-
1559
- override func mouseDown(with event: NSEvent) {
1560
- lastX = event.locationInWindow.x
1561
- }
1562
-
1563
- override func mouseDragged(with event: NSEvent) {
1564
- let x = event.locationInWindow.x
1565
- let delta = x - lastX
1566
- lastX = x
1567
- onDrag?(delta)
1568
- }
1569
-
1570
- override func hitTest(_ point: NSPoint) -> NSView? {
1571
- // Expand hit area to 7pt wide
1572
- let expanded = frame.insetBy(dx: -3, dy: 0)
1573
- return expanded.contains(point) ? self : nil
1574
- }
1575
- }