@lattices/cli 0.4.2 → 0.4.5

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 (70) hide show
  1. package/README.md +3 -0
  2. package/app/Info.plist +2 -2
  3. package/app/Lattices.app/Contents/Info.plist +2 -2
  4. package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/app/Package.swift +6 -0
  6. package/app/Sources/App.swift +10 -0
  7. package/app/Sources/AppDelegate.swift +90 -34
  8. package/app/Sources/AppShellView.swift +2 -0
  9. package/app/Sources/AppTypeClassifier.swift +36 -0
  10. package/app/Sources/AppUpdater.swift +92 -0
  11. package/app/Sources/CheatSheetHUD.swift +1 -0
  12. package/app/Sources/CliActionLauncher.swift +50 -0
  13. package/app/Sources/CommandModeView.swift +4 -24
  14. package/app/Sources/CompanionActivityLog.swift +70 -0
  15. package/app/Sources/CompanionKeyboardController.swift +141 -0
  16. package/app/Sources/DesktopModel.swift +4 -0
  17. package/app/Sources/HandsOffSession.swift +15 -4
  18. package/app/Sources/HomeDashboardView.swift +18 -10
  19. package/app/Sources/HotkeyStore.swift +8 -5
  20. package/app/Sources/IntentEngine.swift +7 -1
  21. package/app/Sources/LatticesApi.swift +125 -4
  22. package/app/Sources/LatticesCompanionBridgeServer.swift +438 -0
  23. package/app/Sources/LatticesCompanionCockpit.swift +555 -0
  24. package/app/Sources/LatticesCompanionSecurityCoordinator.swift +594 -0
  25. package/app/Sources/LatticesCompanionTrackpadController.swift +204 -0
  26. package/app/Sources/LatticesDeckHost.swift +1463 -0
  27. package/app/Sources/LatticesRuntime.swift +61 -0
  28. package/app/Sources/MainView.swift +351 -191
  29. package/app/Sources/MouseFinder.swift +335 -30
  30. package/app/Sources/MouseGestureConfig.swift +364 -0
  31. package/app/Sources/MouseGestureController.swift +1203 -0
  32. package/app/Sources/MouseInputDeviceStore.swift +98 -0
  33. package/app/Sources/MouseInputEventViewer.swift +272 -0
  34. package/app/Sources/MouseShortcutStore.swift +107 -0
  35. package/app/Sources/OmniSearchView.swift +136 -2
  36. package/app/Sources/OmniSearchWindow.swift +65 -5
  37. package/app/Sources/OnboardingView.swift +30 -16
  38. package/app/Sources/PaletteCommand.swift +26 -6
  39. package/app/Sources/PermissionChecker.swift +76 -2
  40. package/app/Sources/PiAuthNextStepCard.swift +148 -0
  41. package/app/Sources/PiAuthPromptCard.swift +90 -0
  42. package/app/Sources/PiChatDock.swift +137 -74
  43. package/app/Sources/PiChatSession.swift +608 -108
  44. package/app/Sources/PiInstallCallout.swift +86 -0
  45. package/app/Sources/PiProviderSetupCallout.swift +99 -0
  46. package/app/Sources/PiWorkspaceView.swift +174 -77
  47. package/app/Sources/Preferences.swift +78 -0
  48. package/app/Sources/ScreenMapState.swift +91 -31
  49. package/app/Sources/ScreenMapView.swift +510 -524
  50. package/app/Sources/ScreenMapWindowController.swift +12 -4
  51. package/app/Sources/SettingsView.swift +869 -152
  52. package/app/Sources/SystemTelemetryMonitor.swift +273 -0
  53. package/app/Sources/VoiceCommandWindow.swift +23 -2
  54. package/app/Sources/WindowDragSnapController.swift +628 -0
  55. package/app/Sources/WindowTiler.swift +328 -65
  56. package/app/Sources/WorkspaceManager.swift +288 -0
  57. package/bin/assistant-intelligence.ts +874 -0
  58. package/bin/handsoff-infer.ts +16 -209
  59. package/bin/handsoff-worker.ts +45 -258
  60. package/bin/lattices-app.ts +62 -0
  61. package/bin/lattices-dev +4 -0
  62. package/bin/lattices.ts +125 -14
  63. package/docs/agents.md +14 -0
  64. package/docs/api.md +55 -0
  65. package/docs/app.md +3 -0
  66. package/docs/companion-deck.md +180 -0
  67. package/docs/config.md +25 -0
  68. package/docs/tiling-reference.md +55 -0
  69. package/docs/voice-error-model.md +73 -0
  70. package/package.json +2 -1
@@ -1,15 +1,67 @@
1
+ import DeckKit
1
2
  import SwiftUI
2
3
 
3
4
  /// Settings content with internal General / Shortcuts tabs.
4
5
  /// Can also render the Docs page when `page == .docs`.
5
6
  struct SettingsContentView: View {
7
+ private enum SettingsSection: String, CaseIterable, Identifiable {
8
+ case general
9
+ case ai
10
+ case search
11
+ case shortcuts
12
+
13
+ var id: String { rawValue }
14
+
15
+ var title: String {
16
+ switch self {
17
+ case .general: return "General"
18
+ case .ai: return "AI"
19
+ case .search: return "Search & OCR"
20
+ case .shortcuts: return "Shortcuts"
21
+ }
22
+ }
23
+
24
+ var icon: String {
25
+ switch self {
26
+ case .general: return "slider.horizontal.3"
27
+ case .ai: return "sparkles"
28
+ case .search: return "text.viewfinder"
29
+ case .shortcuts: return "command"
30
+ }
31
+ }
32
+
33
+ var eyebrow: String {
34
+ switch self {
35
+ case .general: return "Workspace"
36
+ case .ai: return "Agents"
37
+ case .search: return "Indexing"
38
+ case .shortcuts: return "Controls"
39
+ }
40
+ }
41
+
42
+ var summary: String {
43
+ switch self {
44
+ case .general:
45
+ return "Terminal defaults, scan roots, window snapping, and app updates."
46
+ case .ai:
47
+ return "Claude CLI detection plus advisor model and spending controls."
48
+ case .search:
49
+ return "OCR cadence, quality, and recent capture visibility."
50
+ case .shortcuts:
51
+ return "A full map of global hotkeys for workspace movement and tmux flow."
52
+ }
53
+ }
54
+ }
55
+
6
56
  var page: AppPage = .settings
7
57
  @ObservedObject var prefs: Preferences
8
58
  @ObservedObject var scanner: ProjectScanner
9
59
  @ObservedObject var hotkeyStore: HotkeyStore = .shared
60
+ @ObservedObject var appUpdater: AppUpdater = .shared
61
+ @ObservedObject var mouseShortcutStore: MouseShortcutStore = .shared
10
62
  var onBack: (() -> Void)? = nil
11
63
 
12
- @State private var selectedTab = "shortcuts"
64
+ @State private var selectedTab: SettingsSection = .general
13
65
 
14
66
  var body: some View {
15
67
  VStack(spacing: 0) {
@@ -30,12 +82,7 @@ struct SettingsContentView: View {
30
82
  // MARK: - Back Bar
31
83
 
32
84
  private var currentTabLabel: String {
33
- switch selectedTab {
34
- case "general": return "General"
35
- case "search": return "Search & OCR"
36
- case "shortcuts": return "Shortcuts"
37
- default: return page.label
38
- }
85
+ page == .docs ? "Docs" : selectedTab.title
39
86
  }
40
87
 
41
88
  private var backBar: some View {
@@ -64,64 +111,136 @@ struct SettingsContentView: View {
64
111
  }
65
112
  }
66
113
 
67
- // MARK: - Settings Body (General + Shortcuts tabs)
114
+ // MARK: - Settings Body
68
115
 
69
116
  private var settingsBody: some View {
70
- VStack(spacing: 0) {
71
- // Tab bar
72
- HStack(spacing: 2) {
73
- settingsTab(label: "General", id: "general")
74
- settingsTab(label: "AI", id: "ai")
75
- settingsTab(label: "Search & OCR", id: "search")
76
- settingsTab(label: "Shortcuts", id: "shortcuts")
77
- Spacer()
117
+ HStack(spacing: 0) {
118
+ settingsSidebar
119
+ .frame(width: 220, alignment: .top)
120
+
121
+ Rectangle()
122
+ .fill(Palette.border)
123
+ .frame(width: 0.5)
124
+ .frame(maxHeight: .infinity)
125
+
126
+ VStack(spacing: 0) {
127
+ settingsSectionHero(selectedTab)
128
+
129
+ Rectangle().fill(Palette.border).frame(height: 0.5)
130
+
131
+ selectedSectionContent
78
132
  }
79
- .padding(.horizontal, 14)
80
- .padding(.vertical, 6)
133
+ }
134
+ }
81
135
 
82
- Rectangle().fill(Palette.border).frame(height: 0.5)
136
+ private var settingsSidebar: some View {
137
+ VStack(alignment: .leading, spacing: 14) {
138
+ VStack(alignment: .leading, spacing: 6) {
139
+ Text("SETTINGS")
140
+ .font(Typo.pixel(14))
141
+ .foregroundColor(Palette.textDim)
142
+ .tracking(1)
143
+ Text("Tune how Lattices launches workspaces, listens for commands, and navigates the desktop.")
144
+ .font(Typo.caption(11))
145
+ .foregroundColor(Palette.textMuted)
146
+ .fixedSize(horizontal: false, vertical: true)
147
+ }
83
148
 
84
- // Tab content
85
- switch selectedTab {
86
- case "shortcuts": shortcutsContent
87
- case "search": searchOcrContent
88
- case "ai": aiContent
89
- default: generalContent
149
+ VStack(spacing: 6) {
150
+ ForEach(SettingsSection.allCases) { section in
151
+ settingsTab(section)
152
+ }
90
153
  }
154
+
155
+ Spacer(minLength: 0)
91
156
  }
157
+ .padding(16)
92
158
  }
93
159
 
94
- private func settingsTab(label: String, id: String) -> some View {
95
- let active = selectedTab == id
160
+ private func settingsTab(_ section: SettingsSection) -> some View {
161
+ let active = selectedTab == section
96
162
  return Button {
97
- selectedTab = id
163
+ selectedTab = section
98
164
  } label: {
99
- Text(label)
100
- .font(Typo.mono(11))
101
- .foregroundColor(active ? Palette.text : Palette.textMuted)
102
- .padding(.horizontal, 10)
103
- .padding(.vertical, 5)
104
- .background(
105
- ZStack {
106
- if active {
107
- RoundedRectangle(cornerRadius: 6)
108
- .fill(Color.white.opacity(0.06))
109
- RoundedRectangle(cornerRadius: 6)
110
- .strokeBorder(
111
- LinearGradient(
112
- colors: [Color.white.opacity(0.12), Color.white.opacity(0.04)],
113
- startPoint: .top,
114
- endPoint: .bottom
115
- ),
116
- lineWidth: 0.5
117
- )
118
- }
165
+ HStack(alignment: .top, spacing: 10) {
166
+ Image(systemName: section.icon)
167
+ .font(.system(size: 11, weight: .semibold))
168
+ .foregroundColor(active ? Palette.text : Palette.textMuted)
169
+ .frame(width: 16, alignment: .center)
170
+
171
+ VStack(alignment: .leading, spacing: 3) {
172
+ Text(section.title)
173
+ .font(Typo.mono(11))
174
+ .foregroundColor(active ? Palette.text : Palette.textMuted)
175
+
176
+ Text(section.summary)
177
+ .font(Typo.caption(9.5))
178
+ .foregroundColor(Palette.textMuted.opacity(active ? 0.9 : 0.7))
179
+ .fixedSize(horizontal: false, vertical: true)
180
+ }
181
+
182
+ Spacer(minLength: 0)
183
+ }
184
+ .padding(.horizontal, 10)
185
+ .padding(.vertical, 9)
186
+ .contentShape(RoundedRectangle(cornerRadius: 8))
187
+ .background(
188
+ ZStack {
189
+ if active {
190
+ RoundedRectangle(cornerRadius: 8)
191
+ .fill(Color.white.opacity(0.06))
192
+ RoundedRectangle(cornerRadius: 8)
193
+ .strokeBorder(
194
+ LinearGradient(
195
+ colors: [Color.white.opacity(0.12), Color.white.opacity(0.04)],
196
+ startPoint: .top,
197
+ endPoint: .bottom
198
+ ),
199
+ lineWidth: 0.5
200
+ )
119
201
  }
120
- )
202
+ }
203
+ )
121
204
  }
122
205
  .buttonStyle(.plain)
123
206
  }
124
207
 
208
+ private func settingsSectionHero(_ section: SettingsSection) -> some View {
209
+ VStack(alignment: .leading, spacing: 8) {
210
+ Text(section.eyebrow.uppercased())
211
+ .font(Typo.pixel(14))
212
+ .foregroundColor(Palette.textDim)
213
+ .tracking(1)
214
+
215
+ Text(section.title)
216
+ .font(Typo.heading(16))
217
+ .foregroundColor(Palette.text)
218
+
219
+ Text(section.summary)
220
+ .font(Typo.caption(11))
221
+ .foregroundColor(Palette.textMuted)
222
+ .fixedSize(horizontal: false, vertical: true)
223
+ }
224
+ .frame(maxWidth: .infinity, alignment: .leading)
225
+ .padding(.horizontal, 20)
226
+ .padding(.vertical, 14)
227
+ .background(Palette.bg)
228
+ }
229
+
230
+ @ViewBuilder
231
+ private var selectedSectionContent: some View {
232
+ switch selectedTab {
233
+ case .general:
234
+ generalContent
235
+ case .ai:
236
+ aiContent
237
+ case .search:
238
+ searchOcrContent
239
+ case .shortcuts:
240
+ shortcutsContent
241
+ }
242
+ }
243
+
125
244
  // MARK: - Sticky section header
126
245
 
127
246
  private func stickyHeader(_ title: String) -> some View {
@@ -148,6 +267,62 @@ struct SettingsContentView: View {
148
267
  private var generalContent: some View {
149
268
  ScrollView {
150
269
  VStack(alignment: .leading, spacing: 12) {
270
+ settingsCard {
271
+ VStack(alignment: .leading, spacing: 10) {
272
+ HStack(alignment: .center, spacing: 8) {
273
+ Image(systemName: "arrow.down.circle")
274
+ .font(.system(size: 11, weight: .medium))
275
+ .foregroundColor(Palette.running)
276
+ Text("Lattices app")
277
+ .font(Typo.mono(12))
278
+ .foregroundColor(Palette.text)
279
+ Spacer()
280
+ Text("v\(appUpdater.currentVersion)")
281
+ .font(Typo.caption(10))
282
+ .foregroundColor(Palette.textMuted)
283
+ }
284
+
285
+ Text("Install the latest published app build without leaving the menu bar. The app relaunches when the update finishes.")
286
+ .font(Typo.caption(10))
287
+ .foregroundColor(Palette.textMuted)
288
+
289
+ if let status = appUpdater.statusMessage {
290
+ Text(status)
291
+ .font(Typo.caption(9))
292
+ .foregroundColor(Palette.running.opacity(0.85))
293
+ }
294
+
295
+ if let reason = appUpdater.unavailableReason {
296
+ Text(reason)
297
+ .font(Typo.caption(9))
298
+ .foregroundColor(Palette.detach.opacity(0.9))
299
+ }
300
+
301
+ HStack(spacing: 10) {
302
+ Button {
303
+ appUpdater.promptForUpdate()
304
+ } label: {
305
+ Text(appUpdater.isUpdating ? "Updating…" : "Update Lattices")
306
+ .font(Typo.monoBold(10))
307
+ .foregroundColor(Palette.text)
308
+ .padding(.horizontal, 12)
309
+ .padding(.vertical, 5)
310
+ .background(
311
+ RoundedRectangle(cornerRadius: 4)
312
+ .fill(Palette.surfaceHov)
313
+ .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
314
+ )
315
+ }
316
+ .buttonStyle(.plain)
317
+ .disabled(appUpdater.isUpdating)
318
+
319
+ Text("CLI: `lattices app update`")
320
+ .font(Typo.caption(9))
321
+ .foregroundColor(Palette.textMuted.opacity(0.8))
322
+ }
323
+ }
324
+ }
325
+
151
326
  // ── Terminal ──
152
327
  settingsCard {
153
328
  VStack(alignment: .leading, spacing: 8) {
@@ -266,8 +441,136 @@ struct SettingsContentView: View {
266
441
  }
267
442
  }
268
443
  }
444
+
445
+ settingsCard {
446
+ VStack(alignment: .leading, spacing: 10) {
447
+ Text("Window drag snap")
448
+ .font(Typo.mono(11))
449
+ .foregroundColor(Palette.text)
450
+
451
+ HStack {
452
+ Text("Drag-to-snap")
453
+ .font(Typo.mono(10))
454
+ .foregroundColor(Palette.textDim)
455
+ Spacer()
456
+ Toggle("", isOn: $prefs.dragSnapEnabled)
457
+ .toggleStyle(.switch)
458
+ .controlSize(.small)
459
+ .labelsHidden()
460
+ }
461
+
462
+ Text("Hold the configured snap modifier while dragging to reveal landing targets and a live preview, then release it to go back to a free drag. Default: Command.")
463
+ .font(Typo.caption(9))
464
+ .foregroundColor(Palette.textMuted.opacity(0.7))
465
+
466
+ cardDivider
467
+
468
+ Text("Agent-editable rules live in ~/.lattices/snap-zones.json. Changes are picked up on the next drag.")
469
+ .font(Typo.caption(9))
470
+ .foregroundColor(Palette.textMuted.opacity(0.7))
471
+ }
472
+ }
473
+
474
+ settingsCard {
475
+ VStack(alignment: .leading, spacing: 10) {
476
+ Text("Mouse gestures")
477
+ .font(Typo.mono(11))
478
+ .foregroundColor(Palette.text)
479
+
480
+ HStack {
481
+ Text("Middle-click gestures")
482
+ .font(Typo.mono(10))
483
+ .foregroundColor(Palette.textDim)
484
+ Spacer()
485
+ Toggle("", isOn: $prefs.mouseGesturesEnabled)
486
+ .toggleStyle(.switch)
487
+ .controlSize(.small)
488
+ .labelsHidden()
489
+ }
490
+
491
+ 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.")
492
+ .font(Typo.caption(9))
493
+ .foregroundColor(Palette.textMuted.opacity(0.7))
494
+
495
+ cardDivider
496
+
497
+ VStack(alignment: .leading, spacing: 6) {
498
+ Text("Active drag mappings")
499
+ .font(Typo.mono(10))
500
+ .foregroundColor(Palette.textDim)
501
+
502
+ ForEach(mouseShortcutStore.summaryLines.prefix(4), id: \.self) { line in
503
+ Text(line)
504
+ .font(Typo.caption(9))
505
+ .foregroundColor(Palette.textMuted.opacity(0.78))
506
+ }
507
+
508
+ if mouseShortcutStore.summaryLines.isEmpty {
509
+ Text("No active mappings")
510
+ .font(Typo.caption(9))
511
+ .foregroundColor(Palette.textMuted.opacity(0.6))
512
+ }
513
+ }
514
+
515
+ HStack(spacing: 8) {
516
+ Button {
517
+ mouseShortcutStore.openConfiguration()
518
+ } label: {
519
+ Text("Configure...")
520
+ .font(Typo.monoBold(10))
521
+ .foregroundColor(Palette.text)
522
+ .padding(.horizontal, 12)
523
+ .padding(.vertical, 4)
524
+ .background(
525
+ RoundedRectangle(cornerRadius: 4)
526
+ .fill(Palette.surfaceHov)
527
+ .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
528
+ )
529
+ }
530
+ .buttonStyle(.plain)
531
+
532
+ Button {
533
+ MouseInputEventViewer.shared.show()
534
+ } label: {
535
+ Text("Open Event Viewer")
536
+ .font(Typo.monoBold(10))
537
+ .foregroundColor(Palette.text)
538
+ .padding(.horizontal, 12)
539
+ .padding(.vertical, 4)
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
+
548
+ Button {
549
+ mouseShortcutStore.restoreDefaults()
550
+ } label: {
551
+ Text("Restore Defaults")
552
+ .font(Typo.monoBold(10))
553
+ .foregroundColor(Palette.text)
554
+ .padding(.horizontal, 12)
555
+ .padding(.vertical, 4)
556
+ .background(
557
+ RoundedRectangle(cornerRadius: 4)
558
+ .fill(Palette.surfaceHov)
559
+ .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
560
+ )
561
+ }
562
+ .buttonStyle(.plain)
563
+ }
564
+
565
+ 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.")
566
+ .font(Typo.caption(9))
567
+ .foregroundColor(Palette.textMuted.opacity(0.7))
568
+ }
569
+ }
269
570
  }
270
571
  .padding(16)
572
+ .frame(maxWidth: 760, alignment: .leading)
573
+ .frame(maxWidth: .infinity, alignment: .leading)
271
574
  }
272
575
  }
273
576
 
@@ -829,32 +1132,48 @@ struct SettingsContentView: View {
829
1132
  .padding(.vertical, 3)
830
1133
  }
831
1134
 
832
- // MARK: - Shortcuts (Spatial Layout)
1135
+ // MARK: - Shortcuts
833
1136
 
834
1137
  private var shortcutsContent: some View {
835
1138
  VStack(spacing: 0) {
836
1139
  GeometryReader { geo in
837
- let spacing: CGFloat = 16
838
- let pad: CGFloat = 20
839
- let total = geo.size.width - pad * 2 - spacing * 2
840
- let leftW = total * 0.35
841
- let centerW = total * 0.35
842
- let rightW = total * 0.30
1140
+ let contentWidth = max(geo.size.width - 40, 320)
1141
+ let sectionColumns = [
1142
+ GridItem(.adaptive(minimum: min(320, contentWidth), maximum: 440), spacing: 16, alignment: .top)
1143
+ ]
1144
+ let tilingColumns = contentWidth > 860
1145
+ ? [
1146
+ GridItem(.flexible(minimum: 280, maximum: 360), spacing: 16, alignment: .top),
1147
+ GridItem(.flexible(minimum: 320, maximum: 640), spacing: 16, alignment: .top)
1148
+ ]
1149
+ : [GridItem(.flexible(minimum: 0, maximum: .infinity), spacing: 16, alignment: .top)]
843
1150
 
844
1151
  ScrollView {
845
1152
  VStack(alignment: .leading, spacing: 0) {
846
- HStack(alignment: .top, spacing: spacing) {
847
- shortcutsLeftColumn
848
- .frame(width: leftW, alignment: .leading)
849
- .clipped()
850
- shortcutsCenterColumn
851
- .frame(width: centerW, alignment: .leading)
852
- .clipped()
853
- shortcutsRightColumn
854
- .frame(width: rightW, alignment: .leading)
855
- .clipped()
1153
+ VStack(alignment: .leading, spacing: 16) {
1154
+ companionCockpitCard
1155
+
1156
+ shortcutsOverviewCard
1157
+
1158
+ LazyVGrid(columns: sectionColumns, alignment: .leading, spacing: 16) {
1159
+ shortcutsAppCard
1160
+ shortcutsLayersCard
1161
+ }
1162
+
1163
+ shortcutSectionCard(
1164
+ title: "Window Tiling",
1165
+ eyebrow: "Desktop Layout",
1166
+ summary: "See the directional map first, then edit the matching global shortcuts below."
1167
+ ) {
1168
+ LazyVGrid(columns: tilingColumns, alignment: .leading, spacing: 16) {
1169
+ shortcutsTilingVisualizer
1170
+ shortcutsTilingEditors
1171
+ }
1172
+ }
1173
+
1174
+ shortcutsTmuxCard
856
1175
  }
857
- .padding(.horizontal, pad)
1176
+ .padding(.horizontal, 20)
858
1177
  .padding(.vertical, 16)
859
1178
  }
860
1179
  }
@@ -865,7 +1184,18 @@ struct SettingsContentView: View {
865
1184
  separator
866
1185
 
867
1186
  HStack {
1187
+ HStack(spacing: 8) {
1188
+ footerActionButton(icon: "book", label: "Docs") {
1189
+ ScreenMapWindowController.shared.showPage(.docs)
1190
+ }
1191
+
1192
+ footerActionButton(icon: "stethoscope", label: "Diagnostics") {
1193
+ DiagnosticWindow.shared.show()
1194
+ }
1195
+ }
1196
+
868
1197
  Spacer()
1198
+
869
1199
  Button {
870
1200
  hotkeyStore.resetAll()
871
1201
  } label: {
@@ -890,112 +1220,406 @@ struct SettingsContentView: View {
890
1220
  }
891
1221
  }
892
1222
 
893
- // MARK: - Shortcuts: Left Column (App + Layers)
1223
+ // MARK: - Shortcuts: Overview
1224
+
1225
+ private var companionCockpitCard: some View {
1226
+ let layout = LatticesCompanionCockpitCatalog.normalized(prefs.companionCockpitLayout)
1227
+ let selectedPage = layout.pages.first(where: { $0.id == selectedCompanionCockpitPageID }) ?? layout.pages.first
1228
+ let categories = LatticesCompanionShortcutCategory.allCases
1229
+ let trustedDevices = companionTrustedDevices(revision: companionTrustRevision)
1230
+
1231
+ return shortcutSectionCard(
1232
+ title: "Companion Cockpit",
1233
+ eyebrow: "iPad & iPhone",
1234
+ summary: "Define the Mac-authored command deck here, then let the companion app render it. Trackpad proxy runs through the same bridge."
1235
+ ) {
1236
+ VStack(alignment: .leading, spacing: 14) {
1237
+ HStack(alignment: .top, spacing: 12) {
1238
+ VStack(alignment: .leading, spacing: 5) {
1239
+ Text("Trackpad Proxy")
1240
+ .font(Typo.monoBold(11))
1241
+ .foregroundColor(Palette.text)
1242
+ Text("Enable remote pointer control for the iPad trackpad surface. Accessibility permission is still required on the Mac.")
1243
+ .font(Typo.caption(10.5))
1244
+ .foregroundColor(Palette.textMuted)
1245
+ .fixedSize(horizontal: false, vertical: true)
1246
+ }
894
1247
 
895
- private var shortcutsLeftColumn: some View {
896
- VStack(alignment: .leading, spacing: 12) {
897
- columnHeader("App & Layers")
1248
+ Spacer()
1249
+
1250
+ Toggle("", isOn: $prefs.companionTrackpadEnabled)
1251
+ .toggleStyle(.switch)
1252
+ .labelsHidden()
1253
+ }
1254
+
1255
+ VStack(alignment: .leading, spacing: 8) {
1256
+ HStack {
1257
+ VStack(alignment: .leading, spacing: 4) {
1258
+ Text("Trusted Devices")
1259
+ .font(Typo.monoBold(11))
1260
+ .foregroundColor(Palette.text)
1261
+ Text("New companions must be approved on the Mac before they can send encrypted bridge requests.")
1262
+ .font(Typo.caption(10.5))
1263
+ .foregroundColor(Palette.textMuted)
1264
+ .fixedSize(horizontal: false, vertical: true)
1265
+ }
898
1266
 
899
- VStack(alignment: .leading, spacing: 2) {
1267
+ Spacer()
1268
+
1269
+ if trustedDevices.isEmpty == false {
1270
+ Button("Forget All") {
1271
+ LatticesCompanionSecurityCoordinator.shared.clearTrustedDevices()
1272
+ companionTrustRevision += 1
1273
+ }
1274
+ .buttonStyle(.plain)
1275
+ .font(Typo.caption(10.5))
1276
+ .foregroundColor(Palette.textDim)
1277
+ }
1278
+ }
1279
+
1280
+ VStack(alignment: .leading, spacing: 6) {
1281
+ if trustedDevices.isEmpty {
1282
+ Text("No paired iPad or iPhone devices yet.")
1283
+ .font(Typo.caption(10.5))
1284
+ .foregroundColor(Palette.textMuted)
1285
+ } else {
1286
+ ForEach(trustedDevices) { device in
1287
+ HStack(alignment: .top, spacing: 10) {
1288
+ Image(systemName: "iphone.gen3")
1289
+ .font(.system(size: 11, weight: .semibold))
1290
+ .foregroundColor(Palette.textDim)
1291
+ .frame(width: 14)
1292
+
1293
+ VStack(alignment: .leading, spacing: 2) {
1294
+ Text(device.name)
1295
+ .font(Typo.caption(11))
1296
+ .foregroundColor(Palette.text)
1297
+ Text("\(device.fingerprint) · Last seen \(relativeTimestamp(device.lastSeenAt))")
1298
+ .font(Typo.caption(10))
1299
+ .foregroundColor(Palette.textMuted)
1300
+ }
1301
+
1302
+ Spacer(minLength: 0)
1303
+ }
1304
+ }
1305
+ }
1306
+ }
1307
+ .padding(12)
1308
+ .background(shortcutsInsetPanel)
1309
+ }
1310
+
1311
+ if let selectedPage {
1312
+ Picker("Companion page", selection: $selectedCompanionCockpitPageID) {
1313
+ ForEach(layout.pages) { page in
1314
+ Text(page.title).tag(page.id)
1315
+ }
1316
+ }
1317
+ .pickerStyle(.segmented)
1318
+
1319
+ VStack(alignment: .leading, spacing: 8) {
1320
+ if let subtitle = selectedPage.subtitle, !subtitle.isEmpty {
1321
+ Text(subtitle)
1322
+ .font(Typo.caption(10.5))
1323
+ .foregroundColor(Palette.textMuted)
1324
+ }
1325
+
1326
+ LazyVGrid(
1327
+ columns: Array(
1328
+ repeating: GridItem(.flexible(minimum: 120, maximum: 220), spacing: 8, alignment: .top),
1329
+ count: max(2, selectedPage.columns)
1330
+ ),
1331
+ alignment: .leading,
1332
+ spacing: 8
1333
+ ) {
1334
+ ForEach(Array(selectedPage.slotIDs.enumerated()), id: \.offset) { index, shortcutID in
1335
+ companionCockpitSlotMenu(
1336
+ pageID: selectedPage.id,
1337
+ index: index,
1338
+ shortcutID: shortcutID,
1339
+ categories: categories
1340
+ )
1341
+ }
1342
+ }
1343
+ }
1344
+ .padding(12)
1345
+ .background(shortcutsInsetPanel)
1346
+ }
1347
+
1348
+ HStack(spacing: 10) {
1349
+ Text("Changes appear in the iPad companion on the next snapshot refresh.")
1350
+ .font(Typo.caption(10.5))
1351
+ .foregroundColor(Palette.textMuted)
1352
+
1353
+ Spacer()
1354
+
1355
+ Button("Reset Companion Layout") {
1356
+ prefs.resetCompanionCockpitLayout()
1357
+ }
1358
+ .buttonStyle(.plain)
1359
+ .font(Typo.caption(10.5))
1360
+ .foregroundColor(Palette.textDim)
1361
+ }
1362
+ }
1363
+ }
1364
+ }
1365
+
1366
+ private var shortcutsOverviewCard: some View {
1367
+ shortcutSectionCard(
1368
+ title: "Shortcut Map",
1369
+ eyebrow: "Quick Reference",
1370
+ summary: "Global hotkeys are editable here. tmux shortcuts stay as a built-in reference so you can keep your workspace flow in one place."
1371
+ ) {
1372
+ LazyVGrid(
1373
+ columns: [GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 10, alignment: .top)],
1374
+ alignment: .leading,
1375
+ spacing: 10
1376
+ ) {
1377
+ shortcutFactCard(
1378
+ icon: "command",
1379
+ title: "Global Hotkeys",
1380
+ detail: "Edit palette, search, voice, and workspace actions without leaving settings."
1381
+ )
1382
+ shortcutFactCard(
1383
+ icon: "rectangle.split.3x3",
1384
+ title: "Spatial Tiling",
1385
+ detail: "The layout grid mirrors the screen positions used by the menu bar app."
1386
+ )
1387
+ shortcutFactCard(
1388
+ icon: "terminal",
1389
+ title: "tmux Muscle Memory",
1390
+ detail: "Keep the core pane controls visible here while you tune the app-level shortcuts."
1391
+ )
1392
+ }
1393
+ }
1394
+ }
1395
+
1396
+ // MARK: - Shortcuts: App
1397
+
1398
+ private var shortcutsAppCard: some View {
1399
+ shortcutSectionCard(
1400
+ title: "App & Workspace",
1401
+ eyebrow: "Global",
1402
+ summary: "Commands for opening primary surfaces and navigating the desktop companion."
1403
+ ) {
1404
+ VStack(alignment: .leading, spacing: 8) {
900
1405
  ForEach(HotkeyAction.allCases.filter { $0.group == .app }, id: \.rawValue) { action in
901
1406
  compactKeyRecorder(action: action)
902
1407
  }
903
1408
  }
1409
+ }
1410
+ }
904
1411
 
905
- Rectangle().fill(Palette.border).frame(height: 0.5)
1412
+ // MARK: - Shortcuts: Layers
906
1413
 
907
- VStack(alignment: .leading, spacing: 2) {
908
- ForEach(HotkeyAction.layerActions, id: \.rawValue) { action in
909
- compactKeyRecorder(action: action)
1414
+ private var shortcutsLayersCard: some View {
1415
+ shortcutSectionCard(
1416
+ title: "Layers",
1417
+ eyebrow: "Workspace Stack",
1418
+ summary: "Direct jumps stay grouped separately from layer cycling so the numeric map is easier to scan."
1419
+ ) {
1420
+ VStack(alignment: .leading, spacing: 12) {
1421
+ shortcutSubsectionLabel("Jump to a Layer")
1422
+
1423
+ VStack(alignment: .leading, spacing: 8) {
1424
+ ForEach(HotkeyAction.layerActions, id: \.rawValue) { action in
1425
+ compactKeyRecorder(action: action)
1426
+ }
1427
+ }
1428
+
1429
+ cardDivider
1430
+
1431
+ shortcutSubsectionLabel("Cycle & Tag")
1432
+
1433
+ VStack(alignment: .leading, spacing: 8) {
1434
+ ForEach([HotkeyAction.layerPrev, .layerNext, .layerTag], id: \.rawValue) { action in
1435
+ compactKeyRecorder(action: action)
1436
+ }
910
1437
  }
911
1438
  }
912
1439
  }
913
1440
  }
914
1441
 
915
- // MARK: - Shortcuts: Center Column (Tiling)
1442
+ // MARK: - Shortcuts: Tiling
916
1443
 
917
- private var shortcutsCenterColumn: some View {
1444
+ private var shortcutsTilingVisualizer: some View {
918
1445
  VStack(alignment: .leading, spacing: 12) {
919
- columnHeader("Tiling")
920
-
921
- // Monitor visualization 3x3 grid
922
- VStack(spacing: 2) {
923
- HStack(spacing: 2) {
924
- tileCell(action: .tileTopLeft, label: "TL")
925
- tileCell(action: .tileTop, label: "Top")
926
- tileCell(action: .tileTopRight, label: "TR")
1446
+ shortcutSubsectionLabel("Screen Regions")
1447
+
1448
+ VStack(alignment: .leading, spacing: 10) {
1449
+ VStack(spacing: 2) {
1450
+ HStack(spacing: 2) {
1451
+ tileCell(action: .tileTopLeft, label: "TL")
1452
+ tileCell(action: .tileTop, label: "Top")
1453
+ tileCell(action: .tileTopRight, label: "TR")
1454
+ }
1455
+ HStack(spacing: 2) {
1456
+ tileCell(action: .tileLeft, label: "Left")
1457
+ tileCell(action: .tileMaximize, label: "Max")
1458
+ tileCell(action: .tileRight, label: "Right")
1459
+ }
1460
+ HStack(spacing: 2) {
1461
+ tileCell(action: .tileBottomLeft, label: "BL")
1462
+ tileCell(action: .tileBottom, label: "Bottom")
1463
+ tileCell(action: .tileBottomRight, label: "BR")
1464
+ }
927
1465
  }
928
- HStack(spacing: 2) {
929
- tileCell(action: .tileLeft, label: "Left")
930
- tileCell(action: .tileMaximize, label: "Max")
931
- tileCell(action: .tileRight, label: "Right")
1466
+ .padding(8)
1467
+ .background(shortcutsInsetPanel)
1468
+
1469
+ VStack(alignment: .leading, spacing: 6) {
1470
+ Text("Thirds")
1471
+ .font(Typo.caption(10.5))
1472
+ .foregroundColor(Palette.textMuted)
1473
+
1474
+ HStack(spacing: 2) {
1475
+ tileCell(action: .tileLeftThird, label: "\u{2153}L")
1476
+ tileCell(action: .tileCenterThird, label: "\u{2153}C")
1477
+ tileCell(action: .tileRightThird, label: "\u{2153}R")
1478
+ }
932
1479
  }
933
- HStack(spacing: 2) {
934
- tileCell(action: .tileBottomLeft, label: "BL")
935
- tileCell(action: .tileBottom, label: "Bottom")
936
- tileCell(action: .tileBottomRight, label: "BR")
1480
+ .padding(8)
1481
+ .background(shortcutsInsetPanel)
1482
+
1483
+ Text("Use the grid as a visual legend for where each shortcut will place the focused window.")
1484
+ .font(Typo.caption(10.5))
1485
+ .foregroundColor(Palette.textMuted)
1486
+ .fixedSize(horizontal: false, vertical: true)
1487
+ }
1488
+ }
1489
+ }
1490
+
1491
+ private var shortcutsTilingEditors: some View {
1492
+ VStack(alignment: .leading, spacing: 12) {
1493
+ shortcutSubsectionLabel("Editable Bindings")
1494
+
1495
+ VStack(alignment: .leading, spacing: 8) {
1496
+ ForEach([
1497
+ HotkeyAction.tileLeft, .tileRight, .tileTop, .tileBottom,
1498
+ .tileTopLeft, .tileTopRight, .tileBottomLeft, .tileBottomRight
1499
+ ], id: \.rawValue) { action in
1500
+ compactKeyRecorder(action: action)
937
1501
  }
938
1502
  }
939
- .padding(6)
940
- .background(
941
- RoundedRectangle(cornerRadius: 6)
942
- .fill(Color.black.opacity(0.25))
943
- .overlay(
944
- RoundedRectangle(cornerRadius: 6)
945
- .strokeBorder(Palette.border, lineWidth: 0.5)
946
- )
947
- )
948
1503
 
949
- // Thirds row
950
- HStack(spacing: 2) {
951
- tileCell(action: .tileLeftThird, label: "\u{2153}L")
952
- tileCell(action: .tileCenterThird, label: "\u{2153}C")
953
- tileCell(action: .tileRightThird, label: "\u{2153}R")
1504
+ cardDivider
1505
+
1506
+ shortcutSubsectionLabel("Layout Helpers")
1507
+
1508
+ VStack(alignment: .leading, spacing: 8) {
1509
+ ForEach([
1510
+ HotkeyAction.tileLeftThird, .tileCenterThird, .tileRightThird,
1511
+ .tileCenter, .tileMaximize, .tileDistribute, .tileTypeGrid
1512
+ ], id: \.rawValue) { action in
1513
+ compactKeyRecorder(action: action)
1514
+ }
954
1515
  }
955
- .padding(6)
956
- .background(
957
- RoundedRectangle(cornerRadius: 6)
958
- .fill(Color.black.opacity(0.25))
959
- .overlay(
960
- RoundedRectangle(cornerRadius: 6)
961
- .strokeBorder(Palette.border, lineWidth: 0.5)
962
- )
963
- )
1516
+ }
1517
+ }
964
1518
 
965
- // Center + Distribute
966
- HStack(spacing: 4) {
967
- compactKeyRecorder(action: .tileCenter)
968
- compactKeyRecorder(action: .tileDistribute)
1519
+ // MARK: - Shortcuts: tmux
1520
+
1521
+ private var shortcutsTmuxCard: some View {
1522
+ shortcutSectionCard(
1523
+ title: "Inside tmux",
1524
+ eyebrow: "Reference",
1525
+ summary: "These are tmux-native controls. They are shown here for fast recall and are not edited by the app."
1526
+ ) {
1527
+ VStack(alignment: .leading, spacing: 10) {
1528
+ VStack(alignment: .leading, spacing: 8) {
1529
+ shortcutRow("Detach", keys: ["Ctrl+B", "D"])
1530
+ shortcutRow("Kill pane", keys: ["Ctrl+B", "X"])
1531
+ shortcutRow("Pane left", keys: ["Ctrl+B", "\u{2190}"])
1532
+ shortcutRow("Pane right", keys: ["Ctrl+B", "\u{2192}"])
1533
+ shortcutRow("Zoom toggle", keys: ["Ctrl+B", "Z"])
1534
+ shortcutRow("Scroll mode", keys: ["Ctrl+B", "["])
1535
+ }
1536
+ .padding(12)
1537
+ .background(shortcutsInsetPanel)
1538
+
1539
+ Text("Tip: use this as your quick memory jogger while editing the global shortcuts above.")
1540
+ .font(Typo.caption(10.5))
1541
+ .foregroundColor(Palette.textMuted)
1542
+ .fixedSize(horizontal: false, vertical: true)
969
1543
  }
970
1544
  }
971
1545
  }
972
1546
 
973
- // MARK: - Shortcuts: Right Column (tmux)
1547
+ // MARK: - Shortcut section UI
974
1548
 
975
- private var shortcutsRightColumn: some View {
976
- VStack(alignment: .leading, spacing: 12) {
977
- columnHeader("Inside tmux")
1549
+ private func shortcutSectionCard<Content: View>(
1550
+ title: String,
1551
+ eyebrow: String,
1552
+ summary: String,
1553
+ @ViewBuilder content: () -> Content
1554
+ ) -> some View {
1555
+ settingsCard {
1556
+ VStack(alignment: .leading, spacing: 12) {
1557
+ VStack(alignment: .leading, spacing: 5) {
1558
+ Text(eyebrow.uppercased())
1559
+ .font(Typo.pixel(12))
1560
+ .foregroundColor(Palette.textDim)
1561
+ .tracking(1)
978
1562
 
979
- VStack(alignment: .leading, spacing: 6) {
980
- shortcutRow("Detach", keys: ["Ctrl+B", "D"])
981
- shortcutRow("Kill pane", keys: ["Ctrl+B", "X"])
982
- shortcutRow("Pane left", keys: ["Ctrl+B", "\u{2190}"])
983
- shortcutRow("Pane right", keys: ["Ctrl+B", "\u{2192}"])
984
- shortcutRow("Zoom toggle", keys: ["Ctrl+B", "Z"])
985
- shortcutRow("Scroll mode", keys: ["Ctrl+B", "["])
1563
+ Text(title)
1564
+ .font(Typo.monoBold(12))
1565
+ .foregroundColor(Palette.text)
1566
+
1567
+ Text(summary)
1568
+ .font(Typo.caption(10.5))
1569
+ .foregroundColor(Palette.textMuted)
1570
+ .fixedSize(horizontal: false, vertical: true)
1571
+ }
1572
+
1573
+ content()
986
1574
  }
987
1575
  }
988
1576
  }
989
1577
 
990
- // MARK: - Column header
1578
+ private func shortcutFactCard(icon: String, title: String, detail: String) -> some View {
1579
+ VStack(alignment: .leading, spacing: 8) {
1580
+ Image(systemName: icon)
1581
+ .font(.system(size: 12, weight: .semibold))
1582
+ .foregroundColor(Palette.textDim)
1583
+
1584
+ Text(title)
1585
+ .font(Typo.monoBold(11))
1586
+ .foregroundColor(Palette.text)
991
1587
 
992
- private func columnHeader(_ title: String) -> some View {
1588
+ Text(detail)
1589
+ .font(Typo.caption(10))
1590
+ .foregroundColor(Palette.textMuted)
1591
+ .fixedSize(horizontal: false, vertical: true)
1592
+ }
1593
+ .frame(maxWidth: .infinity, alignment: .leading)
1594
+ .padding(12)
1595
+ .background(shortcutsInsetPanel)
1596
+ }
1597
+
1598
+ private func shortcutSubsectionLabel(_ title: String) -> some View {
993
1599
  Text(title.uppercased())
994
- .font(Typo.pixel(12))
1600
+ .font(Typo.pixel(11))
995
1601
  .foregroundColor(Palette.textDim)
996
1602
  .tracking(1)
997
1603
  }
998
1604
 
1605
+ private var shortcutsInsetPanel: some View {
1606
+ RoundedRectangle(cornerRadius: 8)
1607
+ .fill(Color.black.opacity(0.22))
1608
+ .overlay(
1609
+ RoundedRectangle(cornerRadius: 8)
1610
+ .strokeBorder(Palette.border, lineWidth: 0.5)
1611
+ )
1612
+ }
1613
+
1614
+ private func relativeTimestamp(_ date: Date) -> String {
1615
+ RelativeDateTimeFormatter().localizedString(for: date, relativeTo: Date())
1616
+ }
1617
+
1618
+ private func companionTrustedDevices(revision: Int) -> [DeckTrustedDeviceSummary] {
1619
+ _ = revision
1620
+ return LatticesCompanionSecurityCoordinator.shared.trustedDeviceSummaries()
1621
+ }
1622
+
999
1623
  // MARK: - Tile cell (spatial grid item)
1000
1624
 
1001
1625
  private func tileCell(action: HotkeyAction, label: String) -> some View {
@@ -1036,6 +1660,8 @@ struct SettingsContentView: View {
1036
1660
  @State private var collapsedOcrApps: Set<String> = []
1037
1661
 
1038
1662
  @State private var activeTilePopover: HotkeyAction?
1663
+ @State private var selectedCompanionCockpitPageID = "main"
1664
+ @State private var companionTrustRevision = 0
1039
1665
 
1040
1666
  private func tileCellPopoverBinding(for action: HotkeyAction) -> Binding<Bool> {
1041
1667
  Binding(
@@ -1050,6 +1676,83 @@ struct SettingsContentView: View {
1050
1676
  KeyRecorderView(action: action, store: hotkeyStore)
1051
1677
  }
1052
1678
 
1679
+ private func companionCockpitSlotMenu(
1680
+ pageID: String,
1681
+ index: Int,
1682
+ shortcutID: String,
1683
+ categories: [LatticesCompanionShortcutCategory]
1684
+ ) -> some View {
1685
+ let definition = LatticesCompanionCockpitCatalog.definition(for: shortcutID)
1686
+ let label = definition?.title ?? "Empty"
1687
+ let subtitle = definition?.subtitle ?? "Choose a shortcut"
1688
+ let icon = definition?.iconSystemName ?? "square.dashed"
1689
+
1690
+ return Menu {
1691
+ Button("Empty Slot") {
1692
+ prefs.updateCompanionCockpitSlot(pageID: pageID, index: index, shortcutID: "")
1693
+ }
1694
+
1695
+ ForEach(categories) { category in
1696
+ let shortcuts = LatticesCompanionCockpitCatalog.shortcuts.filter {
1697
+ $0.category == category && !$0.id.isEmpty
1698
+ }
1699
+ if !shortcuts.isEmpty {
1700
+ Section(category.title) {
1701
+ ForEach(shortcuts) { shortcut in
1702
+ Button {
1703
+ prefs.updateCompanionCockpitSlot(
1704
+ pageID: pageID,
1705
+ index: index,
1706
+ shortcutID: shortcut.id
1707
+ )
1708
+ } label: {
1709
+ Label(shortcut.title, systemImage: shortcut.iconSystemName)
1710
+ }
1711
+ }
1712
+ }
1713
+ }
1714
+ }
1715
+ } label: {
1716
+ VStack(alignment: .leading, spacing: 8) {
1717
+ HStack(alignment: .top) {
1718
+ Text("Slot \(index + 1)")
1719
+ .font(Typo.pixel(10))
1720
+ .foregroundColor(Palette.textDim)
1721
+ Spacer(minLength: 0)
1722
+ Image(systemName: "chevron.up.chevron.down")
1723
+ .font(.system(size: 10, weight: .semibold))
1724
+ .foregroundColor(Palette.textMuted)
1725
+ }
1726
+
1727
+ Image(systemName: icon)
1728
+ .font(.system(size: 14, weight: .semibold))
1729
+ .foregroundColor(Palette.textDim)
1730
+
1731
+ Text(label)
1732
+ .font(Typo.monoBold(11))
1733
+ .foregroundColor(Palette.text)
1734
+ .lineLimit(2)
1735
+
1736
+ Text(subtitle)
1737
+ .font(Typo.caption(9.5))
1738
+ .foregroundColor(Palette.textMuted)
1739
+ .lineLimit(3)
1740
+ .fixedSize(horizontal: false, vertical: true)
1741
+ }
1742
+ .frame(maxWidth: .infinity, minHeight: 112, alignment: .topLeading)
1743
+ .padding(10)
1744
+ .background(
1745
+ RoundedRectangle(cornerRadius: 8)
1746
+ .fill(Palette.surface)
1747
+ .overlay(
1748
+ RoundedRectangle(cornerRadius: 8)
1749
+ .strokeBorder(Palette.border, lineWidth: 0.5)
1750
+ )
1751
+ )
1752
+ }
1753
+ .buttonStyle(.plain)
1754
+ }
1755
+
1053
1756
  // MARK: - Shortcut row (read-only, for tmux)
1054
1757
 
1055
1758
  private func shortcutRow(_ label: String, keys: [String]) -> some View {
@@ -1137,6 +1840,9 @@ struct SettingsContentView: View {
1137
1840
  HStack(spacing: 8) {
1138
1841
  docsLinkButton(icon: "doc.text", label: "Config format", file: "config.md")
1139
1842
  docsLinkButton(icon: "book", label: "Full concepts", file: "concepts.md")
1843
+ footerActionButton(icon: "stethoscope", label: "Diagnostics") {
1844
+ DiagnosticWindow.shared.show()
1845
+ }
1140
1846
  }
1141
1847
  .padding(.horizontal, 20)
1142
1848
  .padding(.vertical, 12)
@@ -1176,27 +1882,38 @@ struct SettingsContentView: View {
1176
1882
  let path = resolveDocsFile(file)
1177
1883
  NSWorkspace.shared.open(URL(fileURLWithPath: path))
1178
1884
  } label: {
1179
- HStack(spacing: 6) {
1180
- Image(systemName: icon)
1181
- .font(.system(size: 10))
1182
- Text(label)
1183
- .font(Typo.caption(11))
1184
- }
1185
- .foregroundColor(Palette.textDim)
1186
- .padding(.horizontal, 12)
1187
- .padding(.vertical, 6)
1188
- .background(
1189
- RoundedRectangle(cornerRadius: 3)
1190
- .fill(Palette.surface)
1191
- .overlay(
1192
- RoundedRectangle(cornerRadius: 3)
1193
- .strokeBorder(Palette.border, lineWidth: 0.5)
1194
- )
1195
- )
1885
+ footerActionLabel(icon: icon, label: label)
1886
+ }
1887
+ .buttonStyle(.plain)
1888
+ }
1889
+
1890
+ private func footerActionButton(icon: String, label: String, action: @escaping () -> Void) -> some View {
1891
+ Button(action: action) {
1892
+ footerActionLabel(icon: icon, label: label)
1196
1893
  }
1197
1894
  .buttonStyle(.plain)
1198
1895
  }
1199
1896
 
1897
+ private func footerActionLabel(icon: String, label: String) -> some View {
1898
+ HStack(spacing: 6) {
1899
+ Image(systemName: icon)
1900
+ .font(.system(size: 10))
1901
+ Text(label)
1902
+ .font(Typo.caption(11))
1903
+ }
1904
+ .foregroundColor(Palette.textDim)
1905
+ .padding(.horizontal, 12)
1906
+ .padding(.vertical, 6)
1907
+ .background(
1908
+ RoundedRectangle(cornerRadius: 3)
1909
+ .fill(Palette.surface)
1910
+ .overlay(
1911
+ RoundedRectangle(cornerRadius: 3)
1912
+ .strokeBorder(Palette.border, lineWidth: 0.5)
1913
+ )
1914
+ )
1915
+ }
1916
+
1200
1917
  private func resolveDocsFile(_ file: String) -> String {
1201
1918
  let bundle = Bundle.main.bundlePath
1202
1919
  let appDir = (bundle as NSString).deletingLastPathComponent