@lattices/cli 0.4.7 → 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 (31) 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 +60 -1
  7. package/app/Sources/AppShell/AppShellView.swift +10 -0
  8. package/app/Sources/AppShell/CliActionLauncher.swift +2 -2
  9. package/app/Sources/AppShell/MainView.swift +1 -1
  10. package/app/Sources/AppShell/Preferences.swift +29 -1
  11. package/app/Sources/AppShell/SettingsView.swift +498 -58
  12. package/app/Sources/AppShell/SettingsWindow.swift +4 -0
  13. package/app/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +23 -7
  14. package/app/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +35 -0
  15. package/app/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +1 -1
  16. package/app/Sources/Core/Input/KeyboardRemapConfig.swift +69 -0
  17. package/app/Sources/Core/Input/KeyboardRemapController.swift +184 -0
  18. package/app/Sources/Core/Input/KeyboardRemapStore.swift +84 -0
  19. package/app/Sources/Core/Workspace/SessionManager.swift +1 -1
  20. package/app/Sources/Core/Workspace/WorkspaceManager.swift +3 -3
  21. package/bin/lattices-app.ts +11 -0
  22. package/bin/lattices-dev +11 -0
  23. package/bin/lattices.ts +57 -17
  24. package/docs/app.md +30 -2
  25. package/docs/companion-deck.md +29 -0
  26. package/docs/concepts.md +5 -5
  27. package/docs/config.md +34 -9
  28. package/docs/layers.md +1 -1
  29. package/docs/overview.md +1 -1
  30. package/docs/quickstart.md +4 -4
  31. 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:
@@ -60,6 +66,7 @@ struct SettingsContentView: View {
60
66
  @ObservedObject var workspaceManager: WorkspaceManager = .shared
61
67
  @ObservedObject var appUpdater: AppUpdater = .shared
62
68
  @ObservedObject var mouseShortcutStore: MouseShortcutStore = .shared
69
+ @ObservedObject var keyboardRemapStore: KeyboardRemapStore = .shared
63
70
  var onBack: (() -> Void)? = nil
64
71
 
65
72
  @State private var selectedTab: SettingsSection = .general
@@ -78,6 +85,11 @@ struct SettingsContentView: View {
78
85
  .frame(maxWidth: .infinity, maxHeight: .infinity)
79
86
  .clipped()
80
87
  .background(PanelBackground())
88
+ .onAppear {
89
+ if page == .companionSettings {
90
+ selectedTab = .companion
91
+ }
92
+ }
81
93
  }
82
94
 
83
95
  // MARK: - Back Bar
@@ -96,15 +108,17 @@ struct SettingsContentView: View {
96
108
  private var backBar: some View {
97
109
  VStack(spacing: 0) {
98
110
  HStack(spacing: 8) {
99
- Button {
100
- onBack?()
101
- } label: {
102
- Image(systemName: "chevron.left")
103
- .font(.system(size: 10, weight: .semibold))
104
- .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() } }
105
121
  }
106
- .buttonStyle(.plain)
107
- .onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
108
122
 
109
123
  Text(page == .docs ? "Docs" : currentTabLabel)
110
124
  .font(Typo.heading(13))
@@ -240,6 +254,8 @@ struct SettingsContentView: View {
240
254
  switch selectedTab {
241
255
  case .general:
242
256
  generalContent
257
+ case .companion:
258
+ companionContent
243
259
  case .ai:
244
260
  aiContent
245
261
  case .search:
@@ -590,6 +606,126 @@ struct SettingsContentView: View {
590
606
  .foregroundColor(Palette.textMuted.opacity(0.7))
591
607
  }
592
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
+ }
686
+ }
687
+ .padding(16)
688
+ .frame(maxWidth: 760, alignment: .leading)
689
+ .frame(maxWidth: .infinity, alignment: .leading)
690
+ }
691
+ }
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
+ }
593
729
  }
594
730
  .padding(16)
595
731
  .frame(maxWidth: 760, alignment: .leading)
@@ -597,6 +733,328 @@ struct SettingsContentView: View {
597
733
  }
598
734
  }
599
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
+
600
1058
  // MARK: - AI
601
1059
 
602
1060
  private var aiContent: some View {
@@ -1249,7 +1707,7 @@ struct SettingsContentView: View {
1249
1707
  let layout = LatticesCompanionCockpitCatalog.normalized(prefs.companionCockpitLayout)
1250
1708
  let selectedPage = layout.pages.first(where: { $0.id == selectedCompanionCockpitPageID }) ?? layout.pages.first
1251
1709
  let categories = LatticesCompanionShortcutCategory.allCases
1252
- let trustedDevices = companionTrustedDevices(revision: companionTrustRevision)
1710
+ let trustedDeviceCount = companionTrustedDevices(revision: companionTrustRevision).count
1253
1711
 
1254
1712
  return shortcutSectionCard(
1255
1713
  title: "Companion Cockpit",
@@ -1273,63 +1731,45 @@ struct SettingsContentView: View {
1273
1731
  Toggle("", isOn: $prefs.companionTrackpadEnabled)
1274
1732
  .toggleStyle(.switch)
1275
1733
  .labelsHidden()
1734
+ .disabled(!prefs.companionBridgeEnabled)
1735
+ .opacity(prefs.companionBridgeEnabled ? 1 : 0.45)
1276
1736
  }
1277
1737
 
1278
- VStack(alignment: .leading, spacing: 8) {
1279
- HStack {
1280
- VStack(alignment: .leading, spacing: 4) {
1281
- Text("Trusted Devices")
1282
- .font(Typo.monoBold(11))
1283
- .foregroundColor(Palette.text)
1284
- Text("New companions must be approved on the Mac before they can send encrypted bridge requests.")
1285
- .font(Typo.caption(10.5))
1286
- .foregroundColor(Palette.textMuted)
1287
- .fixedSize(horizontal: false, vertical: true)
1288
- }
1289
-
1290
- Spacer()
1291
-
1292
- if trustedDevices.isEmpty == false {
1293
- Button("Forget All") {
1294
- LatticesCompanionSecurityCoordinator.shared.clearTrustedDevices()
1295
- companionTrustRevision += 1
1296
- }
1297
- .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.")
1298
1744
  .font(Typo.caption(10.5))
1299
- .foregroundColor(Palette.textDim)
1300
- }
1745
+ .foregroundColor(Palette.textMuted)
1746
+ .fixedSize(horizontal: false, vertical: true)
1301
1747
  }
1302
1748
 
1303
- VStack(alignment: .leading, spacing: 6) {
1304
- if trustedDevices.isEmpty {
1305
- Text("No paired iPad or iPhone devices yet.")
1306
- .font(Typo.caption(10.5))
1307
- .foregroundColor(Palette.textMuted)
1308
- } else {
1309
- ForEach(trustedDevices) { device in
1310
- HStack(alignment: .top, spacing: 10) {
1311
- Image(systemName: "iphone.gen3")
1312
- .font(.system(size: 11, weight: .semibold))
1313
- .foregroundColor(Palette.textDim)
1314
- .frame(width: 14)
1315
-
1316
- VStack(alignment: .leading, spacing: 2) {
1317
- Text(device.name)
1318
- .font(Typo.caption(11))
1319
- .foregroundColor(Palette.text)
1320
- Text("\(device.fingerprint) · Last seen \(relativeTimestamp(device.lastSeenAt))")
1321
- .font(Typo.caption(10))
1322
- .foregroundColor(Palette.textMuted)
1323
- }
1324
-
1325
- Spacer(minLength: 0)
1326
- }
1327
- }
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))
1328
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
+ )
1329
1768
  }
1330
- .padding(12)
1331
- .background(shortcutsInsetPanel)
1769
+ .buttonStyle(.plain)
1332
1770
  }
1771
+ .padding(12)
1772
+ .background(shortcutsInsetPanel)
1333
1773
 
1334
1774
  if let selectedPage {
1335
1775
  Picker("Companion page", selection: $selectedCompanionCockpitPageID) {
@@ -14,6 +14,10 @@ final class SettingsWindowController {
14
14
  ScreenMapWindowController.shared.showPage(.settings)
15
15
  }
16
16
 
17
+ func showCompanion() {
18
+ ScreenMapWindowController.shared.showPage(.companionSettings)
19
+ }
20
+
17
21
  func close() {
18
22
  ScreenMapWindowController.shared.close()
19
23
  }