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