@lattices/cli 0.3.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 (74) hide show
  1. package/README.md +155 -0
  2. package/app/Lattices.app/Contents/Info.plist +24 -0
  3. package/app/Package.swift +13 -0
  4. package/app/Sources/AccessibilityTextExtractor.swift +111 -0
  5. package/app/Sources/ActionRow.swift +61 -0
  6. package/app/Sources/App.swift +10 -0
  7. package/app/Sources/AppDelegate.swift +242 -0
  8. package/app/Sources/AppShellView.swift +62 -0
  9. package/app/Sources/AppTypeClassifier.swift +70 -0
  10. package/app/Sources/AppWindowShell.swift +63 -0
  11. package/app/Sources/CheatSheetHUD.swift +332 -0
  12. package/app/Sources/CommandModeState.swift +1362 -0
  13. package/app/Sources/CommandModeView.swift +1405 -0
  14. package/app/Sources/CommandModeWindow.swift +192 -0
  15. package/app/Sources/CommandPaletteView.swift +307 -0
  16. package/app/Sources/CommandPaletteWindow.swift +134 -0
  17. package/app/Sources/DaemonProtocol.swift +101 -0
  18. package/app/Sources/DaemonServer.swift +414 -0
  19. package/app/Sources/DesktopModel.swift +149 -0
  20. package/app/Sources/DesktopModelTypes.swift +71 -0
  21. package/app/Sources/DiagnosticLog.swift +271 -0
  22. package/app/Sources/EventBus.swift +30 -0
  23. package/app/Sources/HotkeyManager.swift +254 -0
  24. package/app/Sources/HotkeyStore.swift +338 -0
  25. package/app/Sources/InventoryManager.swift +35 -0
  26. package/app/Sources/InventoryPath.swift +43 -0
  27. package/app/Sources/KeyRecorderView.swift +210 -0
  28. package/app/Sources/LatticesApi.swift +1234 -0
  29. package/app/Sources/LayerBezel.swift +203 -0
  30. package/app/Sources/MainView.swift +479 -0
  31. package/app/Sources/MainWindow.swift +83 -0
  32. package/app/Sources/OcrModel.swift +430 -0
  33. package/app/Sources/OcrStore.swift +329 -0
  34. package/app/Sources/OmniSearchState.swift +283 -0
  35. package/app/Sources/OmniSearchView.swift +288 -0
  36. package/app/Sources/OmniSearchWindow.swift +105 -0
  37. package/app/Sources/OrphanRow.swift +129 -0
  38. package/app/Sources/PaletteCommand.swift +419 -0
  39. package/app/Sources/PermissionChecker.swift +125 -0
  40. package/app/Sources/Preferences.swift +99 -0
  41. package/app/Sources/ProcessModel.swift +199 -0
  42. package/app/Sources/ProcessQuery.swift +151 -0
  43. package/app/Sources/Project.swift +28 -0
  44. package/app/Sources/ProjectRow.swift +368 -0
  45. package/app/Sources/ProjectScanner.swift +128 -0
  46. package/app/Sources/ScreenMapState.swift +2387 -0
  47. package/app/Sources/ScreenMapView.swift +2820 -0
  48. package/app/Sources/ScreenMapWindowController.swift +89 -0
  49. package/app/Sources/SessionManager.swift +72 -0
  50. package/app/Sources/SettingsView.swift +1064 -0
  51. package/app/Sources/SettingsWindow.swift +20 -0
  52. package/app/Sources/TabGroupRow.swift +178 -0
  53. package/app/Sources/Terminal.swift +259 -0
  54. package/app/Sources/TerminalQuery.swift +156 -0
  55. package/app/Sources/TerminalSynthesizer.swift +200 -0
  56. package/app/Sources/Theme.swift +163 -0
  57. package/app/Sources/TilePickerView.swift +209 -0
  58. package/app/Sources/TmuxModel.swift +53 -0
  59. package/app/Sources/TmuxQuery.swift +81 -0
  60. package/app/Sources/WindowTiler.swift +1778 -0
  61. package/app/Sources/WorkspaceManager.swift +575 -0
  62. package/bin/client.js +4 -0
  63. package/bin/daemon-client.js +187 -0
  64. package/bin/lattices-app.js +221 -0
  65. package/bin/lattices.js +1551 -0
  66. package/docs/api.md +924 -0
  67. package/docs/app.md +297 -0
  68. package/docs/concepts.md +135 -0
  69. package/docs/config.md +245 -0
  70. package/docs/layers.md +410 -0
  71. package/docs/ocr.md +185 -0
  72. package/docs/overview.md +94 -0
  73. package/docs/quickstart.md +75 -0
  74. package/package.json +42 -0
@@ -0,0 +1,368 @@
1
+ import SwiftUI
2
+
3
+ struct ProjectRow: View {
4
+ let project: Project
5
+ let onLaunch: () -> Void
6
+ let onDetach: () -> Void
7
+ let onKill: () -> Void
8
+ let onSync: () -> Void
9
+ let onRestart: (String?) -> Void
10
+
11
+ @State private var isHovered = false
12
+ @State private var showCoach = false
13
+ @State private var showTilePicker = false
14
+ @State private var contextSpaces: [SpaceInfo] = []
15
+ @State private var windowInfo: WindowTiler.WindowInfo?
16
+
17
+ var body: some View {
18
+ VStack(spacing: 0) {
19
+ HStack(spacing: 10) {
20
+ // Status bar
21
+ RoundedRectangle(cornerRadius: 1)
22
+ .fill(project.isRunning ? Palette.running : Palette.border)
23
+ .frame(width: 3, height: 32)
24
+
25
+ // Info — tap to highlight window
26
+ VStack(alignment: .leading, spacing: 3) {
27
+ Text(project.name)
28
+ .font(Typo.heading(13))
29
+ .foregroundColor(Palette.text)
30
+ .lineLimit(1)
31
+
32
+ HStack(spacing: 6) {
33
+ if !project.paneSummary.isEmpty {
34
+ Text(project.paneSummary)
35
+ .font(Typo.mono(10))
36
+ .foregroundColor(Palette.textMuted)
37
+ .lineLimit(1)
38
+ } else if let cmd = project.devCommand {
39
+ Text(cmd)
40
+ .font(Typo.mono(10))
41
+ .foregroundColor(Palette.textMuted)
42
+ .lineLimit(1)
43
+ }
44
+
45
+ if project.isRunning, let info = windowInfo {
46
+ Spacer(minLength: 4)
47
+ locationBadge(info)
48
+ }
49
+ }
50
+ }
51
+ .contentShape(Rectangle())
52
+ .onTapGesture {
53
+ if project.isRunning {
54
+ WindowTiler.highlightWindow(session: project.sessionName)
55
+ }
56
+ }
57
+
58
+ Spacer()
59
+
60
+ // Actions
61
+ HStack(spacing: 4) {
62
+ if project.isRunning {
63
+ Button(action: {
64
+ withAnimation(.easeOut(duration: 0.15)) { showTilePicker.toggle() }
65
+ if !showTilePicker {
66
+ // Picker just opened — highlight the window
67
+ WindowTiler.highlightWindow(session: project.sessionName)
68
+ } else {
69
+ WindowHighlight.shared.dismiss()
70
+ }
71
+ }) {
72
+ Image(systemName: "rectangle.split.2x1")
73
+ .font(.system(size: 10))
74
+ .angularButton(Palette.textDim, filled: false)
75
+ }
76
+ .buttonStyle(.plain)
77
+
78
+ Button(action: { handleDetach() }) {
79
+ Text("Detach")
80
+ .angularButton(Palette.detach, filled: false)
81
+ }
82
+ .buttonStyle(.plain)
83
+ }
84
+
85
+ Button(action: onLaunch) {
86
+ Text(project.isRunning ? "Attach" : "Launch")
87
+ .angularButton(project.isRunning ? Palette.running : Palette.launch)
88
+ }
89
+ .buttonStyle(.plain)
90
+ }
91
+ }
92
+ .padding(.horizontal, 10)
93
+ .padding(.vertical, 8)
94
+ .glassCard(hovered: isHovered)
95
+
96
+ // Coach card
97
+ if showCoach {
98
+ CoachView {
99
+ withAnimation(.easeOut(duration: 0.15)) { showCoach = false }
100
+ }
101
+ .transition(.opacity.combined(with: .move(edge: .top)))
102
+ .padding(.top, 4)
103
+ }
104
+
105
+ // Tile picker
106
+ if showTilePicker {
107
+ TilePickerView(
108
+ sessionName: project.sessionName,
109
+ terminal: Preferences.shared.terminal,
110
+ onSelect: { position in
111
+ WindowHighlight.shared.dismiss()
112
+ WindowTiler.tile(
113
+ session: project.sessionName,
114
+ terminal: Preferences.shared.terminal,
115
+ to: position
116
+ )
117
+ withAnimation(.easeOut(duration: 0.15)) { showTilePicker = false }
118
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { refreshWindowInfo() }
119
+ },
120
+ onGoToSpace: { spaceId in
121
+ WindowHighlight.shared.dismiss()
122
+ let result = WindowTiler.moveWindowToSpace(
123
+ session: project.sessionName,
124
+ terminal: Preferences.shared.terminal,
125
+ spaceId: spaceId
126
+ )
127
+ if case .success = result {
128
+ WindowTiler.switchToSpace(spaceId: spaceId)
129
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
130
+ WindowTiler.highlightWindow(session: project.sessionName)
131
+ }
132
+ } else if case .alreadyOnSpace = result {
133
+ WindowTiler.switchToSpace(spaceId: spaceId)
134
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
135
+ WindowTiler.highlightWindow(session: project.sessionName)
136
+ }
137
+ }
138
+ withAnimation(.easeOut(duration: 0.15)) { showTilePicker = false }
139
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { refreshWindowInfo() }
140
+ },
141
+ onDismiss: {
142
+ WindowHighlight.shared.dismiss()
143
+ withAnimation(.easeOut(duration: 0.15)) { showTilePicker = false }
144
+ }
145
+ )
146
+ .transition(.opacity.combined(with: .move(edge: .top)))
147
+ .padding(.top, 4)
148
+ }
149
+ }
150
+ .contentShape(Rectangle())
151
+ .onHover { isHovered = $0 }
152
+ .onAppear {
153
+ if project.isRunning {
154
+ contextSpaces = WindowTiler.getDisplaySpaces().flatMap(\.spaces)
155
+ refreshWindowInfo()
156
+ }
157
+ }
158
+ .contextMenu {
159
+ if project.isRunning {
160
+ Button("Attach") { onLaunch() }
161
+ Button {
162
+ WindowTiler.navigateToWindow(
163
+ session: project.sessionName,
164
+ terminal: Preferences.shared.terminal
165
+ )
166
+ } label: {
167
+ Label("Go to Window", systemImage: "macwindow")
168
+ }
169
+ Button("Detach") { onDetach() }
170
+ Menu("Tile Window") {
171
+ ForEach(TilePosition.allCases) { tile in
172
+ Button {
173
+ WindowTiler.tile(
174
+ session: project.sessionName,
175
+ terminal: Preferences.shared.terminal,
176
+ to: tile
177
+ )
178
+ } label: {
179
+ Label(tile.label, systemImage: tile.icon)
180
+ }
181
+ }
182
+ }
183
+ if !contextSpaces.isEmpty {
184
+ Menu("Go to Space") {
185
+ ForEach(contextSpaces) { space in
186
+ Button {
187
+ let result = WindowTiler.moveWindowToSpace(
188
+ session: project.sessionName,
189
+ terminal: Preferences.shared.terminal,
190
+ spaceId: space.id
191
+ )
192
+ if case .success = result {
193
+ WindowTiler.switchToSpace(spaceId: space.id)
194
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
195
+ WindowTiler.highlightWindow(session: project.sessionName)
196
+ }
197
+ }
198
+ } label: {
199
+ Label(
200
+ "Space \(space.index)\(space.isCurrent ? " (current)" : "")",
201
+ systemImage: space.isCurrent ? "desktopcomputer" : "rectangle.on.rectangle"
202
+ )
203
+ }
204
+ }
205
+ }
206
+ }
207
+ Divider()
208
+ Button("Sync Session") { onSync() }
209
+ Menu("Restart Pane") {
210
+ ForEach(project.paneNames, id: \.self) { name in
211
+ Button(name) { onRestart(name) }
212
+ }
213
+ }
214
+ Divider()
215
+ Button("Kill Session") { onKill() }
216
+ } else {
217
+ Button("Launch") { onLaunch() }
218
+ }
219
+ }
220
+ }
221
+
222
+ // MARK: - Location Badge
223
+
224
+ @ViewBuilder
225
+ private func locationBadge(_ info: WindowTiler.WindowInfo) -> some View {
226
+ HStack(spacing: 3) {
227
+ // Multi-display prefix
228
+ if NSScreen.screens.count > 1 && info.displayIndex > 0 {
229
+ Text("D\(info.displayIndex + 1)")
230
+ .font(Typo.mono(9))
231
+ .foregroundColor(Palette.textMuted)
232
+ Text("\u{00B7}")
233
+ .font(Typo.mono(9))
234
+ .foregroundColor(Palette.textMuted)
235
+ }
236
+
237
+ // Space number
238
+ Text(circledDigit(info.spaceIndex))
239
+ .font(.system(size: 10))
240
+ .foregroundColor(Palette.textDim)
241
+
242
+ // Tile position icon
243
+ if let tile = info.tilePosition {
244
+ Image(systemName: tile.icon)
245
+ .font(.system(size: 9))
246
+ .foregroundColor(Palette.textMuted)
247
+ }
248
+ }
249
+ .padding(.horizontal, 5)
250
+ .padding(.vertical, 2)
251
+ .background(
252
+ RoundedRectangle(cornerRadius: 4)
253
+ .fill(Palette.surface.opacity(0.6))
254
+ )
255
+ .contentShape(Rectangle())
256
+ .onTapGesture {
257
+ WindowTiler.navigateToWindow(
258
+ session: project.sessionName,
259
+ terminal: Preferences.shared.terminal
260
+ )
261
+ }
262
+ }
263
+
264
+ private func circledDigit(_ n: Int) -> String {
265
+ let digits = ["\u{2776}","\u{2777}","\u{2778}","\u{2779}","\u{277A}","\u{277B}","\u{277C}","\u{277D}","\u{277E}"]
266
+ return n >= 1 && n <= 9 ? digits[n - 1] : "S\(n)"
267
+ }
268
+
269
+ private func refreshWindowInfo() {
270
+ guard project.isRunning else { windowInfo = nil; return }
271
+ DispatchQueue.global(qos: .userInitiated).async {
272
+ let info = WindowTiler.getWindowInfo(
273
+ session: project.sessionName,
274
+ terminal: Preferences.shared.terminal
275
+ )
276
+ DispatchQueue.main.async { windowInfo = info }
277
+ }
278
+ }
279
+
280
+ private func handleDetach() {
281
+ if Preferences.shared.mode == .learning {
282
+ withAnimation(.easeOut(duration: 0.15)) { showCoach.toggle() }
283
+ } else {
284
+ onDetach()
285
+ }
286
+ }
287
+ }
288
+
289
+ // MARK: - Coach view
290
+
291
+ struct CoachView: View {
292
+ let onDismiss: () -> Void
293
+
294
+ var body: some View {
295
+ VStack(alignment: .leading, spacing: 10) {
296
+ HStack {
297
+ Text("TMUX SHORTCUTS")
298
+ .font(Typo.pixel(12))
299
+ .foregroundColor(Palette.running)
300
+ Spacer()
301
+ Button(action: onDismiss) {
302
+ Image(systemName: "xmark")
303
+ .font(.system(size: 8, weight: .bold))
304
+ .foregroundColor(Palette.textDim)
305
+ .frame(width: 18, height: 18)
306
+ .background(
307
+ RoundedRectangle(cornerRadius: 3)
308
+ .fill(Palette.surface)
309
+ )
310
+ }
311
+ .buttonStyle(.plain)
312
+ }
313
+
314
+ VStack(spacing: 6) {
315
+ KeyCombo(keys: ["Ctrl+B", "D"], label: "Detach", color: Palette.detach)
316
+ KeyCombo(keys: ["Ctrl+B", "X"], label: "Kill pane", color: Palette.kill)
317
+ KeyCombo(keys: ["Ctrl+B", "\u{2190}\u{2192}"], label: "Switch pane", color: Palette.text)
318
+ }
319
+
320
+ Text("Session stays alive after detaching")
321
+ .font(Typo.caption(10))
322
+ .foregroundColor(Palette.textMuted)
323
+ }
324
+ .padding(12)
325
+ .background(
326
+ RoundedRectangle(cornerRadius: 6)
327
+ .fill(Palette.surface)
328
+ .overlay(
329
+ RoundedRectangle(cornerRadius: 6)
330
+ .strokeBorder(Palette.borderLit, lineWidth: 0.5)
331
+ )
332
+ )
333
+ }
334
+ }
335
+
336
+ struct KeyCombo: View {
337
+ let keys: [String]
338
+ let label: String
339
+ var color: Color = .secondary
340
+
341
+ var body: some View {
342
+ HStack(spacing: 6) {
343
+ HStack(spacing: 3) {
344
+ ForEach(keys, id: \.self) { key in
345
+ Text(key)
346
+ .font(Typo.geistMonoBold(10))
347
+ .foregroundColor(Palette.text)
348
+ .padding(.horizontal, 6)
349
+ .padding(.vertical, 3)
350
+ .background(
351
+ RoundedRectangle(cornerRadius: 3)
352
+ .fill(Palette.bg)
353
+ .overlay(
354
+ RoundedRectangle(cornerRadius: 3)
355
+ .strokeBorder(Palette.border, lineWidth: 0.5)
356
+ )
357
+ )
358
+ }
359
+ }
360
+
361
+ Text(label)
362
+ .font(Typo.caption(11))
363
+ .foregroundColor(color)
364
+
365
+ Spacer()
366
+ }
367
+ }
368
+ }
@@ -0,0 +1,128 @@
1
+ import Foundation
2
+
3
+ class ProjectScanner: ObservableObject {
4
+ static let shared = ProjectScanner()
5
+
6
+ @Published var projects: [Project] = []
7
+
8
+ private var scanRoot: String
9
+
10
+ init(root: String? = nil) {
11
+ self.scanRoot = root ?? Preferences.shared.scanRoot
12
+ }
13
+
14
+ func updateRoot(_ root: String) {
15
+ self.scanRoot = root
16
+ }
17
+
18
+ func scan() {
19
+ let diag = DiagnosticLog.shared
20
+
21
+ // Use find to locate all .lattices.json files — no manual directory walking
22
+ let tFind = diag.startTimed("ProjectScanner: find .lattices.json")
23
+ let task = Process()
24
+ task.executableURL = URL(fileURLWithPath: "/usr/bin/find")
25
+ task.arguments = [scanRoot, "-name", ".lattices.json", "-maxdepth", "3", "-not", "-path", "*/.git/*", "-not", "-path", "*/node_modules/*"]
26
+ let pipe = Pipe()
27
+ task.standardOutput = pipe
28
+ task.standardError = FileHandle.nullDevice
29
+ try? task.run()
30
+ task.waitUntilExit()
31
+ diag.finish(tFind)
32
+
33
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
34
+ let output = String(data: data, encoding: .utf8) ?? ""
35
+ let configPaths = output.split(separator: "\n").map(String.init).filter { !$0.isEmpty }
36
+
37
+ let tParse = diag.startTimed("ProjectScanner: parse \(configPaths.count) configs")
38
+ var found: [Project] = []
39
+
40
+ for configPath in configPaths.sorted() {
41
+ let projectPath = (configPath as NSString).deletingLastPathComponent
42
+ let name = (projectPath as NSString).lastPathComponent
43
+ let (devCmd, pm) = detectDevCommand(at: projectPath)
44
+ let paneInfo = readPaneInfo(at: configPath)
45
+
46
+ var project = Project(
47
+ id: projectPath,
48
+ path: projectPath,
49
+ name: name,
50
+ devCommand: devCmd,
51
+ packageManager: pm,
52
+ hasConfig: true,
53
+ paneCount: paneInfo.count,
54
+ paneNames: paneInfo.names,
55
+ paneSummary: paneInfo.summary,
56
+ isRunning: false
57
+ )
58
+ project.isRunning = isSessionRunning(project.sessionName)
59
+ found.append(project)
60
+ }
61
+ diag.finish(tParse)
62
+
63
+ diag.info("ProjectScanner: found \(found.count) projects (\(found.filter(\.isRunning).count) running)")
64
+ DispatchQueue.main.async { self.projects = found }
65
+ }
66
+
67
+ func refreshStatus() {
68
+ for i in projects.indices {
69
+ projects[i].isRunning = isSessionRunning(projects[i].sessionName)
70
+ }
71
+ }
72
+
73
+ // MARK: - Detection
74
+
75
+ private func detectDevCommand(at path: String) -> (String?, String?) {
76
+ let pkgPath = (path as NSString).appendingPathComponent("package.json")
77
+ guard let data = FileManager.default.contents(atPath: pkgPath),
78
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
79
+ let scripts = json["scripts"] as? [String: String]
80
+ else { return (nil, nil) }
81
+
82
+ let has = { (f: String) in
83
+ FileManager.default.fileExists(atPath: (path as NSString).appendingPathComponent(f))
84
+ }
85
+
86
+ var pm = "npm"
87
+ if has("pnpm-lock.yaml") { pm = "pnpm" }
88
+ else if has("bun.lockb") || has("bun.lock") { pm = "bun" }
89
+ else if has("yarn.lock") { pm = "yarn" }
90
+
91
+ let run = pm == "npm" ? "npm run" : pm
92
+ if scripts["dev"] != nil { return ("\(run) dev", pm) }
93
+ if scripts["start"] != nil { return ("\(run) start", pm) }
94
+ if scripts["serve"] != nil { return ("\(run) serve", pm) }
95
+ if scripts["watch"] != nil { return ("\(run) watch", pm) }
96
+ return (nil, pm)
97
+ }
98
+
99
+ private func readPaneInfo(at configPath: String) -> (count: Int, names: [String], summary: String) {
100
+ guard let data = FileManager.default.contents(atPath: configPath),
101
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
102
+ let panes = json["panes"] as? [[String: Any]]
103
+ else { return (2, ["claude", "server"], "") }
104
+
105
+ let labels = panes.compactMap { pane -> String? in
106
+ if let name = pane["name"] as? String { return name }
107
+ if let cmd = pane["cmd"] as? String {
108
+ let parts = cmd.split(separator: " ")
109
+ return parts.first.map(String.init)
110
+ }
111
+ return nil
112
+ }
113
+ return (panes.count, labels, labels.joined(separator: " · "))
114
+ }
115
+
116
+ private static let tmuxPath = "/opt/homebrew/bin/tmux"
117
+
118
+ private func isSessionRunning(_ name: String) -> Bool {
119
+ let task = Process()
120
+ task.executableURL = URL(fileURLWithPath: Self.tmuxPath)
121
+ task.arguments = ["has-session", "-t", name]
122
+ task.standardOutput = FileHandle.nullDevice
123
+ task.standardError = FileHandle.nullDevice
124
+ try? task.run()
125
+ task.waitUntilExit()
126
+ return task.terminationStatus == 0
127
+ }
128
+ }