@lattices/cli 0.4.6 → 0.4.8

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 (40) hide show
  1. package/README.md +8 -6
  2. package/app/Info.plist +13 -2
  3. package/app/Lattices.app/Contents/Info.plist +13 -2
  4. package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/app/Sources/AppShell/App.swift +7 -1
  6. package/app/Sources/AppShell/AppDelegate.swift +65 -1
  7. package/app/Sources/AppShell/AppShellView.swift +10 -0
  8. package/app/Sources/AppShell/CliActionLauncher.swift +2 -2
  9. package/app/Sources/AppShell/KeyRecorderView.swift +1 -1
  10. package/app/Sources/AppShell/MainView.swift +1 -1
  11. package/app/Sources/AppShell/Preferences.swift +29 -3
  12. package/app/Sources/AppShell/SettingsView.swift +525 -60
  13. package/app/Sources/AppShell/SettingsWindow.swift +4 -0
  14. package/app/Sources/Core/Actions/HotkeyStore.swift +13 -1
  15. package/app/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +23 -7
  16. package/app/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +35 -0
  17. package/app/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +1 -1
  18. package/app/Sources/Core/Desktop/WindowTiler.swift +0 -2
  19. package/app/Sources/Core/Input/KeyboardRemapConfig.swift +69 -0
  20. package/app/Sources/Core/Input/KeyboardRemapController.swift +184 -0
  21. package/app/Sources/Core/Input/KeyboardRemapStore.swift +84 -0
  22. package/app/Sources/Core/Overlays/CommandMode/CommandModeState.swift +101 -0
  23. package/app/Sources/Core/Overlays/CommandMode/CommandModeView.swift +113 -4
  24. package/app/Sources/Core/Overlays/CommandMode/CommandModeWindow.swift +9 -5
  25. package/app/Sources/Core/Overlays/HUD/CheatSheetHUD.swift +1 -0
  26. package/app/Sources/Core/Overlays/ScreenMap/ScreenMapState.swift +0 -1
  27. package/app/Sources/Core/Overlays/ScreenMap/ScreenMapView.swift +20 -7
  28. package/app/Sources/Core/Workspace/SessionManager.swift +1 -1
  29. package/app/Sources/Core/Workspace/WorkspaceManager.swift +62 -7
  30. package/bin/lattices-app.ts +11 -0
  31. package/bin/lattices-dev +11 -0
  32. package/bin/lattices.ts +57 -17
  33. package/docs/app.md +30 -2
  34. package/docs/companion-deck.md +29 -0
  35. package/docs/concepts.md +5 -5
  36. package/docs/config.md +34 -9
  37. package/docs/layers.md +1 -1
  38. package/docs/overview.md +1 -1
  39. package/docs/quickstart.md +4 -4
  40. package/package.json +1 -1
@@ -6,6 +6,7 @@ import SwiftUI
6
6
  struct SettingsContentView: View {
7
7
  private enum SettingsSection: String, CaseIterable, Identifiable {
8
8
  case general
9
+ case companion
9
10
  case ai
10
11
  case search
11
12
  case shortcuts
@@ -15,6 +16,7 @@ struct SettingsContentView: View {
15
16
  var title: String {
16
17
  switch self {
17
18
  case .general: return "General"
19
+ case .companion: return "Companion"
18
20
  case .ai: return "AI"
19
21
  case .search: return "Search & OCR"
20
22
  case .shortcuts: return "Shortcuts"
@@ -24,6 +26,7 @@ struct SettingsContentView: View {
24
26
  var icon: String {
25
27
  switch self {
26
28
  case .general: return "slider.horizontal.3"
29
+ case .companion: return "ipad.and.iphone"
27
30
  case .ai: return "sparkles"
28
31
  case .search: return "text.viewfinder"
29
32
  case .shortcuts: return "command"
@@ -33,6 +36,7 @@ struct SettingsContentView: View {
33
36
  var eyebrow: String {
34
37
  switch self {
35
38
  case .general: return "Workspace"
39
+ case .companion: return "Local Bridge"
36
40
  case .ai: return "Agents"
37
41
  case .search: return "Indexing"
38
42
  case .shortcuts: return "Controls"
@@ -43,6 +47,8 @@ struct SettingsContentView: View {
43
47
  switch self {
44
48
  case .general:
45
49
  return "Terminal defaults, scan roots, window snapping, and app updates."
50
+ case .companion:
51
+ return "Local-network pairing, trusted iPad devices, and bridge security."
46
52
  case .ai:
47
53
  return "Claude CLI detection plus advisor model and spending controls."
48
54
  case .search:
@@ -57,8 +63,10 @@ struct SettingsContentView: View {
57
63
  @ObservedObject var prefs: Preferences
58
64
  @ObservedObject var scanner: ProjectScanner
59
65
  @ObservedObject var hotkeyStore: HotkeyStore = .shared
66
+ @ObservedObject var workspaceManager: WorkspaceManager = .shared
60
67
  @ObservedObject var appUpdater: AppUpdater = .shared
61
68
  @ObservedObject var mouseShortcutStore: MouseShortcutStore = .shared
69
+ @ObservedObject var keyboardRemapStore: KeyboardRemapStore = .shared
62
70
  var onBack: (() -> Void)? = nil
63
71
 
64
72
  @State private var selectedTab: SettingsSection = .general
@@ -77,6 +85,11 @@ struct SettingsContentView: View {
77
85
  .frame(maxWidth: .infinity, maxHeight: .infinity)
78
86
  .clipped()
79
87
  .background(PanelBackground())
88
+ .onAppear {
89
+ if page == .companionSettings {
90
+ selectedTab = .companion
91
+ }
92
+ }
80
93
  }
81
94
 
82
95
  // MARK: - Back Bar
@@ -85,18 +98,27 @@ struct SettingsContentView: View {
85
98
  page == .docs ? "Docs" : selectedTab.title
86
99
  }
87
100
 
101
+ private var snapModifierBinding: Binding<SnapModifierKey> {
102
+ Binding(
103
+ get: { workspaceManager.snapZonesConfig.modifier ?? .command },
104
+ set: { workspaceManager.updateSnapModifier($0) }
105
+ )
106
+ }
107
+
88
108
  private var backBar: some View {
89
109
  VStack(spacing: 0) {
90
110
  HStack(spacing: 8) {
91
- Button {
92
- onBack?()
93
- } label: {
94
- Image(systemName: "chevron.left")
95
- .font(.system(size: 10, weight: .semibold))
96
- .foregroundColor(Palette.textMuted)
111
+ if let onBack {
112
+ Button {
113
+ onBack()
114
+ } label: {
115
+ Image(systemName: "chevron.left")
116
+ .font(.system(size: 10, weight: .semibold))
117
+ .foregroundColor(Palette.textMuted)
118
+ }
119
+ .buttonStyle(.plain)
120
+ .onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
97
121
  }
98
- .buttonStyle(.plain)
99
- .onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
100
122
 
101
123
  Text(page == .docs ? "Docs" : currentTabLabel)
102
124
  .font(Typo.heading(13))
@@ -232,6 +254,8 @@ struct SettingsContentView: View {
232
254
  switch selectedTab {
233
255
  case .general:
234
256
  generalContent
257
+ case .companion:
258
+ companionContent
235
259
  case .ai:
236
260
  aiContent
237
261
  case .search:
@@ -459,13 +483,28 @@ struct SettingsContentView: View {
459
483
  .labelsHidden()
460
484
  }
461
485
 
462
- Text("Hold the configured snap modifier while dragging to reveal landing targets and a live preview, then release it to go back to a free drag. Default: Command.")
486
+ HStack {
487
+ Text("Snap modifier")
488
+ .font(Typo.mono(10))
489
+ .foregroundColor(Palette.textDim)
490
+ Spacer()
491
+ Picker("", selection: snapModifierBinding) {
492
+ ForEach(SnapModifierKey.allCases) { modifier in
493
+ Text(modifier.shortLabel).tag(modifier)
494
+ }
495
+ }
496
+ .pickerStyle(.segmented)
497
+ .labelsHidden()
498
+ .frame(width: 220)
499
+ }
500
+
501
+ Text("Dragging stays normal until you hold \(snapModifierBinding.wrappedValue.label). While that key is down, Lattices reveals snap targets and a live preview for the window you’re moving.")
463
502
  .font(Typo.caption(9))
464
503
  .foregroundColor(Palette.textMuted.opacity(0.7))
465
504
 
466
505
  cardDivider
467
506
 
468
- Text("Agent-editable rules live in ~/.lattices/snap-zones.json. Changes are picked up on the next drag.")
507
+ Text("Advanced landing-zone rules still live in ~/.lattices/snap-zones.json. Modifier changes here take effect on the next drag.")
469
508
  .font(Typo.caption(9))
470
509
  .foregroundColor(Palette.textMuted.opacity(0.7))
471
510
  }
@@ -567,6 +606,83 @@ struct SettingsContentView: View {
567
606
  .foregroundColor(Palette.textMuted.opacity(0.7))
568
607
  }
569
608
  }
609
+
610
+ settingsCard {
611
+ VStack(alignment: .leading, spacing: 10) {
612
+ Text("Keyboard remaps")
613
+ .font(Typo.mono(11))
614
+ .foregroundColor(Palette.text)
615
+
616
+ HStack {
617
+ Text("Caps Lock as Hyper")
618
+ .font(Typo.mono(10))
619
+ .foregroundColor(Palette.textDim)
620
+ Spacer()
621
+ Toggle("", isOn: $prefs.keyboardRemapsEnabled)
622
+ .toggleStyle(.switch)
623
+ .controlSize(.small)
624
+ .labelsHidden()
625
+ }
626
+
627
+ Text("Rules live in ~/.lattices/keyboard-remaps.json. The default maps hold Caps Lock to Hyper and tap Caps Lock to Escape, so the existing Hyper shortcuts work on the laptop keyboard.")
628
+ .font(Typo.caption(9))
629
+ .foregroundColor(Palette.textMuted.opacity(0.7))
630
+
631
+ cardDivider
632
+
633
+ VStack(alignment: .leading, spacing: 6) {
634
+ Text("Active remaps")
635
+ .font(Typo.mono(10))
636
+ .foregroundColor(Palette.textDim)
637
+
638
+ ForEach(keyboardRemapStore.summaryLines.prefix(4), id: \.self) { line in
639
+ Text(line)
640
+ .font(Typo.caption(9))
641
+ .foregroundColor(Palette.textMuted.opacity(0.78))
642
+ }
643
+
644
+ if keyboardRemapStore.summaryLines.isEmpty {
645
+ Text("No active remaps")
646
+ .font(Typo.caption(9))
647
+ .foregroundColor(Palette.textMuted.opacity(0.6))
648
+ }
649
+ }
650
+
651
+ HStack(spacing: 8) {
652
+ Button {
653
+ keyboardRemapStore.openConfiguration()
654
+ } label: {
655
+ Text("Configure...")
656
+ .font(Typo.monoBold(10))
657
+ .foregroundColor(Palette.text)
658
+ .padding(.horizontal, 12)
659
+ .padding(.vertical, 4)
660
+ .background(
661
+ RoundedRectangle(cornerRadius: 4)
662
+ .fill(Palette.surfaceHov)
663
+ .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
664
+ )
665
+ }
666
+ .buttonStyle(.plain)
667
+
668
+ Button {
669
+ keyboardRemapStore.restoreDefaults()
670
+ } label: {
671
+ Text("Restore Defaults")
672
+ .font(Typo.monoBold(10))
673
+ .foregroundColor(Palette.text)
674
+ .padding(.horizontal, 12)
675
+ .padding(.vertical, 4)
676
+ .background(
677
+ RoundedRectangle(cornerRadius: 4)
678
+ .fill(Palette.surfaceHov)
679
+ .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
680
+ )
681
+ }
682
+ .buttonStyle(.plain)
683
+ }
684
+ }
685
+ }
570
686
  }
571
687
  .padding(16)
572
688
  .frame(maxWidth: 760, alignment: .leading)
@@ -574,6 +690,371 @@ struct SettingsContentView: View {
574
690
  }
575
691
  }
576
692
 
693
+ // MARK: - Companion
694
+
695
+ private var companionContent: some View {
696
+ ScrollView {
697
+ VStack(alignment: .leading, spacing: 12) {
698
+ companionBridgeOverviewCard
699
+ companionTrustedDevicesCard
700
+
701
+ settingsCard {
702
+ VStack(alignment: .leading, spacing: 10) {
703
+ Text("Input")
704
+ .font(Typo.mono(11))
705
+ .foregroundColor(Palette.text)
706
+
707
+ HStack(alignment: .top, spacing: 12) {
708
+ VStack(alignment: .leading, spacing: 4) {
709
+ Text("Trackpad proxy")
710
+ .font(Typo.mono(10))
711
+ .foregroundColor(Palette.textDim)
712
+ Text("Allow paired companions with the input.trackpad grant to move the Mac pointer through the encrypted bridge.")
713
+ .font(Typo.caption(9.5))
714
+ .foregroundColor(Palette.textMuted.opacity(0.75))
715
+ .fixedSize(horizontal: false, vertical: true)
716
+ }
717
+
718
+ Spacer()
719
+
720
+ Toggle("", isOn: $prefs.companionTrackpadEnabled)
721
+ .toggleStyle(.switch)
722
+ .controlSize(.small)
723
+ .labelsHidden()
724
+ .disabled(!prefs.companionBridgeEnabled)
725
+ .opacity(prefs.companionBridgeEnabled ? 1 : 0.45)
726
+ }
727
+ }
728
+ }
729
+ }
730
+ .padding(16)
731
+ .frame(maxWidth: 760, alignment: .leading)
732
+ .frame(maxWidth: .infinity, alignment: .leading)
733
+ }
734
+ }
735
+
736
+ private var companionBridgeOverviewCard: some View {
737
+ settingsCard {
738
+ VStack(alignment: .leading, spacing: 12) {
739
+ HStack(alignment: .top, spacing: 10) {
740
+ RoundedRectangle(cornerRadius: 6)
741
+ .fill(Palette.running.opacity(0.14))
742
+ .overlay(
743
+ Image(systemName: "lock.shield")
744
+ .font(.system(size: 13, weight: .semibold))
745
+ .foregroundColor(Palette.running)
746
+ )
747
+ .frame(width: 30, height: 30)
748
+
749
+ VStack(alignment: .leading, spacing: 3) {
750
+ Text(prefs.companionBridgeEnabled ? "Secure local bridge" : "Local bridge off")
751
+ .font(Typo.mono(12))
752
+ .foregroundColor(Palette.text)
753
+ Text(prefs.companionBridgeEnabled
754
+ ? "Bonjour discovery with explicit Mac approval, signed requests, encrypted payloads, and capability grants."
755
+ : "The companion bridge is not listening or advertising on the local network until you turn it on.")
756
+ .font(Typo.caption(10))
757
+ .foregroundColor(Palette.textMuted)
758
+ .fixedSize(horizontal: false, vertical: true)
759
+ }
760
+
761
+ Spacer()
762
+
763
+ Toggle("", isOn: $prefs.companionBridgeEnabled)
764
+ .toggleStyle(.switch)
765
+ .controlSize(.small)
766
+ .labelsHidden()
767
+ }
768
+
769
+ cardDivider
770
+
771
+ LazyVGrid(
772
+ columns: [
773
+ GridItem(.flexible(minimum: 120), spacing: 10, alignment: .leading),
774
+ GridItem(.flexible(minimum: 120), spacing: 10, alignment: .leading),
775
+ GridItem(.flexible(minimum: 120), spacing: 10, alignment: .leading),
776
+ ],
777
+ alignment: .leading,
778
+ spacing: 10
779
+ ) {
780
+ companionBridgeFact(
781
+ label: "Status",
782
+ value: prefs.companionBridgeEnabled ? "enabled" : "off"
783
+ )
784
+ companionBridgeFact(
785
+ label: "Port",
786
+ value: String(LatticesCompanionBridgeServer.defaultPort)
787
+ )
788
+ companionBridgeFact(
789
+ label: "Protocol",
790
+ value: "v\(LatticesCompanionBridgeServer.protocolVersion)"
791
+ )
792
+ }
793
+
794
+ VStack(alignment: .leading, spacing: 5) {
795
+ Text("Enable deep link")
796
+ .font(Typo.mono(10))
797
+ .foregroundColor(Palette.textDim)
798
+ Text("lattices://companion/enable")
799
+ .font(Typo.monoBold(12))
800
+ .foregroundColor(Palette.text)
801
+ .textSelection(.enabled)
802
+ }
803
+ .padding(10)
804
+ .frame(maxWidth: .infinity, alignment: .leading)
805
+ .background(shortcutsInsetPanel)
806
+
807
+ VStack(alignment: .leading, spacing: 5) {
808
+ Text("Mac bridge fingerprint")
809
+ .font(Typo.mono(10))
810
+ .foregroundColor(Palette.textDim)
811
+ Text(LatticesCompanionSecurityCoordinator.shared.bridgeFingerprint)
812
+ .font(Typo.monoBold(13))
813
+ .foregroundColor(Palette.text)
814
+ .textSelection(.enabled)
815
+ }
816
+ .padding(10)
817
+ .frame(maxWidth: .infinity, alignment: .leading)
818
+ .background(shortcutsInsetPanel)
819
+
820
+ HStack(spacing: 6) {
821
+ ForEach(DeckBridgeCapability.defaultCompanionCapabilities, id: \.self) { capability in
822
+ companionCapabilityBadge(capability)
823
+ }
824
+ Spacer(minLength: 0)
825
+ }
826
+ }
827
+ }
828
+ }
829
+
830
+ private var companionTrustedDevicesCard: some View {
831
+ let trustedDevices = companionTrustedDevices(revision: companionTrustRevision)
832
+
833
+ return settingsCard {
834
+ VStack(alignment: .leading, spacing: 12) {
835
+ HStack(alignment: .top, spacing: 12) {
836
+ VStack(alignment: .leading, spacing: 4) {
837
+ Text("Paired devices")
838
+ .font(Typo.mono(12))
839
+ .foregroundColor(Palette.text)
840
+ Text("Only trusted devices can call protected deck and input routes. Pairing grants are listed per device.")
841
+ .font(Typo.caption(10))
842
+ .foregroundColor(Palette.textMuted)
843
+ .fixedSize(horizontal: false, vertical: true)
844
+ }
845
+
846
+ Spacer()
847
+
848
+ HStack(spacing: 8) {
849
+ Button {
850
+ companionTrustRevision += 1
851
+ } label: {
852
+ Image(systemName: "arrow.clockwise")
853
+ .font(.system(size: 10, weight: .semibold))
854
+ .foregroundColor(Palette.textDim)
855
+ .frame(width: 24, height: 24)
856
+ .background(
857
+ RoundedRectangle(cornerRadius: 5)
858
+ .fill(Palette.surfaceHov)
859
+ .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Palette.borderLit, lineWidth: 0.5))
860
+ )
861
+ }
862
+ .buttonStyle(.plain)
863
+
864
+ if trustedDevices.isEmpty == false {
865
+ Button {
866
+ guard confirmForgetTrustedDevices() else { return }
867
+ LatticesCompanionSecurityCoordinator.shared.clearTrustedDevices()
868
+ companionTrustRevision += 1
869
+ } label: {
870
+ Text("Forget All")
871
+ .font(Typo.monoBold(10))
872
+ .foregroundColor(Palette.kill.opacity(0.9))
873
+ .padding(.horizontal, 10)
874
+ .padding(.vertical, 5)
875
+ .background(
876
+ RoundedRectangle(cornerRadius: 5)
877
+ .fill(Palette.kill.opacity(0.10))
878
+ .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Palette.kill.opacity(0.22), lineWidth: 0.5))
879
+ )
880
+ }
881
+ .buttonStyle(.plain)
882
+ }
883
+ }
884
+ }
885
+
886
+ if trustedDevices.isEmpty {
887
+ VStack(alignment: .leading, spacing: 6) {
888
+ Image(systemName: "ipad.and.iphone")
889
+ .font(.system(size: 18, weight: .semibold))
890
+ .foregroundColor(Palette.textMuted)
891
+
892
+ Text("No paired iPad or iPhone devices yet.")
893
+ .font(Typo.caption(10.5))
894
+ .foregroundColor(Palette.textMuted)
895
+
896
+ Text("Open the Lattices companion app on your iPad and select this Mac. You’ll approve the pairing prompt here.")
897
+ .font(Typo.caption(9.5))
898
+ .foregroundColor(Palette.textMuted.opacity(0.72))
899
+ .fixedSize(horizontal: false, vertical: true)
900
+ }
901
+ .padding(12)
902
+ .frame(maxWidth: .infinity, alignment: .leading)
903
+ .background(shortcutsInsetPanel)
904
+ } else {
905
+ VStack(alignment: .leading, spacing: 8) {
906
+ ForEach(trustedDevices) { device in
907
+ companionDeviceRow(device)
908
+ }
909
+ }
910
+ }
911
+ }
912
+ }
913
+ }
914
+
915
+ private func companionBridgeFact(label: String, value: String) -> some View {
916
+ VStack(alignment: .leading, spacing: 5) {
917
+ Text(label.uppercased())
918
+ .font(Typo.pixel(11))
919
+ .foregroundColor(Palette.textDim)
920
+ .tracking(1)
921
+ Text(value)
922
+ .font(Typo.monoBold(11))
923
+ .foregroundColor(Palette.text)
924
+ .lineLimit(1)
925
+ .truncationMode(.middle)
926
+ }
927
+ .padding(10)
928
+ .frame(maxWidth: .infinity, alignment: .leading)
929
+ .background(shortcutsInsetPanel)
930
+ }
931
+
932
+ private func companionDeviceRow(_ device: DeckTrustedDeviceSummary) -> some View {
933
+ HStack(alignment: .top, spacing: 10) {
934
+ RoundedRectangle(cornerRadius: 6)
935
+ .fill(Palette.surfaceHov)
936
+ .overlay(
937
+ Image(systemName: companionDeviceIcon(for: device.name))
938
+ .font(.system(size: 13, weight: .semibold))
939
+ .foregroundColor(Palette.textDim)
940
+ )
941
+ .frame(width: 30, height: 30)
942
+
943
+ VStack(alignment: .leading, spacing: 6) {
944
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
945
+ Text(device.name)
946
+ .font(Typo.monoBold(11))
947
+ .foregroundColor(Palette.text)
948
+ .lineLimit(1)
949
+
950
+ Text(device.fingerprint)
951
+ .font(Typo.mono(10))
952
+ .foregroundColor(Palette.textMuted)
953
+ .lineLimit(1)
954
+
955
+ Spacer(minLength: 0)
956
+ }
957
+
958
+ HStack(spacing: 10) {
959
+ Text("Paired \(relativeTimestamp(device.pairedAt))")
960
+ Text("Last seen \(relativeTimestamp(device.lastSeenAt))")
961
+ }
962
+ .font(Typo.caption(9.5))
963
+ .foregroundColor(Palette.textMuted.opacity(0.78))
964
+
965
+ HStack(spacing: 6) {
966
+ ForEach(device.capabilities, id: \.self) { capability in
967
+ companionCapabilityBadge(capability)
968
+ }
969
+ }
970
+ }
971
+
972
+ Spacer(minLength: 0)
973
+
974
+ Button {
975
+ guard confirmRevokeTrustedDevice(device) else { return }
976
+ LatticesCompanionSecurityCoordinator.shared.revokeTrustedDevice(id: device.id)
977
+ companionTrustRevision += 1
978
+ } label: {
979
+ HStack(spacing: 5) {
980
+ Image(systemName: "xmark.shield")
981
+ .font(.system(size: 10, weight: .semibold))
982
+ Text("Revoke")
983
+ .font(Typo.monoBold(9.5))
984
+ }
985
+ .foregroundColor(Palette.kill.opacity(0.95))
986
+ .padding(.horizontal, 8)
987
+ .padding(.vertical, 5)
988
+ .background(
989
+ RoundedRectangle(cornerRadius: 5)
990
+ .fill(Palette.kill.opacity(0.10))
991
+ .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Palette.kill.opacity(0.22), lineWidth: 0.5))
992
+ )
993
+ }
994
+ .buttonStyle(.plain)
995
+ .help("Revoke this paired device")
996
+ }
997
+ .padding(12)
998
+ .frame(maxWidth: .infinity, alignment: .leading)
999
+ .background(shortcutsInsetPanel)
1000
+ }
1001
+
1002
+ private func companionCapabilityBadge(_ capability: String) -> some View {
1003
+ Text(companionCapabilityLabel(capability))
1004
+ .font(Typo.monoBold(9))
1005
+ .foregroundColor(Palette.running.opacity(0.92))
1006
+ .padding(.horizontal, 7)
1007
+ .padding(.vertical, 3)
1008
+ .background(
1009
+ Capsule()
1010
+ .fill(Palette.running.opacity(0.10))
1011
+ .overlay(Capsule().strokeBorder(Palette.running.opacity(0.18), lineWidth: 0.5))
1012
+ )
1013
+ }
1014
+
1015
+ private func companionCapabilityLabel(_ capability: String) -> String {
1016
+ switch capability {
1017
+ case DeckBridgeCapability.deckRead:
1018
+ return "Deck Read"
1019
+ case DeckBridgeCapability.deckPerform:
1020
+ return "Deck Actions"
1021
+ case DeckBridgeCapability.inputTrackpad:
1022
+ return "Trackpad"
1023
+ default:
1024
+ return capability
1025
+ }
1026
+ }
1027
+
1028
+ private func companionDeviceIcon(for name: String) -> String {
1029
+ name.localizedCaseInsensitiveContains("ipad") ? "ipad" : "iphone"
1030
+ }
1031
+
1032
+ private func confirmForgetTrustedDevices() -> Bool {
1033
+ let alert = NSAlert()
1034
+ alert.alertStyle = .warning
1035
+ alert.messageText = "Forget all paired companion devices?"
1036
+ alert.informativeText = "Your iPad or iPhone will need to pair again before it can control Lattices."
1037
+ alert.addButton(withTitle: "Forget Devices")
1038
+ alert.addButton(withTitle: "Cancel")
1039
+ return alert.runModal() == .alertFirstButtonReturn
1040
+ }
1041
+
1042
+ private func confirmRevokeTrustedDevice(_ device: DeckTrustedDeviceSummary) -> Bool {
1043
+ let alert = NSAlert()
1044
+ alert.alertStyle = .warning
1045
+ alert.messageText = "Revoke \(device.name)?"
1046
+ alert.informativeText = """
1047
+ This removes the paired-device trust record for \(device.name).
1048
+
1049
+ Fingerprint: \(device.fingerprint)
1050
+
1051
+ The device will need to pair again before it can control Lattices.
1052
+ """
1053
+ alert.addButton(withTitle: "Revoke Device")
1054
+ alert.addButton(withTitle: "Cancel")
1055
+ return alert.runModal() == .alertFirstButtonReturn
1056
+ }
1057
+
577
1058
  // MARK: - AI
578
1059
 
579
1060
  private var aiContent: some View {
@@ -1226,7 +1707,7 @@ struct SettingsContentView: View {
1226
1707
  let layout = LatticesCompanionCockpitCatalog.normalized(prefs.companionCockpitLayout)
1227
1708
  let selectedPage = layout.pages.first(where: { $0.id == selectedCompanionCockpitPageID }) ?? layout.pages.first
1228
1709
  let categories = LatticesCompanionShortcutCategory.allCases
1229
- let trustedDevices = companionTrustedDevices(revision: companionTrustRevision)
1710
+ let trustedDeviceCount = companionTrustedDevices(revision: companionTrustRevision).count
1230
1711
 
1231
1712
  return shortcutSectionCard(
1232
1713
  title: "Companion Cockpit",
@@ -1250,63 +1731,45 @@ struct SettingsContentView: View {
1250
1731
  Toggle("", isOn: $prefs.companionTrackpadEnabled)
1251
1732
  .toggleStyle(.switch)
1252
1733
  .labelsHidden()
1734
+ .disabled(!prefs.companionBridgeEnabled)
1735
+ .opacity(prefs.companionBridgeEnabled ? 1 : 0.45)
1253
1736
  }
1254
1737
 
1255
- VStack(alignment: .leading, spacing: 8) {
1256
- HStack {
1257
- VStack(alignment: .leading, spacing: 4) {
1258
- Text("Trusted Devices")
1259
- .font(Typo.monoBold(11))
1260
- .foregroundColor(Palette.text)
1261
- Text("New companions must be approved on the Mac before they can send encrypted bridge requests.")
1262
- .font(Typo.caption(10.5))
1263
- .foregroundColor(Palette.textMuted)
1264
- .fixedSize(horizontal: false, vertical: true)
1265
- }
1266
-
1267
- Spacer()
1268
-
1269
- if trustedDevices.isEmpty == false {
1270
- Button("Forget All") {
1271
- LatticesCompanionSecurityCoordinator.shared.clearTrustedDevices()
1272
- companionTrustRevision += 1
1273
- }
1274
- .buttonStyle(.plain)
1738
+ HStack(alignment: .center, spacing: 12) {
1739
+ VStack(alignment: .leading, spacing: 4) {
1740
+ Text("Pairing and trust")
1741
+ .font(Typo.monoBold(11))
1742
+ .foregroundColor(Palette.text)
1743
+ Text("\(trustedDeviceCount) paired \(trustedDeviceCount == 1 ? "device" : "devices"). Revoke devices and review bridge grants in Companion settings.")
1275
1744
  .font(Typo.caption(10.5))
1276
- .foregroundColor(Palette.textDim)
1277
- }
1745
+ .foregroundColor(Palette.textMuted)
1746
+ .fixedSize(horizontal: false, vertical: true)
1278
1747
  }
1279
1748
 
1280
- VStack(alignment: .leading, spacing: 6) {
1281
- if trustedDevices.isEmpty {
1282
- Text("No paired iPad or iPhone devices yet.")
1283
- .font(Typo.caption(10.5))
1284
- .foregroundColor(Palette.textMuted)
1285
- } else {
1286
- ForEach(trustedDevices) { device in
1287
- HStack(alignment: .top, spacing: 10) {
1288
- Image(systemName: "iphone.gen3")
1289
- .font(.system(size: 11, weight: .semibold))
1290
- .foregroundColor(Palette.textDim)
1291
- .frame(width: 14)
1292
-
1293
- VStack(alignment: .leading, spacing: 2) {
1294
- Text(device.name)
1295
- .font(Typo.caption(11))
1296
- .foregroundColor(Palette.text)
1297
- Text("\(device.fingerprint) · Last seen \(relativeTimestamp(device.lastSeenAt))")
1298
- .font(Typo.caption(10))
1299
- .foregroundColor(Palette.textMuted)
1300
- }
1301
-
1302
- Spacer(minLength: 0)
1303
- }
1304
- }
1749
+ Spacer()
1750
+
1751
+ Button {
1752
+ selectedTab = .companion
1753
+ } label: {
1754
+ HStack(spacing: 5) {
1755
+ Image(systemName: "ipad.and.iphone")
1756
+ .font(.system(size: 10, weight: .semibold))
1757
+ Text("Manage")
1758
+ .font(Typo.monoBold(10))
1305
1759
  }
1760
+ .foregroundColor(Palette.text)
1761
+ .padding(.horizontal, 10)
1762
+ .padding(.vertical, 5)
1763
+ .background(
1764
+ RoundedRectangle(cornerRadius: 5)
1765
+ .fill(Palette.surfaceHov)
1766
+ .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Palette.borderLit, lineWidth: 0.5))
1767
+ )
1306
1768
  }
1307
- .padding(12)
1308
- .background(shortcutsInsetPanel)
1769
+ .buttonStyle(.plain)
1309
1770
  }
1771
+ .padding(12)
1772
+ .background(shortcutsInsetPanel)
1310
1773
 
1311
1774
  if let selectedPage {
1312
1775
  Picker("Companion page", selection: $selectedCompanionCockpitPageID) {
@@ -1541,6 +2004,8 @@ struct SettingsContentView: View {
1541
2004
  .foregroundColor(Palette.textMuted)
1542
2005
  .fixedSize(horizontal: false, vertical: true)
1543
2006
  }
2007
+
2008
+ compactKeyRecorder(action: .tileOrganize)
1544
2009
  }
1545
2010
  }
1546
2011