@lattices/cli 0.3.0 → 0.4.1

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 (111) hide show
  1. package/README.md +85 -9
  2. package/app/Info.plist +30 -0
  3. package/app/Lattices.app/Contents/Info.plist +8 -2
  4. package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/app/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
  6. package/app/Lattices.app/Contents/Resources/tap.wav +0 -0
  7. package/app/Lattices.app/Contents/_CodeSignature/CodeResources +139 -0
  8. package/app/Lattices.entitlements +15 -0
  9. package/app/Package.swift +8 -1
  10. package/app/Resources/tap.wav +0 -0
  11. package/app/Sources/AdvisorLearningStore.swift +90 -0
  12. package/app/Sources/AgentSession.swift +377 -0
  13. package/app/Sources/AppDelegate.swift +45 -12
  14. package/app/Sources/AppShellView.swift +81 -8
  15. package/app/Sources/AudioProvider.swift +386 -0
  16. package/app/Sources/CheatSheetHUD.swift +261 -19
  17. package/app/Sources/DaemonProtocol.swift +13 -0
  18. package/app/Sources/DaemonServer.swift +8 -0
  19. package/app/Sources/DesktopModel.swift +189 -6
  20. package/app/Sources/DesktopModelTypes.swift +2 -0
  21. package/app/Sources/DiagnosticLog.swift +104 -2
  22. package/app/Sources/EventBus.swift +1 -0
  23. package/app/Sources/HUDBottomBar.swift +279 -0
  24. package/app/Sources/HUDController.swift +1158 -0
  25. package/app/Sources/HUDLeftBar.swift +849 -0
  26. package/app/Sources/HUDMinimap.swift +179 -0
  27. package/app/Sources/HUDRightBar.swift +774 -0
  28. package/app/Sources/HUDState.swift +367 -0
  29. package/app/Sources/HUDTopBar.swift +243 -0
  30. package/app/Sources/HandsOffSession.swift +802 -0
  31. package/app/Sources/HomeDashboardView.swift +125 -0
  32. package/app/Sources/HotkeyManager.swift +2 -0
  33. package/app/Sources/HotkeyStore.swift +49 -9
  34. package/app/Sources/IntentEngine.swift +962 -0
  35. package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
  36. package/app/Sources/Intents/DistributeIntent.swift +56 -0
  37. package/app/Sources/Intents/FocusIntent.swift +69 -0
  38. package/app/Sources/Intents/HelpIntent.swift +41 -0
  39. package/app/Sources/Intents/KillIntent.swift +47 -0
  40. package/app/Sources/Intents/LatticeIntent.swift +78 -0
  41. package/app/Sources/Intents/LaunchIntent.swift +67 -0
  42. package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
  43. package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
  44. package/app/Sources/Intents/ScanIntent.swift +52 -0
  45. package/app/Sources/Intents/SearchIntent.swift +190 -0
  46. package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
  47. package/app/Sources/Intents/TileIntent.swift +61 -0
  48. package/app/Sources/LatticesApi.swift +1275 -30
  49. package/app/Sources/LauncherHUD.swift +348 -0
  50. package/app/Sources/MainView.swift +147 -44
  51. package/app/Sources/MouseFinder.swift +222 -0
  52. package/app/Sources/OcrModel.swift +34 -1
  53. package/app/Sources/OmniSearchState.swift +99 -102
  54. package/app/Sources/OnboardingView.swift +457 -0
  55. package/app/Sources/PermissionChecker.swift +2 -12
  56. package/app/Sources/PiChatDock.swift +454 -0
  57. package/app/Sources/PiChatSession.swift +815 -0
  58. package/app/Sources/PiWorkspaceView.swift +364 -0
  59. package/app/Sources/PlacementSpec.swift +195 -0
  60. package/app/Sources/Preferences.swift +59 -0
  61. package/app/Sources/ProjectScanner.swift +58 -45
  62. package/app/Sources/ScreenMapState.swift +701 -55
  63. package/app/Sources/ScreenMapView.swift +843 -103
  64. package/app/Sources/ScreenMapWindowController.swift +22 -0
  65. package/app/Sources/SessionLayerStore.swift +285 -0
  66. package/app/Sources/SessionManager.swift +4 -1
  67. package/app/Sources/SettingsView.swift +186 -3
  68. package/app/Sources/Theme.swift +9 -8
  69. package/app/Sources/TmuxModel.swift +7 -0
  70. package/app/Sources/TmuxQuery.swift +27 -3
  71. package/app/Sources/VoiceChatView.swift +192 -0
  72. package/app/Sources/VoiceCommandWindow.swift +1594 -0
  73. package/app/Sources/VoiceIntentResolver.swift +671 -0
  74. package/app/Sources/VoxClient.swift +454 -0
  75. package/app/Sources/WindowTiler.swift +348 -87
  76. package/app/Sources/WorkspaceManager.swift +127 -18
  77. package/app/Tests/StageDragTests.swift +333 -0
  78. package/app/Tests/StageJoinTests.swift +313 -0
  79. package/app/Tests/StageManagerTests.swift +280 -0
  80. package/app/Tests/StageTileTests.swift +353 -0
  81. package/assets/AppIcon.icns +0 -0
  82. package/bin/client.ts +16 -0
  83. package/bin/{daemon-client.js → daemon-client.ts} +49 -30
  84. package/bin/handsoff-infer.ts +280 -0
  85. package/bin/handsoff-worker.ts +740 -0
  86. package/bin/lattices-app.ts +338 -0
  87. package/bin/lattices-dev +208 -0
  88. package/bin/{lattices.js → lattices.ts} +777 -140
  89. package/bin/project-twin.ts +645 -0
  90. package/docs/agent-execution-plan.md +562 -0
  91. package/docs/agent-layer-guide.md +207 -0
  92. package/docs/agents.md +142 -0
  93. package/docs/api.md +153 -34
  94. package/docs/app.md +29 -1
  95. package/docs/config.md +5 -1
  96. package/docs/handsoff-test-scenarios.md +84 -0
  97. package/docs/layers.md +20 -20
  98. package/docs/ocr.md +14 -5
  99. package/docs/overview.md +5 -1
  100. package/docs/presentation-execution-review.md +491 -0
  101. package/docs/prompts/hands-off-system.md +374 -0
  102. package/docs/prompts/hands-off-turn.md +30 -0
  103. package/docs/prompts/voice-advisor.md +31 -0
  104. package/docs/prompts/voice-fallback.md +23 -0
  105. package/docs/tiling-reference.md +167 -0
  106. package/docs/twins.md +138 -0
  107. package/docs/voice-command-protocol.md +278 -0
  108. package/docs/voice.md +219 -0
  109. package/package.json +29 -11
  110. package/bin/client.js +0 -4
  111. package/bin/lattices-app.js +0 -221
@@ -0,0 +1,367 @@
1
+ import Combine
2
+ import CoreGraphics
3
+ import Foundation
4
+
5
+ // MARK: - HUDItem
6
+
7
+ enum HUDItem: Identifiable, Equatable {
8
+ case project(Project)
9
+ case window(WindowEntry)
10
+
11
+ var id: String {
12
+ switch self {
13
+ case .project(let p): return "project-\(p.id)"
14
+ case .window(let w): return "window-\(w.wid)"
15
+ }
16
+ }
17
+
18
+ var displayName: String {
19
+ switch self {
20
+ case .project(let p): return p.name
21
+ case .window(let w): return w.title
22
+ }
23
+ }
24
+
25
+ var appName: String? {
26
+ switch self {
27
+ case .project: return nil
28
+ case .window(let w): return w.app
29
+ }
30
+ }
31
+
32
+ static func == (lhs: HUDItem, rhs: HUDItem) -> Bool {
33
+ lhs.id == rhs.id
34
+ }
35
+ }
36
+
37
+ // MARK: - Focus target
38
+
39
+ enum HUDFocus: Equatable {
40
+ case search // typing into the left bar search field
41
+ case list // navigating the left bar item list
42
+ case inspector // tabbed over to the right bar
43
+ }
44
+
45
+ // MARK: - HUDState
46
+
47
+ final class HUDState: ObservableObject {
48
+ private static let leftSidebarWidthKey = "hud.leftSidebarWidth"
49
+ private static let defaultLeftSidebarWidth: CGFloat = 320
50
+ private static let minLeftSidebarWidth: CGFloat = 260
51
+ private static let maxLeftSidebarWidth: CGFloat = 420
52
+
53
+ @Published var selectedItem: HUDItem?
54
+ @Published var pinnedItem: HUDItem?
55
+ @Published var hoveredPreviewItem: HUDItem?
56
+ @Published var feedbackMessage: String?
57
+ @Published var query: String = ""
58
+ @Published var selectedIndex: Int = 0
59
+ @Published var focus: HUDFocus = .search
60
+ @Published var voiceActive: Bool = false
61
+ @Published var expandedSections: Set<Int> = [2]
62
+ @Published var leftSidebarWidth: CGFloat = {
63
+ let saved = UserDefaults.standard.double(forKey: HUDState.leftSidebarWidthKey)
64
+ if saved > 0 {
65
+ return min(max(CGFloat(saved), HUDState.minLeftSidebarWidth), HUDState.maxLeftSidebarWidth)
66
+ }
67
+ return HUDState.defaultLeftSidebarWidth
68
+ }()
69
+
70
+ /// Multi-select for tiling — set of item IDs
71
+ @Published var selectedItems: Set<String> = []
72
+
73
+ enum MinimapMode: Equatable { case hidden, docked, expanded }
74
+ @Published var minimapMode: MinimapMode = .docked
75
+
76
+ // MARK: - Tile mode
77
+
78
+ @Published var tileMode: Bool = false
79
+
80
+ /// Snapshot of window positions taken when entering tile mode
81
+ struct WindowSnapshot {
82
+ let wid: UInt32
83
+ let pid: Int32
84
+ let frame: CGRect
85
+ }
86
+ var tileSnapshot: [WindowSnapshot] = []
87
+ var tiledWindows: Set<UInt32> = []
88
+
89
+ /// Pre-computed grid layout — calculated on HUD show, applied instantly on T press
90
+ var precomputedGrid: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
91
+
92
+ /// Snapshot of the flat item list — set by HUDLeftBar so key handler can index into it
93
+ var flatItems: [HUDItem] = []
94
+
95
+ var hoverPreviewAnchorScreenY: CGFloat?
96
+ var previewInteractionActive: Bool = false
97
+
98
+ /// Section offsets for number-key jumping (1=Projects, 2=Terminals, 3=Chrome, 4=Claude)
99
+ var sectionOffsets: [Int: Int] = [:]
100
+
101
+ private var selectionAnchorID: String?
102
+ private var touchedSections: Set<Int> = []
103
+ private var feedbackClearWorkItem: DispatchWorkItem?
104
+
105
+ var leftSidebarWidthRange: ClosedRange<CGFloat> {
106
+ Self.minLeftSidebarWidth...Self.maxLeftSidebarWidth
107
+ }
108
+
109
+ func setLeftSidebarWidth(_ width: CGFloat) {
110
+ let clamped = min(max(width, Self.minLeftSidebarWidth), Self.maxLeftSidebarWidth)
111
+ guard abs(clamped - leftSidebarWidth) > 0.5 else { return }
112
+ leftSidebarWidth = clamped
113
+ UserDefaults.standard.set(Double(clamped), forKey: Self.leftSidebarWidthKey)
114
+ }
115
+
116
+ func isSectionExpanded(_ key: Int) -> Bool {
117
+ expandedSections.contains(key)
118
+ }
119
+
120
+ func toggleSection(_ key: Int) {
121
+ if expandedSections.contains(key) {
122
+ expandedSections.remove(key)
123
+ } else {
124
+ expandedSections.insert(key)
125
+ }
126
+ touchedSections.insert(key)
127
+ }
128
+
129
+ func resetSectionDefaults(hasRunningProjects: Bool) {
130
+ expandedSections = [2]
131
+ if hasRunningProjects {
132
+ expandedSections.insert(1)
133
+ }
134
+ touchedSections = []
135
+ }
136
+
137
+ func syncAutoSectionDefaults(hasRunningProjects: Bool) {
138
+ if !touchedSections.contains(2) {
139
+ expandedSections.insert(2)
140
+ }
141
+ if !touchedSections.contains(1) {
142
+ if hasRunningProjects {
143
+ expandedSections.insert(1)
144
+ } else {
145
+ expandedSections.remove(1)
146
+ }
147
+ }
148
+ }
149
+
150
+ var effectiveSelectionIDs: Set<String> {
151
+ if selectedItems.isEmpty {
152
+ if let selectedItem {
153
+ return [selectedItem.id]
154
+ }
155
+ return []
156
+ }
157
+
158
+ var ids = selectedItems
159
+ if let selectedItem {
160
+ ids.insert(selectedItem.id)
161
+ }
162
+ return ids
163
+ }
164
+
165
+ var multiSelectionCount: Int {
166
+ let count = effectiveSelectionIDs.count
167
+ return count > 1 ? count : 0
168
+ }
169
+
170
+ var transientPreviewItem: HUDItem? {
171
+ if let hoveredPreviewItem {
172
+ return hoveredPreviewItem
173
+ }
174
+ if pinnedItem == nil && focus == .list {
175
+ return selectedItem
176
+ }
177
+ return nil
178
+ }
179
+
180
+ var inspectorCandidateItem: HUDItem? {
181
+ if let pinnedItem {
182
+ return pinnedItem
183
+ }
184
+ if let hoveredPreviewItem {
185
+ return hoveredPreviewItem
186
+ }
187
+ return selectedItem
188
+ }
189
+
190
+ func clearMultiSelection() {
191
+ selectedItems = []
192
+ if let selectedItem {
193
+ selectionAnchorID = selectedItem.id
194
+ } else {
195
+ selectionAnchorID = nil
196
+ }
197
+ }
198
+
199
+ func selectSingle(_ item: HUDItem, index: Int) {
200
+ selectedItem = item
201
+ selectedIndex = index
202
+ selectedItems = []
203
+ selectionAnchorID = item.id
204
+ }
205
+
206
+ func showFeedback(_ message: String, autoClearAfter delay: TimeInterval = 0.9) {
207
+ feedbackClearWorkItem?.cancel()
208
+ feedbackMessage = message
209
+
210
+ let clearWorkItem = DispatchWorkItem { [weak self] in
211
+ guard self?.feedbackMessage == message else { return }
212
+ self?.feedbackMessage = nil
213
+ }
214
+ feedbackClearWorkItem = clearWorkItem
215
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: clearWorkItem)
216
+ }
217
+
218
+ func pinInspector(_ item: HUDItem, source: String = "selection") {
219
+ let timed = AppFeedback.shared.beginTimed(
220
+ "HUD inspect (\(source))",
221
+ state: self,
222
+ feedback: "Inspecting \(item.displayName)"
223
+ )
224
+ if let idx = flatItems.firstIndex(of: item) {
225
+ selectedIndex = idx
226
+ }
227
+ selectedItem = item
228
+ pinnedItem = item
229
+ hoveredPreviewItem = nil
230
+ selectedItems = []
231
+ selectionAnchorID = item.id
232
+ focus = .inspector
233
+ DispatchQueue.main.async {
234
+ AppFeedback.shared.finish(timed)
235
+ }
236
+ }
237
+
238
+ func pinInspectorCandidate(source: String = "preview") {
239
+ guard let item = inspectorCandidateItem else { return }
240
+ pinInspector(item, source: source)
241
+ }
242
+
243
+ func toggleSelection(_ item: HUDItem, index: Int, in items: [HUDItem]) {
244
+ let anchorID = selectionAnchorID ?? selectedItem?.id ?? item.id
245
+
246
+ if selectedItems.isEmpty, let current = selectedItem {
247
+ selectedItems = [current.id]
248
+ }
249
+
250
+ if selectedItems.contains(item.id) {
251
+ selectedItems.remove(item.id)
252
+ } else {
253
+ selectedItems.insert(item.id)
254
+ }
255
+
256
+ if selectedItems.isEmpty {
257
+ selectSingle(item, index: index)
258
+ return
259
+ }
260
+
261
+ if selectedItems.count == 1,
262
+ let remainingID = selectedItems.first,
263
+ let remainingIndex = items.firstIndex(where: { $0.id == remainingID }),
264
+ let remainingItem = items[safe: remainingIndex] {
265
+ selectSingle(remainingItem, index: remainingIndex)
266
+ return
267
+ }
268
+
269
+ selectedItem = item
270
+ selectedIndex = index
271
+ selectionAnchorID = anchorID
272
+ }
273
+
274
+ func selectRange(to item: HUDItem, index: Int, in items: [HUDItem]) {
275
+ guard !items.isEmpty else { return }
276
+
277
+ let anchorID = selectionAnchorID ?? selectedItem?.id ?? item.id
278
+ guard let anchorIndex = items.firstIndex(where: { $0.id == anchorID }) else {
279
+ selectSingle(item, index: index)
280
+ return
281
+ }
282
+
283
+ let lower = min(anchorIndex, index)
284
+ let upper = max(anchorIndex, index)
285
+ let ids = Set(items[lower...upper].map(\.id))
286
+
287
+ selectedItem = item
288
+ selectedIndex = index
289
+ selectedItems = ids.count > 1 ? ids : []
290
+ selectionAnchorID = anchorID
291
+ }
292
+
293
+ func moveSelection(by delta: Int, extend: Bool) {
294
+ let items = flatItems
295
+ guard !items.isEmpty else { return }
296
+
297
+ let currentIndex: Int
298
+ if let selectedItem,
299
+ let idx = items.firstIndex(of: selectedItem) {
300
+ currentIndex = idx
301
+ } else {
302
+ currentIndex = max(0, min(items.count - 1, selectedIndex))
303
+ }
304
+
305
+ let nextIndex = max(0, min(items.count - 1, currentIndex + delta))
306
+ guard let nextItem = items[safe: nextIndex] else { return }
307
+
308
+ if extend {
309
+ selectRange(to: nextItem, index: nextIndex, in: items)
310
+ } else {
311
+ selectSingle(nextItem, index: nextIndex)
312
+ }
313
+ }
314
+
315
+ func reconcileSelection(with items: [HUDItem]) {
316
+ let validIDs = Set(items.map(\.id))
317
+
318
+ selectedItems = selectedItems.intersection(validIDs)
319
+ if let selectedItem, !validIDs.contains(selectedItem.id) {
320
+ self.selectedItem = nil
321
+ }
322
+ if let pinnedItem, !validIDs.contains(pinnedItem.id) {
323
+ self.pinnedItem = nil
324
+ }
325
+ if let hoveredPreviewItem, !validIDs.contains(hoveredPreviewItem.id) {
326
+ self.hoveredPreviewItem = nil
327
+ }
328
+ if let selectionAnchorID, !validIDs.contains(selectionAnchorID) {
329
+ self.selectionAnchorID = selectedItem?.id
330
+ }
331
+
332
+ guard !items.isEmpty else {
333
+ selectedItem = nil
334
+ pinnedItem = nil
335
+ hoveredPreviewItem = nil
336
+ selectedIndex = 0
337
+ selectedItems = []
338
+ selectionAnchorID = nil
339
+ return
340
+ }
341
+
342
+ if selectedItem == nil {
343
+ let clampedIndex = max(0, min(items.count - 1, selectedIndex))
344
+ if let fallback = items[safe: clampedIndex] {
345
+ selectedItem = fallback
346
+ selectedIndex = clampedIndex
347
+ if selectionAnchorID == nil {
348
+ selectionAnchorID = fallback.id
349
+ }
350
+ }
351
+ return
352
+ }
353
+
354
+ if let selectedItem,
355
+ let itemIndex = items.firstIndex(of: selectedItem) {
356
+ selectedIndex = itemIndex
357
+ } else {
358
+ selectedIndex = max(0, min(items.count - 1, selectedIndex))
359
+ }
360
+ }
361
+ }
362
+
363
+ private extension Array {
364
+ subscript(safe index: Int) -> Element? {
365
+ indices.contains(index) ? self[index] : nil
366
+ }
367
+ }
@@ -0,0 +1,243 @@
1
+ import SwiftUI
2
+
3
+ // MARK: - HUDTopBar
4
+
5
+ struct HUDTopBar: View {
6
+ @ObservedObject var state: HUDState
7
+ @ObservedObject private var handsOff = HandsOffSession.shared
8
+ @ObservedObject private var workspace = WorkspaceManager.shared
9
+ var onDismiss: () -> Void
10
+
11
+ var body: some View {
12
+ HStack(spacing: 0) {
13
+ // Logo
14
+ logo
15
+ .padding(.leading, 16)
16
+
17
+ // Voice status (when active)
18
+ if state.voiceActive {
19
+ voiceStatus
20
+ .padding(.leading, 12)
21
+ }
22
+
23
+ Spacer()
24
+
25
+ // Layer strip (Hyprland-style)
26
+ if let layers = workspace.config?.layers, !layers.isEmpty {
27
+ layerStrip(layers)
28
+ .padding(.trailing, 12)
29
+ }
30
+
31
+ // Quick actions
32
+ HStack(spacing: 6) {
33
+ quickAction(icon: "rectangle.3.group", label: "Map", shortcut: "⌃⌥⇧⌘1") {
34
+ onDismiss()
35
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
36
+ ScreenMapWindowController.shared.toggle()
37
+ }
38
+ }
39
+
40
+ quickAction(icon: "magnifyingglass", label: "Search", shortcut: "⌃⌥⇧⌘5") {
41
+ onDismiss()
42
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
43
+ OmniSearchWindow.shared.toggle()
44
+ }
45
+ }
46
+
47
+ quickAction(icon: "text.justify.left", label: "Palette", shortcut: "⇧⌘M") {
48
+ onDismiss()
49
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
50
+ CommandPaletteWindow.shared.toggle()
51
+ }
52
+ }
53
+ }
54
+ .padding(.trailing, 16)
55
+ }
56
+ .frame(maxWidth: .infinity)
57
+ .frame(height: 44)
58
+ .background(Palette.bg)
59
+ }
60
+
61
+ // MARK: - Layer strip (Hyprland-style workspace bar)
62
+
63
+ private func layerStrip(_ layers: [Layer]) -> some View {
64
+ HStack(spacing: 3) {
65
+ ForEach(Array(layers.enumerated()), id: \.element.id) { idx, layer in
66
+ let isActive = idx == workspace.activeLayerIndex
67
+
68
+ Button {
69
+ workspace.focusLayer(index: idx)
70
+ } label: {
71
+ HStack(spacing: 4) {
72
+ Circle()
73
+ .fill(isActive ? Palette.running : Palette.textMuted.opacity(0.3))
74
+ .frame(width: 5, height: 5)
75
+
76
+ Text(layer.label)
77
+ .font(Typo.monoBold(9))
78
+ .foregroundColor(isActive ? Palette.text : Palette.textMuted)
79
+ .lineLimit(1)
80
+ }
81
+ .padding(.horizontal, 8)
82
+ .padding(.vertical, 4)
83
+ .background(
84
+ RoundedRectangle(cornerRadius: 4)
85
+ .fill(isActive ? Palette.surfaceHov : Palette.surface.opacity(0.5))
86
+ .overlay(
87
+ RoundedRectangle(cornerRadius: 4)
88
+ .strokeBorder(isActive ? Palette.running.opacity(0.4) : Palette.border.opacity(0.5), lineWidth: 0.5)
89
+ )
90
+ )
91
+ }
92
+ .buttonStyle(.plain)
93
+ }
94
+ }
95
+ }
96
+
97
+ // MARK: - Logo
98
+
99
+ private var logo: some View {
100
+ HStack(spacing: 7) {
101
+ latticesGrid
102
+ Text("lattices")
103
+ .font(Typo.monoBold(11))
104
+ .foregroundColor(Palette.textMuted.opacity(0.6))
105
+ }
106
+ }
107
+
108
+ /// 3×3 grid matching the menu bar icon — L-shape bright, rest dim
109
+ private var latticesGrid: some View {
110
+ let cellSize: CGFloat = 3
111
+ let gap: CGFloat = 1.5
112
+ let solidCells: Set<Int> = [0, 3, 6, 7, 8]
113
+
114
+ return Canvas { ctx, _ in
115
+ for row in 0..<3 {
116
+ for col in 0..<3 {
117
+ let idx = row * 3 + col
118
+ let x = CGFloat(col) * (cellSize + gap)
119
+ let y = CGFloat(row) * (cellSize + gap)
120
+ let rect = CGRect(x: x, y: y, width: cellSize, height: cellSize)
121
+ let opacity: Double = solidCells.contains(idx) ? 0.5 : 0.15
122
+ ctx.fill(
123
+ Path(roundedRect: rect, cornerRadius: 0.5),
124
+ with: .color(.white.opacity(opacity))
125
+ )
126
+ }
127
+ }
128
+ }
129
+ .frame(width: 3 * 3 + 2 * 1.5, height: 3 * 3 + 2 * 1.5)
130
+ }
131
+
132
+ // MARK: - Voice status
133
+
134
+ private var voiceStatus: some View {
135
+ HStack(spacing: 8) {
136
+ // Pulsing dot + state
137
+ Circle()
138
+ .fill(voiceColor)
139
+ .frame(width: 6, height: 6)
140
+ .overlay(
141
+ Circle()
142
+ .stroke(voiceColor.opacity(0.4), lineWidth: 1)
143
+ .scaleEffect(handsOff.state == .listening ? 2.0 : 1.0)
144
+ .opacity(handsOff.state == .listening ? 0 : 1)
145
+ .animation(
146
+ handsOff.state == .listening
147
+ ? .easeOut(duration: 1.0).repeatForever(autoreverses: false)
148
+ : .default,
149
+ value: handsOff.state
150
+ )
151
+ )
152
+
153
+ Text(voiceLabel)
154
+ .font(Typo.monoBold(9))
155
+ .foregroundColor(voiceColor)
156
+
157
+ // Dialogue: last user message → last response
158
+ if let transcript = handsOff.lastTranscript {
159
+ Rectangle().fill(Palette.border).frame(width: 0.5, height: 16)
160
+
161
+ // What user said
162
+ HStack(spacing: 3) {
163
+ Text("you")
164
+ .font(Typo.monoBold(8))
165
+ .foregroundColor(Palette.textMuted)
166
+ Text(transcript)
167
+ .font(Typo.mono(9))
168
+ .foregroundColor(Palette.text)
169
+ .lineLimit(1)
170
+ }
171
+ .frame(maxWidth: 300, alignment: .leading)
172
+ }
173
+
174
+ if let response = handsOff.lastResponse {
175
+ // What Lattices said back
176
+ HStack(spacing: 3) {
177
+ Text("→")
178
+ .font(Typo.mono(9))
179
+ .foregroundColor(Palette.running)
180
+ Text(response)
181
+ .font(Typo.mono(9))
182
+ .foregroundColor(Palette.textMuted)
183
+ .lineLimit(1)
184
+ }
185
+ .frame(maxWidth: 350, alignment: .leading)
186
+ }
187
+ }
188
+ .padding(.horizontal, 10)
189
+ .padding(.vertical, 4)
190
+ .background(
191
+ RoundedRectangle(cornerRadius: 5)
192
+ .fill(voiceColor.opacity(0.08))
193
+ .overlay(
194
+ RoundedRectangle(cornerRadius: 5)
195
+ .strokeBorder(voiceColor.opacity(0.2), lineWidth: 0.5)
196
+ )
197
+ )
198
+ }
199
+
200
+ private var voiceColor: Color {
201
+ switch handsOff.state {
202
+ case .idle: return Palette.running
203
+ case .connecting: return Palette.detach
204
+ case .listening: return Palette.running
205
+ case .thinking: return Palette.detach
206
+ }
207
+ }
208
+
209
+ private var voiceLabel: String {
210
+ switch handsOff.state {
211
+ case .idle: return "ready"
212
+ case .connecting: return "connecting"
213
+ case .listening: return "listening"
214
+ case .thinking: return "thinking"
215
+ }
216
+ }
217
+
218
+ // MARK: - Quick action button
219
+
220
+ private func quickAction(icon: String, label: String, shortcut: String, action: @escaping () -> Void) -> some View {
221
+ Button(action: action) {
222
+ HStack(spacing: 5) {
223
+ Image(systemName: icon)
224
+ .font(.system(size: 10))
225
+ Text(label)
226
+ .font(Typo.mono(10))
227
+ }
228
+ .foregroundColor(Palette.textMuted)
229
+ .padding(.horizontal, 10)
230
+ .padding(.vertical, 5)
231
+ .background(
232
+ RoundedRectangle(cornerRadius: 5)
233
+ .fill(Palette.surface)
234
+ .overlay(
235
+ RoundedRectangle(cornerRadius: 5)
236
+ .strokeBorder(Palette.border, lineWidth: 0.5)
237
+ )
238
+ )
239
+ }
240
+ .buttonStyle(.plain)
241
+ .help("\(label) (\(shortcut))")
242
+ }
243
+ }