@lattices/cli 0.4.14 → 0.6.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 (181) hide show
  1. package/README.md +5 -7
  2. package/apps/mac/Info.plist +4 -4
  3. package/apps/mac/Lattices.app/Contents/Info.plist +4 -12
  4. package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/bin/lattices-app.ts +110 -17
  6. package/bin/lattices-build +125 -0
  7. package/bin/lattices-dev +89 -16
  8. package/bin/lattices.ts +977 -16
  9. package/docs/agents.md +81 -4
  10. package/docs/ai-chat-ux-review.md +416 -0
  11. package/docs/api.md +135 -3
  12. package/docs/app.md +30 -8
  13. package/docs/config.md +4 -0
  14. package/docs/mouse-gestures.md +60 -1
  15. package/docs/proposals/LAT-004-interactive-overlay-actors.md +1 -1
  16. package/docs/proposals/LAT-005-action-runtime-product-spine.md +914 -0
  17. package/docs/proposals/LAT-006-mira-in-lattices.md +553 -0
  18. package/docs/proposals/LAT-007-unified-app-shell.md +128 -0
  19. package/docs/reference/dewey.config.ts +2 -2
  20. package/docs/release.md +171 -0
  21. package/docs/repo-structure.md +5 -5
  22. package/docs/voice.md +11 -27
  23. package/package.json +11 -10
  24. package/apps/mac/Package.swift +0 -27
  25. package/apps/mac/Sources/AppShell/App.swift +0 -26
  26. package/apps/mac/Sources/AppShell/AppActivationCoordinator.swift +0 -27
  27. package/apps/mac/Sources/AppShell/AppDelegate.swift +0 -189
  28. package/apps/mac/Sources/AppShell/AppServicesBootstrap.swift +0 -25
  29. package/apps/mac/Sources/AppShell/AppShellView.swift +0 -171
  30. package/apps/mac/Sources/AppShell/AppUpdater.swift +0 -305
  31. package/apps/mac/Sources/AppShell/CliActionLauncher.swift +0 -50
  32. package/apps/mac/Sources/AppShell/HomeDashboardView.swift +0 -133
  33. package/apps/mac/Sources/AppShell/HotkeyBootstrap.swift +0 -87
  34. package/apps/mac/Sources/AppShell/KeyRecorderView.swift +0 -210
  35. package/apps/mac/Sources/AppShell/LatticesRuntime.swift +0 -104
  36. package/apps/mac/Sources/AppShell/MainView.swift +0 -847
  37. package/apps/mac/Sources/AppShell/MainWindow.swift +0 -83
  38. package/apps/mac/Sources/AppShell/MenuBarController.swift +0 -177
  39. package/apps/mac/Sources/AppShell/OnboardingView.swift +0 -483
  40. package/apps/mac/Sources/AppShell/PermissionsAssistantView.swift +0 -366
  41. package/apps/mac/Sources/AppShell/PermissionsAssistantWindow.swift +0 -70
  42. package/apps/mac/Sources/AppShell/Preferences.swift +0 -297
  43. package/apps/mac/Sources/AppShell/SettingsView.swift +0 -3163
  44. package/apps/mac/Sources/AppShell/SettingsWindow.swift +0 -34
  45. package/apps/mac/Sources/AppShell/WorkspaceInspectorPresenter.swift +0 -13
  46. package/apps/mac/Sources/Core/Actions/HotkeyManager.swift +0 -256
  47. package/apps/mac/Sources/Core/Actions/HotkeyStore.swift +0 -399
  48. package/apps/mac/Sources/Core/Actions/IntentEngine.swift +0 -988
  49. package/apps/mac/Sources/Core/Actions/IntentSchema.swift +0 -94
  50. package/apps/mac/Sources/Core/Actions/Intents/CreateLayerIntent.swift +0 -54
  51. package/apps/mac/Sources/Core/Actions/Intents/DistributeIntent.swift +0 -56
  52. package/apps/mac/Sources/Core/Actions/Intents/FocusIntent.swift +0 -69
  53. package/apps/mac/Sources/Core/Actions/Intents/HelpIntent.swift +0 -41
  54. package/apps/mac/Sources/Core/Actions/Intents/KillIntent.swift +0 -47
  55. package/apps/mac/Sources/Core/Actions/Intents/LatticeIntent.swift +0 -53
  56. package/apps/mac/Sources/Core/Actions/Intents/LaunchIntent.swift +0 -67
  57. package/apps/mac/Sources/Core/Actions/Intents/ListSessionsIntent.swift +0 -32
  58. package/apps/mac/Sources/Core/Actions/Intents/ListWindowsIntent.swift +0 -30
  59. package/apps/mac/Sources/Core/Actions/Intents/ScanIntent.swift +0 -52
  60. package/apps/mac/Sources/Core/Actions/Intents/SearchIntent.swift +0 -190
  61. package/apps/mac/Sources/Core/Actions/Intents/SwitchLayerIntent.swift +0 -50
  62. package/apps/mac/Sources/Core/Actions/Intents/TileIntent.swift +0 -61
  63. package/apps/mac/Sources/Core/Actions/PaletteCommand.swift +0 -439
  64. package/apps/mac/Sources/Core/Actions/VoiceIntentResolver.swift +0 -713
  65. package/apps/mac/Sources/Core/Companion/CompanionActivityLog.swift +0 -70
  66. package/apps/mac/Sources/Core/Companion/CompanionKeyboardController.swift +0 -141
  67. package/apps/mac/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +0 -454
  68. package/apps/mac/Sources/Core/Companion/LatticesCompanionCockpit.swift +0 -555
  69. package/apps/mac/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +0 -629
  70. package/apps/mac/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +0 -204
  71. package/apps/mac/Sources/Core/Companion/LatticesDeckHost.swift +0 -1463
  72. package/apps/mac/Sources/Core/Daemon/DaemonProtocol.swift +0 -114
  73. package/apps/mac/Sources/Core/Daemon/DaemonServer.swift +0 -427
  74. package/apps/mac/Sources/Core/Daemon/LatticesApi.swift +0 -2965
  75. package/apps/mac/Sources/Core/Desktop/AccessibilityTextExtractor.swift +0 -111
  76. package/apps/mac/Sources/Core/Desktop/AppTypeClassifier.swift +0 -106
  77. package/apps/mac/Sources/Core/Desktop/DesktopModel.swift +0 -331
  78. package/apps/mac/Sources/Core/Desktop/DesktopModelTypes.swift +0 -73
  79. package/apps/mac/Sources/Core/Desktop/InventoryManager.swift +0 -35
  80. package/apps/mac/Sources/Core/Desktop/InventoryPath.swift +0 -43
  81. package/apps/mac/Sources/Core/Desktop/MouseFinder.swift +0 -527
  82. package/apps/mac/Sources/Core/Desktop/OcrModel.swift +0 -467
  83. package/apps/mac/Sources/Core/Desktop/OcrStore.swift +0 -329
  84. package/apps/mac/Sources/Core/Desktop/PlacementSpec.swift +0 -195
  85. package/apps/mac/Sources/Core/Desktop/SessionWindowLocator.swift +0 -139
  86. package/apps/mac/Sources/Core/Desktop/TilePickerView.swift +0 -209
  87. package/apps/mac/Sources/Core/Desktop/WindowCapture.swift +0 -33
  88. package/apps/mac/Sources/Core/Desktop/WindowDragSnapController.swift +0 -429
  89. package/apps/mac/Sources/Core/Desktop/WindowPreviewCard.swift +0 -100
  90. package/apps/mac/Sources/Core/Desktop/WindowPreviewStore.swift +0 -112
  91. package/apps/mac/Sources/Core/Desktop/WindowSelectionStore.swift +0 -76
  92. package/apps/mac/Sources/Core/Desktop/WindowTiler.swift +0 -2222
  93. package/apps/mac/Sources/Core/Input/EventTapBreaker.swift +0 -124
  94. package/apps/mac/Sources/Core/Input/EventTapThread.swift +0 -54
  95. package/apps/mac/Sources/Core/Input/InputCaptureResetCenter.swift +0 -20
  96. package/apps/mac/Sources/Core/Input/KeyboardRemapConfig.swift +0 -69
  97. package/apps/mac/Sources/Core/Input/KeyboardRemapController.swift +0 -346
  98. package/apps/mac/Sources/Core/Input/KeyboardRemapStore.swift +0 -141
  99. package/apps/mac/Sources/Core/Input/MouseGestureConfig.swift +0 -499
  100. package/apps/mac/Sources/Core/Input/MouseGestureController.swift +0 -2583
  101. package/apps/mac/Sources/Core/Input/MouseInputDeviceStore.swift +0 -98
  102. package/apps/mac/Sources/Core/Input/MouseInputEventViewer.swift +0 -272
  103. package/apps/mac/Sources/Core/Input/MouseShortcutStore.swift +0 -170
  104. package/apps/mac/Sources/Core/Input/SecureEventInputMonitor.swift +0 -39
  105. package/apps/mac/Sources/Core/Input/ShapeRecognizer.swift +0 -624
  106. package/apps/mac/Sources/Core/Input/TapBudgetMeter.swift +0 -56
  107. package/apps/mac/Sources/Core/Overlays/AppWindowShell.swift +0 -63
  108. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeState.swift +0 -1566
  109. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeView.swift +0 -1927
  110. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeWindow.swift +0 -196
  111. package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteView.swift +0 -307
  112. package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +0 -67
  113. package/apps/mac/Sources/Core/Overlays/HUD/CheatSheetHUD.swift +0 -576
  114. package/apps/mac/Sources/Core/Overlays/HUD/HUDBottomBar.swift +0 -279
  115. package/apps/mac/Sources/Core/Overlays/HUD/HUDController.swift +0 -1158
  116. package/apps/mac/Sources/Core/Overlays/HUD/HUDLeftBar.swift +0 -849
  117. package/apps/mac/Sources/Core/Overlays/HUD/HUDMinimap.swift +0 -179
  118. package/apps/mac/Sources/Core/Overlays/HUD/HUDRightBar.swift +0 -596
  119. package/apps/mac/Sources/Core/Overlays/HUD/HUDState.swift +0 -367
  120. package/apps/mac/Sources/Core/Overlays/HUD/HUDTopBar.swift +0 -243
  121. package/apps/mac/Sources/Core/Overlays/HUD/LauncherHUD.swift +0 -334
  122. package/apps/mac/Sources/Core/Overlays/HUD/LayerBezel.swift +0 -203
  123. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchState.swift +0 -280
  124. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchView.swift +0 -422
  125. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchWindow.swift +0 -94
  126. package/apps/mac/Sources/Core/Overlays/OverlayPanelShell.swift +0 -241
  127. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapState.swift +0 -3135
  128. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapView.swift +0 -3977
  129. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapWindowController.swift +0 -119
  130. package/apps/mac/Sources/Core/Overlays/ScreenOverlayCanvasController.swift +0 -1217
  131. package/apps/mac/Sources/Core/Overlays/Voice/VoiceCommandWindow.swift +0 -1575
  132. package/apps/mac/Sources/Core/Pi/PiAuthNextStepCard.swift +0 -148
  133. package/apps/mac/Sources/Core/Pi/PiAuthPromptCard.swift +0 -90
  134. package/apps/mac/Sources/Core/Pi/PiChatDock.swift +0 -564
  135. package/apps/mac/Sources/Core/Pi/PiChatSession.swift +0 -1948
  136. package/apps/mac/Sources/Core/Pi/PiInstallCallout.swift +0 -86
  137. package/apps/mac/Sources/Core/Pi/PiProviderSetupCallout.swift +0 -99
  138. package/apps/mac/Sources/Core/Pi/PiWorkspaceView.swift +0 -510
  139. package/apps/mac/Sources/Core/System/Capability.swift +0 -79
  140. package/apps/mac/Sources/Core/System/DiagnosticLog.swift +0 -373
  141. package/apps/mac/Sources/Core/System/EventBus.swift +0 -31
  142. package/apps/mac/Sources/Core/System/PermissionChecker.swift +0 -224
  143. package/apps/mac/Sources/Core/System/ProcessModel.swift +0 -199
  144. package/apps/mac/Sources/Core/System/ProcessQuery.swift +0 -151
  145. package/apps/mac/Sources/Core/System/SystemTelemetryMonitor.swift +0 -273
  146. package/apps/mac/Sources/Core/Voice/AdvisorLearningStore.swift +0 -90
  147. package/apps/mac/Sources/Core/Voice/AgentSession.swift +0 -377
  148. package/apps/mac/Sources/Core/Voice/AudioProvider.swift +0 -555
  149. package/apps/mac/Sources/Core/Voice/HandsOffSession.swift +0 -839
  150. package/apps/mac/Sources/Core/Voice/VoiceChatView.swift +0 -192
  151. package/apps/mac/Sources/Core/Voice/VoxClient.swift +0 -454
  152. package/apps/mac/Sources/Core/Workspace/Project.swift +0 -28
  153. package/apps/mac/Sources/Core/Workspace/ProjectScanner.swift +0 -141
  154. package/apps/mac/Sources/Core/Workspace/SessionLayerStore.swift +0 -285
  155. package/apps/mac/Sources/Core/Workspace/SessionManager.swift +0 -75
  156. package/apps/mac/Sources/Core/Workspace/Terminal/Terminal.swift +0 -259
  157. package/apps/mac/Sources/Core/Workspace/Terminal/TerminalQuery.swift +0 -156
  158. package/apps/mac/Sources/Core/Workspace/Terminal/TerminalSynthesizer.swift +0 -200
  159. package/apps/mac/Sources/Core/Workspace/Tmux/TmuxModel.swift +0 -60
  160. package/apps/mac/Sources/Core/Workspace/Tmux/TmuxQuery.swift +0 -105
  161. package/apps/mac/Sources/Core/Workspace/WorkspaceManager.swift +0 -1027
  162. package/apps/mac/Sources/UI/ActionRow.swift +0 -78
  163. package/apps/mac/Sources/UI/OrphanRow.swift +0 -129
  164. package/apps/mac/Sources/UI/ProjectRow.swift +0 -368
  165. package/apps/mac/Sources/UI/TabGroupRow.swift +0 -178
  166. package/apps/mac/Sources/UI/Theme.swift +0 -164
  167. package/apps/mac/Tests/StageDragTests.swift +0 -333
  168. package/apps/mac/Tests/StageJoinTests.swift +0 -313
  169. package/apps/mac/Tests/StageManagerTests.swift +0 -280
  170. package/apps/mac/Tests/StageTileTests.swift +0 -353
  171. package/swift/Package.swift +0 -20
  172. package/swift/Sources/DeckKit/DeckAction.swift +0 -51
  173. package/swift/Sources/DeckKit/DeckBridgeSecurity.swift +0 -152
  174. package/swift/Sources/DeckKit/DeckCockpit.swift +0 -82
  175. package/swift/Sources/DeckKit/DeckHost.swift +0 -7
  176. package/swift/Sources/DeckKit/DeckManifest.swift +0 -145
  177. package/swift/Sources/DeckKit/DeckRuntimeSnapshot.swift +0 -533
  178. package/swift/Sources/DeckKit/DeckTrackpad.swift +0 -63
  179. package/swift/Sources/DeckKit/DeckValue.swift +0 -93
  180. package/swift/Sources/DeckKit/DeckVoiceError.swift +0 -88
  181. package/swift/Tests/DeckKitTests/DeckKitTests.swift +0 -286
@@ -1,3163 +0,0 @@
1
- import DeckKit
2
- import SwiftUI
3
-
4
- /// Settings content with internal General / Shortcuts tabs.
5
- /// Can also render the Docs page when `page == .docs`.
6
- struct SettingsContentView: View {
7
- private enum SettingsSection: String, CaseIterable, Identifiable {
8
- case general
9
- case shortcuts
10
- case ai
11
- case search
12
- case companion
13
-
14
- var id: String { rawValue }
15
-
16
- var title: String {
17
- switch self {
18
- case .general: return "General"
19
- case .shortcuts: return "Shortcuts"
20
- case .ai: return "AI"
21
- case .search: return "Search & OCR"
22
- case .companion: return "LATS iOS Companion"
23
- }
24
- }
25
-
26
- var icon: String {
27
- switch self {
28
- case .general: return "slider.horizontal.3"
29
- case .shortcuts: return "command"
30
- case .ai: return "sparkles"
31
- case .search: return "text.viewfinder"
32
- case .companion: return "ipad.and.iphone"
33
- }
34
- }
35
-
36
- var eyebrow: String {
37
- switch self {
38
- case .general: return "Workspace"
39
- case .shortcuts: return "Controls"
40
- case .ai: return "Agents"
41
- case .search: return "Indexing"
42
- case .companion: return "Local Bridge"
43
- }
44
- }
45
-
46
- var summary: String {
47
- switch self {
48
- case .general:
49
- return "App updates, permissions, terminal defaults, project discovery, and interaction behavior."
50
- case .shortcuts:
51
- return "A full map of global hotkeys for workspace movement and tmux flow."
52
- case .ai:
53
- return "Claude CLI detection plus advisor model and spending controls."
54
- case .search:
55
- return "OCR cadence, quality, and recent capture visibility."
56
- case .companion:
57
- return "Local-network pairing, trusted iPad devices, and bridge security."
58
- }
59
- }
60
- }
61
-
62
- var page: AppPage = .settings
63
- @ObservedObject var prefs: Preferences
64
- @ObservedObject var scanner: ProjectScanner
65
- @ObservedObject var hotkeyStore: HotkeyStore = .shared
66
- @ObservedObject var workspaceManager: WorkspaceManager = .shared
67
- @ObservedObject var appUpdater: AppUpdater = .shared
68
- @ObservedObject var mouseShortcutStore: MouseShortcutStore = .shared
69
- @ObservedObject var keyboardRemapStore: KeyboardRemapStore = .shared
70
- @ObservedObject var permChecker: PermissionChecker = .shared
71
- @ObservedObject var mouseGestureController: MouseGestureController = .shared
72
- @ObservedObject var keyboardRemapController: KeyboardRemapController = .shared
73
- var onBack: (() -> Void)? = nil
74
-
75
- @State private var selectedTab: SettingsSection = .general
76
-
77
- var body: some View {
78
- VStack(spacing: 0) {
79
- // Back bar
80
- backBar
81
-
82
- if page == .docs {
83
- docsContent
84
- } else {
85
- settingsBody
86
- }
87
- }
88
- .frame(maxWidth: .infinity, maxHeight: .infinity)
89
- .clipped()
90
- .background(PanelBackground())
91
- .onAppear {
92
- permChecker.check()
93
- if page == .companionSettings {
94
- selectedTab = .companion
95
- }
96
- }
97
- .onReceive(NotificationCenter.default.publisher(for: .latticesShowGeneralSettings)) { _ in
98
- selectedTab = .general
99
- }
100
- .onReceive(NotificationCenter.default.publisher(for: .latticesShowAssistantSettings)) { _ in
101
- selectedTab = .ai
102
- }
103
- }
104
-
105
- // MARK: - Back Bar
106
-
107
- private var currentTabLabel: String {
108
- page == .docs ? "Docs" : selectedTab.title
109
- }
110
-
111
- private var snapModifierBinding: Binding<SnapModifierKey> {
112
- Binding(
113
- get: { workspaceManager.snapZonesConfig.modifier ?? .command },
114
- set: { workspaceManager.updateSnapModifier($0) }
115
- )
116
- }
117
-
118
- private var backBar: some View {
119
- VStack(spacing: 0) {
120
- HStack(spacing: 8) {
121
- if let onBack {
122
- Button {
123
- onBack()
124
- } label: {
125
- Image(systemName: "chevron.left")
126
- .font(.system(size: 10, weight: .semibold))
127
- .foregroundColor(Palette.textMuted)
128
- }
129
- .buttonStyle(.plain)
130
- .onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
131
- }
132
-
133
- Text(page == .docs ? "Docs" : currentTabLabel)
134
- .font(Typo.heading(13))
135
- .foregroundColor(Palette.text)
136
-
137
- Spacer()
138
- }
139
- .padding(.horizontal, 16)
140
- .padding(.vertical, 8)
141
-
142
- Rectangle().fill(Palette.border).frame(height: 0.5)
143
- }
144
- }
145
-
146
- // MARK: - Settings Body
147
-
148
- private var settingsBody: some View {
149
- HStack(spacing: 0) {
150
- settingsSidebar
151
- .frame(width: 190, alignment: .top)
152
-
153
- Rectangle()
154
- .fill(Palette.border)
155
- .frame(width: 0.5)
156
- .frame(maxHeight: .infinity)
157
-
158
- VStack(spacing: 0) {
159
- settingsSectionHero(selectedTab)
160
-
161
- Rectangle().fill(Palette.border).frame(height: 0.5)
162
-
163
- selectedSectionContent
164
- }
165
- }
166
- }
167
-
168
- private var settingsSidebar: some View {
169
- VStack(alignment: .leading, spacing: 14) {
170
- VStack(alignment: .leading, spacing: 6) {
171
- Text("SETTINGS")
172
- .font(Typo.pixel(14))
173
- .foregroundColor(Palette.textDim)
174
- .tracking(1)
175
- Text("Tune how Lattices launches workspaces, listens for commands, and navigates the desktop.")
176
- .font(Typo.caption(11))
177
- .foregroundColor(Palette.textMuted)
178
- .fixedSize(horizontal: false, vertical: true)
179
- }
180
-
181
- VStack(spacing: 6) {
182
- ForEach(SettingsSection.allCases) { section in
183
- settingsTab(section)
184
- }
185
- }
186
-
187
- Spacer(minLength: 0)
188
- }
189
- .padding(16)
190
- }
191
-
192
- private func settingsTab(_ section: SettingsSection) -> some View {
193
- let active = selectedTab == section
194
- return Button {
195
- selectedTab = section
196
- } label: {
197
- HStack(alignment: .top, spacing: 10) {
198
- Image(systemName: section.icon)
199
- .font(.system(size: 11, weight: .semibold))
200
- .foregroundColor(active ? Palette.text : Palette.textMuted)
201
- .frame(width: 16, height: 18, alignment: .center)
202
-
203
- VStack(alignment: .leading, spacing: 3) {
204
- Text(section.title)
205
- .font(Typo.mono(11))
206
- .foregroundColor(active ? Palette.text : Palette.textMuted)
207
-
208
- Text(section.eyebrow)
209
- .font(Typo.caption(9))
210
- .foregroundColor(Palette.textMuted.opacity(active ? 0.85 : 0.62))
211
- }
212
-
213
- Spacer(minLength: 0)
214
- }
215
- .padding(.horizontal, 10)
216
- .padding(.vertical, 8)
217
- .contentShape(RoundedRectangle(cornerRadius: 8))
218
- .background(
219
- ZStack {
220
- if active {
221
- RoundedRectangle(cornerRadius: 8)
222
- .fill(Color.white.opacity(0.06))
223
- RoundedRectangle(cornerRadius: 8)
224
- .strokeBorder(
225
- LinearGradient(
226
- colors: [Color.white.opacity(0.12), Color.white.opacity(0.04)],
227
- startPoint: .top,
228
- endPoint: .bottom
229
- ),
230
- lineWidth: 0.5
231
- )
232
- }
233
- }
234
- )
235
- }
236
- .buttonStyle(.plain)
237
- }
238
-
239
- private func settingsSectionHero(_ section: SettingsSection) -> some View {
240
- VStack(alignment: .leading, spacing: 8) {
241
- Text(section.eyebrow.uppercased())
242
- .font(Typo.pixel(14))
243
- .foregroundColor(Palette.textDim)
244
- .tracking(1)
245
-
246
- Text(section.title)
247
- .font(Typo.heading(16))
248
- .foregroundColor(Palette.text)
249
-
250
- Text(section.summary)
251
- .font(Typo.caption(11))
252
- .foregroundColor(Palette.textMuted)
253
- .fixedSize(horizontal: false, vertical: true)
254
- }
255
- .frame(maxWidth: .infinity, alignment: .leading)
256
- .padding(.horizontal, 20)
257
- .padding(.vertical, 14)
258
- .background(Palette.bg)
259
- }
260
-
261
- @ViewBuilder
262
- private var selectedSectionContent: some View {
263
- switch selectedTab {
264
- case .general:
265
- generalContent
266
- case .shortcuts:
267
- shortcutsContent
268
- case .ai:
269
- aiContent
270
- case .search:
271
- searchOcrContent
272
- case .companion:
273
- companionContent
274
- }
275
- }
276
-
277
- // MARK: - Sticky section header
278
-
279
- private func stickyHeader(_ title: String) -> some View {
280
- VStack(spacing: 0) {
281
- HStack {
282
- Text(title.uppercased())
283
- .font(Typo.pixel(14))
284
- .foregroundColor(Palette.textDim)
285
- .tracking(1)
286
- Spacer()
287
- }
288
- .padding(.horizontal, 20)
289
- .padding(.vertical, 8)
290
- .background(Palette.bg)
291
-
292
- Rectangle()
293
- .fill(Palette.border)
294
- .frame(height: 0.5)
295
- }
296
- }
297
-
298
- // MARK: - General
299
-
300
- private var permissionsAssistantCard: some View {
301
- let missing = Capability.allCases.filter { !$0.isGranted }
302
- return settingsCard {
303
- VStack(alignment: .leading, spacing: 12) {
304
- HStack(alignment: .center, spacing: 8) {
305
- RoundedRectangle(cornerRadius: 5)
306
- .fill((missing.isEmpty ? Palette.running : Palette.detach).opacity(0.13))
307
- .overlay(
308
- Image(systemName: missing.isEmpty ? "checkmark.shield.fill" : "exclamationmark.shield.fill")
309
- .font(.system(size: 12, weight: .semibold))
310
- .foregroundColor(missing.isEmpty ? Palette.running : Palette.detach)
311
- )
312
- .frame(width: 26, height: 26)
313
-
314
- VStack(alignment: .leading, spacing: 2) {
315
- Text("Permissions")
316
- .font(Typo.mono(12))
317
- .foregroundColor(Palette.text)
318
- Text(missing.isEmpty ? "Ready for window control, gestures, and OCR" : "\(missing.count) permission \(missing.count == 1 ? "needs" : "need") attention")
319
- .font(Typo.caption(9.5))
320
- .foregroundColor(Palette.textMuted)
321
- }
322
-
323
- Spacer()
324
-
325
- Text(missing.isEmpty ? "All on" : "\(missing.count) off")
326
- .font(Typo.monoBold(9.5))
327
- .foregroundColor(missing.isEmpty ? Palette.running : Palette.detach)
328
- .padding(.horizontal, 7)
329
- .padding(.vertical, 3)
330
- .background(
331
- Capsule()
332
- .fill((missing.isEmpty ? Palette.running : Palette.detach).opacity(0.10))
333
- .overlay(Capsule().strokeBorder((missing.isEmpty ? Palette.running : Palette.detach).opacity(0.18), lineWidth: 0.5))
334
- )
335
- }
336
-
337
- Text("The assistant explains each macOS prompt before you grant it. Advanced privacy panes stay available for review when a synthetic shortcut or input capture needs macOS-level repair.")
338
- .font(Typo.caption(10))
339
- .foregroundColor(Palette.textMuted)
340
-
341
- HStack(spacing: 8) {
342
- ForEach(Capability.allCases) { cap in
343
- HStack(spacing: 4) {
344
- Circle()
345
- .fill(cap.isGranted ? Palette.running : Palette.detach)
346
- .frame(width: 5, height: 5)
347
- Text(cap.title)
348
- .font(Typo.mono(9))
349
- .foregroundColor(Palette.textMuted)
350
- }
351
- .padding(.horizontal, 6)
352
- .padding(.vertical, 2)
353
- .background(
354
- Capsule().fill(Palette.surface)
355
- )
356
- }
357
- Spacer(minLength: 0)
358
- }
359
-
360
- HStack(spacing: 8) {
361
- Button {
362
- PermissionsAssistantWindowController.shared.show(focus: missing.first)
363
- } label: {
364
- Text(missing.isEmpty ? "Open Assistant" : "Set Up")
365
- .font(Typo.monoBold(10))
366
- .foregroundColor(Palette.text)
367
- .padding(.horizontal, 12)
368
- .padding(.vertical, 5)
369
- .background(
370
- RoundedRectangle(cornerRadius: 4)
371
- .fill(Palette.surfaceHov)
372
- .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
373
- )
374
- }
375
- .buttonStyle(.plain)
376
-
377
- Button {
378
- permChecker.check()
379
- } label: {
380
- Image(systemName: "arrow.clockwise")
381
- .font(.system(size: 10, weight: .semibold))
382
- .foregroundColor(Palette.textDim)
383
- .frame(width: 24, height: 22)
384
- }
385
- .buttonStyle(.plain)
386
- .help("Refresh permission status")
387
-
388
- Spacer()
389
-
390
- Button {
391
- permChecker.openAutomationSettings()
392
- } label: {
393
- Text("Automation")
394
- .font(Typo.caption(9))
395
- .foregroundColor(Palette.textMuted.opacity(0.9))
396
- }
397
- .buttonStyle(.plain)
398
-
399
- Button {
400
- permChecker.openInputMonitoringSettings()
401
- } label: {
402
- Text("Input Monitoring")
403
- .font(Typo.caption(9))
404
- .foregroundColor(Palette.textMuted.opacity(0.9))
405
- }
406
- .buttonStyle(.plain)
407
- }
408
- }
409
- }
410
- }
411
-
412
- private var permissionsDetailCard: some View {
413
- settingsCard {
414
- VStack(alignment: .leading, spacing: 10) {
415
- HStack(alignment: .center, spacing: 8) {
416
- Image(systemName: permChecker.allGranted ? "checkmark.shield.fill" : "exclamationmark.shield.fill")
417
- .font(.system(size: 11, weight: .medium))
418
- .foregroundColor(permChecker.allGranted ? Palette.running : Palette.detach)
419
- Text("macOS permissions")
420
- .font(Typo.mono(12))
421
- .foregroundColor(Palette.text)
422
- Spacer()
423
- Button {
424
- permChecker.check()
425
- } label: {
426
- Image(systemName: "arrow.clockwise")
427
- .font(.system(size: 10, weight: .semibold))
428
- .foregroundColor(Palette.textDim)
429
- .frame(width: 24, height: 22)
430
- }
431
- .buttonStyle(.plain)
432
- .help("Refresh permission status")
433
- }
434
-
435
- Text("Window discovery, gestures, remaps, OCR, and synthetic shortcuts all depend on these macOS grants.")
436
- .font(Typo.caption(10))
437
- .foregroundColor(Palette.textMuted)
438
-
439
- VStack(alignment: .leading, spacing: 6) {
440
- permissionSettingsRow(
441
- "Accessibility",
442
- granted: permChecker.accessibility,
443
- detail: "Required for mouse gestures, keyboard remaps, window movement, and focusing windows."
444
- ) {
445
- permChecker.requestAccessibility()
446
- }
447
-
448
- permissionSettingsRow(
449
- "Screen Recording",
450
- granted: permChecker.screenRecording,
451
- detail: "Required for reliable window titles, OCR, and Space-aware window discovery."
452
- ) {
453
- permChecker.requestScreenRecording()
454
- }
455
-
456
- permissionReviewRow(
457
- "Automation",
458
- detail: "Needed when Lattices sends shortcuts through System Events, including gesture-triggered dictation."
459
- ) {
460
- permChecker.openAutomationSettings()
461
- }
462
-
463
- permissionReviewRow(
464
- "Input Monitoring",
465
- detail: "Useful to review if global input capture or synthetic shortcut behavior starts failing."
466
- ) {
467
- permChecker.openInputMonitoringSettings()
468
- }
469
- }
470
- }
471
- }
472
- }
473
-
474
- private var appUpdateCard: some View {
475
- settingsCard {
476
- VStack(alignment: .leading, spacing: 10) {
477
- HStack(alignment: .center, spacing: 10) {
478
- RoundedRectangle(cornerRadius: 6)
479
- .fill((LatticesRuntime.isDevBuild ? Palette.detach : Palette.running).opacity(0.13))
480
- .overlay(
481
- Image(systemName: LatticesRuntime.isDevBuild ? "hammer.fill" : "checkmark.seal.fill")
482
- .font(.system(size: 13, weight: .semibold))
483
- .foregroundColor(LatticesRuntime.isDevBuild ? Palette.detach : Palette.running)
484
- )
485
- .frame(width: 30, height: 30)
486
-
487
- VStack(alignment: .leading, spacing: 4) {
488
- HStack(spacing: 8) {
489
- Text("Lattices app")
490
- .font(Typo.mono(12))
491
- .foregroundColor(Palette.text)
492
- buildChannelBadge
493
- }
494
-
495
- HStack(alignment: .firstTextBaseline, spacing: 8) {
496
- Text(appUpdater.currentDisplayVersion)
497
- .font(Typo.monoBold(13))
498
- .foregroundColor(Palette.text)
499
- Text(LatticesRuntime.buildStatusLabel)
500
- .font(Typo.monoBold(9.5))
501
- .foregroundColor(LatticesRuntime.isDevBuild ? Palette.detach : Palette.running)
502
- }
503
- }
504
-
505
- Spacer()
506
-
507
- Toggle("Auto", isOn: $appUpdater.autoCheckEnabled)
508
- .font(Typo.caption(9))
509
- .toggleStyle(.checkbox)
510
- .foregroundColor(Palette.textMuted.opacity(0.9))
511
- }
512
-
513
- if let update = appUpdater.availableUpdate {
514
- Text("New version v\(update.version) is ready")
515
- .font(Typo.monoBold(10))
516
- .foregroundColor(Palette.detach)
517
- } else if appUpdater.isChecking {
518
- Text("Checking for updates...")
519
- .font(Typo.caption(9))
520
- .foregroundColor(Palette.textMuted)
521
- } else if let error = appUpdater.lastError {
522
- Text(error)
523
- .font(Typo.caption(9))
524
- .foregroundColor(Palette.detach.opacity(0.9))
525
- } else if let checked = appUpdater.lastChecked {
526
- Text("Last checked \(checked, style: .relative)")
527
- .font(Typo.caption(9))
528
- .foregroundColor(Palette.textMuted.opacity(0.8))
529
- }
530
-
531
- HStack(spacing: 10) {
532
- Button {
533
- appUpdater.promptForUpdate()
534
- } label: {
535
- Text(appUpdater.isUpdating ? "Preparing..." : (appUpdater.availableUpdate == nil ? "Check for Updates" : "Update to v\(appUpdater.availableUpdate?.version ?? "")"))
536
- .font(Typo.monoBold(10))
537
- .foregroundColor(Palette.text)
538
- .padding(.horizontal, 12)
539
- .padding(.vertical, 5)
540
- .background(
541
- RoundedRectangle(cornerRadius: 4)
542
- .fill(Palette.surfaceHov)
543
- .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
544
- )
545
- }
546
- .buttonStyle(.plain)
547
- .disabled(appUpdater.isUpdating)
548
-
549
- Button {
550
- Task { await appUpdater.check() }
551
- } label: {
552
- Text(appUpdater.isChecking ? "Checking..." : "Check Now")
553
- .font(Typo.caption(9))
554
- .foregroundColor(Palette.textMuted.opacity(0.9))
555
- }
556
- .buttonStyle(.plain)
557
- .disabled(appUpdater.isChecking)
558
-
559
- Spacer()
560
- }
561
- }
562
- }
563
- }
564
-
565
- private var interactionBehaviorCard: some View {
566
- settingsCard {
567
- VStack(alignment: .leading, spacing: 12) {
568
- Text("Session behavior")
569
- .font(Typo.mono(12))
570
- .foregroundColor(Palette.text)
571
-
572
- HStack {
573
- Text("Detach mode")
574
- .font(Typo.mono(10))
575
- .foregroundColor(Palette.textDim)
576
- Spacer()
577
- Picker("", selection: $prefs.mode) {
578
- Text("Learning").tag(InteractionMode.learning)
579
- Text("Auto").tag(InteractionMode.auto)
580
- }
581
- .pickerStyle(.segmented)
582
- .labelsHidden()
583
- .frame(width: 160)
584
- }
585
-
586
- Text(prefs.mode == .learning
587
- ? "Shows keybinding hints when you detach from a tmux session."
588
- : "Detaches sessions quietly once Lattices has done the workspace handoff.")
589
- .font(Typo.caption(9.5))
590
- .foregroundColor(Palette.textMuted.opacity(0.75))
591
- }
592
- }
593
- }
594
-
595
- private var inputControlsCard: some View {
596
- shortcutSectionCard(
597
- title: "Input Controls",
598
- eyebrow: "Gestures & Remaps",
599
- summary: "Mouse gestures, drag snapping, and Hyper key remaps live alongside the shortcuts they trigger."
600
- ) {
601
- VStack(alignment: .leading, spacing: 12) {
602
- HStack(alignment: .top, spacing: 12) {
603
- VStack(alignment: .leading, spacing: 5) {
604
- Text("Drag-to-snap")
605
- .font(Typo.monoBold(11))
606
- .foregroundColor(Palette.text)
607
- Text("Hold a modifier while dragging a window to reveal snap targets.")
608
- .font(Typo.caption(10))
609
- .foregroundColor(Palette.textMuted)
610
- }
611
-
612
- Spacer()
613
-
614
- Toggle("", isOn: $prefs.dragSnapEnabled)
615
- .toggleStyle(.switch)
616
- .controlSize(.small)
617
- .labelsHidden()
618
- }
619
-
620
- HStack {
621
- Text("Snap modifier")
622
- .font(Typo.mono(10))
623
- .foregroundColor(Palette.textDim)
624
- Spacer()
625
- Picker("", selection: snapModifierBinding) {
626
- ForEach(SnapModifierKey.allCases) { modifier in
627
- Text(modifier.shortLabel).tag(modifier)
628
- }
629
- }
630
- .pickerStyle(.segmented)
631
- .labelsHidden()
632
- .frame(width: 220)
633
- }
634
-
635
- cardDivider
636
-
637
- HStack(alignment: .top, spacing: 12) {
638
- VStack(alignment: .leading, spacing: 5) {
639
- Text("Middle-click gestures")
640
- .font(Typo.monoBold(11))
641
- .foregroundColor(Palette.text)
642
- Text("Directional mouse gestures can switch Spaces, open the Screen Map, or trigger dictation.")
643
- .font(Typo.caption(10))
644
- .foregroundColor(Palette.textMuted)
645
- }
646
-
647
- Spacer()
648
-
649
- Toggle("", isOn: $prefs.mouseGesturesEnabled)
650
- .toggleStyle(.switch)
651
- .controlSize(.small)
652
- .labelsHidden()
653
- }
654
-
655
- breakerStatusRow(
656
- state: mouseGestureController.breakerState,
657
- label: "Mouse gestures"
658
- ) {
659
- mouseGestureController.reArmAfterBreakerTrip()
660
- }
661
-
662
- cardDivider
663
-
664
- HStack(alignment: .top, spacing: 12) {
665
- VStack(alignment: .leading, spacing: 5) {
666
- Text("Caps Lock as Hyper")
667
- .font(Typo.monoBold(11))
668
- .foregroundColor(Palette.text)
669
- Text("Hold Caps Lock for Hyper shortcuts, tap it for Escape.")
670
- .font(Typo.caption(10))
671
- .foregroundColor(Palette.textMuted)
672
- }
673
-
674
- Spacer()
675
-
676
- Toggle("", isOn: $prefs.keyboardRemapsEnabled)
677
- .toggleStyle(.switch)
678
- .controlSize(.small)
679
- .labelsHidden()
680
- }
681
-
682
- breakerStatusRow(
683
- state: keyboardRemapController.breakerState,
684
- label: "Keyboard remaps"
685
- ) {
686
- keyboardRemapController.reArmAfterBreakerTrip()
687
- }
688
- }
689
- }
690
- }
691
-
692
- private var generalContent: some View {
693
- ScrollView {
694
- VStack(alignment: .leading, spacing: 12) {
695
- appUpdateCard
696
-
697
- permissionsAssistantCard
698
-
699
- settingsCard {
700
- VStack(alignment: .leading, spacing: 8) {
701
- Text("Terminal")
702
- .font(Typo.mono(11))
703
- .foregroundColor(Palette.text)
704
-
705
- Picker("", selection: $prefs.terminal) {
706
- ForEach(Terminal.installed) { t in
707
- Text(t.rawValue).tag(t)
708
- }
709
- }
710
- .pickerStyle(.segmented)
711
- .labelsHidden()
712
-
713
- Text("Used for attaching to tmux sessions")
714
- .font(Typo.caption(10))
715
- .foregroundColor(Palette.textMuted)
716
- }
717
- }
718
-
719
- settingsCard {
720
- VStack(alignment: .leading, spacing: 10) {
721
- Text("Project discovery")
722
- .font(Typo.mono(11))
723
- .foregroundColor(Palette.text)
724
-
725
- Text("Project scan root")
726
- .font(Typo.mono(10))
727
- .foregroundColor(Palette.textDim)
728
-
729
- HStack(spacing: 6) {
730
- TextField("~/dev", text: $prefs.scanRoot)
731
- .textFieldStyle(.plain)
732
- .font(Typo.mono(11))
733
- .foregroundColor(Palette.text)
734
- .padding(.horizontal, 8)
735
- .padding(.vertical, 5)
736
- .background(
737
- RoundedRectangle(cornerRadius: 5)
738
- .fill(Color.white.opacity(0.06))
739
- .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5))
740
- )
741
-
742
- Button {
743
- let panel = NSOpenPanel()
744
- panel.canChooseDirectories = true
745
- panel.canChooseFiles = false
746
- panel.allowsMultipleSelection = false
747
- if !prefs.scanRoot.isEmpty {
748
- panel.directoryURL = URL(fileURLWithPath: prefs.scanRoot)
749
- }
750
- if panel.runModal() == .OK, let url = panel.url {
751
- prefs.scanRoot = url.path
752
- }
753
- } label: {
754
- Image(systemName: "folder")
755
- .font(.system(size: 11))
756
- .foregroundColor(Palette.textDim)
757
- .padding(6)
758
- .background(
759
- RoundedRectangle(cornerRadius: 5)
760
- .fill(Color.white.opacity(0.06))
761
- .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5))
762
- )
763
- }
764
- .buttonStyle(.plain)
765
- }
766
-
767
- HStack {
768
- Text("Scans for .lattices.json project configs")
769
- .font(Typo.caption(9))
770
- .foregroundColor(Palette.textMuted.opacity(0.7))
771
- Spacer()
772
- Button {
773
- scanner.updateRoot(prefs.scanRoot)
774
- scanner.scan()
775
- } label: {
776
- Text("Rescan")
777
- .font(Typo.monoBold(10))
778
- .foregroundColor(Palette.text)
779
- .padding(.horizontal, 12)
780
- .padding(.vertical, 4)
781
- .background(
782
- RoundedRectangle(cornerRadius: 4)
783
- .fill(Palette.surfaceHov)
784
- .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
785
- )
786
- }
787
- .buttonStyle(.plain)
788
- }
789
- }
790
- }
791
-
792
- interactionBehaviorCard
793
- }
794
- .padding(16)
795
- .frame(maxWidth: 760, alignment: .leading)
796
- .frame(maxWidth: .infinity, alignment: .leading)
797
- }
798
- }
799
-
800
- private var permissionsContent: some View {
801
- ScrollView {
802
- VStack(alignment: .leading, spacing: 12) {
803
- permissionsAssistantCard
804
-
805
- settingsCard {
806
- VStack(alignment: .leading, spacing: 10) {
807
- HStack(alignment: .center, spacing: 8) {
808
- Image(systemName: permChecker.allGranted ? "checkmark.shield.fill" : "exclamationmark.shield.fill")
809
- .font(.system(size: 11, weight: .medium))
810
- .foregroundColor(permChecker.allGranted ? Palette.running : Palette.detach)
811
- Text("Permissions")
812
- .font(Typo.mono(12))
813
- .foregroundColor(Palette.text)
814
- Spacer()
815
- Button {
816
- permChecker.check()
817
- } label: {
818
- Image(systemName: "arrow.clockwise")
819
- .font(.system(size: 10, weight: .semibold))
820
- .foregroundColor(Palette.textDim)
821
- .frame(width: 24, height: 22)
822
- }
823
- .buttonStyle(.plain)
824
- .help("Refresh permission status")
825
- }
826
-
827
- Text("Lattices uses macOS privacy permissions for window discovery, tiling, gestures, remaps, and synthetic shortcuts.")
828
- .font(Typo.caption(10))
829
- .foregroundColor(Palette.textMuted)
830
-
831
- VStack(alignment: .leading, spacing: 6) {
832
- permissionSettingsRow(
833
- "Accessibility",
834
- granted: permChecker.accessibility,
835
- detail: "Required for mouse gestures, keyboard remaps, window movement, and focusing windows."
836
- ) {
837
- permChecker.requestAccessibility()
838
- }
839
-
840
- permissionSettingsRow(
841
- "Screen Recording",
842
- granted: permChecker.screenRecording,
843
- detail: "Required for reliable window titles, OCR, and Space-aware window discovery."
844
- ) {
845
- permChecker.requestScreenRecording()
846
- }
847
-
848
- permissionReviewRow(
849
- "Automation",
850
- detail: "Needed when Lattices sends shortcuts through System Events, including gesture-triggered dictation."
851
- ) {
852
- permChecker.openAutomationSettings()
853
- }
854
-
855
- permissionReviewRow(
856
- "Input Monitoring",
857
- detail: "Useful to review if global input capture or synthetic shortcut behavior starts failing."
858
- ) {
859
- permChecker.openInputMonitoringSettings()
860
- }
861
- }
862
- }
863
- }
864
- }
865
- .padding(16)
866
- .frame(maxWidth: 760, alignment: .leading)
867
- .frame(maxWidth: .infinity, alignment: .leading)
868
- }
869
- }
870
-
871
- private var appContent: some View {
872
- ScrollView {
873
- VStack(alignment: .leading, spacing: 12) {
874
- settingsCard {
875
- VStack(alignment: .leading, spacing: 12) {
876
- HStack(alignment: .top, spacing: 10) {
877
- Image(systemName: LatticesRuntime.isDevBuild ? "hammer.fill" : "checkmark.seal.fill")
878
- .font(.system(size: 13, weight: .semibold))
879
- .foregroundColor(LatticesRuntime.isDevBuild ? Palette.detach : Palette.running)
880
- .frame(width: 24, height: 24)
881
-
882
- VStack(alignment: .leading, spacing: 6) {
883
- HStack(spacing: 8) {
884
- Text("Lattices app")
885
- .font(Typo.mono(12))
886
- .foregroundColor(Palette.text)
887
- buildChannelBadge
888
- Spacer()
889
- }
890
-
891
- HStack(alignment: .firstTextBaseline, spacing: 8) {
892
- Text(appUpdater.currentDisplayVersion)
893
- .font(Typo.heading(20))
894
- .foregroundColor(Palette.text)
895
- Text(LatticesRuntime.buildStatusLabel)
896
- .font(Typo.monoBold(10))
897
- .foregroundColor(LatticesRuntime.isDevBuild ? Palette.detach : Palette.running)
898
- }
899
-
900
- if let revision = LatticesRuntime.buildRevision {
901
- Text("Build \(revision)")
902
- .font(Typo.caption(9))
903
- .foregroundColor(Palette.textMuted.opacity(0.8))
904
- }
905
- }
906
- }
907
-
908
- Text("Lattices can check for new signed releases and prepare the update here. You’ll confirm before the app quits and relaunches.")
909
- .font(Typo.caption(10))
910
- .foregroundColor(Palette.textMuted)
911
-
912
- if let update = appUpdater.availableUpdate {
913
- VStack(alignment: .leading, spacing: 6) {
914
- HStack(spacing: 6) {
915
- Image(systemName: "gift.fill")
916
- .font(.system(size: 10, weight: .semibold))
917
- .foregroundColor(Palette.detach)
918
- Text("New version v\(update.version) is ready")
919
- .font(Typo.monoBold(10))
920
- .foregroundColor(Palette.detach)
921
- }
922
-
923
- if !update.releaseNotes.isEmpty {
924
- Text(String(update.releaseNotes.prefix(180)) + (update.releaseNotes.count > 180 ? "..." : ""))
925
- .font(Typo.caption(9))
926
- .foregroundColor(Palette.textMuted)
927
- .lineLimit(3)
928
- }
929
- }
930
- .padding(8)
931
- .background(
932
- RoundedRectangle(cornerRadius: 5)
933
- .fill(Palette.surfaceHov.opacity(0.65))
934
- .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Palette.detach.opacity(0.35), lineWidth: 0.5))
935
- )
936
- } else if appUpdater.isChecking {
937
- Text("Checking for updates...")
938
- .font(Typo.caption(9))
939
- .foregroundColor(Palette.textMuted)
940
- } else if let error = appUpdater.lastError {
941
- Text(error)
942
- .font(Typo.caption(9))
943
- .foregroundColor(Palette.detach.opacity(0.9))
944
- } else if let checked = appUpdater.lastChecked {
945
- Text("Last checked \(checked, style: .relative)")
946
- .font(Typo.caption(9))
947
- .foregroundColor(Palette.textMuted.opacity(0.8))
948
- }
949
-
950
- if let status = appUpdater.statusMessage {
951
- Text(status)
952
- .font(Typo.caption(9))
953
- .foregroundColor(Palette.running.opacity(0.85))
954
- }
955
-
956
- if let reason = appUpdater.unavailableReason {
957
- Text(reason)
958
- .font(Typo.caption(9))
959
- .foregroundColor(Palette.detach.opacity(0.9))
960
- }
961
-
962
- HStack(spacing: 10) {
963
- Button {
964
- appUpdater.promptForUpdate()
965
- } label: {
966
- Text(appUpdater.isUpdating ? "Preparing..." : (appUpdater.availableUpdate == nil ? "Check for Updates" : "Update to v\(appUpdater.availableUpdate?.version ?? "")"))
967
- .font(Typo.monoBold(10))
968
- .foregroundColor(Palette.text)
969
- .padding(.horizontal, 12)
970
- .padding(.vertical, 5)
971
- .background(
972
- RoundedRectangle(cornerRadius: 4)
973
- .fill(Palette.surfaceHov)
974
- .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
975
- )
976
- }
977
- .buttonStyle(.plain)
978
- .disabled(appUpdater.isUpdating)
979
-
980
- Button {
981
- Task { await appUpdater.check() }
982
- } label: {
983
- Text(appUpdater.isChecking ? "Checking..." : "Check Now")
984
- .font(Typo.caption(9))
985
- .foregroundColor(Palette.textMuted.opacity(0.9))
986
- }
987
- .buttonStyle(.plain)
988
- .disabled(appUpdater.isChecking)
989
-
990
- Toggle("Auto", isOn: $appUpdater.autoCheckEnabled)
991
- .font(Typo.caption(9))
992
- .toggleStyle(.checkbox)
993
- .foregroundColor(Palette.textMuted.opacity(0.9))
994
-
995
- if appUpdater.availableUpdate != nil {
996
- Button {
997
- appUpdater.viewCurrentRelease()
998
- } label: {
999
- Text("Release Notes")
1000
- .font(Typo.caption(9))
1001
- .foregroundColor(Palette.textMuted.opacity(0.9))
1002
- }
1003
- .buttonStyle(.plain)
1004
-
1005
- Button {
1006
- appUpdater.skipCurrentUpdate()
1007
- } label: {
1008
- Text("Skip")
1009
- .font(Typo.caption(9))
1010
- .foregroundColor(Palette.textMuted.opacity(0.75))
1011
- }
1012
- .buttonStyle(.plain)
1013
- }
1014
-
1015
- Spacer()
1016
-
1017
- Text("CLI: `lattices app update`")
1018
- .font(Typo.caption(9))
1019
- .foregroundColor(Palette.textMuted.opacity(0.8))
1020
- }
1021
- }
1022
- }
1023
- }
1024
- .padding(16)
1025
- .frame(maxWidth: 760, alignment: .leading)
1026
- .frame(maxWidth: .infinity, alignment: .leading)
1027
- }
1028
- }
1029
-
1030
- private var behaviorContent: some View {
1031
- ScrollView {
1032
- VStack(alignment: .leading, spacing: 12) {
1033
- settingsCard {
1034
- VStack(alignment: .leading, spacing: 10) {
1035
- Text("tmux")
1036
- .font(Typo.mono(11))
1037
- .foregroundColor(Palette.text)
1038
-
1039
- HStack {
1040
- Text("Detach mode")
1041
- .font(Typo.mono(10))
1042
- .foregroundColor(Palette.textDim)
1043
- Spacer()
1044
- Picker("", selection: $prefs.mode) {
1045
- Text("Learning").tag(InteractionMode.learning)
1046
- Text("Auto").tag(InteractionMode.auto)
1047
- }
1048
- .pickerStyle(.segmented)
1049
- .labelsHidden()
1050
- .frame(width: 160)
1051
- }
1052
-
1053
- Text(prefs.mode == .learning
1054
- ? "Shows keybinding hints on detach"
1055
- : "Detaches sessions silently")
1056
- .font(Typo.caption(9))
1057
- .foregroundColor(Palette.textMuted.opacity(0.7))
1058
- }
1059
- }
1060
-
1061
- settingsCard {
1062
- VStack(alignment: .leading, spacing: 10) {
1063
- Text("Window drag snap")
1064
- .font(Typo.mono(11))
1065
- .foregroundColor(Palette.text)
1066
-
1067
- HStack {
1068
- Text("Drag-to-snap")
1069
- .font(Typo.mono(10))
1070
- .foregroundColor(Palette.textDim)
1071
- Spacer()
1072
- Toggle("", isOn: $prefs.dragSnapEnabled)
1073
- .toggleStyle(.switch)
1074
- .controlSize(.small)
1075
- .labelsHidden()
1076
- }
1077
-
1078
- HStack {
1079
- Text("Snap modifier")
1080
- .font(Typo.mono(10))
1081
- .foregroundColor(Palette.textDim)
1082
- Spacer()
1083
- Picker("", selection: snapModifierBinding) {
1084
- ForEach(SnapModifierKey.allCases) { modifier in
1085
- Text(modifier.shortLabel).tag(modifier)
1086
- }
1087
- }
1088
- .pickerStyle(.segmented)
1089
- .labelsHidden()
1090
- .frame(width: 220)
1091
- }
1092
-
1093
- 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.")
1094
- .font(Typo.caption(9))
1095
- .foregroundColor(Palette.textMuted.opacity(0.7))
1096
-
1097
- cardDivider
1098
-
1099
- Text("Advanced landing-zone rules still live in ~/.lattices/snap-zones.json. Modifier changes here take effect on the next drag.")
1100
- .font(Typo.caption(9))
1101
- .foregroundColor(Palette.textMuted.opacity(0.7))
1102
- }
1103
- }
1104
-
1105
- settingsCard {
1106
- VStack(alignment: .leading, spacing: 10) {
1107
- Text("Mouse gestures")
1108
- .font(Typo.mono(11))
1109
- .foregroundColor(Palette.text)
1110
-
1111
- HStack {
1112
- Text("Middle-click gestures")
1113
- .font(Typo.mono(10))
1114
- .foregroundColor(Palette.textDim)
1115
- Spacer()
1116
- Toggle("", isOn: $prefs.mouseGesturesEnabled)
1117
- .toggleStyle(.switch)
1118
- .controlSize(.small)
1119
- .labelsHidden()
1120
- }
1121
-
1122
- Text("Rules live in ~/.lattices/mouse-shortcuts.json. The current defaults preserve the working setup: middle-click drag left/right switches Spaces and drag down opens the Screen Map overview.")
1123
- .font(Typo.caption(9))
1124
- .foregroundColor(Palette.textMuted.opacity(0.7))
1125
-
1126
- cardDivider
1127
-
1128
- VStack(alignment: .leading, spacing: 6) {
1129
- Text("Active drag mappings")
1130
- .font(Typo.mono(10))
1131
- .foregroundColor(Palette.textDim)
1132
-
1133
- ForEach(mouseShortcutStore.summaryLines.prefix(4), id: \.self) { line in
1134
- Text(line)
1135
- .font(Typo.caption(9))
1136
- .foregroundColor(Palette.textMuted.opacity(0.78))
1137
- }
1138
-
1139
- if mouseShortcutStore.summaryLines.isEmpty {
1140
- Text("No active mappings")
1141
- .font(Typo.caption(9))
1142
- .foregroundColor(Palette.textMuted.opacity(0.6))
1143
- }
1144
- }
1145
-
1146
- breakerStatusRow(
1147
- state: mouseGestureController.breakerState,
1148
- label: "Mouse gestures"
1149
- ) {
1150
- mouseGestureController.reArmAfterBreakerTrip()
1151
- }
1152
-
1153
- HStack(spacing: 8) {
1154
- Button {
1155
- mouseShortcutStore.openConfiguration()
1156
- } label: {
1157
- Text("Configure...")
1158
- .font(Typo.monoBold(10))
1159
- .foregroundColor(Palette.text)
1160
- .padding(.horizontal, 12)
1161
- .padding(.vertical, 4)
1162
- .background(
1163
- RoundedRectangle(cornerRadius: 4)
1164
- .fill(Palette.surfaceHov)
1165
- .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
1166
- )
1167
- }
1168
- .buttonStyle(.plain)
1169
-
1170
- Button {
1171
- MouseInputEventViewer.shared.show()
1172
- } label: {
1173
- Text("Open Event Viewer")
1174
- .font(Typo.monoBold(10))
1175
- .foregroundColor(Palette.text)
1176
- .padding(.horizontal, 12)
1177
- .padding(.vertical, 4)
1178
- .background(
1179
- RoundedRectangle(cornerRadius: 4)
1180
- .fill(Palette.surfaceHov)
1181
- .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
1182
- )
1183
- }
1184
- .buttonStyle(.plain)
1185
-
1186
- Button {
1187
- mouseShortcutStore.restoreDefaults()
1188
- } label: {
1189
- Text("Restore Defaults")
1190
- .font(Typo.monoBold(10))
1191
- .foregroundColor(Palette.text)
1192
- .padding(.horizontal, 12)
1193
- .padding(.vertical, 4)
1194
- .background(
1195
- RoundedRectangle(cornerRadius: 4)
1196
- .fill(Palette.surfaceHov)
1197
- .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
1198
- )
1199
- }
1200
- .buttonStyle(.plain)
1201
- }
1202
-
1203
- Text("Use Event Viewer to discover what your mouse emits on this machine. The config schema already accepts device selectors, but live gesture matching currently falls back to global rules when macOS doesn't expose the source device.")
1204
- .font(Typo.caption(9))
1205
- .foregroundColor(Palette.textMuted.opacity(0.7))
1206
- }
1207
- }
1208
-
1209
- settingsCard {
1210
- VStack(alignment: .leading, spacing: 10) {
1211
- Text("Keyboard remaps")
1212
- .font(Typo.mono(11))
1213
- .foregroundColor(Palette.text)
1214
-
1215
- HStack {
1216
- Text("Caps Lock as Hyper")
1217
- .font(Typo.mono(10))
1218
- .foregroundColor(Palette.textDim)
1219
- Spacer()
1220
- Toggle("", isOn: $prefs.keyboardRemapsEnabled)
1221
- .toggleStyle(.switch)
1222
- .controlSize(.small)
1223
- .labelsHidden()
1224
- }
1225
-
1226
- 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.")
1227
- .font(Typo.caption(9))
1228
- .foregroundColor(Palette.textMuted.opacity(0.7))
1229
-
1230
- cardDivider
1231
-
1232
- VStack(alignment: .leading, spacing: 6) {
1233
- Text("Active remaps")
1234
- .font(Typo.mono(10))
1235
- .foregroundColor(Palette.textDim)
1236
-
1237
- ForEach(keyboardRemapStore.summaryLines.prefix(4), id: \.self) { line in
1238
- Text(line)
1239
- .font(Typo.caption(9))
1240
- .foregroundColor(Palette.textMuted.opacity(0.78))
1241
- }
1242
-
1243
- if keyboardRemapStore.summaryLines.isEmpty {
1244
- Text("No active remaps")
1245
- .font(Typo.caption(9))
1246
- .foregroundColor(Palette.textMuted.opacity(0.6))
1247
- }
1248
- }
1249
-
1250
- breakerStatusRow(
1251
- state: keyboardRemapController.breakerState,
1252
- label: "Keyboard remaps"
1253
- ) {
1254
- keyboardRemapController.reArmAfterBreakerTrip()
1255
- }
1256
-
1257
- HStack(spacing: 8) {
1258
- Button {
1259
- keyboardRemapStore.openConfiguration()
1260
- } label: {
1261
- Text("Configure...")
1262
- .font(Typo.monoBold(10))
1263
- .foregroundColor(Palette.text)
1264
- .padding(.horizontal, 12)
1265
- .padding(.vertical, 4)
1266
- .background(
1267
- RoundedRectangle(cornerRadius: 4)
1268
- .fill(Palette.surfaceHov)
1269
- .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
1270
- )
1271
- }
1272
- .buttonStyle(.plain)
1273
-
1274
- Button {
1275
- keyboardRemapStore.restoreDefaults()
1276
- } label: {
1277
- Text("Restore Defaults")
1278
- .font(Typo.monoBold(10))
1279
- .foregroundColor(Palette.text)
1280
- .padding(.horizontal, 12)
1281
- .padding(.vertical, 4)
1282
- .background(
1283
- RoundedRectangle(cornerRadius: 4)
1284
- .fill(Palette.surfaceHov)
1285
- .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
1286
- )
1287
- }
1288
- .buttonStyle(.plain)
1289
- }
1290
- }
1291
- }
1292
- }
1293
- .padding(16)
1294
- .frame(maxWidth: 760, alignment: .leading)
1295
- .frame(maxWidth: .infinity, alignment: .leading)
1296
- }
1297
- }
1298
-
1299
- // MARK: - Companion
1300
-
1301
- private var companionContent: some View {
1302
- ScrollView {
1303
- VStack(alignment: .leading, spacing: 12) {
1304
- companionBridgeOverviewCard
1305
- companionTrustedDevicesCard
1306
- companionCockpitCard
1307
- }
1308
- .padding(16)
1309
- .frame(maxWidth: 760, alignment: .leading)
1310
- .frame(maxWidth: .infinity, alignment: .leading)
1311
- }
1312
- }
1313
-
1314
- private var companionBridgeOverviewCard: some View {
1315
- settingsCard {
1316
- VStack(alignment: .leading, spacing: 12) {
1317
- HStack(alignment: .top, spacing: 10) {
1318
- RoundedRectangle(cornerRadius: 6)
1319
- .fill(Palette.running.opacity(0.14))
1320
- .overlay(
1321
- Image(systemName: "lock.shield")
1322
- .font(.system(size: 13, weight: .semibold))
1323
- .foregroundColor(Palette.running)
1324
- )
1325
- .frame(width: 30, height: 30)
1326
-
1327
- VStack(alignment: .leading, spacing: 3) {
1328
- Text(prefs.companionBridgeEnabled ? "Secure local bridge" : "Local bridge off")
1329
- .font(Typo.mono(12))
1330
- .foregroundColor(Palette.text)
1331
- Text(prefs.companionBridgeEnabled
1332
- ? "Bonjour discovery with explicit Mac approval, signed requests, encrypted payloads, and capability grants."
1333
- : "The companion bridge is not listening or advertising on the local network until you turn it on.")
1334
- .font(Typo.caption(10))
1335
- .foregroundColor(Palette.textMuted)
1336
- .fixedSize(horizontal: false, vertical: true)
1337
- }
1338
-
1339
- Spacer()
1340
-
1341
- Toggle("", isOn: $prefs.companionBridgeEnabled)
1342
- .toggleStyle(.switch)
1343
- .controlSize(.small)
1344
- .labelsHidden()
1345
- }
1346
-
1347
- cardDivider
1348
-
1349
- LazyVGrid(
1350
- columns: [
1351
- GridItem(.flexible(minimum: 120), spacing: 10, alignment: .leading),
1352
- GridItem(.flexible(minimum: 120), spacing: 10, alignment: .leading),
1353
- GridItem(.flexible(minimum: 120), spacing: 10, alignment: .leading),
1354
- ],
1355
- alignment: .leading,
1356
- spacing: 10
1357
- ) {
1358
- companionBridgeFact(
1359
- label: "Status",
1360
- value: prefs.companionBridgeEnabled ? "enabled" : "off"
1361
- )
1362
- companionBridgeFact(
1363
- label: "Port",
1364
- value: String(LatticesCompanionBridgeServer.defaultPort)
1365
- )
1366
- companionBridgeFact(
1367
- label: "Protocol",
1368
- value: "v\(LatticesCompanionBridgeServer.protocolVersion)"
1369
- )
1370
- }
1371
-
1372
- VStack(alignment: .leading, spacing: 5) {
1373
- Text("Enable deep link")
1374
- .font(Typo.mono(10))
1375
- .foregroundColor(Palette.textDim)
1376
- Text("lattices://companion/enable")
1377
- .font(Typo.monoBold(12))
1378
- .foregroundColor(Palette.text)
1379
- .textSelection(.enabled)
1380
- }
1381
- .padding(10)
1382
- .frame(maxWidth: .infinity, alignment: .leading)
1383
- .background(shortcutsInsetPanel)
1384
-
1385
- VStack(alignment: .leading, spacing: 5) {
1386
- Text("Mac bridge fingerprint")
1387
- .font(Typo.mono(10))
1388
- .foregroundColor(Palette.textDim)
1389
- Text(LatticesCompanionSecurityCoordinator.shared.bridgeFingerprint)
1390
- .font(Typo.monoBold(13))
1391
- .foregroundColor(Palette.text)
1392
- .textSelection(.enabled)
1393
- }
1394
- .padding(10)
1395
- .frame(maxWidth: .infinity, alignment: .leading)
1396
- .background(shortcutsInsetPanel)
1397
-
1398
- HStack(spacing: 6) {
1399
- ForEach(DeckBridgeCapability.defaultCompanionCapabilities, id: \.self) { capability in
1400
- companionCapabilityBadge(capability)
1401
- }
1402
- Spacer(minLength: 0)
1403
- }
1404
- }
1405
- }
1406
- }
1407
-
1408
- private var companionTrustedDevicesCard: some View {
1409
- let trustedDevices = companionTrustedDevices(revision: companionTrustRevision)
1410
-
1411
- return settingsCard {
1412
- VStack(alignment: .leading, spacing: 12) {
1413
- HStack(alignment: .top, spacing: 12) {
1414
- VStack(alignment: .leading, spacing: 4) {
1415
- Text("Paired devices")
1416
- .font(Typo.mono(12))
1417
- .foregroundColor(Palette.text)
1418
- Text("Only trusted devices can call protected deck and input routes. Pairing grants are listed per device.")
1419
- .font(Typo.caption(10))
1420
- .foregroundColor(Palette.textMuted)
1421
- .fixedSize(horizontal: false, vertical: true)
1422
- }
1423
-
1424
- Spacer()
1425
-
1426
- HStack(spacing: 8) {
1427
- Button {
1428
- companionTrustRevision += 1
1429
- } label: {
1430
- Image(systemName: "arrow.clockwise")
1431
- .font(.system(size: 10, weight: .semibold))
1432
- .foregroundColor(Palette.textDim)
1433
- .frame(width: 24, height: 24)
1434
- .background(
1435
- RoundedRectangle(cornerRadius: 5)
1436
- .fill(Palette.surfaceHov)
1437
- .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Palette.borderLit, lineWidth: 0.5))
1438
- )
1439
- }
1440
- .buttonStyle(.plain)
1441
-
1442
- if trustedDevices.isEmpty == false {
1443
- Button {
1444
- guard confirmForgetTrustedDevices() else { return }
1445
- LatticesCompanionSecurityCoordinator.shared.clearTrustedDevices()
1446
- companionTrustRevision += 1
1447
- } label: {
1448
- Text("Forget All")
1449
- .font(Typo.monoBold(10))
1450
- .foregroundColor(Palette.kill.opacity(0.9))
1451
- .padding(.horizontal, 10)
1452
- .padding(.vertical, 5)
1453
- .background(
1454
- RoundedRectangle(cornerRadius: 5)
1455
- .fill(Palette.kill.opacity(0.10))
1456
- .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Palette.kill.opacity(0.22), lineWidth: 0.5))
1457
- )
1458
- }
1459
- .buttonStyle(.plain)
1460
- }
1461
- }
1462
- }
1463
-
1464
- if trustedDevices.isEmpty {
1465
- VStack(alignment: .leading, spacing: 6) {
1466
- Image(systemName: "ipad.and.iphone")
1467
- .font(.system(size: 18, weight: .semibold))
1468
- .foregroundColor(Palette.textMuted)
1469
-
1470
- Text("No paired iPad or iPhone devices yet.")
1471
- .font(Typo.caption(10.5))
1472
- .foregroundColor(Palette.textMuted)
1473
-
1474
- Text("Open the Lattices companion app on your iPad and select this Mac. You’ll approve the pairing prompt here.")
1475
- .font(Typo.caption(9.5))
1476
- .foregroundColor(Palette.textMuted.opacity(0.72))
1477
- .fixedSize(horizontal: false, vertical: true)
1478
- }
1479
- .padding(12)
1480
- .frame(maxWidth: .infinity, alignment: .leading)
1481
- .background(shortcutsInsetPanel)
1482
- } else {
1483
- VStack(alignment: .leading, spacing: 8) {
1484
- ForEach(trustedDevices) { device in
1485
- companionDeviceRow(device)
1486
- }
1487
- }
1488
- }
1489
- }
1490
- }
1491
- }
1492
-
1493
- private func companionBridgeFact(label: String, value: String) -> some View {
1494
- VStack(alignment: .leading, spacing: 5) {
1495
- Text(label.uppercased())
1496
- .font(Typo.pixel(11))
1497
- .foregroundColor(Palette.textDim)
1498
- .tracking(1)
1499
- Text(value)
1500
- .font(Typo.monoBold(11))
1501
- .foregroundColor(Palette.text)
1502
- .lineLimit(1)
1503
- .truncationMode(.middle)
1504
- }
1505
- .padding(10)
1506
- .frame(maxWidth: .infinity, alignment: .leading)
1507
- .background(shortcutsInsetPanel)
1508
- }
1509
-
1510
- private func companionDeviceRow(_ device: DeckTrustedDeviceSummary) -> some View {
1511
- HStack(alignment: .top, spacing: 10) {
1512
- RoundedRectangle(cornerRadius: 6)
1513
- .fill(Palette.surfaceHov)
1514
- .overlay(
1515
- Image(systemName: companionDeviceIcon(for: device.name))
1516
- .font(.system(size: 13, weight: .semibold))
1517
- .foregroundColor(Palette.textDim)
1518
- )
1519
- .frame(width: 30, height: 30)
1520
-
1521
- VStack(alignment: .leading, spacing: 6) {
1522
- HStack(alignment: .firstTextBaseline, spacing: 8) {
1523
- Text(device.name)
1524
- .font(Typo.monoBold(11))
1525
- .foregroundColor(Palette.text)
1526
- .lineLimit(1)
1527
-
1528
- Text(device.fingerprint)
1529
- .font(Typo.mono(10))
1530
- .foregroundColor(Palette.textMuted)
1531
- .lineLimit(1)
1532
-
1533
- Spacer(minLength: 0)
1534
- }
1535
-
1536
- HStack(spacing: 10) {
1537
- Text("Paired \(relativeTimestamp(device.pairedAt))")
1538
- Text("Last seen \(relativeTimestamp(device.lastSeenAt))")
1539
- }
1540
- .font(Typo.caption(9.5))
1541
- .foregroundColor(Palette.textMuted.opacity(0.78))
1542
-
1543
- HStack(spacing: 6) {
1544
- ForEach(device.capabilities, id: \.self) { capability in
1545
- companionCapabilityBadge(capability)
1546
- }
1547
- }
1548
- }
1549
-
1550
- Spacer(minLength: 0)
1551
-
1552
- Button {
1553
- guard confirmRevokeTrustedDevice(device) else { return }
1554
- LatticesCompanionSecurityCoordinator.shared.revokeTrustedDevice(id: device.id)
1555
- companionTrustRevision += 1
1556
- } label: {
1557
- HStack(spacing: 5) {
1558
- Image(systemName: "xmark.shield")
1559
- .font(.system(size: 10, weight: .semibold))
1560
- Text("Revoke")
1561
- .font(Typo.monoBold(9.5))
1562
- }
1563
- .foregroundColor(Palette.kill.opacity(0.95))
1564
- .padding(.horizontal, 8)
1565
- .padding(.vertical, 5)
1566
- .background(
1567
- RoundedRectangle(cornerRadius: 5)
1568
- .fill(Palette.kill.opacity(0.10))
1569
- .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Palette.kill.opacity(0.22), lineWidth: 0.5))
1570
- )
1571
- }
1572
- .buttonStyle(.plain)
1573
- .help("Revoke this paired device")
1574
- }
1575
- .padding(12)
1576
- .frame(maxWidth: .infinity, alignment: .leading)
1577
- .background(shortcutsInsetPanel)
1578
- }
1579
-
1580
- private func companionCapabilityBadge(_ capability: String) -> some View {
1581
- Text(companionCapabilityLabel(capability))
1582
- .font(Typo.monoBold(9))
1583
- .foregroundColor(Palette.running.opacity(0.92))
1584
- .padding(.horizontal, 7)
1585
- .padding(.vertical, 3)
1586
- .background(
1587
- Capsule()
1588
- .fill(Palette.running.opacity(0.10))
1589
- .overlay(Capsule().strokeBorder(Palette.running.opacity(0.18), lineWidth: 0.5))
1590
- )
1591
- }
1592
-
1593
- private func companionCapabilityLabel(_ capability: String) -> String {
1594
- switch capability {
1595
- case DeckBridgeCapability.deckRead:
1596
- return "Deck Read"
1597
- case DeckBridgeCapability.deckPerform:
1598
- return "Deck Actions"
1599
- case DeckBridgeCapability.inputTrackpad:
1600
- return "Trackpad"
1601
- default:
1602
- return capability
1603
- }
1604
- }
1605
-
1606
- private func companionDeviceIcon(for name: String) -> String {
1607
- name.localizedCaseInsensitiveContains("ipad") ? "ipad" : "iphone"
1608
- }
1609
-
1610
- private func confirmForgetTrustedDevices() -> Bool {
1611
- let alert = NSAlert()
1612
- alert.alertStyle = .warning
1613
- alert.messageText = "Forget all paired companion devices?"
1614
- alert.informativeText = "Your iPad or iPhone will need to pair again before it can control Lattices."
1615
- alert.addButton(withTitle: "Forget Devices")
1616
- alert.addButton(withTitle: "Cancel")
1617
- return alert.runModal() == .alertFirstButtonReturn
1618
- }
1619
-
1620
- private func confirmRevokeTrustedDevice(_ device: DeckTrustedDeviceSummary) -> Bool {
1621
- let alert = NSAlert()
1622
- alert.alertStyle = .warning
1623
- alert.messageText = "Revoke \(device.name)?"
1624
- alert.informativeText = """
1625
- This removes the paired-device trust record for \(device.name).
1626
-
1627
- Fingerprint: \(device.fingerprint)
1628
-
1629
- The device will need to pair again before it can control Lattices.
1630
- """
1631
- alert.addButton(withTitle: "Revoke Device")
1632
- alert.addButton(withTitle: "Cancel")
1633
- return alert.runModal() == .alertFirstButtonReturn
1634
- }
1635
-
1636
- // MARK: - AI
1637
-
1638
- private var aiContent: some View {
1639
- ScrollView {
1640
- VStack(spacing: 12) {
1641
- // ── Claude CLI ──
1642
- settingsCard {
1643
- VStack(alignment: .leading, spacing: 10) {
1644
- HStack(spacing: 8) {
1645
- Image(systemName: "sparkles")
1646
- .font(.system(size: 11, weight: .medium))
1647
- .foregroundColor(Palette.running)
1648
- Text("Claude CLI")
1649
- .font(Typo.mono(12))
1650
- .foregroundColor(Palette.text)
1651
- }
1652
-
1653
- HStack(spacing: 6) {
1654
- TextField("Auto-detected", text: $prefs.claudePath)
1655
- .textFieldStyle(.plain)
1656
- .font(Typo.mono(11))
1657
- .foregroundColor(Palette.text)
1658
- .padding(.horizontal, 8)
1659
- .padding(.vertical, 5)
1660
- .background(
1661
- RoundedRectangle(cornerRadius: 5)
1662
- .fill(Color.white.opacity(0.06))
1663
- .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5))
1664
- )
1665
-
1666
- Button {
1667
- if let resolved = Preferences.resolveClaudePath() {
1668
- prefs.claudePath = resolved
1669
- }
1670
- } label: {
1671
- Text("Detect")
1672
- .font(Typo.monoBold(10))
1673
- .foregroundColor(Palette.text)
1674
- .padding(.horizontal, 10)
1675
- .padding(.vertical, 4)
1676
- .background(
1677
- RoundedRectangle(cornerRadius: 4)
1678
- .fill(Palette.surfaceHov)
1679
- .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
1680
- )
1681
- }
1682
- .buttonStyle(.plain)
1683
- }
1684
-
1685
- let resolved = Preferences.resolveClaudePath()
1686
- if let path = resolved {
1687
- Text("Found: \(path)")
1688
- .font(Typo.caption(9))
1689
- .foregroundColor(Palette.running.opacity(0.8))
1690
- } else {
1691
- Text("Not found — install with: npm i -g @anthropic-ai/claude-code")
1692
- .font(Typo.caption(9))
1693
- .foregroundColor(Palette.detach)
1694
- }
1695
- }
1696
- }
1697
-
1698
- // ── Advisor ──
1699
- settingsCard {
1700
- VStack(alignment: .leading, spacing: 10) {
1701
- Text("Voice advisor")
1702
- .font(Typo.mono(11))
1703
- .foregroundColor(Palette.text)
1704
-
1705
- HStack {
1706
- Text("Model")
1707
- .font(Typo.mono(10))
1708
- .foregroundColor(Palette.textDim)
1709
- Spacer()
1710
- Picker("", selection: $prefs.advisorModel) {
1711
- Text("Haiku").tag("haiku")
1712
- Text("Sonnet").tag("sonnet")
1713
- }
1714
- .pickerStyle(.segmented)
1715
- .labelsHidden()
1716
- .frame(width: 160)
1717
- }
1718
-
1719
- Text("Haiku is fast and cheap. Sonnet is smarter but slower.")
1720
- .font(Typo.caption(9))
1721
- .foregroundColor(Palette.textMuted.opacity(0.7))
1722
-
1723
- cardDivider
1724
-
1725
- HStack {
1726
- Text("Budget per session")
1727
- .font(Typo.mono(10))
1728
- .foregroundColor(Palette.textDim)
1729
- Spacer()
1730
- HStack(spacing: 4) {
1731
- Text("$")
1732
- .font(Typo.mono(11))
1733
- .foregroundColor(Palette.textDim)
1734
- TextField("0.50", value: $prefs.advisorBudgetUSD, formatter: {
1735
- let f = NumberFormatter()
1736
- f.numberStyle = .decimal
1737
- f.minimumFractionDigits = 2
1738
- f.maximumFractionDigits = 2
1739
- return f
1740
- }())
1741
- .textFieldStyle(.plain)
1742
- .font(Typo.monoBold(11))
1743
- .foregroundColor(Palette.text)
1744
- .multilineTextAlignment(.center)
1745
- .frame(width: 50)
1746
- .padding(.horizontal, 4)
1747
- .padding(.vertical, 3)
1748
- .background(
1749
- RoundedRectangle(cornerRadius: 5)
1750
- .fill(Color.white.opacity(0.06))
1751
- .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5))
1752
- )
1753
- }
1754
- }
1755
-
1756
- Text("Max spend per Claude CLI invocation")
1757
- .font(Typo.caption(9))
1758
- .foregroundColor(Palette.textMuted.opacity(0.7))
1759
-
1760
- cardDivider
1761
-
1762
- // Session stats
1763
- let stats = AgentPool.shared.haiku.sessionStats
1764
- HStack(spacing: 12) {
1765
- if stats.contextWindow > 0 {
1766
- HStack(spacing: 4) {
1767
- Circle()
1768
- .fill(stats.contextUsage > 0.6 ? Palette.detach : Palette.running)
1769
- .frame(width: 5, height: 5)
1770
- Text("Context: \(Int(stats.contextUsage * 100))%")
1771
- .font(Typo.mono(10))
1772
- .foregroundColor(Palette.textMuted)
1773
- }
1774
- }
1775
- if stats.costUSD > 0 {
1776
- Text("Session cost: $\(String(format: "%.3f", stats.costUSD))")
1777
- .font(Typo.mono(10))
1778
- .foregroundColor(Palette.textMuted)
1779
- }
1780
-
1781
- Spacer()
1782
-
1783
- let learningCount = AdvisorLearningStore.shared.entryCount
1784
- if learningCount > 0 {
1785
- Text("\(learningCount) learned")
1786
- .font(Typo.mono(9))
1787
- .foregroundColor(Palette.textMuted.opacity(0.6))
1788
- }
1789
- }
1790
- }
1791
- }
1792
- }
1793
- .padding(16)
1794
- }
1795
- }
1796
-
1797
- private var buildChannelBadge: some View {
1798
- let tint = LatticesRuntime.isDevBuild ? Palette.detach : Palette.running
1799
-
1800
- return Text(LatticesRuntime.buildChannelLabel)
1801
- .font(Typo.monoBold(9))
1802
- .foregroundColor(tint)
1803
- .padding(.horizontal, 6)
1804
- .padding(.vertical, 3)
1805
- .background(
1806
- Capsule()
1807
- .fill(tint.opacity(0.12))
1808
- )
1809
- }
1810
-
1811
- // MARK: - Search & OCR
1812
-
1813
- private func ocrNumField(_ value: Binding<Double>, width: CGFloat = 50) -> some View {
1814
- TextField("", value: value, formatter: NumberFormatter())
1815
- .textFieldStyle(.plain)
1816
- .font(Typo.monoBold(11))
1817
- .foregroundColor(Palette.text)
1818
- .multilineTextAlignment(.center)
1819
- .frame(width: width)
1820
- .padding(.horizontal, 4)
1821
- .padding(.vertical, 3)
1822
- .background(
1823
- RoundedRectangle(cornerRadius: 5)
1824
- .fill(Color.white.opacity(0.06))
1825
- .overlay(
1826
- RoundedRectangle(cornerRadius: 5)
1827
- .strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)
1828
- )
1829
- )
1830
- }
1831
-
1832
- private func ocrIntField(_ value: Binding<Int>, width: CGFloat = 36) -> some View {
1833
- TextField("", value: value, formatter: NumberFormatter())
1834
- .textFieldStyle(.plain)
1835
- .font(Typo.monoBold(11))
1836
- .foregroundColor(Palette.text)
1837
- .multilineTextAlignment(.center)
1838
- .frame(width: width)
1839
- .padding(.horizontal, 4)
1840
- .padding(.vertical, 3)
1841
- .background(
1842
- RoundedRectangle(cornerRadius: 5)
1843
- .fill(Color.white.opacity(0.06))
1844
- .overlay(
1845
- RoundedRectangle(cornerRadius: 5)
1846
- .strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)
1847
- )
1848
- )
1849
- }
1850
-
1851
- private func ocrSectionLabel(_ text: String) -> some View {
1852
- Text(text)
1853
- .font(Typo.monoBold(10))
1854
- .foregroundColor(Palette.textDim)
1855
- .tracking(0.5)
1856
- }
1857
-
1858
- private var searchOcrContent: some View {
1859
- ScrollView {
1860
- VStack(spacing: 12) {
1861
- // ── Screen Text Recognition Card ──
1862
- settingsCard {
1863
- VStack(alignment: .leading, spacing: 10) {
1864
- // Header row: label + toggle
1865
- HStack {
1866
- HStack(spacing: 8) {
1867
- RoundedRectangle(cornerRadius: 4)
1868
- .fill(prefs.ocrEnabled ? Palette.running.opacity(0.15) : Palette.surface)
1869
- .overlay(
1870
- Image(systemName: "text.viewfinder")
1871
- .font(.system(size: 11, weight: .medium))
1872
- .foregroundColor(prefs.ocrEnabled ? Palette.running : Palette.textMuted)
1873
- )
1874
- .frame(width: 24, height: 24)
1875
-
1876
- VStack(alignment: .leading, spacing: 1) {
1877
- Text("Screen text recognition")
1878
- .font(Typo.mono(12))
1879
- .foregroundColor(Palette.text)
1880
- Text("Vision OCR on visible windows")
1881
- .font(Typo.caption(10))
1882
- .foregroundColor(Palette.textMuted)
1883
- }
1884
- }
1885
- Spacer()
1886
- Toggle("", isOn: Binding(
1887
- get: { prefs.ocrEnabled },
1888
- set: { OcrModel.shared.setEnabled($0) }
1889
- ))
1890
- .toggleStyle(.switch)
1891
- .controlSize(.small)
1892
- .labelsHidden()
1893
- }
1894
-
1895
- // Accuracy
1896
- HStack(spacing: 8) {
1897
- Text("Accuracy")
1898
- .font(Typo.mono(10))
1899
- .foregroundColor(Palette.textDim)
1900
- Picker("", selection: $prefs.ocrAccuracy) {
1901
- Text("Accurate").tag("accurate")
1902
- Text("Fast").tag("fast")
1903
- }
1904
- .pickerStyle(.segmented)
1905
- .labelsHidden()
1906
- .frame(width: 140)
1907
- Spacer()
1908
- }
1909
- .padding(.leading, 32)
1910
- }
1911
- }
1912
-
1913
- // ── Scan Schedule Card ──
1914
- settingsCard {
1915
- VStack(alignment: .leading, spacing: 10) {
1916
- ocrSectionLabel("Schedule")
1917
-
1918
- // Quick scan sentence
1919
- HStack(spacing: 0) {
1920
- Text("Quick scan top ")
1921
- .font(Typo.mono(11))
1922
- .foregroundColor(Palette.textDim)
1923
- ocrIntField($prefs.ocrQuickLimit, width: 32)
1924
- Text(" windows every ")
1925
- .font(Typo.mono(11))
1926
- .foregroundColor(Palette.textDim)
1927
- ocrNumField($prefs.ocrQuickInterval, width: 42)
1928
- Text("s")
1929
- .font(Typo.mono(11))
1930
- .foregroundColor(Palette.textDim)
1931
- Spacer()
1932
- }
1933
-
1934
- cardDivider
1935
-
1936
- // Deep scan sentence
1937
- HStack(spacing: 0) {
1938
- Text("Deep scan up to ")
1939
- .font(Typo.mono(11))
1940
- .foregroundColor(Palette.textDim)
1941
- ocrIntField($prefs.ocrDeepLimit, width: 32)
1942
- Text(" windows every ")
1943
- .font(Typo.mono(11))
1944
- .foregroundColor(Palette.textDim)
1945
- ocrNumField($prefs.ocrDeepInterval, width: 52)
1946
- Text("s")
1947
- .font(Typo.mono(11))
1948
- .foregroundColor(Palette.textDim)
1949
- Spacer()
1950
- }
1951
-
1952
- HStack(spacing: 0) {
1953
- Text("OCR budget: ")
1954
- .font(Typo.mono(11))
1955
- .foregroundColor(Palette.textDim)
1956
- ocrIntField($prefs.ocrDeepBudget, width: 32)
1957
- Text(" windows per scan")
1958
- .font(Typo.mono(11))
1959
- .foregroundColor(Palette.textDim)
1960
- Spacer()
1961
- }
1962
-
1963
- // Human-readable deep interval
1964
- let h = Int(prefs.ocrDeepInterval / 3600)
1965
- let m = Int(prefs.ocrDeepInterval.truncatingRemainder(dividingBy: 3600) / 60)
1966
- if h > 0 || m > 0 {
1967
- Text("≈ \(h > 0 ? "\(h)h" : "")\(m > 0 ? " \(m)m" : "")")
1968
- .font(Typo.caption(9))
1969
- .foregroundColor(Palette.textMuted.opacity(0.6))
1970
- .padding(.leading, 2)
1971
- }
1972
- }
1973
- }
1974
-
1975
- // ── Status Card ──
1976
- settingsCard {
1977
- HStack(spacing: 8) {
1978
- let ocrResults = OcrModel.shared.results
1979
- let isScanning = OcrModel.shared.isScanning
1980
-
1981
- Circle()
1982
- .fill(isScanning ? Palette.detach : (prefs.ocrEnabled ? Palette.running : Palette.textMuted))
1983
- .frame(width: 6, height: 6)
1984
-
1985
- Text(isScanning ? "Scanning..." : (prefs.ocrEnabled ? "\(ocrResults.count) windows cached" : "Disabled"))
1986
- .font(Typo.mono(10))
1987
- .foregroundColor(Palette.textMuted)
1988
-
1989
- Spacer()
1990
-
1991
- Button {
1992
- OcrModel.shared.scan()
1993
- } label: {
1994
- HStack(spacing: 4) {
1995
- Image(systemName: "arrow.clockwise")
1996
- .font(.system(size: 9, weight: .semibold))
1997
- Text("Scan Now")
1998
- .font(Typo.monoBold(10))
1999
- }
2000
- .foregroundColor(prefs.ocrEnabled ? Palette.text : Palette.textMuted)
2001
- .padding(.horizontal, 10)
2002
- .padding(.vertical, 4)
2003
- .background(
2004
- RoundedRectangle(cornerRadius: 4)
2005
- .fill(prefs.ocrEnabled ? Palette.surfaceHov : Palette.surface)
2006
- .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
2007
- )
2008
- }
2009
- .buttonStyle(.plain)
2010
- .disabled(!prefs.ocrEnabled)
2011
- }
2012
- }
2013
-
2014
- // ── Recent Captures ──
2015
- recentCapturesSection
2016
- }
2017
- .padding(16)
2018
- }
2019
- }
2020
-
2021
- // MARK: - Recent Captures Browser
2022
-
2023
- private var recentCapturesSection: some View {
2024
- let ocrResults = OcrModel.shared.results
2025
- let grouped = Dictionary(grouping: ocrResults.values, by: \.app)
2026
- .sorted { $0.value.count > $1.value.count }
2027
-
2028
- return Group {
2029
- if !grouped.isEmpty {
2030
- settingsCard {
2031
- VStack(alignment: .leading, spacing: 8) {
2032
- ocrSectionLabel("Recent Captures")
2033
-
2034
- ForEach(grouped, id: \.key) { app, windows in
2035
- ocrAppGroup(app: app, windows: windows.sorted { $0.timestamp > $1.timestamp })
2036
- }
2037
- }
2038
- }
2039
- }
2040
- }
2041
- }
2042
-
2043
- private func ocrAppGroup(app: String, windows: [OcrWindowResult]) -> some View {
2044
- let isCollapsed = collapsedOcrApps.contains(app)
2045
-
2046
- return VStack(alignment: .leading, spacing: 0) {
2047
- // App header
2048
- Button {
2049
- withAnimation(.easeInOut(duration: 0.15)) {
2050
- if isCollapsed {
2051
- collapsedOcrApps.remove(app)
2052
- } else {
2053
- collapsedOcrApps.insert(app)
2054
- }
2055
- }
2056
- } label: {
2057
- HStack(spacing: 6) {
2058
- Image(systemName: isCollapsed ? "chevron.right" : "chevron.down")
2059
- .font(.system(size: 8, weight: .semibold))
2060
- .foregroundColor(Palette.textMuted)
2061
- .frame(width: 10)
2062
-
2063
- Text(app)
2064
- .font(Typo.monoBold(11))
2065
- .foregroundColor(Palette.text)
2066
-
2067
- Text("(\(windows.count))")
2068
- .font(Typo.mono(10))
2069
- .foregroundColor(Palette.textMuted)
2070
-
2071
- Spacer()
2072
- }
2073
- .padding(.vertical, 4)
2074
- .contentShape(Rectangle())
2075
- }
2076
- .buttonStyle(.plain)
2077
-
2078
- if !isCollapsed {
2079
- VStack(alignment: .leading, spacing: 2) {
2080
- ForEach(windows, id: \.wid) { win in
2081
- ocrWindowRow(win)
2082
- }
2083
- }
2084
- .padding(.leading, 16)
2085
- }
2086
- }
2087
- }
2088
-
2089
- private func ocrWindowRow(_ win: OcrWindowResult) -> some View {
2090
- let isExpanded = expandedOcrWindow == win.wid
2091
- let preview = String(win.fullText.prefix(80)).replacingOccurrences(of: "\n", with: " ")
2092
-
2093
- return VStack(alignment: .leading, spacing: 0) {
2094
- Button {
2095
- withAnimation(.easeInOut(duration: 0.15)) {
2096
- expandedOcrWindow = isExpanded ? nil : win.wid
2097
- }
2098
- } label: {
2099
- HStack(spacing: 6) {
2100
- Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
2101
- .font(.system(size: 7, weight: .semibold))
2102
- .foregroundColor(Palette.textMuted)
2103
- .frame(width: 8)
2104
-
2105
- VStack(alignment: .leading, spacing: 2) {
2106
- HStack(spacing: 6) {
2107
- Text(win.title.isEmpty ? "Untitled" : win.title)
2108
- .font(Typo.mono(10))
2109
- .foregroundColor(Palette.text)
2110
- .lineLimit(1)
2111
-
2112
- Spacer()
2113
-
2114
- Text(ocrRelativeTime(win.timestamp))
2115
- .font(Typo.caption(9))
2116
- .foregroundColor(Palette.textMuted)
2117
- }
2118
-
2119
- if !isExpanded && !preview.isEmpty {
2120
- Text(preview)
2121
- .font(Typo.caption(9))
2122
- .foregroundColor(Palette.textMuted.opacity(0.7))
2123
- .lineLimit(1)
2124
- }
2125
- }
2126
- }
2127
- .padding(.vertical, 4)
2128
- .contentShape(Rectangle())
2129
- }
2130
- .buttonStyle(.plain)
2131
-
2132
- if isExpanded {
2133
- ocrExpandedDetail(win)
2134
- .padding(.leading, 14)
2135
- .padding(.vertical, 4)
2136
- }
2137
- }
2138
- }
2139
-
2140
- private func ocrExpandedDetail(_ win: OcrWindowResult) -> some View {
2141
- VStack(alignment: .leading, spacing: 6) {
2142
- // Metadata row
2143
- HStack(spacing: 10) {
2144
- let avgConfidence = win.texts.isEmpty ? 0 : win.texts.map(\.confidence).reduce(0, +) / Float(win.texts.count)
2145
- Text("\(win.texts.count) blocks")
2146
- .font(Typo.caption(9))
2147
- .foregroundColor(Palette.textMuted)
2148
- Text("confidence: \(String(format: "%.0f%%", avgConfidence * 100))")
2149
- .font(Typo.caption(9))
2150
- .foregroundColor(Palette.textMuted)
2151
- Spacer()
2152
- }
2153
-
2154
- // Full text in scrollable monospaced area
2155
- ScrollView {
2156
- Text(win.fullText)
2157
- .font(.system(size: 10, design: .monospaced))
2158
- .foregroundColor(Palette.textDim)
2159
- .textSelection(.enabled)
2160
- .frame(maxWidth: .infinity, alignment: .leading)
2161
- .padding(8)
2162
- }
2163
- .frame(maxHeight: 150)
2164
- .background(
2165
- RoundedRectangle(cornerRadius: 4)
2166
- .fill(Color.black.opacity(0.2))
2167
- .overlay(
2168
- RoundedRectangle(cornerRadius: 4)
2169
- .strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
2170
- )
2171
- )
2172
- }
2173
- }
2174
-
2175
- private func ocrRelativeTime(_ date: Date) -> String {
2176
- let seconds = Int(-date.timeIntervalSinceNow)
2177
- if seconds < 60 { return "just now" }
2178
- let minutes = seconds / 60
2179
- if minutes < 60 { return "\(minutes)m ago" }
2180
- let hours = minutes / 60
2181
- if hours < 24 { return "\(hours)h ago" }
2182
- return "\(hours / 24)d ago"
2183
- }
2184
-
2185
- // MARK: - Settings Card
2186
-
2187
- private func settingsCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
2188
- content()
2189
- .padding(.horizontal, 14)
2190
- .padding(.vertical, 12)
2191
- .frame(maxWidth: .infinity, alignment: .leading)
2192
- .liquidGlass()
2193
- }
2194
-
2195
- private var cardDivider: some View {
2196
- Rectangle()
2197
- .fill(
2198
- LinearGradient(
2199
- colors: [Color.white.opacity(0.03), Color.white.opacity(0.08), Color.white.opacity(0.03)],
2200
- startPoint: .leading,
2201
- endPoint: .trailing
2202
- )
2203
- )
2204
- .frame(height: 0.5)
2205
- .padding(.vertical, 3)
2206
- }
2207
-
2208
- private func permissionSettingsRow(
2209
- _ title: String,
2210
- granted: Bool,
2211
- detail: String,
2212
- action: @escaping () -> Void
2213
- ) -> some View {
2214
- Button {
2215
- if granted {
2216
- permChecker.check()
2217
- } else {
2218
- action()
2219
- }
2220
- } label: {
2221
- permissionRowContent(
2222
- title,
2223
- status: granted ? "granted" : "not set",
2224
- statusColor: granted ? Palette.running : Palette.detach,
2225
- icon: granted ? "checkmark.circle.fill" : "exclamationmark.circle.fill",
2226
- iconColor: granted ? Palette.running : Palette.detach,
2227
- detail: detail,
2228
- showsExternalLink: !granted
2229
- )
2230
- }
2231
- .buttonStyle(.plain)
2232
- .help(granted ? "Refresh permission status" : "Open macOS permission flow")
2233
- }
2234
-
2235
- private func permissionReviewRow(
2236
- _ title: String,
2237
- detail: String,
2238
- action: @escaping () -> Void
2239
- ) -> some View {
2240
- Button(action: action) {
2241
- permissionRowContent(
2242
- title,
2243
- status: "review",
2244
- statusColor: Palette.textDim,
2245
- icon: "gearshape.2.fill",
2246
- iconColor: Palette.textDim,
2247
- detail: detail,
2248
- showsExternalLink: true
2249
- )
2250
- }
2251
- .buttonStyle(.plain)
2252
- .help("Open macOS Privacy & Security settings")
2253
- }
2254
-
2255
- private func permissionRowContent(
2256
- _ title: String,
2257
- status: String,
2258
- statusColor: Color,
2259
- icon: String,
2260
- iconColor: Color,
2261
- detail: String,
2262
- showsExternalLink: Bool
2263
- ) -> some View {
2264
- HStack(alignment: .top, spacing: 8) {
2265
- Image(systemName: icon)
2266
- .font(.system(size: 10, weight: .semibold))
2267
- .foregroundColor(iconColor)
2268
- .frame(width: 12, height: 16)
2269
-
2270
- VStack(alignment: .leading, spacing: 2) {
2271
- HStack(spacing: 6) {
2272
- Text(title)
2273
- .font(Typo.mono(10))
2274
- .foregroundColor(Palette.text)
2275
- Text(status)
2276
- .font(Typo.mono(9))
2277
- .foregroundColor(statusColor)
2278
- Spacer()
2279
- if showsExternalLink {
2280
- Image(systemName: "arrow.up.forward.square")
2281
- .font(.system(size: 9))
2282
- .foregroundColor(Palette.textMuted)
2283
- }
2284
- }
2285
-
2286
- Text(detail)
2287
- .font(Typo.caption(9))
2288
- .foregroundColor(Palette.textMuted.opacity(0.75))
2289
- .fixedSize(horizontal: false, vertical: true)
2290
- }
2291
- }
2292
- .padding(.horizontal, 8)
2293
- .padding(.vertical, 7)
2294
- .background(
2295
- RoundedRectangle(cornerRadius: 5)
2296
- .fill(Palette.surfaceHov.opacity(status == "not set" ? 0.75 : 0.35))
2297
- .overlay(
2298
- RoundedRectangle(cornerRadius: 5)
2299
- .strokeBorder(status == "not set" ? Palette.detach.opacity(0.22) : Palette.borderLit.opacity(0.6), lineWidth: 0.5)
2300
- )
2301
- )
2302
- }
2303
-
2304
- @ViewBuilder
2305
- private func breakerStatusRow(
2306
- state: EventTapBreaker.State,
2307
- label: String,
2308
- onReArm: @escaping () -> Void
2309
- ) -> some View {
2310
- switch state {
2311
- case .armed:
2312
- EmptyView()
2313
- case .paused(let cooldownSec):
2314
- HStack(spacing: 8) {
2315
- Circle()
2316
- .fill(Color.orange)
2317
- .frame(width: 6, height: 6)
2318
- Text("\(label) paused — \(cooldownSec)s cooldown")
2319
- .font(Typo.caption(9))
2320
- .foregroundColor(Palette.textMuted)
2321
- Spacer()
2322
- }
2323
- case .disabled:
2324
- HStack(spacing: 8) {
2325
- Circle()
2326
- .fill(Color.red)
2327
- .frame(width: 6, height: 6)
2328
- Text("\(label) disabled — tap callback exceeded OS budget repeatedly")
2329
- .font(Typo.caption(9))
2330
- .foregroundColor(Palette.textMuted)
2331
- Spacer()
2332
- Button {
2333
- onReArm()
2334
- } label: {
2335
- Text("Re-enable")
2336
- .font(Typo.monoBold(10))
2337
- .foregroundColor(Palette.text)
2338
- .padding(.horizontal, 10)
2339
- .padding(.vertical, 3)
2340
- .background(
2341
- RoundedRectangle(cornerRadius: 4)
2342
- .fill(Palette.surfaceHov)
2343
- .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
2344
- )
2345
- }
2346
- .buttonStyle(.plain)
2347
- }
2348
- }
2349
- }
2350
-
2351
- // MARK: - Shortcuts
2352
-
2353
- private var shortcutsContent: some View {
2354
- VStack(spacing: 0) {
2355
- GeometryReader { geo in
2356
- let contentWidth = max(geo.size.width - 40, 320)
2357
- let sectionColumns = [
2358
- GridItem(.adaptive(minimum: min(320, contentWidth), maximum: 440), spacing: 16, alignment: .top)
2359
- ]
2360
- let tilingColumns = contentWidth > 860
2361
- ? [
2362
- GridItem(.flexible(minimum: 280, maximum: 360), spacing: 16, alignment: .top),
2363
- GridItem(.flexible(minimum: 320, maximum: 640), spacing: 16, alignment: .top)
2364
- ]
2365
- : [GridItem(.flexible(minimum: 0, maximum: .infinity), spacing: 16, alignment: .top)]
2366
-
2367
- ScrollView {
2368
- VStack(alignment: .leading, spacing: 0) {
2369
- VStack(alignment: .leading, spacing: 16) {
2370
- shortcutsOverviewCard
2371
- inputControlsCard
2372
-
2373
- LazyVGrid(columns: sectionColumns, alignment: .leading, spacing: 16) {
2374
- shortcutsAppCard
2375
- shortcutsLayersCard
2376
- }
2377
-
2378
- shortcutSectionCard(
2379
- title: "Window Tiling",
2380
- eyebrow: "Desktop Layout",
2381
- summary: "See the directional map first, then edit the matching global shortcuts below."
2382
- ) {
2383
- LazyVGrid(columns: tilingColumns, alignment: .leading, spacing: 16) {
2384
- shortcutsTilingVisualizer
2385
- shortcutsTilingEditors
2386
- }
2387
- }
2388
-
2389
- shortcutsTmuxCard
2390
- }
2391
- .padding(.horizontal, 20)
2392
- .padding(.vertical, 16)
2393
- }
2394
- }
2395
- }
2396
-
2397
- Spacer(minLength: 0)
2398
-
2399
- separator
2400
-
2401
- HStack {
2402
- HStack(spacing: 8) {
2403
- footerActionButton(icon: "book", label: "Docs") {
2404
- ScreenMapWindowController.shared.showPage(.docs)
2405
- }
2406
-
2407
- footerActionButton(icon: "stethoscope", label: "Diagnostics") {
2408
- DiagnosticWindow.shared.show()
2409
- }
2410
- }
2411
-
2412
- Spacer()
2413
-
2414
- Button {
2415
- hotkeyStore.resetAll()
2416
- } label: {
2417
- Text("Reset All to Defaults")
2418
- .font(Typo.caption(11))
2419
- .foregroundColor(Palette.textDim)
2420
- .padding(.horizontal, 12)
2421
- .padding(.vertical, 5)
2422
- .background(
2423
- RoundedRectangle(cornerRadius: 3)
2424
- .fill(Palette.surface)
2425
- .overlay(
2426
- RoundedRectangle(cornerRadius: 3)
2427
- .strokeBorder(Palette.border, lineWidth: 0.5)
2428
- )
2429
- )
2430
- }
2431
- .buttonStyle(.plain)
2432
- }
2433
- .padding(.horizontal, 20)
2434
- .padding(.vertical, 10)
2435
- }
2436
- }
2437
-
2438
- // MARK: - Shortcuts: Overview
2439
-
2440
- private var companionCockpitCard: some View {
2441
- let layout = LatticesCompanionCockpitCatalog.normalized(prefs.companionCockpitLayout)
2442
- let selectedPage = layout.pages.first(where: { $0.id == selectedCompanionCockpitPageID }) ?? layout.pages.first
2443
- let categories = LatticesCompanionShortcutCategory.allCases
2444
- let trustedDeviceCount = companionTrustedDevices(revision: companionTrustRevision).count
2445
-
2446
- return shortcutSectionCard(
2447
- title: "Companion Cockpit",
2448
- eyebrow: "iPad & iPhone",
2449
- summary: "Define the Mac-authored command deck here, then let the companion app render it. Trackpad proxy runs through the same bridge."
2450
- ) {
2451
- VStack(alignment: .leading, spacing: 14) {
2452
- HStack(alignment: .top, spacing: 12) {
2453
- VStack(alignment: .leading, spacing: 5) {
2454
- Text("Trackpad Proxy")
2455
- .font(Typo.monoBold(11))
2456
- .foregroundColor(Palette.text)
2457
- Text("Enable remote pointer control for the iPad trackpad surface. Accessibility permission is still required on the Mac.")
2458
- .font(Typo.caption(10.5))
2459
- .foregroundColor(Palette.textMuted)
2460
- .fixedSize(horizontal: false, vertical: true)
2461
- }
2462
-
2463
- Spacer()
2464
-
2465
- Toggle("", isOn: $prefs.companionTrackpadEnabled)
2466
- .toggleStyle(.switch)
2467
- .labelsHidden()
2468
- .disabled(!prefs.companionBridgeEnabled)
2469
- .opacity(prefs.companionBridgeEnabled ? 1 : 0.45)
2470
- }
2471
-
2472
- HStack(alignment: .center, spacing: 12) {
2473
- VStack(alignment: .leading, spacing: 4) {
2474
- Text("Pairing and trust")
2475
- .font(Typo.monoBold(11))
2476
- .foregroundColor(Palette.text)
2477
- Text("\(trustedDeviceCount) paired \(trustedDeviceCount == 1 ? "device" : "devices"). Revoke devices and review bridge grants in Companion settings.")
2478
- .font(Typo.caption(10.5))
2479
- .foregroundColor(Palette.textMuted)
2480
- .fixedSize(horizontal: false, vertical: true)
2481
- }
2482
-
2483
- Spacer()
2484
-
2485
- Button {
2486
- selectedTab = .companion
2487
- } label: {
2488
- HStack(spacing: 5) {
2489
- Image(systemName: "ipad.and.iphone")
2490
- .font(.system(size: 10, weight: .semibold))
2491
- Text("Manage")
2492
- .font(Typo.monoBold(10))
2493
- }
2494
- .foregroundColor(Palette.text)
2495
- .padding(.horizontal, 10)
2496
- .padding(.vertical, 5)
2497
- .background(
2498
- RoundedRectangle(cornerRadius: 5)
2499
- .fill(Palette.surfaceHov)
2500
- .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Palette.borderLit, lineWidth: 0.5))
2501
- )
2502
- }
2503
- .buttonStyle(.plain)
2504
- }
2505
- .padding(12)
2506
- .background(shortcutsInsetPanel)
2507
-
2508
- if let selectedPage {
2509
- Picker("Companion page", selection: $selectedCompanionCockpitPageID) {
2510
- ForEach(layout.pages) { page in
2511
- Text(page.title).tag(page.id)
2512
- }
2513
- }
2514
- .pickerStyle(.segmented)
2515
-
2516
- VStack(alignment: .leading, spacing: 8) {
2517
- if let subtitle = selectedPage.subtitle, !subtitle.isEmpty {
2518
- Text(subtitle)
2519
- .font(Typo.caption(10.5))
2520
- .foregroundColor(Palette.textMuted)
2521
- }
2522
-
2523
- LazyVGrid(
2524
- columns: Array(
2525
- repeating: GridItem(.flexible(minimum: 120, maximum: 220), spacing: 8, alignment: .top),
2526
- count: max(2, selectedPage.columns)
2527
- ),
2528
- alignment: .leading,
2529
- spacing: 8
2530
- ) {
2531
- ForEach(Array(selectedPage.slotIDs.enumerated()), id: \.offset) { index, shortcutID in
2532
- companionCockpitSlotMenu(
2533
- pageID: selectedPage.id,
2534
- index: index,
2535
- shortcutID: shortcutID,
2536
- categories: categories
2537
- )
2538
- }
2539
- }
2540
- }
2541
- .padding(12)
2542
- .background(shortcutsInsetPanel)
2543
- }
2544
-
2545
- HStack(spacing: 10) {
2546
- Text("Changes appear in the iPad companion on the next snapshot refresh.")
2547
- .font(Typo.caption(10.5))
2548
- .foregroundColor(Palette.textMuted)
2549
-
2550
- Spacer()
2551
-
2552
- Button("Reset Companion Layout") {
2553
- prefs.resetCompanionCockpitLayout()
2554
- }
2555
- .buttonStyle(.plain)
2556
- .font(Typo.caption(10.5))
2557
- .foregroundColor(Palette.textDim)
2558
- }
2559
- }
2560
- }
2561
- }
2562
-
2563
- private var shortcutsOverviewCard: some View {
2564
- shortcutSectionCard(
2565
- title: "Shortcut Map",
2566
- eyebrow: "Quick Reference",
2567
- summary: "Global hotkeys are editable here. tmux shortcuts stay as a built-in reference so you can keep your workspace flow in one place."
2568
- ) {
2569
- LazyVGrid(
2570
- columns: [GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 10, alignment: .top)],
2571
- alignment: .leading,
2572
- spacing: 10
2573
- ) {
2574
- shortcutFactCard(
2575
- icon: "command",
2576
- title: "Global Hotkeys",
2577
- detail: "Edit palette, search, voice, and workspace actions without leaving settings."
2578
- )
2579
- shortcutFactCard(
2580
- icon: "rectangle.split.3x3",
2581
- title: "Spatial Tiling",
2582
- detail: "The layout grid mirrors the screen positions used by the menu bar app."
2583
- )
2584
- shortcutFactCard(
2585
- icon: "terminal",
2586
- title: "tmux Muscle Memory",
2587
- detail: "Keep the core pane controls visible here while you tune the app-level shortcuts."
2588
- )
2589
- }
2590
- }
2591
- }
2592
-
2593
- // MARK: - Shortcuts: App
2594
-
2595
- private var shortcutsAppCard: some View {
2596
- shortcutSectionCard(
2597
- title: "App & Workspace",
2598
- eyebrow: "Global",
2599
- summary: "Commands for opening primary surfaces and navigating the desktop companion."
2600
- ) {
2601
- VStack(alignment: .leading, spacing: 8) {
2602
- ForEach(HotkeyAction.allCases.filter { $0.group == .app }, id: \.rawValue) { action in
2603
- compactKeyRecorder(action: action)
2604
- }
2605
- }
2606
- }
2607
- }
2608
-
2609
- // MARK: - Shortcuts: Layers
2610
-
2611
- private var shortcutsLayersCard: some View {
2612
- shortcutSectionCard(
2613
- title: "Layers",
2614
- eyebrow: "Workspace Stack",
2615
- summary: "Direct jumps stay grouped separately from layer cycling so the numeric map is easier to scan."
2616
- ) {
2617
- VStack(alignment: .leading, spacing: 12) {
2618
- shortcutSubsectionLabel("Jump to a Layer")
2619
-
2620
- VStack(alignment: .leading, spacing: 8) {
2621
- ForEach(HotkeyAction.layerActions, id: \.rawValue) { action in
2622
- compactKeyRecorder(action: action)
2623
- }
2624
- }
2625
-
2626
- cardDivider
2627
-
2628
- shortcutSubsectionLabel("Cycle & Tag")
2629
-
2630
- VStack(alignment: .leading, spacing: 8) {
2631
- ForEach([HotkeyAction.layerPrev, .layerNext, .layerTag], id: \.rawValue) { action in
2632
- compactKeyRecorder(action: action)
2633
- }
2634
- }
2635
- }
2636
- }
2637
- }
2638
-
2639
- // MARK: - Shortcuts: Tiling
2640
-
2641
- private var shortcutsTilingVisualizer: some View {
2642
- VStack(alignment: .leading, spacing: 12) {
2643
- shortcutSubsectionLabel("Screen Regions")
2644
-
2645
- VStack(alignment: .leading, spacing: 10) {
2646
- VStack(spacing: 2) {
2647
- HStack(spacing: 2) {
2648
- tileCell(action: .tileTopLeft, label: "TL")
2649
- tileCell(action: .tileTop, label: "Top")
2650
- tileCell(action: .tileTopRight, label: "TR")
2651
- }
2652
- HStack(spacing: 2) {
2653
- tileCell(action: .tileLeft, label: "Left")
2654
- tileCell(action: .tileMaximize, label: "Max")
2655
- tileCell(action: .tileRight, label: "Right")
2656
- }
2657
- HStack(spacing: 2) {
2658
- tileCell(action: .tileBottomLeft, label: "BL")
2659
- tileCell(action: .tileBottom, label: "Bottom")
2660
- tileCell(action: .tileBottomRight, label: "BR")
2661
- }
2662
- }
2663
- .padding(8)
2664
- .background(shortcutsInsetPanel)
2665
-
2666
- VStack(alignment: .leading, spacing: 6) {
2667
- Text("Thirds")
2668
- .font(Typo.caption(10.5))
2669
- .foregroundColor(Palette.textMuted)
2670
-
2671
- HStack(spacing: 2) {
2672
- tileCell(action: .tileLeftThird, label: "\u{2153}L")
2673
- tileCell(action: .tileCenterThird, label: "\u{2153}C")
2674
- tileCell(action: .tileRightThird, label: "\u{2153}R")
2675
- }
2676
- }
2677
- .padding(8)
2678
- .background(shortcutsInsetPanel)
2679
-
2680
- Text("Use the grid as a visual legend for where each shortcut will place the focused window.")
2681
- .font(Typo.caption(10.5))
2682
- .foregroundColor(Palette.textMuted)
2683
- .fixedSize(horizontal: false, vertical: true)
2684
- }
2685
- }
2686
- }
2687
-
2688
- private var shortcutsTilingEditors: some View {
2689
- VStack(alignment: .leading, spacing: 12) {
2690
- shortcutSubsectionLabel("Editable Bindings")
2691
-
2692
- VStack(alignment: .leading, spacing: 8) {
2693
- ForEach([
2694
- HotkeyAction.tileLeft, .tileRight, .tileTop, .tileBottom,
2695
- .tileTopLeft, .tileTopRight, .tileBottomLeft, .tileBottomRight
2696
- ], id: \.rawValue) { action in
2697
- compactKeyRecorder(action: action)
2698
- }
2699
- }
2700
-
2701
- cardDivider
2702
-
2703
- shortcutSubsectionLabel("Layout Helpers")
2704
-
2705
- VStack(alignment: .leading, spacing: 8) {
2706
- ForEach([
2707
- HotkeyAction.tileLeftThird, .tileCenterThird, .tileRightThird,
2708
- .tileCenter, .tileMaximize, .tileDistribute, .tileTypeGrid
2709
- ], id: \.rawValue) { action in
2710
- compactKeyRecorder(action: action)
2711
- }
2712
- }
2713
- }
2714
- }
2715
-
2716
- // MARK: - Shortcuts: tmux
2717
-
2718
- private var shortcutsTmuxCard: some View {
2719
- shortcutSectionCard(
2720
- title: "Inside tmux",
2721
- eyebrow: "Reference",
2722
- summary: "These are tmux-native controls. They are shown here for fast recall and are not edited by the app."
2723
- ) {
2724
- VStack(alignment: .leading, spacing: 10) {
2725
- VStack(alignment: .leading, spacing: 8) {
2726
- shortcutRow("Detach", keys: ["Ctrl+B", "D"])
2727
- shortcutRow("Kill pane", keys: ["Ctrl+B", "X"])
2728
- shortcutRow("Pane left", keys: ["Ctrl+B", "\u{2190}"])
2729
- shortcutRow("Pane right", keys: ["Ctrl+B", "\u{2192}"])
2730
- shortcutRow("Zoom toggle", keys: ["Ctrl+B", "Z"])
2731
- shortcutRow("Scroll mode", keys: ["Ctrl+B", "["])
2732
- }
2733
- .padding(12)
2734
- .background(shortcutsInsetPanel)
2735
-
2736
- Text("Tip: use this as your quick memory jogger while editing the global shortcuts above.")
2737
- .font(Typo.caption(10.5))
2738
- .foregroundColor(Palette.textMuted)
2739
- .fixedSize(horizontal: false, vertical: true)
2740
- }
2741
-
2742
- compactKeyRecorder(action: .tileOrganize)
2743
- }
2744
- }
2745
-
2746
- // MARK: - Shortcut section UI
2747
-
2748
- private func shortcutSectionCard<Content: View>(
2749
- title: String,
2750
- eyebrow: String,
2751
- summary: String,
2752
- @ViewBuilder content: () -> Content
2753
- ) -> some View {
2754
- settingsCard {
2755
- VStack(alignment: .leading, spacing: 12) {
2756
- VStack(alignment: .leading, spacing: 5) {
2757
- Text(eyebrow.uppercased())
2758
- .font(Typo.pixel(12))
2759
- .foregroundColor(Palette.textDim)
2760
- .tracking(1)
2761
-
2762
- Text(title)
2763
- .font(Typo.monoBold(12))
2764
- .foregroundColor(Palette.text)
2765
-
2766
- Text(summary)
2767
- .font(Typo.caption(10.5))
2768
- .foregroundColor(Palette.textMuted)
2769
- .fixedSize(horizontal: false, vertical: true)
2770
- }
2771
-
2772
- content()
2773
- }
2774
- }
2775
- }
2776
-
2777
- private func shortcutFactCard(icon: String, title: String, detail: String) -> some View {
2778
- VStack(alignment: .leading, spacing: 8) {
2779
- Image(systemName: icon)
2780
- .font(.system(size: 12, weight: .semibold))
2781
- .foregroundColor(Palette.textDim)
2782
-
2783
- Text(title)
2784
- .font(Typo.monoBold(11))
2785
- .foregroundColor(Palette.text)
2786
-
2787
- Text(detail)
2788
- .font(Typo.caption(10))
2789
- .foregroundColor(Palette.textMuted)
2790
- .fixedSize(horizontal: false, vertical: true)
2791
- }
2792
- .frame(maxWidth: .infinity, alignment: .leading)
2793
- .padding(12)
2794
- .background(shortcutsInsetPanel)
2795
- }
2796
-
2797
- private func shortcutSubsectionLabel(_ title: String) -> some View {
2798
- Text(title.uppercased())
2799
- .font(Typo.pixel(11))
2800
- .foregroundColor(Palette.textDim)
2801
- .tracking(1)
2802
- }
2803
-
2804
- private var shortcutsInsetPanel: some View {
2805
- RoundedRectangle(cornerRadius: 8)
2806
- .fill(Color.black.opacity(0.22))
2807
- .overlay(
2808
- RoundedRectangle(cornerRadius: 8)
2809
- .strokeBorder(Palette.border, lineWidth: 0.5)
2810
- )
2811
- }
2812
-
2813
- private func relativeTimestamp(_ date: Date) -> String {
2814
- RelativeDateTimeFormatter().localizedString(for: date, relativeTo: Date())
2815
- }
2816
-
2817
- private func companionTrustedDevices(revision: Int) -> [DeckTrustedDeviceSummary] {
2818
- _ = revision
2819
- return LatticesCompanionSecurityCoordinator.shared.trustedDeviceSummaries()
2820
- }
2821
-
2822
- // MARK: - Tile cell (spatial grid item)
2823
-
2824
- private func tileCell(action: HotkeyAction, label: String) -> some View {
2825
- let binding = hotkeyStore.bindings[action]
2826
- let badgeText = binding?.displayParts.last ?? ""
2827
-
2828
- return Button {
2829
- // Open inline key recorder for this action
2830
- } label: {
2831
- VStack(spacing: 3) {
2832
- Text(label)
2833
- .font(Typo.caption(9))
2834
- .foregroundColor(Palette.textDim)
2835
- Text(badgeText)
2836
- .font(Typo.geistMonoBold(9))
2837
- .foregroundColor(Palette.text)
2838
- }
2839
- .frame(maxWidth: .infinity)
2840
- .frame(height: 42)
2841
- .background(
2842
- RoundedRectangle(cornerRadius: 4)
2843
- .fill(Palette.surface)
2844
- .overlay(
2845
- RoundedRectangle(cornerRadius: 4)
2846
- .strokeBorder(Palette.border, lineWidth: 0.5)
2847
- )
2848
- )
2849
- }
2850
- .buttonStyle(.plain)
2851
- .popover(isPresented: tileCellPopoverBinding(for: action)) {
2852
- KeyRecorderView(action: action, store: hotkeyStore)
2853
- .padding(12)
2854
- .frame(width: 300)
2855
- }
2856
- }
2857
-
2858
- @State private var expandedOcrWindow: UInt32?
2859
- @State private var collapsedOcrApps: Set<String> = []
2860
-
2861
- @State private var activeTilePopover: HotkeyAction?
2862
- @State private var selectedCompanionCockpitPageID = "main"
2863
- @State private var companionTrustRevision = 0
2864
-
2865
- private func tileCellPopoverBinding(for action: HotkeyAction) -> Binding<Bool> {
2866
- Binding(
2867
- get: { activeTilePopover == action },
2868
- set: { if !$0 { activeTilePopover = nil } }
2869
- )
2870
- }
2871
-
2872
- // MARK: - Compact key recorder
2873
-
2874
- private func compactKeyRecorder(action: HotkeyAction) -> some View {
2875
- KeyRecorderView(action: action, store: hotkeyStore)
2876
- }
2877
-
2878
- private func companionCockpitSlotMenu(
2879
- pageID: String,
2880
- index: Int,
2881
- shortcutID: String,
2882
- categories: [LatticesCompanionShortcutCategory]
2883
- ) -> some View {
2884
- let definition = LatticesCompanionCockpitCatalog.definition(for: shortcutID)
2885
- let label = definition?.title ?? "Empty"
2886
- let subtitle = definition?.subtitle ?? "Choose a shortcut"
2887
- let icon = definition?.iconSystemName ?? "square.dashed"
2888
-
2889
- return Menu {
2890
- Button("Empty Slot") {
2891
- prefs.updateCompanionCockpitSlot(pageID: pageID, index: index, shortcutID: "")
2892
- }
2893
-
2894
- ForEach(categories) { category in
2895
- let shortcuts = LatticesCompanionCockpitCatalog.shortcuts.filter {
2896
- $0.category == category && !$0.id.isEmpty
2897
- }
2898
- if !shortcuts.isEmpty {
2899
- Section(category.title) {
2900
- ForEach(shortcuts) { shortcut in
2901
- Button {
2902
- prefs.updateCompanionCockpitSlot(
2903
- pageID: pageID,
2904
- index: index,
2905
- shortcutID: shortcut.id
2906
- )
2907
- } label: {
2908
- Label(shortcut.title, systemImage: shortcut.iconSystemName)
2909
- }
2910
- }
2911
- }
2912
- }
2913
- }
2914
- } label: {
2915
- VStack(alignment: .leading, spacing: 8) {
2916
- HStack(alignment: .top) {
2917
- Text("Slot \(index + 1)")
2918
- .font(Typo.pixel(10))
2919
- .foregroundColor(Palette.textDim)
2920
- Spacer(minLength: 0)
2921
- Image(systemName: "chevron.up.chevron.down")
2922
- .font(.system(size: 10, weight: .semibold))
2923
- .foregroundColor(Palette.textMuted)
2924
- }
2925
-
2926
- Image(systemName: icon)
2927
- .font(.system(size: 14, weight: .semibold))
2928
- .foregroundColor(Palette.textDim)
2929
-
2930
- Text(label)
2931
- .font(Typo.monoBold(11))
2932
- .foregroundColor(Palette.text)
2933
- .lineLimit(2)
2934
-
2935
- Text(subtitle)
2936
- .font(Typo.caption(9.5))
2937
- .foregroundColor(Palette.textMuted)
2938
- .lineLimit(3)
2939
- .fixedSize(horizontal: false, vertical: true)
2940
- }
2941
- .frame(maxWidth: .infinity, minHeight: 112, alignment: .topLeading)
2942
- .padding(10)
2943
- .background(
2944
- RoundedRectangle(cornerRadius: 8)
2945
- .fill(Palette.surface)
2946
- .overlay(
2947
- RoundedRectangle(cornerRadius: 8)
2948
- .strokeBorder(Palette.border, lineWidth: 0.5)
2949
- )
2950
- )
2951
- }
2952
- .buttonStyle(.plain)
2953
- }
2954
-
2955
- // MARK: - Shortcut row (read-only, for tmux)
2956
-
2957
- private func shortcutRow(_ label: String, keys: [String]) -> some View {
2958
- HStack {
2959
- Text(label)
2960
- .font(Typo.caption(11))
2961
- .foregroundColor(Palette.textDim)
2962
- .frame(width: 80, alignment: .trailing)
2963
-
2964
- HStack(spacing: 4) {
2965
- ForEach(keys, id: \.self) { key in
2966
- keyBadge(key)
2967
- }
2968
- }
2969
- .padding(.leading, 8)
2970
-
2971
- Spacer()
2972
- }
2973
- }
2974
-
2975
- // MARK: - Docs
2976
-
2977
- private var docsContent: some View {
2978
- ScrollView {
2979
- LazyVStack(alignment: .leading, spacing: 0, pinnedViews: .sectionHeaders) {
2980
- Section(header: stickyHeader("What is lattices?")) {
2981
- Text("A developer workspace launcher. It creates pre-configured terminal layouts for your projects using tmux \u{2014} go from \u{201C}I want to work on X\u{201D} to a full environment in one click.")
2982
- .font(Typo.caption(11))
2983
- .foregroundColor(Palette.textDim)
2984
- .lineSpacing(3)
2985
- .padding(.horizontal, 20)
2986
- .padding(.vertical, 12)
2987
- }
2988
-
2989
- Section(header: stickyHeader("Glossary")) {
2990
- VStack(alignment: .leading, spacing: 12) {
2991
- glossaryItem("Session",
2992
- "A persistent workspace that lives in the background. Survives terminal crashes, disconnects, even closing your laptop.")
2993
- glossaryItem("Pane",
2994
- "A single terminal view inside a session. A typical setup has two panes \u{2014} Claude Code on the left, dev server on the right.")
2995
- glossaryItem("Attach",
2996
- "Connect your terminal window to an existing session. The session was already running \u{2014} you\u{2019}re just viewing it.")
2997
- glossaryItem("Detach",
2998
- "Disconnect your terminal but keep the session alive. Your dev server keeps running, Claude keeps thinking.")
2999
- glossaryItem("tmux",
3000
- "Terminal multiplexer \u{2014} the engine behind lattices. It manages sessions, panes, and layouts. lattices configures it so you don\u{2019}t have to.")
3001
- }
3002
- .padding(.horizontal, 20)
3003
- .padding(.vertical, 12)
3004
- }
3005
-
3006
- Section(header: stickyHeader("How it works")) {
3007
- VStack(alignment: .leading, spacing: 8) {
3008
- flowStep("1", "Create a .lattices.json in your project root")
3009
- flowStep("2", "lattices reads the config and builds a tmux session")
3010
- flowStep("3", "Each pane gets its command (claude, dev server, etc.)")
3011
- flowStep("4", "Session persists in the background until you kill it")
3012
- flowStep("5", "Attach and detach from any terminal, any time")
3013
- }
3014
- .padding(.horizontal, 20)
3015
- .padding(.vertical, 12)
3016
- }
3017
-
3018
- Section(header: stickyHeader("Voice commands")) {
3019
- VStack(alignment: .leading, spacing: 8) {
3020
- flowStep("⌥", "Hold Option key to speak, release to stop")
3021
- flowStep("⇥", "Tab to arm/disarm the mic")
3022
- flowStep("⎋", "Escape to dismiss")
3023
-
3024
- Text("Built-in commands: find, show, open, tile, kill, scan")
3025
- .font(Typo.caption(10.5))
3026
- .foregroundColor(Palette.textMuted)
3027
- .padding(.top, 4)
3028
-
3029
- Text("When local matching fails, Claude Haiku advises with follow-up suggestions. Configure the AI model and budget in Settings → AI.")
3030
- .font(Typo.caption(10.5))
3031
- .foregroundColor(Palette.textMuted)
3032
- .lineSpacing(2)
3033
- }
3034
- .padding(.horizontal, 20)
3035
- .padding(.vertical, 12)
3036
- }
3037
-
3038
- Section(header: stickyHeader("Reference")) {
3039
- HStack(spacing: 8) {
3040
- docsLinkButton(icon: "doc.text", label: "Config format", file: "config.md")
3041
- docsLinkButton(icon: "book", label: "Full concepts", file: "concepts.md")
3042
- footerActionButton(icon: "stethoscope", label: "Diagnostics") {
3043
- DiagnosticWindow.shared.show()
3044
- }
3045
- }
3046
- .padding(.horizontal, 20)
3047
- .padding(.vertical, 12)
3048
- }
3049
- }
3050
- }
3051
- }
3052
-
3053
- // MARK: - Docs helpers
3054
-
3055
- private func glossaryItem(_ term: String, _ definition: String) -> some View {
3056
- VStack(alignment: .leading, spacing: 3) {
3057
- Text(term)
3058
- .font(Typo.monoBold(11))
3059
- .foregroundColor(Palette.text)
3060
- Text(definition)
3061
- .font(Typo.caption(10.5))
3062
- .foregroundColor(Palette.textMuted)
3063
- .lineSpacing(2)
3064
- }
3065
- }
3066
-
3067
- private func flowStep(_ number: String, _ text: String) -> some View {
3068
- HStack(alignment: .top, spacing: 8) {
3069
- Text(number)
3070
- .font(Typo.monoBold(10))
3071
- .foregroundColor(Palette.running)
3072
- .frame(width: 14)
3073
- Text(text)
3074
- .font(Typo.caption(11))
3075
- .foregroundColor(Palette.textDim)
3076
- }
3077
- }
3078
-
3079
- private func docsLinkButton(icon: String, label: String, file: String) -> some View {
3080
- Button {
3081
- let path = resolveDocsFile(file)
3082
- NSWorkspace.shared.open(URL(fileURLWithPath: path))
3083
- } label: {
3084
- footerActionLabel(icon: icon, label: label)
3085
- }
3086
- .buttonStyle(.plain)
3087
- }
3088
-
3089
- private func footerActionButton(icon: String, label: String, action: @escaping () -> Void) -> some View {
3090
- Button(action: action) {
3091
- footerActionLabel(icon: icon, label: label)
3092
- }
3093
- .buttonStyle(.plain)
3094
- }
3095
-
3096
- private func footerActionLabel(icon: String, label: String) -> some View {
3097
- HStack(spacing: 6) {
3098
- Image(systemName: icon)
3099
- .font(.system(size: 10))
3100
- Text(label)
3101
- .font(Typo.caption(11))
3102
- }
3103
- .foregroundColor(Palette.textDim)
3104
- .padding(.horizontal, 12)
3105
- .padding(.vertical, 6)
3106
- .background(
3107
- RoundedRectangle(cornerRadius: 3)
3108
- .fill(Palette.surface)
3109
- .overlay(
3110
- RoundedRectangle(cornerRadius: 3)
3111
- .strokeBorder(Palette.border, lineWidth: 0.5)
3112
- )
3113
- )
3114
- }
3115
-
3116
- private func resolveDocsFile(_ file: String) -> String {
3117
- let bundle = Bundle.main.bundlePath
3118
- let appDir = (bundle as NSString).deletingLastPathComponent
3119
- let docsPath = ((appDir as NSString).appendingPathComponent("../docs/\(file)") as NSString).standardizingPath
3120
- if FileManager.default.fileExists(atPath: docsPath) { return docsPath }
3121
- // Fallback: look relative to the repo root (dev builds)
3122
- let repoGuess = ((appDir as NSString).appendingPathComponent("../../docs/\(file)") as NSString).standardizingPath
3123
- return FileManager.default.fileExists(atPath: repoGuess) ? repoGuess : docsPath
3124
- }
3125
-
3126
- // MARK: - Shared helpers
3127
-
3128
- private var separator: some View {
3129
- Rectangle()
3130
- .fill(Palette.border)
3131
- .frame(height: 0.5)
3132
- }
3133
-
3134
- private func settingsRow<Content: View>(_ label: String, @ViewBuilder content: () -> Content) -> some View {
3135
- HStack(alignment: .top, spacing: 0) {
3136
- Text(label)
3137
- .font(Typo.caption(11))
3138
- .foregroundColor(Palette.textDim)
3139
- .frame(width: 100, alignment: .trailing)
3140
- .padding(.top, 2)
3141
-
3142
- content()
3143
- .padding(.leading, 16)
3144
- .frame(maxWidth: .infinity, alignment: .leading)
3145
- }
3146
- }
3147
-
3148
- private func keyBadge(_ key: String) -> some View {
3149
- Text(key)
3150
- .font(Typo.geistMonoBold(10))
3151
- .foregroundColor(Palette.text)
3152
- .padding(.horizontal, 6)
3153
- .padding(.vertical, 3)
3154
- .background(
3155
- RoundedRectangle(cornerRadius: 3)
3156
- .fill(Palette.surface)
3157
- .overlay(
3158
- RoundedRectangle(cornerRadius: 3)
3159
- .strokeBorder(Palette.border, lineWidth: 0.5)
3160
- )
3161
- )
3162
- }
3163
- }