@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
@@ -0,0 +1,348 @@
1
+ import AppKit
2
+ import SwiftUI
3
+
4
+ // MARK: - LauncherHUD (singleton window controller)
5
+
6
+ final class LauncherHUD {
7
+ static let shared = LauncherHUD()
8
+
9
+ private var panel: NSPanel?
10
+ private var localMonitor: Any?
11
+ private var globalMonitor: Any?
12
+
13
+ var isVisible: Bool { panel?.isVisible ?? false }
14
+
15
+ func toggle() {
16
+ if isVisible { dismiss() } else { show() }
17
+ }
18
+
19
+ func show() {
20
+ guard panel == nil else { return }
21
+
22
+ // Ensure projects are fresh
23
+ ProjectScanner.shared.scan()
24
+
25
+ let view = LauncherView(dismiss: { [weak self] in self?.dismiss() })
26
+ .preferredColorScheme(.dark)
27
+
28
+ let hosting = NSHostingView(rootView: view)
29
+
30
+ let p = NSPanel(
31
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 480),
32
+ styleMask: [.borderless, .nonactivatingPanel],
33
+ backing: .buffered,
34
+ defer: false
35
+ )
36
+ p.isOpaque = false
37
+ p.backgroundColor = .clear
38
+ p.level = .floating
39
+ p.hasShadow = true
40
+ p.hidesOnDeactivate = false
41
+ p.isReleasedWhenClosed = false
42
+ p.isMovableByWindowBackground = false
43
+ p.contentView = hosting
44
+
45
+ // Center on mouse screen
46
+ let mouseLocation = NSEvent.mouseLocation
47
+ let screen = NSScreen.screens.first(where: { $0.frame.contains(mouseLocation) }) ?? NSScreen.main ?? NSScreen.screens.first!
48
+ let screenFrame = screen.visibleFrame
49
+ let x = screenFrame.midX - 210
50
+ let y = screenFrame.midY - 240 + (screenFrame.height * 0.08)
51
+ p.setFrameOrigin(NSPoint(x: x, y: y))
52
+
53
+ p.alphaValue = 0
54
+ p.orderFrontRegardless()
55
+
56
+ NSAnimationContext.runAnimationGroup { ctx in
57
+ ctx.duration = 0.12
58
+ p.animator().alphaValue = 1.0
59
+ }
60
+
61
+ self.panel = p
62
+ installMonitors()
63
+ }
64
+
65
+ func dismiss() {
66
+ guard let p = panel else { return }
67
+ removeMonitors()
68
+
69
+ NSAnimationContext.runAnimationGroup({ ctx in
70
+ ctx.duration = 0.15
71
+ p.animator().alphaValue = 0
72
+ }) { [weak self] in
73
+ p.orderOut(nil)
74
+ self?.panel = nil
75
+ }
76
+ }
77
+
78
+ // MARK: - Event monitors
79
+
80
+ private func installMonitors() {
81
+ localMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in
82
+ if event.keyCode == 53 { // Escape
83
+ self?.dismiss()
84
+ }
85
+ }
86
+ globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
87
+ // Don't dismiss if clicking inside the panel
88
+ guard let panel = self?.panel else { return }
89
+ let loc = event.locationInWindow
90
+ if !panel.frame.contains(NSEvent.mouseLocation) {
91
+ self?.dismiss()
92
+ }
93
+ }
94
+ }
95
+
96
+ private func removeMonitors() {
97
+ if let m = localMonitor { NSEvent.removeMonitor(m); localMonitor = nil }
98
+ if let m = globalMonitor { NSEvent.removeMonitor(m); globalMonitor = nil }
99
+ }
100
+ }
101
+
102
+ // MARK: - LauncherView
103
+
104
+ struct LauncherView: View {
105
+ var dismiss: () -> Void
106
+
107
+ @ObservedObject private var scanner = ProjectScanner.shared
108
+ @ObservedObject private var tmux = TmuxModel.shared
109
+ @State private var query = ""
110
+ @State private var selectedIndex = 0
111
+ @State private var hoveredId: String?
112
+
113
+ private var filtered: [Project] {
114
+ if query.isEmpty { return scanner.projects }
115
+ let q = query.lowercased()
116
+ return scanner.projects.filter {
117
+ $0.name.lowercased().contains(q) ||
118
+ ($0.paneSummary ?? "").lowercased().contains(q)
119
+ }
120
+ }
121
+
122
+ var body: some View {
123
+ VStack(spacing: 0) {
124
+ // Search bar
125
+ HStack(spacing: 10) {
126
+ Image(systemName: "magnifyingglass")
127
+ .font(.system(size: 13))
128
+ .foregroundColor(Palette.textMuted)
129
+
130
+ ZStack(alignment: .leading) {
131
+ if query.isEmpty {
132
+ Text("Launch a project...")
133
+ .font(Typo.mono(13))
134
+ .foregroundColor(Palette.textMuted)
135
+ }
136
+ TextField("", text: $query)
137
+ .font(Typo.mono(13))
138
+ .foregroundColor(Palette.text)
139
+ .textFieldStyle(.plain)
140
+ .onSubmit { launchSelected() }
141
+ }
142
+
143
+ if !query.isEmpty {
144
+ Button {
145
+ query = ""
146
+ selectedIndex = 0
147
+ } label: {
148
+ Image(systemName: "xmark.circle.fill")
149
+ .font(.system(size: 12))
150
+ .foregroundColor(Palette.textMuted)
151
+ }
152
+ .buttonStyle(.plain)
153
+ }
154
+ }
155
+ .padding(.horizontal, 16)
156
+ .padding(.vertical, 12)
157
+
158
+ Rectangle().fill(Palette.border).frame(height: 0.5)
159
+
160
+ // Project list
161
+ if filtered.isEmpty {
162
+ Spacer()
163
+ VStack(spacing: 8) {
164
+ Image(systemName: "folder.badge.questionmark")
165
+ .font(.system(size: 24))
166
+ .foregroundColor(Palette.textMuted)
167
+ Text(scanner.projects.isEmpty ? "No projects found" : "No matches")
168
+ .font(Typo.mono(12))
169
+ .foregroundColor(Palette.textMuted)
170
+ if scanner.projects.isEmpty {
171
+ Text("Add .lattices.json to your projects")
172
+ .font(Typo.mono(10))
173
+ .foregroundColor(Palette.textMuted.opacity(0.6))
174
+ }
175
+ }
176
+ Spacer()
177
+ } else {
178
+ ScrollViewReader { proxy in
179
+ ScrollView {
180
+ LazyVStack(spacing: 2) {
181
+ ForEach(Array(filtered.enumerated()), id: \.element.id) { index, project in
182
+ projectRow(project, index: index)
183
+ .id(project.id)
184
+ }
185
+ }
186
+ .padding(.vertical, 6)
187
+ .padding(.horizontal, 8)
188
+ }
189
+ .onChange(of: selectedIndex) { newVal in
190
+ if let project = filtered[safe: newVal] {
191
+ proxy.scrollTo(project.id, anchor: .center)
192
+ }
193
+ }
194
+ }
195
+ }
196
+
197
+ Rectangle().fill(Palette.border).frame(height: 0.5)
198
+
199
+ // Footer
200
+ HStack(spacing: 16) {
201
+ HStack(spacing: 4) {
202
+ keyBadge("↑↓")
203
+ Text("Navigate")
204
+ .font(Typo.mono(10))
205
+ .foregroundColor(Palette.textMuted)
206
+ }
207
+ HStack(spacing: 4) {
208
+ keyBadge("↵")
209
+ Text("Launch")
210
+ .font(Typo.mono(10))
211
+ .foregroundColor(Palette.textMuted)
212
+ }
213
+ HStack(spacing: 4) {
214
+ keyBadge("ESC")
215
+ Text("Close")
216
+ .font(Typo.mono(10))
217
+ .foregroundColor(Palette.textMuted)
218
+ }
219
+ Spacer()
220
+ Text("\(filtered.count) project\(filtered.count == 1 ? "" : "s")")
221
+ .font(Typo.mono(10))
222
+ .foregroundColor(Palette.textMuted)
223
+ }
224
+ .padding(.horizontal, 16)
225
+ .padding(.vertical, 8)
226
+ }
227
+ .frame(width: 420, height: 480)
228
+ .background(
229
+ RoundedRectangle(cornerRadius: 12)
230
+ .fill(Palette.bg)
231
+ .overlay(
232
+ RoundedRectangle(cornerRadius: 12)
233
+ .strokeBorder(Palette.borderLit, lineWidth: 0.5)
234
+ )
235
+ )
236
+ .clipShape(RoundedRectangle(cornerRadius: 12))
237
+ .onChange(of: query) { _ in selectedIndex = 0 }
238
+ }
239
+
240
+ // MARK: - Project row
241
+
242
+ private func projectRow(_ project: Project, index: Int) -> some View {
243
+ let isSelected = index == selectedIndex
244
+ let isHovered = hoveredId == project.id
245
+
246
+ return Button {
247
+ launch(project)
248
+ } label: {
249
+ HStack(spacing: 10) {
250
+ // Status dot
251
+ Circle()
252
+ .fill(project.isRunning ? Palette.running : Palette.textMuted.opacity(0.3))
253
+ .frame(width: 7, height: 7)
254
+
255
+ // Name + pane info
256
+ VStack(alignment: .leading, spacing: 2) {
257
+ Text(project.name)
258
+ .font(Typo.monoBold(12))
259
+ .foregroundColor(Palette.text)
260
+ .lineLimit(1)
261
+
262
+ HStack(spacing: 6) {
263
+ if !project.paneSummary.isEmpty {
264
+ let summary = project.paneSummary
265
+ Text(summary)
266
+ .font(Typo.mono(10))
267
+ .foregroundColor(Palette.textDim)
268
+ .lineLimit(1)
269
+ }
270
+ }
271
+ }
272
+
273
+ Spacer()
274
+
275
+ // Status badge
276
+ if project.isRunning {
277
+ Text("running")
278
+ .font(Typo.mono(9))
279
+ .foregroundColor(Palette.running)
280
+ .padding(.horizontal, 6)
281
+ .padding(.vertical, 2)
282
+ .background(
283
+ RoundedRectangle(cornerRadius: 3)
284
+ .fill(Palette.running.opacity(0.10))
285
+ )
286
+ } else {
287
+ Image(systemName: "play.fill")
288
+ .font(.system(size: 9))
289
+ .foregroundColor(isSelected || isHovered ? Palette.text : Palette.textMuted)
290
+ }
291
+ }
292
+ .padding(.horizontal, 10)
293
+ .padding(.vertical, 8)
294
+ .background(
295
+ RoundedRectangle(cornerRadius: 6)
296
+ .fill(isSelected ? Palette.surfaceHov : (isHovered ? Palette.surface : Color.clear))
297
+ .overlay(
298
+ RoundedRectangle(cornerRadius: 6)
299
+ .strokeBorder(isSelected ? Palette.borderLit : Color.clear, lineWidth: 0.5)
300
+ )
301
+ )
302
+ }
303
+ .buttonStyle(.plain)
304
+ .onHover { over in hoveredId = over ? project.id : nil }
305
+ }
306
+
307
+ // MARK: - Actions
308
+
309
+ private func moveSelection(_ delta: Int) {
310
+ let count = filtered.count
311
+ guard count > 0 else { return }
312
+ selectedIndex = (selectedIndex + delta + count) % count
313
+ }
314
+
315
+ private func launchSelected() {
316
+ guard let project = filtered[safe: selectedIndex] else { return }
317
+ launch(project)
318
+ }
319
+
320
+ private func launch(_ project: Project) {
321
+ SessionManager.launch(project: project)
322
+ dismiss()
323
+ }
324
+
325
+ private func keyBadge(_ key: String) -> some View {
326
+ Text(key)
327
+ .font(Typo.geistMonoBold(9))
328
+ .foregroundColor(Palette.text)
329
+ .padding(.horizontal, 5)
330
+ .padding(.vertical, 2)
331
+ .background(
332
+ RoundedRectangle(cornerRadius: 3)
333
+ .fill(Palette.surface)
334
+ .overlay(
335
+ RoundedRectangle(cornerRadius: 3)
336
+ .strokeBorder(Palette.border, lineWidth: 0.5)
337
+ )
338
+ )
339
+ }
340
+ }
341
+
342
+ // MARK: - Safe array subscript
343
+
344
+ private extension Array {
345
+ subscript(safe index: Int) -> Element? {
346
+ indices.contains(index) ? self[index] : nil
347
+ }
348
+ }
@@ -1,7 +1,13 @@
1
1
  import SwiftUI
2
2
 
3
+ enum MainViewLayout {
4
+ case popover
5
+ case embedded
6
+ }
7
+
3
8
  struct MainView: View {
4
9
  @ObservedObject var scanner: ProjectScanner
10
+ var layout: MainViewLayout = .popover
5
11
  @StateObject private var prefs = Preferences.shared
6
12
  @StateObject private var permChecker = PermissionChecker.shared
7
13
  @ObservedObject private var workspace = WorkspaceManager.shared
@@ -9,6 +15,8 @@ struct MainView: View {
9
15
  @State private var searchText = ""
10
16
  @State private var hasCheckedSetup = false
11
17
  @State private var bannerDismissed = false
18
+ @State private var tmuxBannerDismissed = false
19
+ @ObservedObject private var tmuxModel = TmuxModel.shared
12
20
  @State private var orphanSectionCollapsed = true
13
21
  private var filtered: [Project] {
14
22
  if searchText.isEmpty { return scanner.projects }
@@ -31,7 +39,14 @@ struct MainView: View {
31
39
  VStack(spacing: 0) {
32
40
  mainContent
33
41
  }
34
- .frame(minWidth: 380, idealWidth: 380, maxWidth: 600, minHeight: 460, idealHeight: 460, maxHeight: .infinity)
42
+ .frame(
43
+ minWidth: layout == .popover ? 380 : 0,
44
+ idealWidth: layout == .popover ? 380 : nil,
45
+ maxWidth: .infinity,
46
+ minHeight: layout == .popover ? 520 : 0,
47
+ idealHeight: layout == .popover ? 560 : nil,
48
+ maxHeight: .infinity
49
+ )
35
50
  .background(PanelBackground())
36
51
  .preferredColorScheme(.dark)
37
52
  .onAppear {
@@ -61,36 +76,32 @@ struct MainView: View {
61
76
 
62
77
  private var mainContent: some View {
63
78
  VStack(spacing: 0) {
64
- // Title bar
65
- HStack {
66
- Text("lattices")
67
- .font(Typo.title())
68
- .foregroundColor(Palette.text)
79
+ if layout == .popover {
80
+ HStack {
81
+ Text("Lattices")
82
+ .font(Typo.mono(14))
83
+ .foregroundColor(Palette.text)
69
84
 
70
- if runningCount > 0 || !inventory.orphans.isEmpty {
71
- let total = runningCount + inventory.orphans.count
72
- Text("\(total) session\(total == 1 ? "" : "s")")
73
- .font(Typo.mono(10))
74
- .foregroundColor(Palette.running)
75
- .padding(.leading, 4)
76
- } else {
77
- Text("None")
78
- .font(Typo.mono(10))
79
- .foregroundColor(Palette.textMuted)
80
- .padding(.leading, 4)
81
- }
82
-
83
- Spacer()
85
+ Spacer()
84
86
 
85
- headerButton(icon: "arrow.up.left.and.arrow.down.right") {
86
- (NSApp.delegate as? AppDelegate)?.dismissPopover()
87
- MainWindow.shared.show()
87
+ headerButton(icon: "house") {
88
+ (NSApp.delegate as? AppDelegate)?.dismissPopover()
89
+ ScreenMapWindowController.shared.showPage(.home)
90
+ }
91
+ headerButton(icon: "terminal") {
92
+ (NSApp.delegate as? AppDelegate)?.dismissPopover()
93
+ ScreenMapWindowController.shared.showPage(.pi)
94
+ }
95
+ headerButton(icon: "arrow.up.left.and.arrow.down.right") {
96
+ (NSApp.delegate as? AppDelegate)?.dismissPopover()
97
+ MainWindow.shared.show()
98
+ }
99
+ headerButton(icon: "arrow.clockwise") { scanner.scan(); inventory.refresh() }
88
100
  }
89
- headerButton(icon: "arrow.clockwise") { scanner.scan(); inventory.refresh() }
101
+ .padding(.horizontal, 18)
102
+ .padding(.top, 18)
103
+ .padding(.bottom, 12)
90
104
  }
91
- .padding(.horizontal, 18)
92
- .padding(.top, 14)
93
- .padding(.bottom, 10)
94
105
 
95
106
  // Layer switcher
96
107
  if let config = workspace.config, let layers = config.layers, layers.count > 1 {
@@ -129,6 +140,11 @@ struct MainView: View {
129
140
  permissionBanner
130
141
  }
131
142
 
143
+ // tmux not-found banner
144
+ if !tmuxModel.isAvailable && !tmuxBannerDismissed {
145
+ tmuxBanner
146
+ }
147
+
132
148
  Rectangle()
133
149
  .fill(Palette.border)
134
150
  .frame(height: 0.5)
@@ -195,6 +211,13 @@ struct MainView: View {
195
211
 
196
212
  // Actions footer
197
213
  actionsSection
214
+
215
+ Rectangle()
216
+ .fill(Palette.border)
217
+ .frame(height: 0.5)
218
+
219
+ // Bottom bar
220
+ bottomBar
198
221
  }
199
222
  }
200
223
 
@@ -278,38 +301,54 @@ struct MainView: View {
278
301
  ActionRow(shortcut: "6", label: "Omni Search", hotkey: hotkeyLabel(.omniSearch), icon: "magnifyingglass", accentColor: Palette.running) {
279
302
  OmniSearchWindow.shared.toggle()
280
303
  }
304
+ ActionRow(shortcut: "7", label: "Voice Command", hotkey: hotkeyLabel(.voiceCommand), icon: "mic", accentColor: AudioLayer.shared.isListening ? Palette.running : Palette.textDim) {
305
+ let audio = AudioLayer.shared
306
+ if audio.isListening { audio.stopVoiceCommand() } else { audio.startVoiceCommand() }
307
+ }
308
+ }
309
+ .padding(.vertical, 4)
310
+ .background(Palette.surface.opacity(0.4))
311
+ }
281
312
 
282
- Rectangle()
283
- .fill(Palette.border)
284
- .frame(height: 0.5)
285
- .padding(.horizontal, 10)
286
-
287
- ActionRow(shortcut: "S", label: "Settings", icon: "gearshape") {
313
+ private var bottomBar: some View {
314
+ HStack(spacing: 16) {
315
+ bottomBarButton(icon: "gearshape", label: "Settings") {
288
316
  SettingsWindowController.shared.show()
289
317
  }
290
- HStack(spacing: 0) {
291
- ActionRow(shortcut: "D", label: "Diagnostics", icon: "stethoscope") {
318
+
319
+ HStack(spacing: 4) {
320
+ bottomBarButton(icon: "stethoscope", label: "Diagnostics") {
292
321
  DiagnosticWindow.shared.toggle()
293
322
  }
294
323
  if !permChecker.allGranted {
295
324
  Circle()
296
325
  .fill(Palette.detach)
297
- .frame(width: 6, height: 6)
298
- .padding(.trailing, 14)
326
+ .frame(width: 5, height: 5)
299
327
  }
300
328
  }
301
329
 
302
- Rectangle()
303
- .fill(Palette.border)
304
- .frame(height: 0.5)
305
- .padding(.horizontal, 10)
330
+ Spacer()
306
331
 
307
- ActionRow(shortcut: "Q", label: "Quit", icon: "power", accentColor: Palette.kill) {
332
+ bottomBarButton(icon: "power", label: "Quit", color: Palette.kill) {
308
333
  NSApp.terminate(nil)
309
334
  }
310
335
  }
311
- .padding(.vertical, 4)
312
- .background(Palette.surface.opacity(0.4))
336
+ .padding(.horizontal, 16)
337
+ .padding(.vertical, 8)
338
+ .background(Palette.bg)
339
+ }
340
+
341
+ private func bottomBarButton(icon: String, label: String, color: Color = Palette.textMuted, action: @escaping () -> Void) -> some View {
342
+ Button(action: action) {
343
+ HStack(spacing: 4) {
344
+ Image(systemName: icon)
345
+ .font(.system(size: 10, weight: .medium))
346
+ Text(label)
347
+ .font(Typo.mono(9))
348
+ }
349
+ .foregroundColor(color)
350
+ }
351
+ .buttonStyle(.plain)
313
352
  }
314
353
 
315
354
  private func hotkeyLabel(_ action: HotkeyAction) -> String? {
@@ -381,6 +420,70 @@ struct MainView: View {
381
420
  .padding(.bottom, 10)
382
421
  }
383
422
 
423
+ // MARK: - tmux banner
424
+
425
+ private var tmuxBanner: some View {
426
+ VStack(alignment: .leading, spacing: 6) {
427
+ HStack {
428
+ Image(systemName: "terminal")
429
+ .font(.system(size: 10))
430
+ .foregroundColor(Palette.detach)
431
+ Text("TMUX NOT FOUND")
432
+ .font(Typo.monoBold(10))
433
+ .foregroundColor(Palette.detach)
434
+ Spacer()
435
+ Button { tmuxBannerDismissed = true } label: {
436
+ Image(systemName: "xmark")
437
+ .font(.system(size: 8, weight: .bold))
438
+ .foregroundColor(Palette.textMuted)
439
+ }
440
+ .buttonStyle(.plain)
441
+ }
442
+
443
+ Text("Session management requires tmux. Install it with Homebrew:")
444
+ .font(Typo.mono(10))
445
+ .foregroundColor(Palette.text)
446
+
447
+ Button {
448
+ let task = Process()
449
+ task.executableURL = URL(fileURLWithPath: "/bin/zsh")
450
+ task.arguments = ["-lc", "brew install tmux"]
451
+ task.standardOutput = FileHandle.nullDevice
452
+ task.standardError = FileHandle.nullDevice
453
+ try? task.run()
454
+ } label: {
455
+ HStack(spacing: 6) {
456
+ Image(systemName: "arrow.down.circle")
457
+ .font(.system(size: 10))
458
+ Text("brew install tmux")
459
+ .font(Typo.monoBold(10))
460
+ }
461
+ .padding(.vertical, 4)
462
+ .padding(.horizontal, 8)
463
+ .background(
464
+ RoundedRectangle(cornerRadius: 4)
465
+ .fill(Palette.detach.opacity(0.06))
466
+ )
467
+ }
468
+ .buttonStyle(.plain)
469
+
470
+ Text("Window tiling, search, and OCR work without tmux.")
471
+ .font(Typo.mono(9))
472
+ .foregroundColor(Palette.textMuted)
473
+ }
474
+ .padding(12)
475
+ .background(
476
+ RoundedRectangle(cornerRadius: 5)
477
+ .fill(Palette.detach.opacity(0.08))
478
+ .overlay(
479
+ RoundedRectangle(cornerRadius: 5)
480
+ .strokeBorder(Palette.detach.opacity(0.20), lineWidth: 0.5)
481
+ )
482
+ )
483
+ .padding(.horizontal, 14)
484
+ .padding(.bottom, 10)
485
+ }
486
+
384
487
  private func permissionRow(_ name: String, granted: Bool, open: @escaping () -> Void) -> some View {
385
488
  Button(action: { if !granted { open() } }) {
386
489
  HStack(spacing: 6) {
@@ -60,8 +60,11 @@ final class OcrModel: ObservableObject {
60
60
  return
61
61
  }
62
62
  let deepInterval = prefs.ocrDeepInterval
63
- // Defer initial scan — let the first timer tick handle it (grace period on launch)
64
63
  DiagnosticLog.shared.info("OcrModel: starting (quick=\(self.interval)s/\(prefs.ocrQuickLimit)win, deep=\(deepInterval)s/\(prefs.ocrDeepLimit)win)")
64
+ // Run initial scan immediately so search works right away
65
+ DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 2) { [weak self] in
66
+ self?.quickScan()
67
+ }
65
68
  timer = Timer.scheduledTimer(withTimeInterval: self.interval, repeats: true) { [weak self] _ in
66
69
  guard let self, self.enabled else { return }
67
70
  self.quickScan()
@@ -90,6 +93,36 @@ final class OcrModel: ObservableObject {
90
93
  }
91
94
  }
92
95
 
96
+ // MARK: - Single Window Scan
97
+
98
+ /// Scan a single window by wid (AX extraction, instant).
99
+ func scanSingle(wid: UInt32) {
100
+ guard let entry = DesktopModel.shared.windows[wid] else { return }
101
+ queue.async { [weak self] in
102
+ guard let self else { return }
103
+ if let axResult = self.axExtractor.extract(pid: entry.pid, wid: wid) {
104
+ let blocks = axResult.texts.map { text in
105
+ OcrTextBlock(text: text, confidence: 1.0, boundingBox: .zero)
106
+ }
107
+ let result = OcrWindowResult(
108
+ wid: wid,
109
+ app: entry.app,
110
+ title: entry.title,
111
+ frame: entry.frame,
112
+ texts: blocks,
113
+ fullText: axResult.fullText,
114
+ timestamp: Date(),
115
+ source: .accessibility
116
+ )
117
+ OcrStore.shared.insert(results: [result])
118
+ DispatchQueue.main.async {
119
+ self.results[wid] = result
120
+ DiagnosticLog.shared.info("OcrModel: single scan wid=\(wid) → \(axResult.texts.count) blocks")
121
+ }
122
+ }
123
+ }
124
+ }
125
+
93
126
  // MARK: - Scan
94
127
 
95
128
  /// Quick scan: AX-only text extraction for topmost windows (called every 60s).