@lattices/cli 0.3.0 → 0.4.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 (95) hide show
  1. package/README.md +85 -9
  2. package/app/Package.swift +8 -1
  3. package/app/Sources/AdvisorLearningStore.swift +90 -0
  4. package/app/Sources/AgentSession.swift +377 -0
  5. package/app/Sources/AppDelegate.swift +44 -12
  6. package/app/Sources/AppShellView.swift +81 -8
  7. package/app/Sources/AudioProvider.swift +386 -0
  8. package/app/Sources/CheatSheetHUD.swift +261 -19
  9. package/app/Sources/DaemonProtocol.swift +13 -0
  10. package/app/Sources/DaemonServer.swift +8 -0
  11. package/app/Sources/DesktopModel.swift +164 -5
  12. package/app/Sources/DesktopModelTypes.swift +2 -0
  13. package/app/Sources/DiagnosticLog.swift +104 -2
  14. package/app/Sources/EventBus.swift +1 -0
  15. package/app/Sources/HUDBottomBar.swift +279 -0
  16. package/app/Sources/HUDController.swift +1158 -0
  17. package/app/Sources/HUDLeftBar.swift +849 -0
  18. package/app/Sources/HUDMinimap.swift +179 -0
  19. package/app/Sources/HUDRightBar.swift +774 -0
  20. package/app/Sources/HUDState.swift +367 -0
  21. package/app/Sources/HUDTopBar.swift +243 -0
  22. package/app/Sources/HandsOffSession.swift +733 -0
  23. package/app/Sources/HomeDashboardView.swift +125 -0
  24. package/app/Sources/HotkeyManager.swift +2 -0
  25. package/app/Sources/HotkeyStore.swift +45 -9
  26. package/app/Sources/IntentEngine.swift +925 -0
  27. package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
  28. package/app/Sources/Intents/DistributeIntent.swift +56 -0
  29. package/app/Sources/Intents/FocusIntent.swift +69 -0
  30. package/app/Sources/Intents/HelpIntent.swift +41 -0
  31. package/app/Sources/Intents/KillIntent.swift +47 -0
  32. package/app/Sources/Intents/LatticeIntent.swift +78 -0
  33. package/app/Sources/Intents/LaunchIntent.swift +67 -0
  34. package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
  35. package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
  36. package/app/Sources/Intents/ScanIntent.swift +52 -0
  37. package/app/Sources/Intents/SearchIntent.swift +190 -0
  38. package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
  39. package/app/Sources/Intents/TileIntent.swift +61 -0
  40. package/app/Sources/LatticesApi.swift +1235 -30
  41. package/app/Sources/LauncherHUD.swift +348 -0
  42. package/app/Sources/MainView.swift +147 -44
  43. package/app/Sources/OcrModel.swift +34 -1
  44. package/app/Sources/OmniSearchState.swift +99 -102
  45. package/app/Sources/OnboardingView.swift +457 -0
  46. package/app/Sources/PermissionChecker.swift +2 -12
  47. package/app/Sources/PiChatDock.swift +454 -0
  48. package/app/Sources/PiChatSession.swift +815 -0
  49. package/app/Sources/PiWorkspaceView.swift +364 -0
  50. package/app/Sources/PlacementSpec.swift +195 -0
  51. package/app/Sources/Preferences.swift +59 -0
  52. package/app/Sources/ProjectScanner.swift +1 -1
  53. package/app/Sources/ScreenMapState.swift +701 -55
  54. package/app/Sources/ScreenMapView.swift +843 -103
  55. package/app/Sources/ScreenMapWindowController.swift +22 -0
  56. package/app/Sources/SessionLayerStore.swift +285 -0
  57. package/app/Sources/SessionManager.swift +4 -1
  58. package/app/Sources/SettingsView.swift +186 -3
  59. package/app/Sources/Theme.swift +9 -8
  60. package/app/Sources/TmuxModel.swift +7 -0
  61. package/app/Sources/TmuxQuery.swift +27 -3
  62. package/app/Sources/VoiceChatView.swift +192 -0
  63. package/app/Sources/VoiceCommandWindow.swift +1594 -0
  64. package/app/Sources/VoiceIntentResolver.swift +671 -0
  65. package/app/Sources/VoxClient.swift +454 -0
  66. package/app/Sources/WindowTiler.swift +348 -87
  67. package/app/Sources/WorkspaceManager.swift +127 -18
  68. package/bin/client.ts +16 -0
  69. package/bin/{daemon-client.js → daemon-client.ts} +49 -30
  70. package/bin/handsoff-infer.ts +280 -0
  71. package/bin/handsoff-worker.ts +731 -0
  72. package/bin/{lattices-app.js → lattices-app.ts} +67 -32
  73. package/bin/lattices-dev +160 -0
  74. package/bin/{lattices.js → lattices.ts} +600 -137
  75. package/bin/project-twin.ts +645 -0
  76. package/docs/agent-execution-plan.md +562 -0
  77. package/docs/agents.md +142 -0
  78. package/docs/api.md +153 -34
  79. package/docs/app.md +29 -1
  80. package/docs/config.md +5 -1
  81. package/docs/handsoff-test-scenarios.md +84 -0
  82. package/docs/layers.md +20 -20
  83. package/docs/ocr.md +14 -5
  84. package/docs/overview.md +5 -1
  85. package/docs/presentation-execution-review.md +491 -0
  86. package/docs/prompts/hands-off-system.md +374 -0
  87. package/docs/prompts/hands-off-turn.md +30 -0
  88. package/docs/prompts/voice-advisor.md +31 -0
  89. package/docs/prompts/voice-fallback.md +23 -0
  90. package/docs/tiling-reference.md +167 -0
  91. package/docs/twins.md +138 -0
  92. package/docs/voice-command-protocol.md +278 -0
  93. package/docs/voice.md +219 -0
  94. package/package.json +21 -10
  95. package/bin/client.js +0 -4
@@ -78,6 +78,28 @@ final class ScreenMapWindowController: ObservableObject {
78
78
  show()
79
79
  }
80
80
 
81
+ /// Open screen map focused on a specific window.
82
+ func showWindow(wid: UInt32) {
83
+ activePage = .screenMap
84
+ show()
85
+
86
+ // Avoid overlapping the voice panel — nudge screen map below it
87
+ if let w = window, let voicePanel = VoiceCommandWindow.shared.panel, voicePanel.isVisible {
88
+ let voiceBottom = voicePanel.frame.minY
89
+ let mapFrame = w.frame
90
+ if mapFrame.maxY > voiceBottom - 10 {
91
+ // Position just below the voice panel
92
+ let newY = voiceBottom - mapFrame.height - 16
93
+ w.setFrameOrigin(NSPoint(x: mapFrame.origin.x, y: max(newY, 40)))
94
+ }
95
+ }
96
+
97
+ // Select after a brief delay so the controller has time to populate
98
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
99
+ self?.controller?.selectSingle(wid)
100
+ }
101
+ }
102
+
81
103
  func close() {
82
104
  controller?.endPreview()
83
105
  window?.orderOut(nil)
@@ -0,0 +1,285 @@
1
+ import AppKit
2
+
3
+ // MARK: - WindowRef
4
+
5
+ struct WindowRef: Codable, Identifiable {
6
+ let id: String
7
+
8
+ // ── Intent (stable, survives restarts) ──
9
+ var app: String
10
+ var contentHint: String?
11
+ var tile: String?
12
+ var display: Int?
13
+
14
+ // ── Runtime (ephemeral, filled when window is live) ──
15
+ var wid: UInt32?
16
+ var pid: Int32?
17
+ var title: String?
18
+ var frame: WindowFrame?
19
+
20
+ init(id: String = UUID().uuidString, app: String, contentHint: String? = nil,
21
+ tile: String? = nil, display: Int? = nil,
22
+ wid: UInt32? = nil, pid: Int32? = nil, title: String? = nil, frame: WindowFrame? = nil) {
23
+ self.id = id
24
+ self.app = app
25
+ self.contentHint = contentHint
26
+ self.tile = tile
27
+ self.display = display
28
+ self.wid = wid
29
+ self.pid = pid
30
+ self.title = title
31
+ self.frame = frame
32
+ }
33
+ }
34
+
35
+ // MARK: - SessionLayer
36
+
37
+ struct SessionLayer: Identifiable, Codable {
38
+ let id: String
39
+ var name: String
40
+ var windows: [WindowRef]
41
+
42
+ init(id: String = UUID().uuidString, name: String, windows: [WindowRef] = []) {
43
+ self.id = id
44
+ self.name = name
45
+ self.windows = windows
46
+ }
47
+ }
48
+
49
+ // MARK: - SessionLayerStore
50
+
51
+ final class SessionLayerStore: ObservableObject {
52
+ static let shared = SessionLayerStore()
53
+
54
+ @Published var layers: [SessionLayer] = []
55
+ @Published var activeIndex: Int = -1
56
+
57
+ private init() {
58
+ // Listen for window changes to reconcile stale refs
59
+ EventBus.shared.subscribe { [weak self] event in
60
+ if case .windowsChanged = event {
61
+ DispatchQueue.main.async {
62
+ self?.reconcile()
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ // MARK: - CRUD
69
+
70
+ @discardableResult
71
+ func create(name: String, windows: [WindowRef] = []) -> SessionLayer {
72
+ let layer = SessionLayer(name: name, windows: windows)
73
+ layers.append(layer)
74
+ DiagnosticLog.shared.info("SessionLayerStore: created '\(name)' with \(windows.count) refs")
75
+ // If this is the first layer, activate it
76
+ if layers.count == 1 { activeIndex = 0 }
77
+ return layer
78
+ }
79
+
80
+ func delete(id: String) {
81
+ guard let idx = layers.firstIndex(where: { $0.id == id }) else { return }
82
+ // Clear layer tags for windows in this layer
83
+ for ref in layers[idx].windows {
84
+ if let wid = ref.wid {
85
+ DesktopModel.shared.removeLayerTag(wid: wid)
86
+ }
87
+ }
88
+ layers.remove(at: idx)
89
+ // Adjust activeIndex
90
+ if layers.isEmpty {
91
+ activeIndex = -1
92
+ } else if activeIndex >= layers.count {
93
+ activeIndex = layers.count - 1
94
+ }
95
+ }
96
+
97
+ func rename(id: String, name: String) {
98
+ guard let idx = layers.firstIndex(where: { $0.id == id }) else { return }
99
+ layers[idx].name = name
100
+ }
101
+
102
+ func clear() {
103
+ DesktopModel.shared.clearLayerTags()
104
+ layers.removeAll()
105
+ activeIndex = -1
106
+ LayerBezel.shared.invalidateCache()
107
+ }
108
+
109
+ func layerById(_ id: String) -> SessionLayer? {
110
+ layers.first { $0.id == id }
111
+ }
112
+
113
+ func layerByName(_ name: String) -> SessionLayer? {
114
+ layers.first { $0.name.localizedCaseInsensitiveCompare(name) == .orderedSame }
115
+ }
116
+
117
+ // MARK: - Window Management
118
+
119
+ func assign(ref: WindowRef, toLayerId id: String) {
120
+ guard let idx = layers.firstIndex(where: { $0.id == id }) else { return }
121
+ layers[idx].windows.append(ref)
122
+ if let wid = ref.wid {
123
+ DesktopModel.shared.assignLayer(wid: wid, layerId: layers[idx].name)
124
+ }
125
+ }
126
+
127
+ func assignByWid(_ wid: UInt32, toLayerId id: String) {
128
+ guard let idx = layers.firstIndex(where: { $0.id == id }) else { return }
129
+ guard let entry = DesktopModel.shared.windows[wid] else { return }
130
+ // Don't add duplicates
131
+ if layers[idx].windows.contains(where: { $0.wid == wid }) { return }
132
+ let ref = WindowRef(
133
+ app: entry.app,
134
+ contentHint: entry.title,
135
+ wid: entry.wid,
136
+ pid: entry.pid,
137
+ title: entry.title,
138
+ frame: entry.frame
139
+ )
140
+ layers[idx].windows.append(ref)
141
+ DesktopModel.shared.assignLayer(wid: wid, layerId: layers[idx].name)
142
+ }
143
+
144
+ func remove(refId: String, fromLayerId id: String) {
145
+ guard let idx = layers.firstIndex(where: { $0.id == id }) else { return }
146
+ if let refIdx = layers[idx].windows.firstIndex(where: { $0.id == refId }) {
147
+ if let wid = layers[idx].windows[refIdx].wid {
148
+ DesktopModel.shared.removeLayerTag(wid: wid)
149
+ }
150
+ layers[idx].windows.remove(at: refIdx)
151
+ }
152
+ }
153
+
154
+ func tagFrontmostWindow() {
155
+ guard let frontApp = NSWorkspace.shared.frontmostApplication,
156
+ frontApp.bundleIdentifier != "com.arach.lattices" else { return }
157
+
158
+ let pid = frontApp.processIdentifier
159
+ // Find the frontmost window for this app
160
+ guard let entry = DesktopModel.shared.windows.values
161
+ .first(where: { $0.pid == pid }) else { return }
162
+
163
+ // If no layers exist, create one
164
+ if layers.isEmpty {
165
+ create(name: "Layer 1")
166
+ }
167
+
168
+ // If no active layer, use first
169
+ let targetIndex = activeIndex >= 0 ? activeIndex : 0
170
+ guard targetIndex < layers.count else { return }
171
+
172
+ let layerId = layers[targetIndex].id
173
+ assignByWid(entry.wid, toLayerId: layerId)
174
+ DiagnosticLog.shared.info("SessionLayerStore: tagged \(entry.app) '\(entry.title)' → '\(layers[targetIndex].name)'")
175
+
176
+ // Show bezel feedback
177
+ let allNames = layers.map(\.name)
178
+ LayerBezel.shared.show(
179
+ label: layers[targetIndex].name,
180
+ index: targetIndex,
181
+ total: layers.count,
182
+ allLabels: allNames
183
+ )
184
+ }
185
+
186
+ // MARK: - Switching
187
+
188
+ func switchTo(index: Int) {
189
+ guard index >= 0, index < layers.count else { return }
190
+ activeIndex = index
191
+
192
+ DesktopModel.shared.poll()
193
+
194
+ var resolved: [(wid: UInt32, pid: Int32)] = []
195
+ for i in layers[index].windows.indices {
196
+ if let r = resolve(&layers[index].windows[i]) {
197
+ resolved.append(r)
198
+ }
199
+ }
200
+
201
+ if !resolved.isEmpty {
202
+ WindowTiler.raiseWindowsAndReactivate(windows: resolved)
203
+ }
204
+
205
+ let allNames = layers.map(\.name)
206
+ LayerBezel.shared.show(
207
+ label: layers[index].name,
208
+ index: index,
209
+ total: layers.count,
210
+ allLabels: allNames
211
+ )
212
+
213
+ DiagnosticLog.shared.info("SessionLayerStore: switched to '\(layers[index].name)' (\(resolved.count)/\(layers[index].windows.count) resolved)")
214
+ }
215
+
216
+ func cycleNext() {
217
+ guard !layers.isEmpty else { return }
218
+ let next = (activeIndex + 1) % layers.count
219
+ switchTo(index: next)
220
+ }
221
+
222
+ func cyclePrev() {
223
+ guard !layers.isEmpty else { return }
224
+ let prev = activeIndex <= 0 ? layers.count - 1 : activeIndex - 1
225
+ switchTo(index: prev)
226
+ }
227
+
228
+ // MARK: - Resolution
229
+
230
+ private func resolve(_ ref: inout WindowRef) -> (wid: UInt32, pid: Int32)? {
231
+ // 1. Fast path: wid still valid
232
+ if let wid = ref.wid, let entry = DesktopModel.shared.windows[wid] {
233
+ ref.pid = entry.pid
234
+ ref.title = entry.title
235
+ ref.frame = entry.frame
236
+ return (wid, entry.pid)
237
+ }
238
+
239
+ // 2. Re-resolve by app + contentHint
240
+ if let entry = DesktopModel.shared.windowForApp(app: ref.app, title: ref.contentHint) {
241
+ ref.wid = entry.wid
242
+ ref.pid = entry.pid
243
+ ref.title = entry.title
244
+ ref.frame = entry.frame
245
+ DesktopModel.shared.assignLayer(wid: entry.wid, layerId: layerNameForRef(ref))
246
+ return (entry.wid, entry.pid)
247
+ }
248
+
249
+ // 3. Window not found — dormant
250
+ ref.wid = nil
251
+ ref.pid = nil
252
+ ref.title = nil
253
+ ref.frame = nil
254
+ return nil
255
+ }
256
+
257
+ private func layerNameForRef(_ ref: WindowRef) -> String {
258
+ for layer in layers {
259
+ if layer.windows.contains(where: { $0.id == ref.id }) {
260
+ return layer.name
261
+ }
262
+ }
263
+ return ""
264
+ }
265
+
266
+ // MARK: - Reconciliation
267
+
268
+ func reconcile() {
269
+ let desktop = DesktopModel.shared
270
+ for layerIdx in layers.indices {
271
+ for refIdx in layers[layerIdx].windows.indices {
272
+ let ref = layers[layerIdx].windows[refIdx]
273
+ guard let wid = ref.wid else { continue }
274
+ if desktop.windows[wid] == nil {
275
+ // Window gone — clear runtime, keep intent
276
+ layers[layerIdx].windows[refIdx].wid = nil
277
+ layers[layerIdx].windows[refIdx].pid = nil
278
+ layers[layerIdx].windows[refIdx].title = nil
279
+ layers[layerIdx].windows[refIdx].frame = nil
280
+ desktop.removeLayerTag(wid: wid)
281
+ }
282
+ }
283
+ }
284
+ }
285
+ }
@@ -2,12 +2,15 @@ import AppKit
2
2
 
3
3
  enum SessionManager {
4
4
  private static let latticesPath = "/opt/homebrew/bin/lattices"
5
- private static let tmuxPath = "/opt/homebrew/bin/tmux"
5
+ private static var tmuxPath: String { TmuxQuery.resolvedPath ?? "/opt/homebrew/bin/tmux" }
6
6
 
7
7
  /// Launch or reattach — if session is running, find and focus the existing window
8
8
  static func launch(project: Project) {
9
9
  let terminal = Preferences.shared.terminal
10
10
  if project.isRunning {
11
+ if let window = DesktopModel.shared.windowForSession(project.sessionName) {
12
+ DesktopModel.shared.markInteraction(wid: window.wid)
13
+ }
11
14
  terminal.focusOrAttach(session: project.sessionName)
12
15
  } else {
13
16
  terminal.launch(command: latticesPath, in: project.path)
@@ -71,6 +71,7 @@ struct SettingsContentView: View {
71
71
  // Tab bar
72
72
  HStack(spacing: 2) {
73
73
  settingsTab(label: "General", id: "general")
74
+ settingsTab(label: "AI", id: "ai")
74
75
  settingsTab(label: "Search & OCR", id: "search")
75
76
  settingsTab(label: "Shortcuts", id: "shortcuts")
76
77
  Spacer()
@@ -84,6 +85,7 @@ struct SettingsContentView: View {
84
85
  switch selectedTab {
85
86
  case "shortcuts": shortcutsContent
86
87
  case "search": searchOcrContent
88
+ case "ai": aiContent
87
89
  default: generalContent
88
90
  }
89
91
  }
@@ -269,6 +271,167 @@ struct SettingsContentView: View {
269
271
  }
270
272
  }
271
273
 
274
+ // MARK: - AI
275
+
276
+ private var aiContent: some View {
277
+ ScrollView {
278
+ VStack(spacing: 12) {
279
+ // ── Claude CLI ──
280
+ settingsCard {
281
+ VStack(alignment: .leading, spacing: 10) {
282
+ HStack(spacing: 8) {
283
+ Image(systemName: "sparkles")
284
+ .font(.system(size: 11, weight: .medium))
285
+ .foregroundColor(Palette.running)
286
+ Text("Claude CLI")
287
+ .font(Typo.mono(12))
288
+ .foregroundColor(Palette.text)
289
+ }
290
+
291
+ HStack(spacing: 6) {
292
+ TextField("Auto-detected", text: $prefs.claudePath)
293
+ .textFieldStyle(.plain)
294
+ .font(Typo.mono(11))
295
+ .foregroundColor(Palette.text)
296
+ .padding(.horizontal, 8)
297
+ .padding(.vertical, 5)
298
+ .background(
299
+ RoundedRectangle(cornerRadius: 5)
300
+ .fill(Color.white.opacity(0.06))
301
+ .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5))
302
+ )
303
+
304
+ Button {
305
+ if let resolved = Preferences.resolveClaudePath() {
306
+ prefs.claudePath = resolved
307
+ }
308
+ } label: {
309
+ Text("Detect")
310
+ .font(Typo.monoBold(10))
311
+ .foregroundColor(Palette.text)
312
+ .padding(.horizontal, 10)
313
+ .padding(.vertical, 4)
314
+ .background(
315
+ RoundedRectangle(cornerRadius: 4)
316
+ .fill(Palette.surfaceHov)
317
+ .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
318
+ )
319
+ }
320
+ .buttonStyle(.plain)
321
+ }
322
+
323
+ let resolved = Preferences.resolveClaudePath()
324
+ if let path = resolved {
325
+ Text("Found: \(path)")
326
+ .font(Typo.caption(9))
327
+ .foregroundColor(Palette.running.opacity(0.8))
328
+ } else {
329
+ Text("Not found — install with: npm i -g @anthropic-ai/claude-code")
330
+ .font(Typo.caption(9))
331
+ .foregroundColor(Palette.detach)
332
+ }
333
+ }
334
+ }
335
+
336
+ // ── Advisor ──
337
+ settingsCard {
338
+ VStack(alignment: .leading, spacing: 10) {
339
+ Text("Voice advisor")
340
+ .font(Typo.mono(11))
341
+ .foregroundColor(Palette.text)
342
+
343
+ HStack {
344
+ Text("Model")
345
+ .font(Typo.mono(10))
346
+ .foregroundColor(Palette.textDim)
347
+ Spacer()
348
+ Picker("", selection: $prefs.advisorModel) {
349
+ Text("Haiku").tag("haiku")
350
+ Text("Sonnet").tag("sonnet")
351
+ }
352
+ .pickerStyle(.segmented)
353
+ .labelsHidden()
354
+ .frame(width: 160)
355
+ }
356
+
357
+ Text("Haiku is fast and cheap. Sonnet is smarter but slower.")
358
+ .font(Typo.caption(9))
359
+ .foregroundColor(Palette.textMuted.opacity(0.7))
360
+
361
+ cardDivider
362
+
363
+ HStack {
364
+ Text("Budget per session")
365
+ .font(Typo.mono(10))
366
+ .foregroundColor(Palette.textDim)
367
+ Spacer()
368
+ HStack(spacing: 4) {
369
+ Text("$")
370
+ .font(Typo.mono(11))
371
+ .foregroundColor(Palette.textDim)
372
+ TextField("0.50", value: $prefs.advisorBudgetUSD, formatter: {
373
+ let f = NumberFormatter()
374
+ f.numberStyle = .decimal
375
+ f.minimumFractionDigits = 2
376
+ f.maximumFractionDigits = 2
377
+ return f
378
+ }())
379
+ .textFieldStyle(.plain)
380
+ .font(Typo.monoBold(11))
381
+ .foregroundColor(Palette.text)
382
+ .multilineTextAlignment(.center)
383
+ .frame(width: 50)
384
+ .padding(.horizontal, 4)
385
+ .padding(.vertical, 3)
386
+ .background(
387
+ RoundedRectangle(cornerRadius: 5)
388
+ .fill(Color.white.opacity(0.06))
389
+ .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5))
390
+ )
391
+ }
392
+ }
393
+
394
+ Text("Max spend per Claude CLI invocation")
395
+ .font(Typo.caption(9))
396
+ .foregroundColor(Palette.textMuted.opacity(0.7))
397
+
398
+ cardDivider
399
+
400
+ // Session stats
401
+ let stats = AgentPool.shared.haiku.sessionStats
402
+ HStack(spacing: 12) {
403
+ if stats.contextWindow > 0 {
404
+ HStack(spacing: 4) {
405
+ Circle()
406
+ .fill(stats.contextUsage > 0.6 ? Palette.detach : Palette.running)
407
+ .frame(width: 5, height: 5)
408
+ Text("Context: \(Int(stats.contextUsage * 100))%")
409
+ .font(Typo.mono(10))
410
+ .foregroundColor(Palette.textMuted)
411
+ }
412
+ }
413
+ if stats.costUSD > 0 {
414
+ Text("Session cost: $\(String(format: "%.3f", stats.costUSD))")
415
+ .font(Typo.mono(10))
416
+ .foregroundColor(Palette.textMuted)
417
+ }
418
+
419
+ Spacer()
420
+
421
+ let learningCount = AdvisorLearningStore.shared.entryCount
422
+ if learningCount > 0 {
423
+ Text("\(learningCount) learned")
424
+ .font(Typo.mono(9))
425
+ .foregroundColor(Palette.textMuted.opacity(0.6))
426
+ }
427
+ }
428
+ }
429
+ }
430
+ }
431
+ .padding(16)
432
+ }
433
+ }
434
+
272
435
  // MARK: - Search & OCR
273
436
 
274
437
  private func ocrNumField(_ value: Binding<Double>, width: CGFloat = 50) -> some View {
@@ -950,6 +1113,26 @@ struct SettingsContentView: View {
950
1113
  .padding(.vertical, 12)
951
1114
  }
952
1115
 
1116
+ Section(header: stickyHeader("Voice commands")) {
1117
+ VStack(alignment: .leading, spacing: 8) {
1118
+ flowStep("⌥", "Hold Option key to speak, release to stop")
1119
+ flowStep("⇥", "Tab to arm/disarm the mic")
1120
+ flowStep("⎋", "Escape to dismiss")
1121
+
1122
+ Text("Built-in commands: find, show, open, tile, kill, scan")
1123
+ .font(Typo.caption(10.5))
1124
+ .foregroundColor(Palette.textMuted)
1125
+ .padding(.top, 4)
1126
+
1127
+ Text("When local matching fails, Claude Haiku advises with follow-up suggestions. Configure the AI model and budget in Settings → AI.")
1128
+ .font(Typo.caption(10.5))
1129
+ .foregroundColor(Palette.textMuted)
1130
+ .lineSpacing(2)
1131
+ }
1132
+ .padding(.horizontal, 20)
1133
+ .padding(.vertical, 12)
1134
+ }
1135
+
953
1136
  Section(header: stickyHeader("Reference")) {
954
1137
  HStack(spacing: 8) {
955
1138
  docsLinkButton(icon: "doc.text", label: "Config format", file: "config.md")
@@ -1015,13 +1198,13 @@ struct SettingsContentView: View {
1015
1198
  }
1016
1199
 
1017
1200
  private func resolveDocsFile(_ file: String) -> String {
1018
- let devPath = "/Users/arach/dev/lattice/docs/\(file)"
1019
- if FileManager.default.fileExists(atPath: devPath) { return devPath }
1020
1201
  let bundle = Bundle.main.bundlePath
1021
1202
  let appDir = (bundle as NSString).deletingLastPathComponent
1022
1203
  let docsPath = ((appDir as NSString).appendingPathComponent("../docs/\(file)") as NSString).standardizingPath
1023
1204
  if FileManager.default.fileExists(atPath: docsPath) { return docsPath }
1024
- return devPath
1205
+ // Fallback: look relative to the repo root (dev builds)
1206
+ let repoGuess = ((appDir as NSString).appendingPathComponent("../../docs/\(file)") as NSString).standardizingPath
1207
+ return FileManager.default.fileExists(atPath: repoGuess) ? repoGuess : docsPath
1025
1208
  }
1026
1209
 
1027
1210
  // MARK: - Shared helpers
@@ -3,17 +3,18 @@ import SwiftUI
3
3
  // MARK: - Colors
4
4
 
5
5
  enum Palette {
6
- // Base surfaces — warm dark
7
- static let bg = Color(red: 0.11, green: 0.11, blue: 0.12) // #1C1C1E
8
- static let surface = Color(red: 0.15, green: 0.15, blue: 0.16) // Raised cards
9
- static let surfaceHov = Color(red: 0.18, green: 0.18, blue: 0.19) // Hovered cards
10
- static let border = Color.white.opacity(0.05)
11
- static let borderLit = Color.white.opacity(0.10)
6
+ // Base surfaces
7
+ static let bg = Color(red: 0.08, green: 0.08, blue: 0.09) // #141416
8
+ static let bgSidebar = Color(red: 0.08, green: 0.08, blue: 0.09) // same as bg
9
+ static let surface = Color(white: 0.10) // Raised cards
10
+ static let surfaceHov = Color(white: 0.14) // Hovered cards
11
+ static let border = Color.white.opacity(0.08)
12
+ static let borderLit = Color.white.opacity(0.14)
12
13
 
13
14
  // Text
14
15
  static let text = Color.white.opacity(0.92)
15
- static let textDim = Color.white.opacity(0.50)
16
- static let textMuted = Color.white.opacity(0.30)
16
+ static let textDim = Color.white.opacity(0.58)
17
+ static let textMuted = Color.white.opacity(0.40)
17
18
 
18
19
  // Functional accents
19
20
  static let running = Color(red: 0.20, green: 0.78, blue: 0.45) // Green
@@ -4,10 +4,17 @@ final class TmuxModel: ObservableObject {
4
4
  static let shared = TmuxModel()
5
5
 
6
6
  @Published private(set) var sessions: [TmuxSession] = []
7
+ @Published private(set) var isAvailable: Bool = TmuxQuery.isAvailable
7
8
  private var timer: Timer?
8
9
 
9
10
  func start(interval: TimeInterval = 3.0) {
10
11
  guard timer == nil else { return }
12
+
13
+ if !isAvailable {
14
+ DiagnosticLog.shared.warn("TmuxModel: tmux not found — session features disabled")
15
+ return
16
+ }
17
+
11
18
  DiagnosticLog.shared.info("TmuxModel: starting (interval=\(interval)s)")
12
19
  poll()
13
20
  timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
@@ -23,20 +23,44 @@ struct TmuxPane: Identifiable {
23
23
  // MARK: - Query
24
24
 
25
25
  enum TmuxQuery {
26
- private static let tmuxPath = "/opt/homebrew/bin/tmux"
26
+ /// Resolved path to the tmux binary, or nil if not found
27
+ static let resolvedPath: String? = {
28
+ let candidates = [
29
+ "/opt/homebrew/bin/tmux", // Apple Silicon Homebrew
30
+ "/usr/local/bin/tmux", // Intel Homebrew
31
+ "/usr/bin/tmux", // unlikely on macOS, but check
32
+ "/opt/local/bin/tmux", // MacPorts
33
+ ]
34
+ for path in candidates {
35
+ if FileManager.default.isExecutableFile(atPath: path) {
36
+ return path
37
+ }
38
+ }
39
+ // Fall back to PATH lookup via /usr/bin/which
40
+ let result = ProcessQuery.shell(["/usr/bin/which", "tmux"]).trimmingCharacters(in: .whitespacesAndNewlines)
41
+ if !result.isEmpty && FileManager.default.isExecutableFile(atPath: result) {
42
+ return result
43
+ }
44
+ return nil
45
+ }()
46
+
47
+ /// Whether tmux is available on this system
48
+ static var isAvailable: Bool { resolvedPath != nil }
27
49
 
28
50
  /// List all tmux sessions with their panes in exactly 2 shell calls
29
51
  static func listSessions() -> [TmuxSession] {
52
+ guard let tmux = resolvedPath else { return [] }
53
+
30
54
  // Query 1: all sessions
31
55
  let sessionsRaw = shell([
32
- tmuxPath, "list-sessions", "-F",
56
+ tmux, "list-sessions", "-F",
33
57
  "#{session_name}\t#{session_windows}\t#{session_created}\t#{session_attached}"
34
58
  ])
35
59
  guard !sessionsRaw.isEmpty else { return [] }
36
60
 
37
61
  // Query 2: all panes across all sessions
38
62
  let panesRaw = shell([
39
- tmuxPath, "list-panes", "-a", "-F",
63
+ tmux, "list-panes", "-a", "-F",
40
64
  "#{session_name}\t#{window_index}\t#{window_name}\t#{pane_id}\t#{pane_title}\t#{pane_current_command}\t#{pane_pid}\t#{pane_active}"
41
65
  ])
42
66