@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.
Files changed (95) hide show
  1. package/README.md +85 -9
  2. package/app/Package.swift +8 -1
  3. package/app/Sources/AdvisorLearningStore.swift +90 -0
  4. package/app/Sources/AgentSession.swift +377 -0
  5. package/app/Sources/AppDelegate.swift +44 -12
  6. package/app/Sources/AppShellView.swift +81 -8
  7. package/app/Sources/AudioProvider.swift +386 -0
  8. package/app/Sources/CheatSheetHUD.swift +261 -19
  9. package/app/Sources/DaemonProtocol.swift +13 -0
  10. package/app/Sources/DaemonServer.swift +8 -0
  11. package/app/Sources/DesktopModel.swift +164 -5
  12. package/app/Sources/DesktopModelTypes.swift +2 -0
  13. package/app/Sources/DiagnosticLog.swift +104 -2
  14. package/app/Sources/EventBus.swift +1 -0
  15. package/app/Sources/HUDBottomBar.swift +279 -0
  16. package/app/Sources/HUDController.swift +1158 -0
  17. package/app/Sources/HUDLeftBar.swift +849 -0
  18. package/app/Sources/HUDMinimap.swift +179 -0
  19. package/app/Sources/HUDRightBar.swift +774 -0
  20. package/app/Sources/HUDState.swift +367 -0
  21. package/app/Sources/HUDTopBar.swift +243 -0
  22. package/app/Sources/HandsOffSession.swift +733 -0
  23. package/app/Sources/HomeDashboardView.swift +125 -0
  24. package/app/Sources/HotkeyManager.swift +2 -0
  25. package/app/Sources/HotkeyStore.swift +45 -9
  26. package/app/Sources/IntentEngine.swift +925 -0
  27. package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
  28. package/app/Sources/Intents/DistributeIntent.swift +56 -0
  29. package/app/Sources/Intents/FocusIntent.swift +69 -0
  30. package/app/Sources/Intents/HelpIntent.swift +41 -0
  31. package/app/Sources/Intents/KillIntent.swift +47 -0
  32. package/app/Sources/Intents/LatticeIntent.swift +78 -0
  33. package/app/Sources/Intents/LaunchIntent.swift +67 -0
  34. package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
  35. package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
  36. package/app/Sources/Intents/ScanIntent.swift +52 -0
  37. package/app/Sources/Intents/SearchIntent.swift +190 -0
  38. package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
  39. package/app/Sources/Intents/TileIntent.swift +61 -0
  40. package/app/Sources/LatticesApi.swift +1235 -30
  41. package/app/Sources/LauncherHUD.swift +348 -0
  42. package/app/Sources/MainView.swift +147 -44
  43. package/app/Sources/OcrModel.swift +34 -1
  44. package/app/Sources/OmniSearchState.swift +99 -102
  45. package/app/Sources/OnboardingView.swift +457 -0
  46. package/app/Sources/PermissionChecker.swift +2 -12
  47. package/app/Sources/PiChatDock.swift +454 -0
  48. package/app/Sources/PiChatSession.swift +815 -0
  49. package/app/Sources/PiWorkspaceView.swift +364 -0
  50. package/app/Sources/PlacementSpec.swift +195 -0
  51. package/app/Sources/Preferences.swift +59 -0
  52. package/app/Sources/ProjectScanner.swift +1 -1
  53. package/app/Sources/ScreenMapState.swift +701 -55
  54. package/app/Sources/ScreenMapView.swift +843 -103
  55. package/app/Sources/ScreenMapWindowController.swift +22 -0
  56. package/app/Sources/SessionLayerStore.swift +285 -0
  57. package/app/Sources/SessionManager.swift +4 -1
  58. package/app/Sources/SettingsView.swift +186 -3
  59. package/app/Sources/Theme.swift +9 -8
  60. package/app/Sources/TmuxModel.swift +7 -0
  61. package/app/Sources/TmuxQuery.swift +27 -3
  62. package/app/Sources/VoiceChatView.swift +192 -0
  63. package/app/Sources/VoiceCommandWindow.swift +1594 -0
  64. package/app/Sources/VoiceIntentResolver.swift +671 -0
  65. package/app/Sources/VoxClient.swift +454 -0
  66. package/app/Sources/WindowTiler.swift +348 -87
  67. package/app/Sources/WorkspaceManager.swift +127 -18
  68. package/bin/client.ts +16 -0
  69. package/bin/{daemon-client.js → daemon-client.ts} +49 -30
  70. package/bin/handsoff-infer.ts +280 -0
  71. package/bin/handsoff-worker.ts +731 -0
  72. package/bin/{lattices-app.js → lattices-app.ts} +67 -32
  73. package/bin/lattices-dev +160 -0
  74. package/bin/{lattices.js → lattices.ts} +600 -137
  75. package/bin/project-twin.ts +645 -0
  76. package/docs/agent-execution-plan.md +562 -0
  77. package/docs/agents.md +142 -0
  78. package/docs/api.md +153 -34
  79. package/docs/app.md +29 -1
  80. package/docs/config.md +5 -1
  81. package/docs/handsoff-test-scenarios.md +84 -0
  82. package/docs/layers.md +20 -20
  83. package/docs/ocr.md +14 -5
  84. package/docs/overview.md +5 -1
  85. package/docs/presentation-execution-review.md +491 -0
  86. package/docs/prompts/hands-off-system.md +374 -0
  87. package/docs/prompts/hands-off-turn.md +30 -0
  88. package/docs/prompts/voice-advisor.md +31 -0
  89. package/docs/prompts/voice-fallback.md +23 -0
  90. package/docs/tiling-reference.md +167 -0
  91. package/docs/twins.md +138 -0
  92. package/docs/voice-command-protocol.md +278 -0
  93. package/docs/voice.md +219 -0
  94. package/package.json +21 -10
  95. package/bin/client.js +0 -4
@@ -7,6 +7,9 @@ struct ScreenMapView: View {
7
7
  @ObservedObject var controller: ScreenMapController
8
8
  var onNavigate: ((AppPage) -> Void)? = nil
9
9
  @ObservedObject private var daemon = DaemonServer.shared
10
+ @ObservedObject private var handsOff = HandsOffSession.shared
11
+ @ObservedObject private var diagnosticLog = DiagnosticLog.shared
12
+ @StateObject private var piChat = PiChatSession.shared
10
13
  @State private var eventMonitor: Any?
11
14
  @State private var mouseDownMonitor: Any?
12
15
  @State private var mouseDragMonitor: Any?
@@ -28,7 +31,7 @@ struct ScreenMapView: View {
28
31
  @State private var mouseMovedMonitor: Any?
29
32
  @State private var sidebarWidth: CGFloat = 180
30
33
  @State private var isDraggingSidebar: Bool = false
31
- @State private var inspectorWidth: CGFloat = 200
34
+ @State private var inspectorWidth: CGFloat = 280
32
35
  @State private var isDraggingInspector: Bool = false
33
36
  @FocusState private var isSearchFieldFocused: Bool
34
37
  @State private var searchHoveredDisplayIndex: Int? = nil
@@ -70,13 +73,13 @@ struct ScreenMapView: View {
70
73
  if controller.isSearchActive, let editor = controller.editor {
71
74
  floatingSearchOverlay(editor: editor)
72
75
  }
73
- // Zoom controls — bottom-right corner of canvas
76
+ // Viewport controls — bottom-right corner of canvas
74
77
  if let editor = controller.editor {
75
78
  VStack {
76
79
  Spacer()
77
80
  HStack {
78
81
  Spacer()
79
- canvasZoomControls(editor: editor)
82
+ canvasViewportDock(editor: editor)
80
83
  .padding(10)
81
84
  }
82
85
  }
@@ -84,10 +87,14 @@ struct ScreenMapView: View {
84
87
  }
85
88
  if let editor = controller.editor {
86
89
  panelResizeHandle(isActive: $isDraggingInspector, width: $inspectorWidth,
87
- range: 160...360, edge: .leading)
90
+ range: 220...480, edge: .leading)
88
91
  inspectorPane(editor: editor)
89
92
  }
90
93
  }
94
+ if piChat.isVisible {
95
+ PiChatDock(session: piChat)
96
+ .transition(.move(edge: .bottom).combined(with: .opacity))
97
+ }
91
98
  footerBar
92
99
  }
93
100
  .background(Palette.bg)
@@ -111,6 +118,7 @@ struct ScreenMapView: View {
111
118
  HStack(spacing: 4) {
112
119
  Button {
113
120
  editor.cyclePreviousDisplay()
121
+ controller.focusViewportPreset(editor.activeViewportPreset ?? .main, flashView: false)
114
122
  controller.flash(editor.focusedDisplay?.label ?? "All displays")
115
123
  controller.objectWillChange.send()
116
124
  } label: {
@@ -124,6 +132,7 @@ struct ScreenMapView: View {
124
132
 
125
133
  Button {
126
134
  editor.focusDisplay(nil)
135
+ controller.focusViewportPreset(editor.activeViewportPreset ?? .main, flashView: false)
127
136
  controller.objectWillChange.send()
128
137
  } label: {
129
138
  displayToolbarPill(name: "All", isActive: editor.focusedDisplayIndex == nil)
@@ -134,6 +143,7 @@ struct ScreenMapView: View {
134
143
  let isActive = editor.focusedDisplayIndex == disp.index
135
144
  Button {
136
145
  editor.focusDisplay(disp.index)
146
+ controller.focusViewportPreset(editor.activeViewportPreset ?? .main, flashView: false)
137
147
  controller.objectWillChange.send()
138
148
  } label: {
139
149
  displayToolbarPill(
@@ -147,6 +157,7 @@ struct ScreenMapView: View {
147
157
 
148
158
  Button {
149
159
  editor.cycleNextDisplay()
160
+ controller.focusViewportPreset(editor.activeViewportPreset ?? .main, flashView: false)
150
161
  controller.flash(editor.focusedDisplay?.label ?? "All displays")
151
162
  controller.objectWillChange.send()
152
163
  } label: {
@@ -256,6 +267,8 @@ struct ScreenMapView: View {
256
267
  .font(Typo.monoBold(9))
257
268
  .foregroundColor(Palette.textMuted)
258
269
 
270
+ inspectorCanvasContextCard(editor: editor, selectedCount: selectedWindows.count)
271
+
259
272
  if selectedWindows.isEmpty {
260
273
  VStack(spacing: 8) {
261
274
  Text("No Selection")
@@ -268,7 +281,7 @@ struct ScreenMapView: View {
268
281
  .lineLimit(3)
269
282
  }
270
283
  .frame(maxWidth: .infinity)
271
- .padding(.top, 40)
284
+ .padding(.top, 20)
272
285
  }
273
286
 
274
287
  ForEach(selectedWindows) { win in
@@ -284,27 +297,87 @@ struct ScreenMapView: View {
284
297
  .frame(width: inspectorWidth)
285
298
  }
286
299
 
300
+ private func inspectorCanvasContextCard(editor: ScreenMapEditorState, selectedCount: Int) -> some View {
301
+ let viewport = editor.viewportWorldRect
302
+ let world = editor.canvasWorldBounds
303
+ let scope = editor.focusedDisplay.map { "\(editor.spatialNumber(for: $0.index)). \($0.label)" } ?? "All Displays"
304
+ let layers = editor.selectedLayers.isEmpty
305
+ ? "All Layers"
306
+ : editor.selectedLayers.sorted().map { editor.layerDisplayName(for: $0) }.joined(separator: ", ")
307
+
308
+ return VStack(alignment: .leading, spacing: 4) {
309
+ inspectorRow(label: "Scope", value: scope)
310
+ inspectorRow(label: "Layers", value: layers)
311
+ inspectorRow(label: "View", value: "\(Int(viewport.midX)), \(Int(viewport.midY)) · \(Int(viewport.width))×\(Int(viewport.height))")
312
+ inspectorRow(label: "World", value: "\(Int(world.width))×\(Int(world.height))")
313
+ inspectorRow(label: "Set", value: controller.activeWindowSet?.name ?? "None")
314
+ inspectorRow(label: "Select", value: "\(selectedCount) window\(selectedCount == 1 ? "" : "s")")
315
+ }
316
+ .padding(8)
317
+ .background(
318
+ RoundedRectangle(cornerRadius: 6)
319
+ .fill(Color.black.opacity(0.25))
320
+ .overlay(
321
+ RoundedRectangle(cornerRadius: 6)
322
+ .strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
323
+ )
324
+ )
325
+ }
326
+
287
327
  // MARK: - Inspector Window Card
288
328
 
289
329
  private func inspectorWindowCard(win: ScreenMapWindowEntry, editor: ScreenMapEditorState) -> some View {
290
- VStack(alignment: .leading, spacing: 6) {
330
+ let desktopEntry = DesktopModel.shared.windows[UInt32(win.id)]
331
+ let ocrText = OcrModel.shared.results[UInt32(win.id)]?.fullText
332
+ let layerTag = DesktopModel.shared.windowLayerTags[UInt32(win.id)]
333
+
334
+ return VStack(alignment: .leading, spacing: 8) {
335
+ // Header: app + visibility
291
336
  HStack(spacing: 5) {
292
337
  Circle()
293
338
  .fill(Self.layerColor(for: win.layer))
294
339
  .frame(width: 6, height: 6)
295
340
  Text(win.app)
296
- .font(Typo.monoBold(10))
341
+ .font(Typo.monoBold(11))
297
342
  .foregroundColor(Palette.text)
298
343
  .lineLimit(1)
344
+ Spacer()
345
+ if desktopEntry?.isOnScreen == true {
346
+ Text("visible")
347
+ .font(Typo.monoBold(7))
348
+ .foregroundColor(Palette.running)
349
+ .padding(.horizontal, 4)
350
+ .padding(.vertical, 1)
351
+ .background(
352
+ RoundedRectangle(cornerRadius: 2)
353
+ .fill(Palette.running.opacity(0.1))
354
+ )
355
+ }
299
356
  }
357
+
358
+ // Title
300
359
  if !win.title.isEmpty {
301
360
  Text(win.title)
302
- .font(Typo.mono(9))
361
+ .font(Typo.mono(10))
303
362
  .foregroundColor(Palette.textDim)
304
363
  .lineLimit(3)
364
+ .textSelection(.enabled)
305
365
  }
366
+
367
+ // Identity
368
+ HStack(spacing: 10) {
369
+ inspectorLabel(label: "wid", value: "\(win.id)")
370
+ if let entry = desktopEntry {
371
+ inspectorLabel(label: "pid", value: "\(entry.pid)")
372
+ }
373
+ }
374
+
375
+ // Layout info
306
376
  VStack(alignment: .leading, spacing: 3) {
307
377
  inspectorRow(label: "Layer", value: editor.layerDisplayName(for: win.layer))
378
+ if let tag = layerTag {
379
+ inspectorRow(label: "Tag", value: tag)
380
+ }
308
381
  inspectorRow(label: "Display", value: {
309
382
  if let disp = editor.displays.first(where: { $0.index == win.displayIndex }) {
310
383
  return "\(editor.spatialNumber(for: disp.index)). \(disp.label)"
@@ -312,15 +385,32 @@ struct ScreenMapView: View {
312
385
  return "Display \(win.displayIndex)"
313
386
  }())
314
387
  inspectorRow(label: "Size",
315
- value: "\(Int(win.editedFrame.width))×\(Int(win.editedFrame.height))")
388
+ value: "\(Int(win.virtualFrame.width))×\(Int(win.virtualFrame.height))")
316
389
  inspectorRow(label: "Position",
317
- value: "(\(Int(win.editedFrame.origin.x)), \(Int(win.editedFrame.origin.y)))")
390
+ value: "(\(Int(win.virtualFrame.origin.x)), \(Int(win.virtualFrame.origin.y)))")
318
391
  inspectorRow(label: "Z-Index", value: "\(win.zIndex)")
319
392
  if win.hasEdits {
320
393
  inspectorRow(label: "Original",
321
394
  value: "\(Int(win.originalFrame.width))×\(Int(win.originalFrame.height))")
322
395
  }
396
+ if let entry = desktopEntry, !entry.spaceIds.isEmpty {
397
+ inspectorRow(label: "Spaces", value: entry.spaceIds.map(String.init).joined(separator: ", "))
398
+ }
399
+ }
400
+
401
+ // Session
402
+ if let session = desktopEntry?.latticesSession {
403
+ HStack(spacing: 4) {
404
+ Text("session")
405
+ .font(Typo.monoBold(8))
406
+ .foregroundColor(Palette.textMuted)
407
+ Text(session)
408
+ .font(Typo.mono(9))
409
+ .foregroundColor(Palette.running)
410
+ .lineLimit(1)
411
+ }
323
412
  }
413
+
324
414
  if win.hasEdits {
325
415
  HStack(spacing: 4) {
326
416
  Circle()
@@ -331,8 +421,32 @@ struct ScreenMapView: View {
331
421
  .foregroundColor(Color.orange)
332
422
  }
333
423
  }
424
+
425
+ // OCR snippet
426
+ if let ocr = ocrText, !ocr.isEmpty {
427
+ VStack(alignment: .leading, spacing: 2) {
428
+ Text("SCREEN TEXT")
429
+ .font(Typo.monoBold(8))
430
+ .foregroundColor(Palette.textMuted)
431
+ Text(String(ocr.prefix(400)))
432
+ .font(Typo.mono(8))
433
+ .foregroundColor(Palette.textMuted)
434
+ .lineLimit(8)
435
+ .textSelection(.enabled)
436
+ }
437
+ .padding(6)
438
+ .background(
439
+ RoundedRectangle(cornerRadius: 4)
440
+ .fill(Palette.bg.opacity(0.5))
441
+ )
442
+ }
443
+
444
+ // Window actions — contextual to this card
445
+ if let entry = desktopEntry {
446
+ windowCardActions(wid: UInt32(win.id), entry: entry)
447
+ }
334
448
  }
335
- .padding(8)
449
+ .padding(10)
336
450
  .background(
337
451
  RoundedRectangle(cornerRadius: 6)
338
452
  .fill(Palette.surface)
@@ -343,6 +457,101 @@ struct ScreenMapView: View {
343
457
  )
344
458
  }
345
459
 
460
+ private func windowCardActions(wid: UInt32, entry: WindowEntry) -> some View {
461
+ let actions: [(key: String, label: String, action: () -> Void)] = [
462
+ ("f", "focus", { [controller] in
463
+ controller.focusWindowOnScreen(wid)
464
+ }),
465
+ ("h", "highlight", {
466
+ WindowTiler.highlightWindowById(wid: wid)
467
+ }),
468
+ ("←", "tile left", {
469
+ WindowTiler.focusWindow(wid: wid, pid: entry.pid)
470
+ WindowTiler.tileWindowById(wid: wid, pid: entry.pid, to: .left)
471
+ }),
472
+ ("→", "tile right", {
473
+ WindowTiler.focusWindow(wid: wid, pid: entry.pid)
474
+ WindowTiler.tileWindowById(wid: wid, pid: entry.pid, to: .right)
475
+ }),
476
+ ("m", "maximize", {
477
+ WindowTiler.focusWindow(wid: wid, pid: entry.pid)
478
+ WindowTiler.tileWindowById(wid: wid, pid: entry.pid, to: .maximize)
479
+ }),
480
+ ("r", "rescan", {
481
+ OcrModel.shared.scanSingle(wid: wid)
482
+ }),
483
+ ("c", "copy info", { [controller] in
484
+ let info = [
485
+ "wid: \(wid)",
486
+ "app: \(entry.app)",
487
+ "title: \(entry.title)",
488
+ "pid: \(entry.pid)",
489
+ "frame: \(Int(entry.frame.x)),\(Int(entry.frame.y)) \(Int(entry.frame.w))×\(Int(entry.frame.h))",
490
+ entry.latticesSession.map { "session: \($0)" },
491
+ DesktopModel.shared.windowLayerTags[wid].map { "layer: \($0)" },
492
+ ].compactMap { $0 }.joined(separator: "\n")
493
+ NSPasteboard.general.clearContents()
494
+ NSPasteboard.general.setString(info, forType: .string)
495
+ controller.flash("Copied")
496
+ }),
497
+ ]
498
+
499
+ let columns = [GridItem(.flexible()), GridItem(.flexible())]
500
+
501
+ return VStack(spacing: 0) {
502
+ Rectangle().fill(Palette.border).frame(height: 0.5)
503
+ .padding(.horizontal, -10)
504
+ .padding(.top, 4)
505
+
506
+ LazyVGrid(columns: columns, spacing: 3) {
507
+ ForEach(Array(actions.enumerated()), id: \.offset) { _, item in
508
+ let isHov = hoveredShelfAction == "w_\(wid)_\(item.label)"
509
+ Button(action: item.action) {
510
+ HStack(spacing: 4) {
511
+ Text(item.key)
512
+ .font(.system(size: 8))
513
+ .foregroundColor(Self.shelfGreen)
514
+ .frame(width: 14)
515
+ Text(item.label)
516
+ .font(Typo.mono(8))
517
+ .foregroundColor(isHov ? Palette.text : Palette.textDim)
518
+ .lineLimit(1)
519
+ Spacer()
520
+ }
521
+ .padding(.horizontal, 6)
522
+ .padding(.vertical, 4)
523
+ .background(
524
+ RoundedRectangle(cornerRadius: 4)
525
+ .fill(isHov ? Palette.surfaceHov : Palette.surface)
526
+ .overlay(
527
+ RoundedRectangle(cornerRadius: 4)
528
+ .strokeBorder(isHov ? Palette.borderLit : Palette.border, lineWidth: 0.5)
529
+ )
530
+ )
531
+ .contentShape(Rectangle())
532
+ }
533
+ .buttonStyle(.plain)
534
+ .onHover { h in
535
+ let key = "w_\(wid)_\(item.label)"
536
+ hoveredShelfAction = h ? key : (hoveredShelfAction == key ? nil : hoveredShelfAction)
537
+ }
538
+ }
539
+ }
540
+ .padding(.top, 6)
541
+ }
542
+ }
543
+
544
+ private func inspectorLabel(label: String, value: String) -> some View {
545
+ HStack(spacing: 3) {
546
+ Text(label)
547
+ .font(Typo.monoBold(8))
548
+ .foregroundColor(Palette.textMuted)
549
+ Text(value)
550
+ .font(Typo.mono(9))
551
+ .foregroundColor(Palette.textDim)
552
+ }
553
+ }
554
+
346
555
  // MARK: - Floating Search Overlay
347
556
 
348
557
  private func floatingSearchOverlay(editor: ScreenMapEditorState) -> some View {
@@ -663,7 +872,7 @@ struct ScreenMapView: View {
663
872
  }
664
873
  }
665
874
 
666
- // MARK: - Inspector Action Tray
875
+ // MARK: - Inspector Bottom Rail
667
876
 
668
877
  private func inspectorActionTray(editor: ScreenMapEditorState) -> some View {
669
878
  let actions: [(key: String, label: String, action: () -> Void)] = [
@@ -672,6 +881,8 @@ struct ScreenMapView: View {
672
881
  ("t", "tile", { [controller] in controller.tileLayer() }),
673
882
  ("d", "distrib", { [controller] in controller.distributeVisible() }),
674
883
  ("g", "grow", { [controller] in controller.fitAvailableSpace() }),
884
+ ("u", "set", { [controller] in controller.createWindowSetFromSelection() }),
885
+ ("m", "project", { [controller] in controller.materializeViewport() }),
675
886
  ("c", "merge", { [controller] in controller.consolidateLayers() }),
676
887
  ("f", "flatten", { [controller] in controller.flattenLayers() }),
677
888
  ("v", "preview", { [controller] in controller.previewLayer() }),
@@ -751,8 +962,7 @@ struct ScreenMapView: View {
751
962
  }
752
963
  if isZoomed {
753
964
  Button {
754
- editor.resetZoomPan()
755
- controller.flash("Fit all")
965
+ controller.focusViewportPreset(.overview)
756
966
  } label: {
757
967
  HStack(spacing: 4) {
758
968
  Text("r")
@@ -850,10 +1060,171 @@ struct ScreenMapView: View {
850
1060
  }
851
1061
  .padding(.horizontal, 6)
852
1062
  .padding(.bottom, 4)
1063
+
1064
+ Rectangle().fill(Palette.border).frame(height: 0.5)
1065
+ inspectorVoiceTray
1066
+
1067
+ Rectangle().fill(Palette.border).frame(height: 0.5)
1068
+ inspectorLogTray
853
1069
  }
854
1070
  .background(Color(red: 0.06, green: 0.06, blue: 0.07))
855
1071
  }
856
1072
 
1073
+ private var inspectorVoiceStateLabel: String {
1074
+ switch handsOff.state {
1075
+ case .idle: return handsOff.lastTranscript == nil ? "ready" : "idle"
1076
+ case .connecting: return "connecting"
1077
+ case .listening: return "listening"
1078
+ case .thinking: return "thinking"
1079
+ }
1080
+ }
1081
+
1082
+ private var inspectorVoiceColor: Color {
1083
+ switch handsOff.state {
1084
+ case .idle: return Palette.textMuted.opacity(0.55)
1085
+ case .connecting: return Palette.detach
1086
+ case .listening: return Palette.running
1087
+ case .thinking: return Palette.detach
1088
+ }
1089
+ }
1090
+
1091
+ private var visibleDiagnosticEntries: [DiagnosticLog.Entry] {
1092
+ let entries = diagnosticLog.entries
1093
+ let tail = 8
1094
+ if entries.count <= tail { return entries }
1095
+ return Array(entries.suffix(tail))
1096
+ }
1097
+
1098
+ private var inspectorVoiceTray: some View {
1099
+ VStack(alignment: .leading, spacing: 6) {
1100
+ HStack(spacing: 6) {
1101
+ Text("VOICE")
1102
+ .font(Typo.monoBold(8))
1103
+ .foregroundColor(Palette.textMuted)
1104
+ Spacer()
1105
+ Circle()
1106
+ .fill(inspectorVoiceColor)
1107
+ .frame(width: 6, height: 6)
1108
+ Text(inspectorVoiceStateLabel)
1109
+ .font(Typo.mono(8))
1110
+ .foregroundColor(inspectorVoiceColor)
1111
+ Text("V")
1112
+ .font(Typo.monoBold(7))
1113
+ .foregroundColor(Palette.textDim)
1114
+ .padding(.horizontal, 4)
1115
+ .padding(.vertical, 2)
1116
+ .background(
1117
+ RoundedRectangle(cornerRadius: 3)
1118
+ .fill(Palette.surface)
1119
+ .overlay(
1120
+ RoundedRectangle(cornerRadius: 3)
1121
+ .strokeBorder(Palette.border, lineWidth: 0.5)
1122
+ )
1123
+ )
1124
+ }
1125
+
1126
+ if let transcript = handsOff.lastTranscript, !transcript.isEmpty {
1127
+ VStack(alignment: .leading, spacing: 2) {
1128
+ Text("heard")
1129
+ .font(Typo.mono(7))
1130
+ .foregroundColor(Palette.textMuted)
1131
+ Text(transcript)
1132
+ .font(Typo.mono(8))
1133
+ .foregroundColor(Palette.text)
1134
+ .lineLimit(2)
1135
+ }
1136
+ } else {
1137
+ Text("Voice activity will show up here. Press V to talk.")
1138
+ .font(Typo.mono(8))
1139
+ .foregroundColor(Palette.textMuted)
1140
+ .lineLimit(2)
1141
+ }
1142
+
1143
+ if let response = handsOff.lastResponse, !response.isEmpty {
1144
+ VStack(alignment: .leading, spacing: 2) {
1145
+ Text("response")
1146
+ .font(Typo.mono(7))
1147
+ .foregroundColor(Palette.textMuted)
1148
+ Text(response)
1149
+ .font(Typo.mono(8))
1150
+ .foregroundColor(Palette.textDim)
1151
+ .lineLimit(2)
1152
+ }
1153
+ }
1154
+ }
1155
+ .padding(.horizontal, 8)
1156
+ .padding(.vertical, 8)
1157
+ }
1158
+
1159
+ private var inspectorLogTray: some View {
1160
+ VStack(alignment: .leading, spacing: 6) {
1161
+ HStack(spacing: 6) {
1162
+ Text("LOGS")
1163
+ .font(Typo.monoBold(8))
1164
+ .foregroundColor(Palette.textMuted)
1165
+ Spacer()
1166
+ if !visibleDiagnosticEntries.isEmpty {
1167
+ Button("copy") {
1168
+ let text = visibleDiagnosticEntries.map { entry in
1169
+ "\(Self.inspectorLogTimeFormatter.string(from: entry.time)) \(entry.icon) \(entry.message)"
1170
+ }.joined(separator: "\n")
1171
+ NSPasteboard.general.clearContents()
1172
+ NSPasteboard.general.setString(text, forType: .string)
1173
+ controller.flash("Copied logs")
1174
+ }
1175
+ .font(Typo.mono(7))
1176
+ .foregroundColor(Palette.textMuted)
1177
+ .buttonStyle(.plain)
1178
+ }
1179
+ Button("open") {
1180
+ DiagnosticWindow.shared.toggle()
1181
+ }
1182
+ .font(Typo.mono(7))
1183
+ .foregroundColor(Palette.textMuted)
1184
+ .buttonStyle(.plain)
1185
+ }
1186
+
1187
+ if visibleDiagnosticEntries.isEmpty {
1188
+ Text("Waiting for diagnostic activity.")
1189
+ .font(Typo.mono(8))
1190
+ .foregroundColor(Palette.textMuted)
1191
+ } else {
1192
+ VStack(alignment: .leading, spacing: 5) {
1193
+ ForEach(visibleDiagnosticEntries) { entry in
1194
+ HStack(alignment: .top, spacing: 6) {
1195
+ Text(Self.inspectorLogTimeFormatter.string(from: entry.time))
1196
+ .font(Typo.mono(7))
1197
+ .foregroundColor(Palette.textMuted)
1198
+ .frame(width: 52, alignment: .leading)
1199
+ Text(entry.icon)
1200
+ .font(Typo.monoBold(7))
1201
+ .foregroundColor(inspectorLogColor(entry.level))
1202
+ .frame(width: 8, alignment: .leading)
1203
+ Text(entry.message)
1204
+ .font(Typo.mono(8))
1205
+ .foregroundColor(inspectorLogColor(entry.level))
1206
+ .lineLimit(2)
1207
+ .frame(maxWidth: .infinity, alignment: .leading)
1208
+ }
1209
+ }
1210
+ }
1211
+ .frame(maxWidth: .infinity, alignment: .leading)
1212
+ }
1213
+ }
1214
+ .padding(.horizontal, 8)
1215
+ .padding(.vertical, 8)
1216
+ .frame(maxWidth: .infinity, alignment: .leading)
1217
+ }
1218
+
1219
+ private func inspectorLogColor(_ level: DiagnosticLog.Entry.Level) -> Color {
1220
+ switch level {
1221
+ case .info: return Palette.textDim
1222
+ case .success: return Palette.running
1223
+ case .warning: return Palette.detach
1224
+ case .error: return Palette.kill
1225
+ }
1226
+ }
1227
+
857
1228
  private func inspectorRow(label: String, value: String) -> some View {
858
1229
  HStack(alignment: .top, spacing: 0) {
859
1230
  Text(label)
@@ -891,6 +1262,13 @@ struct ScreenMapView: View {
891
1262
  .font(Typo.mono(9))
892
1263
  .foregroundColor(Palette.textDim)
893
1264
 
1265
+ Text("·")
1266
+ .foregroundColor(Palette.textMuted)
1267
+
1268
+ Text(editor.viewportPresetSummary.uppercased())
1269
+ .font(Typo.monoBold(8))
1270
+ .foregroundColor(Palette.textMuted)
1271
+
894
1272
  if let focused = editor.focusedDisplay {
895
1273
  Text("·")
896
1274
  .foregroundColor(Palette.textMuted)
@@ -1095,6 +1473,10 @@ struct ScreenMapView: View {
1095
1473
  }
1096
1474
  .coordinateSpace(name: "layerSidebar")
1097
1475
 
1476
+ Spacer(minLength: 8)
1477
+ windowSetsSection(editor: editor)
1478
+ Spacer(minLength: 8)
1479
+ canvasExplorer(editor: editor)
1098
1480
  Spacer(minLength: 8)
1099
1481
  sidebarMiniMap(editor: editor)
1100
1482
  }
@@ -1112,6 +1494,119 @@ struct ScreenMapView: View {
1112
1494
  return wins.sorted { $0.zIndex < $1.zIndex }
1113
1495
  }
1114
1496
 
1497
+ private func windowSetsSection(editor: ScreenMapEditorState) -> some View {
1498
+ let sets = controller.windowSets
1499
+ let canSave = !controller.selectedWindowIds.isEmpty
1500
+
1501
+ return VStack(alignment: .leading, spacing: 4) {
1502
+ HStack(spacing: 6) {
1503
+ Text("SETS")
1504
+ .font(Typo.monoBold(8))
1505
+ .foregroundColor(Palette.textMuted)
1506
+ Text("\(sets.count)")
1507
+ .font(Typo.mono(7))
1508
+ .foregroundColor(Palette.textDim)
1509
+ Spacer()
1510
+ Button {
1511
+ controller.createWindowSetFromSelection()
1512
+ } label: {
1513
+ Text("u save")
1514
+ .font(Typo.monoBold(7))
1515
+ .foregroundColor(canSave ? Self.shelfGreen : Palette.textMuted)
1516
+ .padding(.horizontal, 5)
1517
+ .padding(.vertical, 3)
1518
+ .background(
1519
+ RoundedRectangle(cornerRadius: 4)
1520
+ .fill(canSave ? Self.shelfGreen.opacity(0.12) : Palette.surface.opacity(0.7))
1521
+ .overlay(
1522
+ RoundedRectangle(cornerRadius: 4)
1523
+ .strokeBorder(canSave ? Self.shelfGreen.opacity(0.25) : Palette.border, lineWidth: 0.5)
1524
+ )
1525
+ )
1526
+ }
1527
+ .buttonStyle(.plain)
1528
+ .disabled(!canSave)
1529
+ }
1530
+
1531
+ if sets.isEmpty {
1532
+ Text("Save a selection to create a reusable cluster.")
1533
+ .font(Typo.mono(7))
1534
+ .foregroundColor(Palette.textMuted)
1535
+ .padding(.top, 2)
1536
+ } else {
1537
+ ForEach(sets) { set in
1538
+ windowSetRow(set: set, editor: editor)
1539
+ }
1540
+ }
1541
+ }
1542
+ .padding(6)
1543
+ .background(
1544
+ RoundedRectangle(cornerRadius: 6)
1545
+ .fill(Color.black.opacity(0.4))
1546
+ .overlay(
1547
+ RoundedRectangle(cornerRadius: 6)
1548
+ .strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
1549
+ )
1550
+ )
1551
+ }
1552
+
1553
+ private func windowSetRow(set: ScreenMapWindowSet, editor: ScreenMapEditorState) -> some View {
1554
+ let liveCount = editor.windows(matching: set.windowIds).count
1555
+ let isActive = controller.activeWindowSetID == set.id
1556
+
1557
+ return HStack(spacing: 6) {
1558
+ Button {
1559
+ controller.focusWindowSet(set)
1560
+ } label: {
1561
+ HStack(spacing: 6) {
1562
+ Circle()
1563
+ .fill(isActive ? Self.shelfGreen : Palette.textMuted.opacity(0.7))
1564
+ .frame(width: 6, height: 6)
1565
+ VStack(alignment: .leading, spacing: 1) {
1566
+ Text(set.name)
1567
+ .font(Typo.monoBold(8))
1568
+ .foregroundColor(isActive ? Palette.text : Palette.textDim)
1569
+ .lineLimit(1)
1570
+ Text("\(liveCount) window\(liveCount == 1 ? "" : "s")")
1571
+ .font(Typo.mono(7))
1572
+ .foregroundColor(Palette.textMuted)
1573
+ .lineLimit(1)
1574
+ }
1575
+ Spacer()
1576
+ }
1577
+ .padding(.horizontal, 6)
1578
+ .padding(.vertical, 4)
1579
+ .background(
1580
+ RoundedRectangle(cornerRadius: 4)
1581
+ .fill(isActive ? Self.shelfGreen.opacity(0.08) : Palette.surface.opacity(0.35))
1582
+ .overlay(
1583
+ RoundedRectangle(cornerRadius: 4)
1584
+ .strokeBorder(isActive ? Self.shelfGreen.opacity(0.2) : Color.white.opacity(0.04), lineWidth: 0.5)
1585
+ )
1586
+ )
1587
+ }
1588
+ .buttonStyle(.plain)
1589
+
1590
+ Button {
1591
+ controller.deleteWindowSet(set)
1592
+ } label: {
1593
+ Image(systemName: "xmark")
1594
+ .font(.system(size: 7, weight: .bold))
1595
+ .foregroundColor(Palette.textMuted)
1596
+ .frame(width: 16, height: 16)
1597
+ .background(
1598
+ RoundedRectangle(cornerRadius: 3)
1599
+ .fill(Palette.surface.opacity(0.8))
1600
+ .overlay(
1601
+ RoundedRectangle(cornerRadius: 3)
1602
+ .strokeBorder(Palette.border, lineWidth: 0.5)
1603
+ )
1604
+ )
1605
+ }
1606
+ .buttonStyle(.plain)
1607
+ }
1608
+ }
1609
+
1115
1610
  private func layerTreeHeader(label: String, count: Int, isActive: Bool, color: Color,
1116
1611
  isExpandable: Bool = false, isExpanded: Bool = false,
1117
1612
  onToggleExpand: (() -> Void)? = nil,
@@ -1165,8 +1660,8 @@ struct ScreenMapView: View {
1165
1660
 
1166
1661
  let bboxPad: CGFloat = (!isFocused && displays.count > 1) ? 40 : 0
1167
1662
  let bbox: CGRect = {
1168
- if let focused = editor?.focusedDisplay {
1169
- return focused.cgRect
1663
+ if let editor {
1664
+ return editor.canvasWorldBounds
1170
1665
  }
1171
1666
  guard !displays.isEmpty else {
1172
1667
  let s = NSScreen.main?.frame ?? CGRect(x: 0, y: 0, width: 1920, height: 1080)
@@ -1220,10 +1715,11 @@ struct ScreenMapView: View {
1220
1715
  .frame(width: mapW, height: mapH)
1221
1716
  .offset(x: centerX + panOffset.x, y: centerY + panOffset.y)
1222
1717
  .onAppear {
1223
- cacheGeometry(editor: editor, fitScale: fitScale, scale: effScale,
1224
- offsetX: centerX, offsetY: centerY,
1225
- screenSize: CGSize(width: screenW, height: screenH),
1226
- bboxOrigin: bboxOriginPt)
1718
+ syncCanvasGeometry(editor: editor, fitScale: fitScale, scale: effScale,
1719
+ offsetX: centerX, offsetY: centerY,
1720
+ viewportSize: CGSize(width: max(geo.size.width - 16, 1), height: max(geo.size.height - 16, 1)),
1721
+ screenSize: CGSize(width: screenW, height: screenH),
1722
+ bboxOrigin: bboxOriginPt)
1227
1723
  }
1228
1724
  .onChange(of: geo.size) { _ in
1229
1725
  let newFitScale = min((geo.size.width - 24) / screenW, (geo.size.height - 16) / screenH)
@@ -1232,10 +1728,32 @@ struct ScreenMapView: View {
1232
1728
  let newMapH = screenH * newEffScale
1233
1729
  let newCX = (geo.size.width - newMapW) / 2
1234
1730
  let newCY = (geo.size.height - newMapH) / 2
1235
- cacheGeometry(editor: editor, fitScale: newFitScale, scale: newEffScale,
1236
- offsetX: newCX, offsetY: newCY,
1237
- screenSize: CGSize(width: screenW, height: screenH),
1238
- bboxOrigin: bboxOriginPt)
1731
+ syncCanvasGeometry(editor: editor, fitScale: newFitScale, scale: newEffScale,
1732
+ offsetX: newCX, offsetY: newCY,
1733
+ viewportSize: CGSize(width: max(geo.size.width - 16, 1), height: max(geo.size.height - 16, 1)),
1734
+ screenSize: CGSize(width: screenW, height: screenH),
1735
+ bboxOrigin: bboxOriginPt)
1736
+ }
1737
+ .onChange(of: bbox) { _ in
1738
+ syncCanvasGeometry(editor: editor, fitScale: fitScale, scale: effScale,
1739
+ offsetX: centerX, offsetY: centerY,
1740
+ viewportSize: CGSize(width: max(geo.size.width - 16, 1), height: max(geo.size.height - 16, 1)),
1741
+ screenSize: CGSize(width: screenW, height: screenH),
1742
+ bboxOrigin: bboxOriginPt)
1743
+ }
1744
+ .onChange(of: zoomLevel) { _ in
1745
+ syncCanvasGeometry(editor: editor, fitScale: fitScale, scale: effScale,
1746
+ offsetX: centerX, offsetY: centerY,
1747
+ viewportSize: CGSize(width: max(geo.size.width - 16, 1), height: max(geo.size.height - 16, 1)),
1748
+ screenSize: CGSize(width: screenW, height: screenH),
1749
+ bboxOrigin: bboxOriginPt)
1750
+ }
1751
+ .onChange(of: editor?.canvasNavigationRevision ?? 0) { _ in
1752
+ syncCanvasGeometry(editor: editor, fitScale: fitScale, scale: effScale,
1753
+ offsetX: centerX, offsetY: centerY,
1754
+ viewportSize: CGSize(width: max(geo.size.width - 16, 1), height: max(geo.size.height - 16, 1)),
1755
+ screenSize: CGSize(width: screenW, height: screenH),
1756
+ bboxOrigin: bboxOriginPt)
1239
1757
  }
1240
1758
  }
1241
1759
  .padding(8)
@@ -1371,7 +1889,7 @@ struct ScreenMapView: View {
1371
1889
 
1372
1890
  @ViewBuilder
1373
1891
  private func windowTile(win: ScreenMapWindowEntry, editor: ScreenMapEditorState?, scale: CGFloat, bboxOrigin: CGPoint = .zero) -> some View {
1374
- let f = win.editedFrame
1892
+ let f = win.virtualFrame
1375
1893
  let x = (f.origin.x - bboxOrigin.x) * scale
1376
1894
  let y = (f.origin.y - bboxOrigin.y) * scale
1377
1895
  let w = max(f.width * scale, 4)
@@ -1426,7 +1944,7 @@ struct ScreenMapView: View {
1426
1944
  .lineLimit(1)
1427
1945
  }
1428
1946
  if h > 50 {
1429
- Text("\(Int(win.originalFrame.width))x\(Int(win.originalFrame.height))")
1947
+ Text("\(Int(win.virtualFrame.width))x\(Int(win.virtualFrame.height))")
1430
1948
  .font(Typo.mono(6))
1431
1949
  .foregroundColor(Palette.textMuted)
1432
1950
  }
@@ -1527,13 +2045,51 @@ struct ScreenMapView: View {
1527
2045
  .allowsHitTesting(false)
1528
2046
  }
1529
2047
 
1530
- // MARK: - Canvas Zoom Controls
2048
+ // MARK: - Canvas Viewport Controls
2049
+
2050
+ private func canvasViewportDock(editor: ScreenMapEditorState) -> some View {
2051
+ VStack(alignment: .trailing, spacing: 6) {
2052
+ HStack(spacing: 4) {
2053
+ ForEach(ScreenMapViewportPreset.allCases) { preset in
2054
+ canvasViewportPresetPill(preset, isActive: editor.activeViewportPreset == preset)
2055
+ }
2056
+ }
2057
+ canvasZoomControls(editor: editor)
2058
+ }
2059
+ }
2060
+
2061
+ private func canvasViewportPresetPill(_ preset: ScreenMapViewportPreset, isActive: Bool) -> some View {
2062
+ Button {
2063
+ controller.focusViewportPreset(preset)
2064
+ } label: {
2065
+ HStack(spacing: 4) {
2066
+ Text(preset.keyHint)
2067
+ .font(Typo.monoBold(8))
2068
+ .foregroundColor(isActive ? Color.black : Palette.textDim)
2069
+ Text(preset.shortLabel)
2070
+ .font(Typo.monoBold(8))
2071
+ .foregroundColor(isActive ? Color.black : Palette.text)
2072
+ }
2073
+ .padding(.horizontal, 7)
2074
+ .padding(.vertical, 4)
2075
+ .background(
2076
+ RoundedRectangle(cornerRadius: 5)
2077
+ .fill(isActive ? Self.shelfGreen.opacity(0.95) : Color(red: 0.1, green: 0.1, blue: 0.11).opacity(0.88))
2078
+ .overlay(
2079
+ RoundedRectangle(cornerRadius: 5)
2080
+ .strokeBorder(isActive ? Self.shelfGreen.opacity(0.95) : Palette.border, lineWidth: 0.5)
2081
+ )
2082
+ )
2083
+ }
2084
+ .buttonStyle(.plain)
2085
+ }
1531
2086
 
1532
2087
  private func canvasZoomControls(editor: ScreenMapEditorState) -> some View {
1533
2088
  let pct = Int(editor.zoomLevel * 100)
1534
2089
  return HStack(spacing: 0) {
1535
2090
  Button {
1536
2091
  let newZoom = max(ScreenMapEditorState.minZoom, editor.zoomLevel - 0.25)
2092
+ editor.activeViewportPreset = nil
1537
2093
  editor.zoomLevel = newZoom
1538
2094
  editor.objectWillChange.send()
1539
2095
  controller.objectWillChange.send()
@@ -1548,9 +2104,7 @@ struct ScreenMapView: View {
1548
2104
  Rectangle().fill(Palette.border).frame(width: 0.5, height: 12)
1549
2105
 
1550
2106
  Button {
1551
- editor.resetZoomPan()
1552
- controller.flash("Fit all")
1553
- controller.objectWillChange.send()
2107
+ controller.focusViewportPreset(.overview)
1554
2108
  } label: {
1555
2109
  Text("\(pct)%")
1556
2110
  .font(Typo.mono(9))
@@ -1563,6 +2117,7 @@ struct ScreenMapView: View {
1563
2117
 
1564
2118
  Button {
1565
2119
  let newZoom = min(ScreenMapEditorState.maxZoom, editor.zoomLevel + 0.25)
2120
+ editor.activeViewportPreset = nil
1566
2121
  editor.zoomLevel = newZoom
1567
2122
  editor.objectWillChange.send()
1568
2123
  controller.objectWillChange.send()
@@ -1710,6 +2265,11 @@ struct ScreenMapView: View {
1710
2265
 
1711
2266
  // Right: docs + logs
1712
2267
  HStack(spacing: 10) {
2268
+ statusBarButton(icon: "terminal", label: piChat.isVisible ? "Hide Pi" : "Pi") {
2269
+ withAnimation(.easeOut(duration: 0.16)) {
2270
+ piChat.toggleVisibility()
2271
+ }
2272
+ }
1713
2273
  statusBarButton(icon: "book", label: "Docs") {
1714
2274
  onNavigate?(.docs)
1715
2275
  }
@@ -1765,78 +2325,229 @@ struct ScreenMapView: View {
1765
2325
  @ViewBuilder
1766
2326
  private func sidebarMiniMap(editor: ScreenMapEditorState) -> some View {
1767
2327
  let displays = editor.displays
2328
+ let windows = editor.focusedDisplayIndex != nil ? editor.focusedVisibleWindows : editor.visibleWindows
2329
+ let world = editor.canvasWorldBounds
2330
+ let viewport = editor.viewportWorldRect
2331
+ let miniW: CGFloat = sidebarWidth - 28
2332
+ let miniH: CGFloat = 118
2333
+ let scaleW = miniW / max(world.width, 1)
2334
+ let scaleH = miniH / max(world.height, 1)
2335
+ let scale = min(scaleW, scaleH)
2336
+ let drawW = world.width * scale
2337
+ let drawH = world.height * scale
2338
+ let offsetX = (miniW - drawW) / 2
2339
+ let offsetY = (miniH - drawH) / 2
1768
2340
 
1769
- if displays.count > 1 {
1770
- let union: CGRect = {
1771
- var u = displays[0].cgRect
1772
- for d in displays.dropFirst() { u = u.union(d.cgRect) }
1773
- return u
1774
- }()
1775
- let miniW: CGFloat = sidebarWidth - 28
1776
- let scaleW = miniW / max(union.width, 1)
1777
- let scaleH: CGFloat = 80 / max(union.height, 1)
1778
- let scale = min(scaleW, scaleH)
1779
- let contentW = union.width * scale
1780
- let contentH = union.height * scale
1781
-
1782
- VStack(spacing: 4) {
1783
- ZStack {
1784
- ZStack(alignment: .topLeading) {
1785
- ForEach(displays, id: \.index) { disp in
1786
- let isFocused = editor.focusedDisplayIndex == disp.index
1787
- let dx = (disp.cgRect.origin.x - union.origin.x) * scale
1788
- let dy = (disp.cgRect.origin.y - union.origin.y) * scale
1789
- let dw = disp.cgRect.width * scale
1790
- let dh = disp.cgRect.height * scale
1791
- let inset: CGFloat = 1.5
1792
- let fontSize: CGFloat = min(dw, dh) > 28 ? 11 : (min(dw, dh) > 16 ? 9 : 7)
1793
-
1794
- Button {
1795
- editor.focusDisplay(disp.index)
1796
- controller.objectWillChange.send()
1797
- } label: {
1798
- ZStack {
1799
- RoundedRectangle(cornerRadius: 3)
1800
- .fill(isFocused ? Palette.running.opacity(0.15) : Color.white.opacity(0.06))
1801
- RoundedRectangle(cornerRadius: 3)
1802
- .strokeBorder(isFocused ? Palette.running.opacity(0.7) : Color.white.opacity(0.15), lineWidth: isFocused ? 1.5 : 0.5)
1803
- Text("\(editor.spatialNumber(for: disp.index))")
1804
- .font(.system(size: fontSize, weight: .bold, design: .monospaced))
1805
- .foregroundColor(isFocused ? Palette.running : Color.white.opacity(0.35))
1806
- }
1807
- .frame(width: max(dw - inset * 2, 12), height: max(dh - inset * 2, 12))
1808
- }
1809
- .buttonStyle(.plain)
1810
- .offset(x: dx + inset, y: dy + inset)
1811
- }
2341
+ VStack(alignment: .leading, spacing: 6) {
2342
+ HStack(spacing: 6) {
2343
+ Text("MAP")
2344
+ .font(Typo.monoBold(8))
2345
+ .foregroundColor(Palette.textMuted)
2346
+ Spacer()
2347
+ Text("drag to pan")
2348
+ .font(Typo.mono(7))
2349
+ .foregroundColor(Palette.textMuted)
2350
+ }
2351
+
2352
+ ZStack(alignment: .topLeading) {
2353
+ RoundedRectangle(cornerRadius: 6)
2354
+ .fill(Color.black.opacity(0.28))
2355
+
2356
+ ZStack(alignment: .topLeading) {
2357
+ RoundedRectangle(cornerRadius: 5)
2358
+ .fill(Palette.bg.opacity(0.35))
2359
+ .frame(width: drawW, height: drawH)
2360
+ .offset(x: offsetX, y: offsetY)
2361
+
2362
+ ForEach(displays, id: \.index) { disp in
2363
+ let dx = (disp.cgRect.origin.x - world.origin.x) * scale + offsetX
2364
+ let dy = (disp.cgRect.origin.y - world.origin.y) * scale + offsetY
2365
+ let dw = disp.cgRect.width * scale
2366
+ let dh = disp.cgRect.height * scale
2367
+ let isFocused = editor.focusedDisplayIndex == nil || editor.focusedDisplayIndex == disp.index
2368
+
2369
+ RoundedRectangle(cornerRadius: 3)
2370
+ .fill(isFocused ? Color.white.opacity(0.05) : Color.white.opacity(0.02))
2371
+ .overlay(
2372
+ RoundedRectangle(cornerRadius: 3)
2373
+ .strokeBorder(
2374
+ editor.focusedDisplayIndex == disp.index ? Palette.running.opacity(0.55) : Color.white.opacity(0.12),
2375
+ lineWidth: editor.focusedDisplayIndex == disp.index ? 1 : 0.5
2376
+ )
2377
+ )
2378
+ .frame(width: max(dw, 12), height: max(dh, 12))
2379
+ .offset(x: dx, y: dy)
1812
2380
  }
1813
- .frame(width: contentW, height: contentH)
2381
+
2382
+ ForEach(Array(windows.sorted(by: { $0.zIndex > $1.zIndex }).enumerated()), id: \.element.id) { _, win in
2383
+ let rect = win.virtualFrame
2384
+ let x = (rect.origin.x - world.origin.x) * scale + offsetX
2385
+ let y = (rect.origin.y - world.origin.y) * scale + offsetY
2386
+ let w = max(rect.width * scale, 2)
2387
+ let h = max(rect.height * scale, 2)
2388
+ let isSelected = controller.selectedWindowIds.contains(win.id)
2389
+
2390
+ RoundedRectangle(cornerRadius: 1.5)
2391
+ .fill((isSelected ? Palette.running : Self.layerColor(for: win.layer)).opacity(isSelected ? 0.35 : 0.18))
2392
+ .overlay(
2393
+ RoundedRectangle(cornerRadius: 1.5)
2394
+ .strokeBorder(isSelected ? Palette.running.opacity(0.85) : Color.white.opacity(0.12), lineWidth: isSelected ? 1 : 0.5)
2395
+ )
2396
+ .frame(width: w, height: h)
2397
+ .offset(x: x, y: y)
2398
+ }
2399
+
2400
+ let viewportX = (viewport.origin.x - world.origin.x) * scale + offsetX
2401
+ let viewportY = (viewport.origin.y - world.origin.y) * scale + offsetY
2402
+ let viewportW = max(viewport.width * scale, 12)
2403
+ let viewportH = max(viewport.height * scale, 12)
2404
+
2405
+ RoundedRectangle(cornerRadius: 4)
2406
+ .strokeBorder(Palette.running.opacity(0.9), lineWidth: 1.25)
2407
+ .background(
2408
+ RoundedRectangle(cornerRadius: 4)
2409
+ .fill(Palette.running.opacity(0.08))
2410
+ )
2411
+ .frame(width: viewportW, height: viewportH)
2412
+ .offset(x: viewportX, y: viewportY)
1814
2413
  }
1815
- .frame(width: miniW, height: max(contentH, 48), alignment: .topLeading)
1816
- .clipped()
2414
+ }
2415
+ .frame(width: miniW, height: miniH)
2416
+ .clipShape(RoundedRectangle(cornerRadius: 6))
2417
+ .contentShape(Rectangle())
2418
+ .gesture(
2419
+ DragGesture(minimumDistance: 0)
2420
+ .onChanged { value in
2421
+ let localX = min(max(value.location.x - offsetX, 0), drawW)
2422
+ let localY = min(max(value.location.y - offsetY, 0), drawH)
2423
+ let worldPoint = CGPoint(
2424
+ x: world.origin.x + localX / max(scale, 0.0001),
2425
+ y: world.origin.y + localY / max(scale, 0.0001)
2426
+ )
2427
+ controller.recenterViewport(at: worldPoint)
2428
+ }
2429
+ )
1817
2430
 
1818
- Button {
1819
- editor.focusDisplay(nil)
1820
- controller.objectWillChange.send()
1821
- } label: {
1822
- Text("ALL")
1823
- .font(Typo.monoBold(7))
1824
- .foregroundColor(editor.focusedDisplayIndex == nil ? Palette.running : Palette.textDim)
1825
- .frame(maxWidth: .infinity)
1826
- .padding(.vertical, 2)
2431
+ HStack(spacing: 6) {
2432
+ mapScopePill("ALL", isActive: editor.focusedDisplayIndex == nil) {
2433
+ controller.focusCanvas(on: editor.canvasWorldBounds, focusDisplay: nil, zoomToFit: true)
1827
2434
  }
1828
- .buttonStyle(.plain)
2435
+ ForEach(editor.spatialDisplayOrder, id: \.index) { disp in
2436
+ mapScopePill("\(editor.spatialNumber(for: disp.index))", isActive: editor.focusedDisplayIndex == disp.index) {
2437
+ controller.focusCanvas(
2438
+ on: editor.canvasExplorerRegions.first(where: { $0.kind == .display && $0.displayIndex == disp.index })?.rect ?? disp.cgRect,
2439
+ focusDisplay: disp.index,
2440
+ zoomToFit: true
2441
+ )
2442
+ }
2443
+ }
2444
+ }
2445
+ }
2446
+ .padding(6)
2447
+ .background(
2448
+ RoundedRectangle(cornerRadius: 6)
2449
+ .fill(Color.black.opacity(0.4))
2450
+ .overlay(
2451
+ RoundedRectangle(cornerRadius: 6)
2452
+ .strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
2453
+ )
2454
+ )
2455
+ }
2456
+
2457
+ private func canvasExplorer(editor: ScreenMapEditorState) -> some View {
2458
+ let regions = editor.canvasExplorerRegions
2459
+
2460
+ return VStack(alignment: .leading, spacing: 4) {
2461
+ HStack {
2462
+ Text("EXPLORER")
2463
+ .font(Typo.monoBold(8))
2464
+ .foregroundColor(Palette.textMuted)
2465
+ Spacer()
2466
+ if let viewport = controller.editor?.viewportWorldRect {
2467
+ Text("\(Int(viewport.midX)),\(Int(viewport.midY))")
2468
+ .font(Typo.mono(7))
2469
+ .foregroundColor(Palette.textMuted)
2470
+ }
2471
+ }
2472
+
2473
+ ForEach(regions.prefix(8)) { region in
2474
+ canvasExplorerRow(region: region)
1829
2475
  }
1830
- .padding(6)
2476
+ }
2477
+ .padding(6)
2478
+ .background(
2479
+ RoundedRectangle(cornerRadius: 6)
2480
+ .fill(Color.black.opacity(0.4))
2481
+ .overlay(
2482
+ RoundedRectangle(cornerRadius: 6)
2483
+ .strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
2484
+ )
2485
+ )
2486
+ }
2487
+
2488
+ private func canvasExplorerRow(region: ScreenMapCanvasRegion) -> some View {
2489
+ let tint: Color = {
2490
+ switch region.kind {
2491
+ case .overview: return Palette.running
2492
+ case .display: return Color.blue.opacity(0.8)
2493
+ case .layer: return Self.layerColor(for: region.layer ?? 0)
2494
+ }
2495
+ }()
2496
+
2497
+ return Button {
2498
+ controller.jumpToCanvasRegion(region)
2499
+ controller.flash(region.title)
2500
+ } label: {
2501
+ HStack(spacing: 6) {
2502
+ Circle()
2503
+ .fill(tint)
2504
+ .frame(width: 6, height: 6)
2505
+ VStack(alignment: .leading, spacing: 1) {
2506
+ Text(region.title)
2507
+ .font(Typo.monoBold(8))
2508
+ .foregroundColor(Palette.text)
2509
+ .lineLimit(1)
2510
+ Text(region.subtitle)
2511
+ .font(Typo.mono(7))
2512
+ .foregroundColor(Palette.textMuted)
2513
+ .lineLimit(1)
2514
+ }
2515
+ Spacer()
2516
+ Text("\(region.count)")
2517
+ .font(Typo.mono(7))
2518
+ .foregroundColor(Palette.textDim)
2519
+ }
2520
+ .padding(.horizontal, 6)
2521
+ .padding(.vertical, 4)
1831
2522
  .background(
1832
- RoundedRectangle(cornerRadius: 6)
1833
- .fill(Color.black.opacity(0.4))
2523
+ RoundedRectangle(cornerRadius: 4)
2524
+ .fill(tint.opacity(0.08))
1834
2525
  .overlay(
1835
- RoundedRectangle(cornerRadius: 6)
1836
- .strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
2526
+ RoundedRectangle(cornerRadius: 4)
2527
+ .strokeBorder(tint.opacity(0.18), lineWidth: 0.5)
1837
2528
  )
1838
2529
  )
1839
2530
  }
2531
+ .buttonStyle(.plain)
2532
+ }
2533
+
2534
+ private func mapScopePill(_ label: String, isActive: Bool, action: @escaping () -> Void) -> some View {
2535
+ Button(action: action) {
2536
+ Text(label)
2537
+ .font(Typo.monoBold(7))
2538
+ .foregroundColor(isActive ? Palette.running : Palette.textDim)
2539
+ .padding(.horizontal, 6)
2540
+ .padding(.vertical, 3)
2541
+ .background(
2542
+ RoundedRectangle(cornerRadius: 4)
2543
+ .fill(isActive ? Palette.running.opacity(0.12) : Palette.surface.opacity(0.7))
2544
+ .overlay(
2545
+ RoundedRectangle(cornerRadius: 4)
2546
+ .strokeBorder(isActive ? Palette.running.opacity(0.3) : Palette.border, lineWidth: 0.5)
2547
+ )
2548
+ )
2549
+ }
2550
+ .buttonStyle(.plain)
1840
2551
  }
1841
2552
 
1842
2553
  // MARK: - Flash Overlay
@@ -1880,14 +2591,17 @@ struct ScreenMapView: View {
1880
2591
 
1881
2592
  // MARK: - Helpers
1882
2593
 
1883
- private func cacheGeometry(editor: ScreenMapEditorState?, fitScale: CGFloat? = nil, scale: CGFloat,
1884
- offsetX: CGFloat, offsetY: CGFloat,
1885
- screenSize: CGSize, bboxOrigin: CGPoint = .zero) {
2594
+ private func syncCanvasGeometry(editor: ScreenMapEditorState?, fitScale: CGFloat? = nil, scale: CGFloat,
2595
+ offsetX: CGFloat, offsetY: CGFloat,
2596
+ viewportSize: CGSize,
2597
+ screenSize: CGSize, bboxOrigin: CGPoint = .zero) {
1886
2598
  if let fs = fitScale { editor?.fitScale = fs }
1887
2599
  editor?.scale = scale
1888
2600
  editor?.mapOrigin = CGPoint(x: offsetX, y: offsetY)
2601
+ editor?.viewportSize = viewportSize
1889
2602
  editor?.screenSize = screenSize
1890
2603
  editor?.bboxOrigin = bboxOrigin
2604
+ controller.applyPendingCanvasNavigationIfNeeded()
1891
2605
  }
1892
2606
 
1893
2607
  // MARK: - Layer Colors
@@ -1896,6 +2610,12 @@ struct ScreenMapView: View {
1896
2610
  .green, .cyan, .orange, .purple, .pink, .yellow, .mint, .indigo
1897
2611
  ]
1898
2612
 
2613
+ private static let inspectorLogTimeFormatter: DateFormatter = {
2614
+ let formatter = DateFormatter()
2615
+ formatter.dateFormat = "HH:mm:ss"
2616
+ return formatter
2617
+ }()
2618
+
1899
2619
  private static func layerColor(for layer: Int) -> Color {
1900
2620
  layerColors[layer % layerColors.count]
1901
2621
  }
@@ -1904,10 +2624,10 @@ struct ScreenMapView: View {
1904
2624
  guard let disp = displays.first(where: { $0.index == win.displayIndex }) else { return nil }
1905
2625
  let screenW = disp.cgRect.width
1906
2626
  let screenH = disp.cgRect.height
1907
- let relX = win.originalFrame.origin.x - disp.cgRect.origin.x
1908
- let relY = win.originalFrame.origin.y - disp.cgRect.origin.y
1909
- let winW = win.originalFrame.width
1910
- let winH = win.originalFrame.height
2627
+ let relX = win.virtualFrame.origin.x - disp.cgRect.origin.x
2628
+ let relY = win.virtualFrame.origin.y - disp.cgRect.origin.y
2629
+ let winW = win.virtualFrame.width
2630
+ let winH = win.virtualFrame.height
1911
2631
  let tolerance: CGFloat = 30
1912
2632
 
1913
2633
  for pos in TilePosition.allCases {
@@ -1975,6 +2695,9 @@ struct ScreenMapView: View {
1975
2695
  // Only handle keys when our window is the key window
1976
2696
  guard let win = ScreenMapWindowController.shared.nsWindow,
1977
2697
  win.isKeyWindow else { return event }
2698
+ if isEditableTextResponder(win.firstResponder) {
2699
+ return event
2700
+ }
1978
2701
  // Track space key for canvas drag-to-pan
1979
2702
  if event.keyCode == 49 && !controller.isSearchActive {
1980
2703
  if event.type == .keyDown && !event.isARepeat {
@@ -1994,6 +2717,20 @@ struct ScreenMapView: View {
1994
2717
  }
1995
2718
  }
1996
2719
 
2720
+ private func isEditableTextResponder(_ responder: NSResponder?) -> Bool {
2721
+ if let textView = responder as? NSTextView {
2722
+ return textView.isEditable || textView.isFieldEditor
2723
+ }
2724
+
2725
+ if let textField = responder as? NSTextField {
2726
+ return textField.isEditable
2727
+ }
2728
+
2729
+ guard let responder else { return false }
2730
+ let className = NSStringFromClass(type(of: responder))
2731
+ return className.contains("FieldEditor") || className.contains("TextView")
2732
+ }
2733
+
1997
2734
  private func removeKeyHandler() {
1998
2735
  if let monitor = eventMonitor {
1999
2736
  NSEvent.removeMonitor(monitor)
@@ -2038,6 +2775,7 @@ struct ScreenMapView: View {
2038
2775
  if isSpaceHeld, let start = spaceDragStart, let editor = controller.editor {
2039
2776
  let dx = event.locationInWindow.x - start.x
2040
2777
  let dy = event.locationInWindow.y - start.y
2778
+ editor.activeViewportPreset = nil
2041
2779
  editor.panOffset = CGPoint(x: spaceDragPanStart.x + dx, y: spaceDragPanStart.y - dy)
2042
2780
  editor.objectWillChange.send()
2043
2781
  controller.objectWillChange.send()
@@ -2053,7 +2791,7 @@ struct ScreenMapView: View {
2053
2791
  if editor.draggingWindowId != hitId {
2054
2792
  editor.draggingWindowId = hitId
2055
2793
  if let idx = editor.windows.firstIndex(where: { $0.id == hitId }) {
2056
- editor.dragStartFrame = editor.windows[idx].editedFrame
2794
+ editor.dragStartFrame = editor.windows[idx].virtualFrame
2057
2795
  }
2058
2796
  controller.selectSingle(hitId)
2059
2797
  }
@@ -2110,7 +2848,7 @@ struct ScreenMapView: View {
2110
2848
  newFrame.size.height = max(minH, startFrame.height + screenDy)
2111
2849
  }
2112
2850
 
2113
- editor.windows[idx].editedFrame = newFrame
2851
+ editor.syncLayoutFrame(at: idx, to: newFrame)
2114
2852
  editor.objectWillChange.send()
2115
2853
  controller.objectWillChange.send()
2116
2854
  return nil
@@ -2197,11 +2935,13 @@ struct ScreenMapView: View {
2197
2935
  let newPanX = cursorFromCenter.x - ratio * (cursorFromCenter.x - editor.panOffset.x)
2198
2936
  let newPanY = cursorFromCenter.y - ratio * (cursorFromCenter.y - editor.panOffset.y)
2199
2937
 
2938
+ editor.activeViewportPreset = nil
2200
2939
  editor.zoomLevel = newZoom
2201
2940
  editor.panOffset = CGPoint(x: newPanX, y: newPanY)
2202
2941
  editor.objectWillChange.send()
2203
2942
  controller.objectWillChange.send()
2204
2943
  } else {
2944
+ editor.activeViewportPreset = nil
2205
2945
  editor.panOffset = CGPoint(
2206
2946
  x: editor.panOffset.x + event.scrollingDeltaX,
2207
2947
  y: editor.panOffset.y - event.scrollingDeltaY
@@ -2289,7 +3029,7 @@ struct ScreenMapView: View {
2289
3029
  let windowPool = editor.focusedDisplayIndex != nil ? editor.focusedVisibleWindows : editor.windows
2290
3030
  let sorted = windowPool.sorted(by: { $0.zIndex < $1.zIndex })
2291
3031
  for win in sorted {
2292
- let f = win.editedFrame
3032
+ let f = win.virtualFrame
2293
3033
  let mapRect = CGRect(
2294
3034
  x: (f.origin.x - bboxOrig.x) * effScale,
2295
3035
  y: (f.origin.y - bboxOrig.y) * effScale,
@@ -2320,7 +3060,7 @@ struct ScreenMapView: View {
2320
3060
  let windowPool = editor.focusedDisplayIndex != nil ? editor.focusedVisibleWindows : editor.windows
2321
3061
  let sorted = windowPool.sorted(by: { $0.zIndex < $1.zIndex })
2322
3062
  for win in sorted {
2323
- let f = win.editedFrame
3063
+ let f = win.virtualFrame
2324
3064
  let mapRect = CGRect(
2325
3065
  x: (f.origin.x - bboxOrig.x) * effScale,
2326
3066
  y: (f.origin.y - bboxOrig.y) * effScale,
@@ -2468,7 +3208,7 @@ struct ScreenMapPreviewOverlay: View {
2468
3208
  Color.black.opacity(0.88)
2469
3209
 
2470
3210
  ForEach(windows) { win in
2471
- let f = win.editedFrame
3211
+ let f = win.virtualFrame
2472
3212
  let x = f.origin.x - screenCGOrigin.x
2473
3213
  let y = f.origin.y - screenCGOrigin.y
2474
3214
  let w = f.width