@lattices/cli 0.4.7 → 0.4.9

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 (32) 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 +64 -1
  7. package/app/Sources/AppShell/AppShellView.swift +10 -0
  8. package/app/Sources/AppShell/AppUpdater.swift +216 -4
  9. package/app/Sources/AppShell/CliActionLauncher.swift +2 -2
  10. package/app/Sources/AppShell/MainView.swift +1 -1
  11. package/app/Sources/AppShell/Preferences.swift +29 -1
  12. package/app/Sources/AppShell/SettingsView.swift +576 -61
  13. package/app/Sources/AppShell/SettingsWindow.swift +4 -0
  14. package/app/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +23 -7
  15. package/app/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +35 -0
  16. package/app/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +1 -1
  17. package/app/Sources/Core/Input/KeyboardRemapConfig.swift +69 -0
  18. package/app/Sources/Core/Input/KeyboardRemapController.swift +184 -0
  19. package/app/Sources/Core/Input/KeyboardRemapStore.swift +84 -0
  20. package/app/Sources/Core/Workspace/SessionManager.swift +1 -1
  21. package/app/Sources/Core/Workspace/WorkspaceManager.swift +3 -3
  22. package/bin/lattices-app.ts +11 -0
  23. package/bin/lattices-dev +11 -0
  24. package/bin/lattices.ts +57 -17
  25. package/docs/app.md +30 -2
  26. package/docs/companion-deck.md +29 -0
  27. package/docs/concepts.md +5 -5
  28. package/docs/config.md +34 -9
  29. package/docs/layers.md +1 -1
  30. package/docs/overview.md +1 -1
  31. package/docs/quickstart.md +4 -4
  32. 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:
@@ -285,15 +301,53 @@ struct SettingsContentView: View {
285
301
  .font(Typo.mono(12))
286
302
  .foregroundColor(Palette.text)
287
303
  Spacer()
288
- Text("v\(appUpdater.currentVersion)")
304
+ Text("Current v\(appUpdater.currentVersion)")
289
305
  .font(Typo.caption(10))
290
306
  .foregroundColor(Palette.textMuted)
291
307
  }
292
308
 
293
- Text("Install the latest published app build without leaving the menu bar. The app relaunches when the update finishes.")
309
+ Text("Lattices can check for new signed releases and prepare the update here. You’ll confirm before the app quits and relaunches.")
294
310
  .font(Typo.caption(10))
295
311
  .foregroundColor(Palette.textMuted)
296
312
 
313
+ if let update = appUpdater.availableUpdate {
314
+ VStack(alignment: .leading, spacing: 6) {
315
+ HStack(spacing: 6) {
316
+ Image(systemName: "gift.fill")
317
+ .font(.system(size: 10, weight: .semibold))
318
+ .foregroundColor(Palette.detach)
319
+ Text("New version v\(update.version) is ready")
320
+ .font(Typo.monoBold(10))
321
+ .foregroundColor(Palette.detach)
322
+ }
323
+
324
+ if !update.releaseNotes.isEmpty {
325
+ Text(String(update.releaseNotes.prefix(180)) + (update.releaseNotes.count > 180 ? "..." : ""))
326
+ .font(Typo.caption(9))
327
+ .foregroundColor(Palette.textMuted)
328
+ .lineLimit(3)
329
+ }
330
+ }
331
+ .padding(8)
332
+ .background(
333
+ RoundedRectangle(cornerRadius: 5)
334
+ .fill(Palette.surfaceHov.opacity(0.65))
335
+ .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Palette.detach.opacity(0.35), lineWidth: 0.5))
336
+ )
337
+ } else if appUpdater.isChecking {
338
+ Text("Checking for updates...")
339
+ .font(Typo.caption(9))
340
+ .foregroundColor(Palette.textMuted)
341
+ } else if let error = appUpdater.lastError {
342
+ Text(error)
343
+ .font(Typo.caption(9))
344
+ .foregroundColor(Palette.detach.opacity(0.9))
345
+ } else if let checked = appUpdater.lastChecked {
346
+ Text("Last checked \(checked, style: .relative)")
347
+ .font(Typo.caption(9))
348
+ .foregroundColor(Palette.textMuted.opacity(0.8))
349
+ }
350
+
297
351
  if let status = appUpdater.statusMessage {
298
352
  Text(status)
299
353
  .font(Typo.caption(9))
@@ -310,7 +364,7 @@ struct SettingsContentView: View {
310
364
  Button {
311
365
  appUpdater.promptForUpdate()
312
366
  } label: {
313
- Text(appUpdater.isUpdating ? "Updating…" : "Update Lattices")
367
+ Text(appUpdater.isUpdating ? "Preparing…" : (appUpdater.availableUpdate == nil ? "Check for Updates" : "Update to v\(appUpdater.availableUpdate?.version ?? "")"))
314
368
  .font(Typo.monoBold(10))
315
369
  .foregroundColor(Palette.text)
316
370
  .padding(.horizontal, 12)
@@ -324,6 +378,43 @@ struct SettingsContentView: View {
324
378
  .buttonStyle(.plain)
325
379
  .disabled(appUpdater.isUpdating)
326
380
 
381
+ Button {
382
+ Task { await appUpdater.check() }
383
+ } label: {
384
+ Text(appUpdater.isChecking ? "Checking..." : "Check Now")
385
+ .font(Typo.caption(9))
386
+ .foregroundColor(Palette.textMuted.opacity(0.9))
387
+ }
388
+ .buttonStyle(.plain)
389
+ .disabled(appUpdater.isChecking)
390
+
391
+ Toggle("Auto", isOn: $appUpdater.autoCheckEnabled)
392
+ .font(Typo.caption(9))
393
+ .toggleStyle(.checkbox)
394
+ .foregroundColor(Palette.textMuted.opacity(0.9))
395
+
396
+ if appUpdater.availableUpdate != nil {
397
+ Button {
398
+ appUpdater.viewCurrentRelease()
399
+ } label: {
400
+ Text("Release Notes")
401
+ .font(Typo.caption(9))
402
+ .foregroundColor(Palette.textMuted.opacity(0.9))
403
+ }
404
+ .buttonStyle(.plain)
405
+
406
+ Button {
407
+ appUpdater.skipCurrentUpdate()
408
+ } label: {
409
+ Text("Skip")
410
+ .font(Typo.caption(9))
411
+ .foregroundColor(Palette.textMuted.opacity(0.75))
412
+ }
413
+ .buttonStyle(.plain)
414
+ }
415
+
416
+ Spacer()
417
+
327
418
  Text("CLI: `lattices app update`")
328
419
  .font(Typo.caption(9))
329
420
  .foregroundColor(Palette.textMuted.opacity(0.8))
@@ -590,6 +681,83 @@ struct SettingsContentView: View {
590
681
  .foregroundColor(Palette.textMuted.opacity(0.7))
591
682
  }
592
683
  }
684
+
685
+ settingsCard {
686
+ VStack(alignment: .leading, spacing: 10) {
687
+ Text("Keyboard remaps")
688
+ .font(Typo.mono(11))
689
+ .foregroundColor(Palette.text)
690
+
691
+ HStack {
692
+ Text("Caps Lock as Hyper")
693
+ .font(Typo.mono(10))
694
+ .foregroundColor(Palette.textDim)
695
+ Spacer()
696
+ Toggle("", isOn: $prefs.keyboardRemapsEnabled)
697
+ .toggleStyle(.switch)
698
+ .controlSize(.small)
699
+ .labelsHidden()
700
+ }
701
+
702
+ 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.")
703
+ .font(Typo.caption(9))
704
+ .foregroundColor(Palette.textMuted.opacity(0.7))
705
+
706
+ cardDivider
707
+
708
+ VStack(alignment: .leading, spacing: 6) {
709
+ Text("Active remaps")
710
+ .font(Typo.mono(10))
711
+ .foregroundColor(Palette.textDim)
712
+
713
+ ForEach(keyboardRemapStore.summaryLines.prefix(4), id: \.self) { line in
714
+ Text(line)
715
+ .font(Typo.caption(9))
716
+ .foregroundColor(Palette.textMuted.opacity(0.78))
717
+ }
718
+
719
+ if keyboardRemapStore.summaryLines.isEmpty {
720
+ Text("No active remaps")
721
+ .font(Typo.caption(9))
722
+ .foregroundColor(Palette.textMuted.opacity(0.6))
723
+ }
724
+ }
725
+
726
+ HStack(spacing: 8) {
727
+ Button {
728
+ keyboardRemapStore.openConfiguration()
729
+ } label: {
730
+ Text("Configure...")
731
+ .font(Typo.monoBold(10))
732
+ .foregroundColor(Palette.text)
733
+ .padding(.horizontal, 12)
734
+ .padding(.vertical, 4)
735
+ .background(
736
+ RoundedRectangle(cornerRadius: 4)
737
+ .fill(Palette.surfaceHov)
738
+ .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
739
+ )
740
+ }
741
+ .buttonStyle(.plain)
742
+
743
+ Button {
744
+ keyboardRemapStore.restoreDefaults()
745
+ } label: {
746
+ Text("Restore Defaults")
747
+ .font(Typo.monoBold(10))
748
+ .foregroundColor(Palette.text)
749
+ .padding(.horizontal, 12)
750
+ .padding(.vertical, 4)
751
+ .background(
752
+ RoundedRectangle(cornerRadius: 4)
753
+ .fill(Palette.surfaceHov)
754
+ .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
755
+ )
756
+ }
757
+ .buttonStyle(.plain)
758
+ }
759
+ }
760
+ }
593
761
  }
594
762
  .padding(16)
595
763
  .frame(maxWidth: 760, alignment: .leading)
@@ -597,6 +765,371 @@ struct SettingsContentView: View {
597
765
  }
598
766
  }
599
767
 
768
+ // MARK: - Companion
769
+
770
+ private var companionContent: some View {
771
+ ScrollView {
772
+ VStack(alignment: .leading, spacing: 12) {
773
+ companionBridgeOverviewCard
774
+ companionTrustedDevicesCard
775
+
776
+ settingsCard {
777
+ VStack(alignment: .leading, spacing: 10) {
778
+ Text("Input")
779
+ .font(Typo.mono(11))
780
+ .foregroundColor(Palette.text)
781
+
782
+ HStack(alignment: .top, spacing: 12) {
783
+ VStack(alignment: .leading, spacing: 4) {
784
+ Text("Trackpad proxy")
785
+ .font(Typo.mono(10))
786
+ .foregroundColor(Palette.textDim)
787
+ Text("Allow paired companions with the input.trackpad grant to move the Mac pointer through the encrypted bridge.")
788
+ .font(Typo.caption(9.5))
789
+ .foregroundColor(Palette.textMuted.opacity(0.75))
790
+ .fixedSize(horizontal: false, vertical: true)
791
+ }
792
+
793
+ Spacer()
794
+
795
+ Toggle("", isOn: $prefs.companionTrackpadEnabled)
796
+ .toggleStyle(.switch)
797
+ .controlSize(.small)
798
+ .labelsHidden()
799
+ .disabled(!prefs.companionBridgeEnabled)
800
+ .opacity(prefs.companionBridgeEnabled ? 1 : 0.45)
801
+ }
802
+ }
803
+ }
804
+ }
805
+ .padding(16)
806
+ .frame(maxWidth: 760, alignment: .leading)
807
+ .frame(maxWidth: .infinity, alignment: .leading)
808
+ }
809
+ }
810
+
811
+ private var companionBridgeOverviewCard: some View {
812
+ settingsCard {
813
+ VStack(alignment: .leading, spacing: 12) {
814
+ HStack(alignment: .top, spacing: 10) {
815
+ RoundedRectangle(cornerRadius: 6)
816
+ .fill(Palette.running.opacity(0.14))
817
+ .overlay(
818
+ Image(systemName: "lock.shield")
819
+ .font(.system(size: 13, weight: .semibold))
820
+ .foregroundColor(Palette.running)
821
+ )
822
+ .frame(width: 30, height: 30)
823
+
824
+ VStack(alignment: .leading, spacing: 3) {
825
+ Text(prefs.companionBridgeEnabled ? "Secure local bridge" : "Local bridge off")
826
+ .font(Typo.mono(12))
827
+ .foregroundColor(Palette.text)
828
+ Text(prefs.companionBridgeEnabled
829
+ ? "Bonjour discovery with explicit Mac approval, signed requests, encrypted payloads, and capability grants."
830
+ : "The companion bridge is not listening or advertising on the local network until you turn it on.")
831
+ .font(Typo.caption(10))
832
+ .foregroundColor(Palette.textMuted)
833
+ .fixedSize(horizontal: false, vertical: true)
834
+ }
835
+
836
+ Spacer()
837
+
838
+ Toggle("", isOn: $prefs.companionBridgeEnabled)
839
+ .toggleStyle(.switch)
840
+ .controlSize(.small)
841
+ .labelsHidden()
842
+ }
843
+
844
+ cardDivider
845
+
846
+ LazyVGrid(
847
+ columns: [
848
+ GridItem(.flexible(minimum: 120), spacing: 10, alignment: .leading),
849
+ GridItem(.flexible(minimum: 120), spacing: 10, alignment: .leading),
850
+ GridItem(.flexible(minimum: 120), spacing: 10, alignment: .leading),
851
+ ],
852
+ alignment: .leading,
853
+ spacing: 10
854
+ ) {
855
+ companionBridgeFact(
856
+ label: "Status",
857
+ value: prefs.companionBridgeEnabled ? "enabled" : "off"
858
+ )
859
+ companionBridgeFact(
860
+ label: "Port",
861
+ value: String(LatticesCompanionBridgeServer.defaultPort)
862
+ )
863
+ companionBridgeFact(
864
+ label: "Protocol",
865
+ value: "v\(LatticesCompanionBridgeServer.protocolVersion)"
866
+ )
867
+ }
868
+
869
+ VStack(alignment: .leading, spacing: 5) {
870
+ Text("Enable deep link")
871
+ .font(Typo.mono(10))
872
+ .foregroundColor(Palette.textDim)
873
+ Text("lattices://companion/enable")
874
+ .font(Typo.monoBold(12))
875
+ .foregroundColor(Palette.text)
876
+ .textSelection(.enabled)
877
+ }
878
+ .padding(10)
879
+ .frame(maxWidth: .infinity, alignment: .leading)
880
+ .background(shortcutsInsetPanel)
881
+
882
+ VStack(alignment: .leading, spacing: 5) {
883
+ Text("Mac bridge fingerprint")
884
+ .font(Typo.mono(10))
885
+ .foregroundColor(Palette.textDim)
886
+ Text(LatticesCompanionSecurityCoordinator.shared.bridgeFingerprint)
887
+ .font(Typo.monoBold(13))
888
+ .foregroundColor(Palette.text)
889
+ .textSelection(.enabled)
890
+ }
891
+ .padding(10)
892
+ .frame(maxWidth: .infinity, alignment: .leading)
893
+ .background(shortcutsInsetPanel)
894
+
895
+ HStack(spacing: 6) {
896
+ ForEach(DeckBridgeCapability.defaultCompanionCapabilities, id: \.self) { capability in
897
+ companionCapabilityBadge(capability)
898
+ }
899
+ Spacer(minLength: 0)
900
+ }
901
+ }
902
+ }
903
+ }
904
+
905
+ private var companionTrustedDevicesCard: some View {
906
+ let trustedDevices = companionTrustedDevices(revision: companionTrustRevision)
907
+
908
+ return settingsCard {
909
+ VStack(alignment: .leading, spacing: 12) {
910
+ HStack(alignment: .top, spacing: 12) {
911
+ VStack(alignment: .leading, spacing: 4) {
912
+ Text("Paired devices")
913
+ .font(Typo.mono(12))
914
+ .foregroundColor(Palette.text)
915
+ Text("Only trusted devices can call protected deck and input routes. Pairing grants are listed per device.")
916
+ .font(Typo.caption(10))
917
+ .foregroundColor(Palette.textMuted)
918
+ .fixedSize(horizontal: false, vertical: true)
919
+ }
920
+
921
+ Spacer()
922
+
923
+ HStack(spacing: 8) {
924
+ Button {
925
+ companionTrustRevision += 1
926
+ } label: {
927
+ Image(systemName: "arrow.clockwise")
928
+ .font(.system(size: 10, weight: .semibold))
929
+ .foregroundColor(Palette.textDim)
930
+ .frame(width: 24, height: 24)
931
+ .background(
932
+ RoundedRectangle(cornerRadius: 5)
933
+ .fill(Palette.surfaceHov)
934
+ .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Palette.borderLit, lineWidth: 0.5))
935
+ )
936
+ }
937
+ .buttonStyle(.plain)
938
+
939
+ if trustedDevices.isEmpty == false {
940
+ Button {
941
+ guard confirmForgetTrustedDevices() else { return }
942
+ LatticesCompanionSecurityCoordinator.shared.clearTrustedDevices()
943
+ companionTrustRevision += 1
944
+ } label: {
945
+ Text("Forget All")
946
+ .font(Typo.monoBold(10))
947
+ .foregroundColor(Palette.kill.opacity(0.9))
948
+ .padding(.horizontal, 10)
949
+ .padding(.vertical, 5)
950
+ .background(
951
+ RoundedRectangle(cornerRadius: 5)
952
+ .fill(Palette.kill.opacity(0.10))
953
+ .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Palette.kill.opacity(0.22), lineWidth: 0.5))
954
+ )
955
+ }
956
+ .buttonStyle(.plain)
957
+ }
958
+ }
959
+ }
960
+
961
+ if trustedDevices.isEmpty {
962
+ VStack(alignment: .leading, spacing: 6) {
963
+ Image(systemName: "ipad.and.iphone")
964
+ .font(.system(size: 18, weight: .semibold))
965
+ .foregroundColor(Palette.textMuted)
966
+
967
+ Text("No paired iPad or iPhone devices yet.")
968
+ .font(Typo.caption(10.5))
969
+ .foregroundColor(Palette.textMuted)
970
+
971
+ Text("Open the Lattices companion app on your iPad and select this Mac. You’ll approve the pairing prompt here.")
972
+ .font(Typo.caption(9.5))
973
+ .foregroundColor(Palette.textMuted.opacity(0.72))
974
+ .fixedSize(horizontal: false, vertical: true)
975
+ }
976
+ .padding(12)
977
+ .frame(maxWidth: .infinity, alignment: .leading)
978
+ .background(shortcutsInsetPanel)
979
+ } else {
980
+ VStack(alignment: .leading, spacing: 8) {
981
+ ForEach(trustedDevices) { device in
982
+ companionDeviceRow(device)
983
+ }
984
+ }
985
+ }
986
+ }
987
+ }
988
+ }
989
+
990
+ private func companionBridgeFact(label: String, value: String) -> some View {
991
+ VStack(alignment: .leading, spacing: 5) {
992
+ Text(label.uppercased())
993
+ .font(Typo.pixel(11))
994
+ .foregroundColor(Palette.textDim)
995
+ .tracking(1)
996
+ Text(value)
997
+ .font(Typo.monoBold(11))
998
+ .foregroundColor(Palette.text)
999
+ .lineLimit(1)
1000
+ .truncationMode(.middle)
1001
+ }
1002
+ .padding(10)
1003
+ .frame(maxWidth: .infinity, alignment: .leading)
1004
+ .background(shortcutsInsetPanel)
1005
+ }
1006
+
1007
+ private func companionDeviceRow(_ device: DeckTrustedDeviceSummary) -> some View {
1008
+ HStack(alignment: .top, spacing: 10) {
1009
+ RoundedRectangle(cornerRadius: 6)
1010
+ .fill(Palette.surfaceHov)
1011
+ .overlay(
1012
+ Image(systemName: companionDeviceIcon(for: device.name))
1013
+ .font(.system(size: 13, weight: .semibold))
1014
+ .foregroundColor(Palette.textDim)
1015
+ )
1016
+ .frame(width: 30, height: 30)
1017
+
1018
+ VStack(alignment: .leading, spacing: 6) {
1019
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
1020
+ Text(device.name)
1021
+ .font(Typo.monoBold(11))
1022
+ .foregroundColor(Palette.text)
1023
+ .lineLimit(1)
1024
+
1025
+ Text(device.fingerprint)
1026
+ .font(Typo.mono(10))
1027
+ .foregroundColor(Palette.textMuted)
1028
+ .lineLimit(1)
1029
+
1030
+ Spacer(minLength: 0)
1031
+ }
1032
+
1033
+ HStack(spacing: 10) {
1034
+ Text("Paired \(relativeTimestamp(device.pairedAt))")
1035
+ Text("Last seen \(relativeTimestamp(device.lastSeenAt))")
1036
+ }
1037
+ .font(Typo.caption(9.5))
1038
+ .foregroundColor(Palette.textMuted.opacity(0.78))
1039
+
1040
+ HStack(spacing: 6) {
1041
+ ForEach(device.capabilities, id: \.self) { capability in
1042
+ companionCapabilityBadge(capability)
1043
+ }
1044
+ }
1045
+ }
1046
+
1047
+ Spacer(minLength: 0)
1048
+
1049
+ Button {
1050
+ guard confirmRevokeTrustedDevice(device) else { return }
1051
+ LatticesCompanionSecurityCoordinator.shared.revokeTrustedDevice(id: device.id)
1052
+ companionTrustRevision += 1
1053
+ } label: {
1054
+ HStack(spacing: 5) {
1055
+ Image(systemName: "xmark.shield")
1056
+ .font(.system(size: 10, weight: .semibold))
1057
+ Text("Revoke")
1058
+ .font(Typo.monoBold(9.5))
1059
+ }
1060
+ .foregroundColor(Palette.kill.opacity(0.95))
1061
+ .padding(.horizontal, 8)
1062
+ .padding(.vertical, 5)
1063
+ .background(
1064
+ RoundedRectangle(cornerRadius: 5)
1065
+ .fill(Palette.kill.opacity(0.10))
1066
+ .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Palette.kill.opacity(0.22), lineWidth: 0.5))
1067
+ )
1068
+ }
1069
+ .buttonStyle(.plain)
1070
+ .help("Revoke this paired device")
1071
+ }
1072
+ .padding(12)
1073
+ .frame(maxWidth: .infinity, alignment: .leading)
1074
+ .background(shortcutsInsetPanel)
1075
+ }
1076
+
1077
+ private func companionCapabilityBadge(_ capability: String) -> some View {
1078
+ Text(companionCapabilityLabel(capability))
1079
+ .font(Typo.monoBold(9))
1080
+ .foregroundColor(Palette.running.opacity(0.92))
1081
+ .padding(.horizontal, 7)
1082
+ .padding(.vertical, 3)
1083
+ .background(
1084
+ Capsule()
1085
+ .fill(Palette.running.opacity(0.10))
1086
+ .overlay(Capsule().strokeBorder(Palette.running.opacity(0.18), lineWidth: 0.5))
1087
+ )
1088
+ }
1089
+
1090
+ private func companionCapabilityLabel(_ capability: String) -> String {
1091
+ switch capability {
1092
+ case DeckBridgeCapability.deckRead:
1093
+ return "Deck Read"
1094
+ case DeckBridgeCapability.deckPerform:
1095
+ return "Deck Actions"
1096
+ case DeckBridgeCapability.inputTrackpad:
1097
+ return "Trackpad"
1098
+ default:
1099
+ return capability
1100
+ }
1101
+ }
1102
+
1103
+ private func companionDeviceIcon(for name: String) -> String {
1104
+ name.localizedCaseInsensitiveContains("ipad") ? "ipad" : "iphone"
1105
+ }
1106
+
1107
+ private func confirmForgetTrustedDevices() -> Bool {
1108
+ let alert = NSAlert()
1109
+ alert.alertStyle = .warning
1110
+ alert.messageText = "Forget all paired companion devices?"
1111
+ alert.informativeText = "Your iPad or iPhone will need to pair again before it can control Lattices."
1112
+ alert.addButton(withTitle: "Forget Devices")
1113
+ alert.addButton(withTitle: "Cancel")
1114
+ return alert.runModal() == .alertFirstButtonReturn
1115
+ }
1116
+
1117
+ private func confirmRevokeTrustedDevice(_ device: DeckTrustedDeviceSummary) -> Bool {
1118
+ let alert = NSAlert()
1119
+ alert.alertStyle = .warning
1120
+ alert.messageText = "Revoke \(device.name)?"
1121
+ alert.informativeText = """
1122
+ This removes the paired-device trust record for \(device.name).
1123
+
1124
+ Fingerprint: \(device.fingerprint)
1125
+
1126
+ The device will need to pair again before it can control Lattices.
1127
+ """
1128
+ alert.addButton(withTitle: "Revoke Device")
1129
+ alert.addButton(withTitle: "Cancel")
1130
+ return alert.runModal() == .alertFirstButtonReturn
1131
+ }
1132
+
600
1133
  // MARK: - AI
601
1134
 
602
1135
  private var aiContent: some View {
@@ -1249,7 +1782,7 @@ struct SettingsContentView: View {
1249
1782
  let layout = LatticesCompanionCockpitCatalog.normalized(prefs.companionCockpitLayout)
1250
1783
  let selectedPage = layout.pages.first(where: { $0.id == selectedCompanionCockpitPageID }) ?? layout.pages.first
1251
1784
  let categories = LatticesCompanionShortcutCategory.allCases
1252
- let trustedDevices = companionTrustedDevices(revision: companionTrustRevision)
1785
+ let trustedDeviceCount = companionTrustedDevices(revision: companionTrustRevision).count
1253
1786
 
1254
1787
  return shortcutSectionCard(
1255
1788
  title: "Companion Cockpit",
@@ -1273,63 +1806,45 @@ struct SettingsContentView: View {
1273
1806
  Toggle("", isOn: $prefs.companionTrackpadEnabled)
1274
1807
  .toggleStyle(.switch)
1275
1808
  .labelsHidden()
1809
+ .disabled(!prefs.companionBridgeEnabled)
1810
+ .opacity(prefs.companionBridgeEnabled ? 1 : 0.45)
1276
1811
  }
1277
1812
 
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)
1813
+ HStack(alignment: .center, spacing: 12) {
1814
+ VStack(alignment: .leading, spacing: 4) {
1815
+ Text("Pairing and trust")
1816
+ .font(Typo.monoBold(11))
1817
+ .foregroundColor(Palette.text)
1818
+ Text("\(trustedDeviceCount) paired \(trustedDeviceCount == 1 ? "device" : "devices"). Revoke devices and review bridge grants in Companion settings.")
1298
1819
  .font(Typo.caption(10.5))
1299
- .foregroundColor(Palette.textDim)
1300
- }
1820
+ .foregroundColor(Palette.textMuted)
1821
+ .fixedSize(horizontal: false, vertical: true)
1301
1822
  }
1302
1823
 
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
- }
1824
+ Spacer()
1825
+
1826
+ Button {
1827
+ selectedTab = .companion
1828
+ } label: {
1829
+ HStack(spacing: 5) {
1830
+ Image(systemName: "ipad.and.iphone")
1831
+ .font(.system(size: 10, weight: .semibold))
1832
+ Text("Manage")
1833
+ .font(Typo.monoBold(10))
1328
1834
  }
1835
+ .foregroundColor(Palette.text)
1836
+ .padding(.horizontal, 10)
1837
+ .padding(.vertical, 5)
1838
+ .background(
1839
+ RoundedRectangle(cornerRadius: 5)
1840
+ .fill(Palette.surfaceHov)
1841
+ .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Palette.borderLit, lineWidth: 0.5))
1842
+ )
1329
1843
  }
1330
- .padding(12)
1331
- .background(shortcutsInsetPanel)
1844
+ .buttonStyle(.plain)
1332
1845
  }
1846
+ .padding(12)
1847
+ .background(shortcutsInsetPanel)
1333
1848
 
1334
1849
  if let selectedPage {
1335
1850
  Picker("Companion page", selection: $selectedCompanionCockpitPageID) {