@lattices/cli 0.3.0 → 0.4.1

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 (111) hide show
  1. package/README.md +85 -9
  2. package/app/Info.plist +30 -0
  3. package/app/Lattices.app/Contents/Info.plist +8 -2
  4. package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/app/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
  6. package/app/Lattices.app/Contents/Resources/tap.wav +0 -0
  7. package/app/Lattices.app/Contents/_CodeSignature/CodeResources +139 -0
  8. package/app/Lattices.entitlements +15 -0
  9. package/app/Package.swift +8 -1
  10. package/app/Resources/tap.wav +0 -0
  11. package/app/Sources/AdvisorLearningStore.swift +90 -0
  12. package/app/Sources/AgentSession.swift +377 -0
  13. package/app/Sources/AppDelegate.swift +45 -12
  14. package/app/Sources/AppShellView.swift +81 -8
  15. package/app/Sources/AudioProvider.swift +386 -0
  16. package/app/Sources/CheatSheetHUD.swift +261 -19
  17. package/app/Sources/DaemonProtocol.swift +13 -0
  18. package/app/Sources/DaemonServer.swift +8 -0
  19. package/app/Sources/DesktopModel.swift +189 -6
  20. package/app/Sources/DesktopModelTypes.swift +2 -0
  21. package/app/Sources/DiagnosticLog.swift +104 -2
  22. package/app/Sources/EventBus.swift +1 -0
  23. package/app/Sources/HUDBottomBar.swift +279 -0
  24. package/app/Sources/HUDController.swift +1158 -0
  25. package/app/Sources/HUDLeftBar.swift +849 -0
  26. package/app/Sources/HUDMinimap.swift +179 -0
  27. package/app/Sources/HUDRightBar.swift +774 -0
  28. package/app/Sources/HUDState.swift +367 -0
  29. package/app/Sources/HUDTopBar.swift +243 -0
  30. package/app/Sources/HandsOffSession.swift +802 -0
  31. package/app/Sources/HomeDashboardView.swift +125 -0
  32. package/app/Sources/HotkeyManager.swift +2 -0
  33. package/app/Sources/HotkeyStore.swift +49 -9
  34. package/app/Sources/IntentEngine.swift +962 -0
  35. package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
  36. package/app/Sources/Intents/DistributeIntent.swift +56 -0
  37. package/app/Sources/Intents/FocusIntent.swift +69 -0
  38. package/app/Sources/Intents/HelpIntent.swift +41 -0
  39. package/app/Sources/Intents/KillIntent.swift +47 -0
  40. package/app/Sources/Intents/LatticeIntent.swift +78 -0
  41. package/app/Sources/Intents/LaunchIntent.swift +67 -0
  42. package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
  43. package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
  44. package/app/Sources/Intents/ScanIntent.swift +52 -0
  45. package/app/Sources/Intents/SearchIntent.swift +190 -0
  46. package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
  47. package/app/Sources/Intents/TileIntent.swift +61 -0
  48. package/app/Sources/LatticesApi.swift +1275 -30
  49. package/app/Sources/LauncherHUD.swift +348 -0
  50. package/app/Sources/MainView.swift +147 -44
  51. package/app/Sources/MouseFinder.swift +222 -0
  52. package/app/Sources/OcrModel.swift +34 -1
  53. package/app/Sources/OmniSearchState.swift +99 -102
  54. package/app/Sources/OnboardingView.swift +457 -0
  55. package/app/Sources/PermissionChecker.swift +2 -12
  56. package/app/Sources/PiChatDock.swift +454 -0
  57. package/app/Sources/PiChatSession.swift +815 -0
  58. package/app/Sources/PiWorkspaceView.swift +364 -0
  59. package/app/Sources/PlacementSpec.swift +195 -0
  60. package/app/Sources/Preferences.swift +59 -0
  61. package/app/Sources/ProjectScanner.swift +58 -45
  62. package/app/Sources/ScreenMapState.swift +701 -55
  63. package/app/Sources/ScreenMapView.swift +843 -103
  64. package/app/Sources/ScreenMapWindowController.swift +22 -0
  65. package/app/Sources/SessionLayerStore.swift +285 -0
  66. package/app/Sources/SessionManager.swift +4 -1
  67. package/app/Sources/SettingsView.swift +186 -3
  68. package/app/Sources/Theme.swift +9 -8
  69. package/app/Sources/TmuxModel.swift +7 -0
  70. package/app/Sources/TmuxQuery.swift +27 -3
  71. package/app/Sources/VoiceChatView.swift +192 -0
  72. package/app/Sources/VoiceCommandWindow.swift +1594 -0
  73. package/app/Sources/VoiceIntentResolver.swift +671 -0
  74. package/app/Sources/VoxClient.swift +454 -0
  75. package/app/Sources/WindowTiler.swift +348 -87
  76. package/app/Sources/WorkspaceManager.swift +127 -18
  77. package/app/Tests/StageDragTests.swift +333 -0
  78. package/app/Tests/StageJoinTests.swift +313 -0
  79. package/app/Tests/StageManagerTests.swift +280 -0
  80. package/app/Tests/StageTileTests.swift +353 -0
  81. package/assets/AppIcon.icns +0 -0
  82. package/bin/client.ts +16 -0
  83. package/bin/{daemon-client.js → daemon-client.ts} +49 -30
  84. package/bin/handsoff-infer.ts +280 -0
  85. package/bin/handsoff-worker.ts +740 -0
  86. package/bin/lattices-app.ts +338 -0
  87. package/bin/lattices-dev +208 -0
  88. package/bin/{lattices.js → lattices.ts} +777 -140
  89. package/bin/project-twin.ts +645 -0
  90. package/docs/agent-execution-plan.md +562 -0
  91. package/docs/agent-layer-guide.md +207 -0
  92. package/docs/agents.md +142 -0
  93. package/docs/api.md +153 -34
  94. package/docs/app.md +29 -1
  95. package/docs/config.md +5 -1
  96. package/docs/handsoff-test-scenarios.md +84 -0
  97. package/docs/layers.md +20 -20
  98. package/docs/ocr.md +14 -5
  99. package/docs/overview.md +5 -1
  100. package/docs/presentation-execution-review.md +491 -0
  101. package/docs/prompts/hands-off-system.md +374 -0
  102. package/docs/prompts/hands-off-turn.md +30 -0
  103. package/docs/prompts/voice-advisor.md +31 -0
  104. package/docs/prompts/voice-fallback.md +23 -0
  105. package/docs/tiling-reference.md +167 -0
  106. package/docs/twins.md +138 -0
  107. package/docs/voice-command-protocol.md +278 -0
  108. package/docs/voice.md +219 -0
  109. package/package.json +29 -11
  110. package/bin/client.js +0 -4
  111. package/bin/lattices-app.js +0 -221
@@ -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
+