@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
package/README.md ADDED
@@ -0,0 +1,155 @@
1
+ <picture>
2
+ <img alt="lattices" src="site/public/og.png" />
3
+ </picture>
4
+
5
+ # lattices
6
+
7
+ A workspace control plane for macOS. Manage persistent terminal sessions,
8
+ tile and organize your windows, and index the text on your screen — all
9
+ controllable from the CLI or a 30-method daemon API.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ npm install -g @lattices/cli
15
+ ```
16
+
17
+ ## Quick start
18
+
19
+ ```sh
20
+ # Launch the menu bar app
21
+ lattices app
22
+
23
+ # Open the command palette from anywhere
24
+ # Cmd+Shift+M
25
+ ```
26
+
27
+ ## Persistent terminal sessions
28
+
29
+ Declare your dev environment in a `.lattices.json`: which panes, which
30
+ commands, what layout. Lattices builds it, runs it, and keeps it alive.
31
+ Close your laptop, reboot, come back a week later — your editor, dev
32
+ server, and test runner are exactly where you left them.
33
+
34
+ ```sh
35
+ cd my-project && lattices
36
+ ```
37
+
38
+ No config? It reads your `package.json` and picks the right dev command
39
+ automatically.
40
+
41
+ ### Configuration
42
+
43
+ Drop a `.lattices.json` in your project root:
44
+
45
+ ```json
46
+ {
47
+ "ensure": true,
48
+ "panes": [
49
+ { "name": "claude", "cmd": "claude", "size": 60 },
50
+ { "name": "server", "cmd": "pnpm dev" },
51
+ { "name": "tests", "cmd": "pnpm test --watch" }
52
+ ]
53
+ }
54
+ ```
55
+
56
+ ### Layouts
57
+
58
+ ```
59
+ 2 panes 3+ panes
60
+
61
+ ┌──────────┬───────┐ ┌──────────┬───────┐
62
+ │ claude │server │ │ claude │server │
63
+ │ (60%) │(40%) │ │ (60%) ├───────┤
64
+ └──────────┴───────┘ │ │tests │
65
+ └──────────┴───────┘
66
+ ```
67
+
68
+ ### Workspace layers
69
+
70
+ Group projects into switchable contexts. `Cmd+Option+1` tiles your
71
+ frontend and API side by side. `Cmd+Option+2` for the mobile stack.
72
+ Sessions stay alive across switches.
73
+
74
+ ### Tab groups
75
+
76
+ Bundle related repos as tabs in one session. Each tab gets its own
77
+ pane layout from its `.lattices.json`.
78
+
79
+ ```sh
80
+ lattices group talkie # Launch iOS, macOS, Web, API as tabs
81
+ lattices tab talkie iOS # Switch to the iOS tab
82
+ ```
83
+
84
+ ## Window tiling and awareness
85
+
86
+ A native menu bar app tracks every window across all your monitors.
87
+ Tile with hotkeys, organize into switchable layers, snap to grids.
88
+
89
+ It reads your windows too — extracting text from UI elements every
90
+ 60 seconds and running Vision OCR on background windows every 2 hours.
91
+ Everything is searchable.
92
+
93
+ ```sh
94
+ lattices scan # View current screen text
95
+ lattices scan search "error" # Search across all indexed text
96
+ lattices scan recent # Browse scan history
97
+ lattices scan deep # Trigger a Vision OCR scan now
98
+ ```
99
+
100
+ ## A programmable desktop
101
+
102
+ The menu bar app runs a daemon with 30 RPC methods and 5 real-time
103
+ events over WebSocket. Anything you can do from the app, an agent or
104
+ script can do over the API.
105
+
106
+ ```js
107
+ import { daemonCall } from '@lattices/cli/daemon-client'
108
+
109
+ const windows = await daemonCall('windows.list')
110
+ await daemonCall('session.launch', { path: '/Users/you/dev/frontend' })
111
+ await daemonCall('window.tile', { session: 'frontend-a1b2c3', position: 'left' })
112
+
113
+ // Read the screen
114
+ await daemonCall('ocr.scan')
115
+ const errors = await daemonCall('ocr.search', { query: 'error OR failed' })
116
+ ```
117
+
118
+ Claude Code skills, MCP servers, or your own scripts can drive your
119
+ desktop the same way you do.
120
+
121
+ ## CLI
122
+
123
+ ```
124
+ lattices Create or reattach to session
125
+ lattices init Generate .lattices.json
126
+ lattices ls List active sessions
127
+ lattices kill [name] Kill a session
128
+ lattices tile <position> Tile frontmost window
129
+ lattices group [id] Launch or attach a tab group
130
+ lattices tab <group> [tab] Switch tab within a group
131
+ lattices scan View current screen text
132
+ lattices scan search <q> Search indexed text
133
+ lattices scan recent [n] Browse scan history
134
+ lattices scan deep Trigger Vision OCR now
135
+ lattices app Launch the menu bar app
136
+ lattices help Show help
137
+ ```
138
+
139
+ ## Requirements
140
+
141
+ - macOS 13.0+
142
+ - Node.js 18+
143
+
144
+ ### Optional
145
+
146
+ - tmux for persistent terminal sessions (`brew install tmux`)
147
+ - Swift 5.9+ to build the menu bar app from source
148
+
149
+ ## Docs
150
+
151
+ [lattices.dev/docs](https://lattices.dev/docs/overview)
152
+
153
+ ## License
154
+
155
+ MIT
@@ -0,0 +1,24 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleIdentifier</key>
6
+ <string>com.arach.lattices</string>
7
+ <key>CFBundleName</key>
8
+ <string>Lattices</string>
9
+ <key>CFBundleExecutable</key>
10
+ <string>Lattices</string>
11
+ <key>CFBundleIconFile</key>
12
+ <string>AppIcon</string>
13
+ <key>CFBundlePackageType</key>
14
+ <string>APPL</string>
15
+ <key>CFBundleVersion</key>
16
+ <string>1</string>
17
+ <key>CFBundleShortVersionString</key>
18
+ <string>0.1.0</string>
19
+ <key>LSUIElement</key>
20
+ <true/>
21
+ <key>NSSupportsAutomaticTermination</key>
22
+ <true/>
23
+ </dict>
24
+ </plist>
@@ -0,0 +1,13 @@
1
+ // swift-tools-version: 5.9
2
+ import PackageDescription
3
+
4
+ let package = Package(
5
+ name: "Lattices",
6
+ platforms: [.macOS(.v13)],
7
+ targets: [
8
+ .executableTarget(
9
+ name: "Lattices",
10
+ path: "Sources"
11
+ )
12
+ ]
13
+ )
@@ -0,0 +1,111 @@
1
+ import AppKit
2
+
3
+ // Private API: get CGWindowID from an AXUIElement (already declared in WindowTiler.swift)
4
+ // We reuse the same _AXUIElementGetWindow binding.
5
+
6
+ struct AXTextResult {
7
+ let wid: UInt32
8
+ let texts: [String]
9
+ let fullText: String
10
+ }
11
+
12
+ final class AccessibilityTextExtractor {
13
+ static let timeoutSeconds: TimeInterval = 0.2
14
+ static let maxDepth: Int = 4
15
+ static let maxChildrenPerNode: Int = 30
16
+
17
+ /// Extract text from a window's AX element tree.
18
+ /// Returns nil if AX fails or yields fewer than `minChars` characters.
19
+ func extract(pid: Int32, wid: UInt32, minChars: Int = 12) -> AXTextResult? {
20
+ let appRef = AXUIElementCreateApplication(pid)
21
+
22
+ // Find the AXUIElement matching this wid
23
+ var windowsRef: CFTypeRef?
24
+ let err = AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef)
25
+ guard err == .success, let axWindows = windowsRef as? [AXUIElement] else { return nil }
26
+
27
+ var targetWindow: AXUIElement?
28
+ for axWin in axWindows {
29
+ var winID: CGWindowID = 0
30
+ if _AXUIElementGetWindow(axWin, &winID) == .success, winID == CGWindowID(wid) {
31
+ targetWindow = axWin
32
+ break
33
+ }
34
+ }
35
+
36
+ guard let window = targetWindow else { return nil }
37
+
38
+ let deadline = Date().addingTimeInterval(Self.timeoutSeconds)
39
+ var collected: [String] = []
40
+
41
+ walkChildren(element: window, depth: 0, deadline: deadline, collected: &collected)
42
+
43
+ let fullText = collected.joined(separator: "\n")
44
+ guard fullText.count >= minChars else { return nil }
45
+
46
+ return AXTextResult(wid: wid, texts: collected, fullText: fullText)
47
+ }
48
+
49
+ // MARK: - Tree Walker
50
+
51
+ private func walkChildren(
52
+ element: AXUIElement,
53
+ depth: Int,
54
+ deadline: Date,
55
+ collected: inout [String]
56
+ ) {
57
+ guard depth < Self.maxDepth, Date() < deadline else { return }
58
+
59
+ // Extract text attributes from this element
60
+ extractText(from: element, into: &collected)
61
+
62
+ // Get children — prefer visible children, fall back to all children
63
+ var childrenRef: CFTypeRef?
64
+ var gotChildren = false
65
+
66
+ let visErr = AXUIElementCopyAttributeValue(element, kAXVisibleChildrenAttribute as CFString, &childrenRef)
67
+ if visErr == .success, let children = childrenRef as? [AXUIElement], !children.isEmpty {
68
+ gotChildren = true
69
+ let capped = children.prefix(Self.maxChildrenPerNode)
70
+ for child in capped {
71
+ guard Date() < deadline else { return }
72
+ walkChildren(element: child, depth: depth + 1, deadline: deadline, collected: &collected)
73
+ }
74
+ }
75
+
76
+ if !gotChildren {
77
+ childrenRef = nil
78
+ let childErr = AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &childrenRef)
79
+ if childErr == .success, let children = childrenRef as? [AXUIElement] {
80
+ let capped = children.prefix(Self.maxChildrenPerNode)
81
+ for child in capped {
82
+ guard Date() < deadline else { return }
83
+ walkChildren(element: child, depth: depth + 1, deadline: deadline, collected: &collected)
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
+ private func extractText(from element: AXUIElement, into collected: inout [String]) {
90
+ // kAXValueAttribute — text field contents, labels, etc.
91
+ var valueRef: CFTypeRef?
92
+ if AXUIElementCopyAttributeValue(element, kAXValueAttribute as CFString, &valueRef) == .success,
93
+ let str = valueRef as? String, !str.isEmpty, str.count > 1 {
94
+ collected.append(str)
95
+ }
96
+
97
+ // kAXTitleAttribute — window/button titles
98
+ var titleRef: CFTypeRef?
99
+ if AXUIElementCopyAttributeValue(element, kAXTitleAttribute as CFString, &titleRef) == .success,
100
+ let str = titleRef as? String, !str.isEmpty, str.count > 1 {
101
+ collected.append(str)
102
+ }
103
+
104
+ // kAXDescriptionAttribute — accessible descriptions
105
+ var descRef: CFTypeRef?
106
+ if AXUIElementCopyAttributeValue(element, kAXDescriptionAttribute as CFString, &descRef) == .success,
107
+ let str = descRef as? String, !str.isEmpty, str.count > 1 {
108
+ collected.append(str)
109
+ }
110
+ }
111
+ }
@@ -0,0 +1,61 @@
1
+ import SwiftUI
2
+
3
+ /// A single action row with shortcut badge, label, optional icon, and hotkey hint.
4
+ struct ActionRow: View {
5
+ let shortcut: String
6
+ let label: String
7
+ var hotkey: String? = nil
8
+ var icon: String? = nil
9
+ var accentColor: Color = Palette.textDim
10
+ var action: () -> Void
11
+
12
+ @State private var isHovered = false
13
+
14
+ var body: some View {
15
+ Button(action: action) {
16
+ HStack(spacing: 10) {
17
+ // Shortcut badge
18
+ Text(shortcut)
19
+ .font(Typo.monoBold(10))
20
+ .foregroundColor(accentColor)
21
+ .frame(width: 18, height: 18)
22
+ .background(
23
+ RoundedRectangle(cornerRadius: 4)
24
+ .fill(accentColor.opacity(0.12))
25
+ )
26
+
27
+ // Icon
28
+ if let icon {
29
+ Image(systemName: icon)
30
+ .font(.system(size: 11, weight: .medium))
31
+ .foregroundColor(isHovered ? Palette.text : Palette.textDim)
32
+ .frame(width: 14)
33
+ }
34
+
35
+ // Label
36
+ Text(label)
37
+ .font(Typo.mono(12))
38
+ .foregroundColor(isHovered ? Palette.text : Palette.textDim)
39
+ .lineLimit(1)
40
+
41
+ Spacer()
42
+
43
+ // Hotkey
44
+ if let hotkey {
45
+ Text(hotkey)
46
+ .font(Typo.mono(10))
47
+ .foregroundColor(Palette.textMuted)
48
+ }
49
+ }
50
+ .padding(.horizontal, 10)
51
+ .padding(.vertical, 6)
52
+ .background(
53
+ RoundedRectangle(cornerRadius: 5)
54
+ .fill(isHovered ? Palette.surfaceHov : Color.clear)
55
+ )
56
+ .contentShape(Rectangle())
57
+ }
58
+ .buttonStyle(.plain)
59
+ .onHover { isHovered = $0 }
60
+ }
61
+ }
@@ -0,0 +1,10 @@
1
+ import SwiftUI
2
+
3
+ @main
4
+ struct LatticesApp: App {
5
+ @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
6
+
7
+ var body: some Scene {
8
+ Settings { EmptyView() }
9
+ }
10
+ }
@@ -0,0 +1,242 @@
1
+ import AppKit
2
+ import SwiftUI
3
+
4
+ /// Manages the NSStatusItem (menu bar icon), left-click popover, and right-click context menu.
5
+ /// Replaces the previous SwiftUI MenuBarExtra approach for full click-event control.
6
+ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
7
+
8
+ private var statusItem: NSStatusItem!
9
+ private var popover: NSPopover?
10
+ private var contextMenu: NSMenu!
11
+
12
+ /// 3×3 grid icon for the menu bar — L-shape bright, rest dim (template for auto light/dark)
13
+ private static let menuBarIcon: NSImage = {
14
+ let size: CGFloat = 18
15
+ let img = NSImage(size: NSSize(width: size, height: size), flipped: true) { _ in
16
+ let pad: CGFloat = 2
17
+ let gap: CGFloat = 1.5
18
+ let cellSize = (size - 2 * pad - 2 * gap) / 3
19
+
20
+ let solidCells: Set<Int> = [0, 3, 6, 7, 8]
21
+
22
+ for row in 0..<3 {
23
+ for col in 0..<3 {
24
+ let idx = row * 3 + col
25
+ let x = pad + CGFloat(col) * (cellSize + gap)
26
+ let y = pad + CGFloat(row) * (cellSize + gap)
27
+ let rect = NSRect(x: x, y: y, width: cellSize, height: cellSize)
28
+
29
+ if solidCells.contains(idx) {
30
+ NSColor.black.setFill()
31
+ } else {
32
+ NSColor.black.withAlphaComponent(0.25).setFill()
33
+ }
34
+ let path = NSBezierPath(roundedRect: rect, xRadius: 0.8, yRadius: 0.8)
35
+ path.fill()
36
+ }
37
+ }
38
+ return true
39
+ }
40
+ img.isTemplate = true
41
+ return img
42
+ }()
43
+
44
+ /// Toggle between .accessory (hidden from Dock/Cmd+Tab) and .regular (visible)
45
+ /// based on whether any managed windows are open.
46
+ static func updateActivationPolicy() {
47
+ let hasVisibleWindow =
48
+ CommandModeWindow.shared.isVisible ||
49
+ CommandPaletteWindow.shared.isVisible ||
50
+ MainWindow.shared.isVisible ||
51
+ ScreenMapWindowController.shared.isVisible ||
52
+ OmniSearchWindow.shared.isVisible
53
+ let desired: NSApplication.ActivationPolicy = hasVisibleWindow ? .regular : .accessory
54
+ if NSApp.activationPolicy() != desired {
55
+ NSApp.setActivationPolicy(desired)
56
+ if desired == .regular {
57
+ NSApp.activate(ignoringOtherApps: true)
58
+ }
59
+ }
60
+ }
61
+
62
+ func applicationDidFinishLaunching(_ notification: Notification) {
63
+ NSApp.setActivationPolicy(.accessory)
64
+ NSApp.appearance = NSAppearance(named: .darkAqua)
65
+
66
+ // --- Status item ---
67
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
68
+ if let button = statusItem.button {
69
+ button.image = Self.menuBarIcon
70
+ button.action = #selector(statusItemClicked(_:))
71
+ button.sendAction(on: [.leftMouseUp, .rightMouseUp])
72
+ button.target = self
73
+ }
74
+
75
+ // --- Context menu (right-click) ---
76
+ contextMenu = buildContextMenu()
77
+
78
+ // --- Hotkey registration ---
79
+ let scanner = ProjectScanner.shared
80
+ CommandPaletteWindow.shared.configure(scanner: scanner)
81
+
82
+ let store = HotkeyStore.shared
83
+ store.register(action: .palette) { CommandPaletteWindow.shared.toggle() }
84
+ store.register(action: .screenMap) { ScreenMapWindowController.shared.toggle() }
85
+ store.register(action: .bezel) { WindowBezel.showBezelForFrontmostWindow() }
86
+ store.register(action: .cheatSheet) { CheatSheetHUD.shared.toggle() }
87
+ store.register(action: .desktopInventory) { CommandModeWindow.shared.toggle() }
88
+ store.register(action: .omniSearch) { OmniSearchWindow.shared.toggle() }
89
+
90
+ // Layer-switching hotkeys
91
+ let workspace = WorkspaceManager.shared
92
+ let layerCount = (workspace.config?.layers ?? []).count
93
+ for (i, action) in HotkeyAction.layerActions.prefix(layerCount).enumerated() {
94
+ let index = i
95
+ store.register(action: action) {
96
+ workspace.tileLayer(index: index, launch: true, force: true)
97
+ EventBus.shared.post(.layerSwitched(index: index))
98
+ }
99
+ }
100
+
101
+ // Tiling hotkeys
102
+ let tileMap: [(HotkeyAction, TilePosition)] = [
103
+ (.tileLeft, .left), (.tileRight, .right),
104
+ (.tileMaximize, .maximize), (.tileCenter, .center),
105
+ (.tileTopLeft, .topLeft), (.tileTopRight, .topRight),
106
+ (.tileBottomLeft, .bottomLeft), (.tileBottomRight, .bottomRight),
107
+ (.tileTop, .top), (.tileBottom, .bottom),
108
+ (.tileLeftThird, .leftThird), (.tileCenterThird, .centerThird),
109
+ (.tileRightThird, .rightThird),
110
+ ]
111
+ for (action, position) in tileMap {
112
+ store.register(action: action) { WindowTiler.tileFrontmostViaAX(to: position) }
113
+ }
114
+ store.register(action: .tileDistribute) { WindowTiler.distributeVisible() }
115
+
116
+ // Check macOS permissions (Accessibility, Screen Recording)
117
+ PermissionChecker.shared.check()
118
+
119
+ // Start daemon services
120
+ let diag = DiagnosticLog.shared
121
+ let tBoot = diag.startTimed("Daemon services boot")
122
+ OcrStore.shared.open()
123
+ DesktopModel.shared.start()
124
+ OcrModel.shared.start()
125
+ TmuxModel.shared.start()
126
+ ProcessModel.shared.start()
127
+ LatticesApi.setup()
128
+ DaemonServer.shared.start()
129
+ diag.finish(tBoot)
130
+
131
+ // --diagnostics flag: auto-open diagnostics panel on launch
132
+ if CommandLine.arguments.contains("--diagnostics") {
133
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
134
+ DiagnosticWindow.shared.show()
135
+ }
136
+ }
137
+
138
+ // --screen-map flag: auto-open screen map on launch
139
+ if CommandLine.arguments.contains("--screen-map") {
140
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
141
+ ScreenMapWindowController.shared.show()
142
+ }
143
+ }
144
+ }
145
+
146
+ // MARK: - Status item click handler
147
+
148
+ @objc private func statusItemClicked(_ sender: Any?) {
149
+ guard let event = NSApp.currentEvent, let button = statusItem.button else { return }
150
+
151
+ if event.type == .rightMouseUp {
152
+ // Right-click → context menu
153
+ contextMenu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 4), in: button)
154
+ } else {
155
+ // Left-click → toggle popover
156
+ if let shown = popover, shown.isShown {
157
+ shown.performClose(sender)
158
+ } else {
159
+ let p = makePopover()
160
+ p.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
161
+ p.contentViewController?.view.window?.makeKey()
162
+ }
163
+ }
164
+ }
165
+
166
+ /// Dismiss the popover programmatically (e.g. from the pop-out button).
167
+ func dismissPopover() {
168
+ popover?.performClose(nil)
169
+ }
170
+
171
+ /// Create a fresh popover each time so the SwiftUI view tree isn't kept alive
172
+ /// when the popover is closed — prevents continuous CPU usage from @Published updates.
173
+ private func makePopover() -> NSPopover {
174
+ let t = DiagnosticLog.shared.startTimed("makePopover")
175
+ let p = NSPopover()
176
+ p.contentViewController = NSHostingController(rootView: MainView(scanner: ProjectScanner.shared))
177
+ p.behavior = .transient
178
+ p.contentSize = NSSize(width: 380, height: 520)
179
+ p.appearance = NSAppearance(named: .darkAqua)
180
+ p.delegate = self
181
+ popover = p
182
+ DiagnosticLog.shared.finish(t)
183
+ return p
184
+ }
185
+
186
+ func popoverDidClose(_ notification: Notification) {
187
+ // Tear down the SwiftUI view tree so observed models stop driving re-renders
188
+ popover?.contentViewController = nil
189
+ popover = nil
190
+ }
191
+
192
+ // MARK: - Context menu
193
+
194
+ private func buildContextMenu() -> NSMenu {
195
+ let menu = NSMenu()
196
+
197
+ let actions: [(String, String, Selector)] = [
198
+ ("Command Palette", "⌘⇧M", #selector(menuCommandPalette)),
199
+ ("Screen Map", "", #selector(menuScreenMap)),
200
+ ("Desktop Inventory", "", #selector(menuDesktopInventory)),
201
+ ("Window Bezel", "", #selector(menuWindowBezel)),
202
+ ("Cheat Sheet", "", #selector(menuCheatSheet)),
203
+ ("Omni Search", "", #selector(menuOmniSearch)),
204
+ ]
205
+ for (title, shortcut, action) in actions {
206
+ let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
207
+ item.target = self
208
+ if !shortcut.isEmpty {
209
+ // Display-only; the actual hotkey is global
210
+ }
211
+ menu.addItem(item)
212
+ }
213
+
214
+ menu.addItem(.separator())
215
+
216
+ let settings = NSMenuItem(title: "Settings…", action: #selector(menuSettings), keyEquivalent: ",")
217
+ settings.target = self
218
+ menu.addItem(settings)
219
+
220
+ let diag = NSMenuItem(title: "Diagnostics", action: #selector(menuDiagnostics), keyEquivalent: "")
221
+ diag.target = self
222
+ menu.addItem(diag)
223
+
224
+ menu.addItem(.separator())
225
+
226
+ let quit = NSMenuItem(title: "Quit Lattices", action: #selector(menuQuit), keyEquivalent: "q")
227
+ quit.target = self
228
+ menu.addItem(quit)
229
+
230
+ return menu
231
+ }
232
+
233
+ @objc private func menuCommandPalette() { CommandPaletteWindow.shared.toggle() }
234
+ @objc private func menuScreenMap() { ScreenMapWindowController.shared.toggle() }
235
+ @objc private func menuDesktopInventory() { CommandModeWindow.shared.toggle() }
236
+ @objc private func menuWindowBezel() { WindowBezel.showBezelForFrontmostWindow() }
237
+ @objc private func menuCheatSheet() { CheatSheetHUD.shared.toggle() }
238
+ @objc private func menuOmniSearch() { OmniSearchWindow.shared.toggle() }
239
+ @objc private func menuSettings() { SettingsWindowController.shared.show() }
240
+ @objc private func menuDiagnostics() { DiagnosticWindow.shared.toggle() }
241
+ @objc private func menuQuit() { NSApp.terminate(nil) }
242
+ }
@@ -0,0 +1,62 @@
1
+ import SwiftUI
2
+
3
+ // MARK: - Navigation Pages
4
+
5
+ enum AppPage: String, CaseIterable {
6
+ case screenMap
7
+ case settings
8
+ case docs
9
+
10
+ var label: String {
11
+ switch self {
12
+ case .screenMap: return "Screen Map"
13
+ case .settings: return "Settings"
14
+ case .docs: return "Docs"
15
+ }
16
+ }
17
+
18
+ var icon: String {
19
+ switch self {
20
+ case .screenMap: return "rectangle.3.group"
21
+ case .settings: return "gearshape"
22
+ case .docs: return "book"
23
+ }
24
+ }
25
+ }
26
+
27
+ // MARK: - App Shell View
28
+
29
+ struct AppShellView: View {
30
+ @ObservedObject var controller: ScreenMapController
31
+ @ObservedObject var windowController = ScreenMapWindowController.shared
32
+
33
+ var body: some View {
34
+ contentArea
35
+ .background(Palette.bg)
36
+ }
37
+
38
+ // MARK: - Content Area
39
+
40
+ @ViewBuilder
41
+ private var contentArea: some View {
42
+ switch windowController.activePage {
43
+ case .screenMap:
44
+ ScreenMapView(controller: controller, onNavigate: { page in
45
+ windowController.activePage = page
46
+ })
47
+ case .settings:
48
+ SettingsContentView(
49
+ prefs: Preferences.shared,
50
+ scanner: ProjectScanner.shared,
51
+ onBack: { windowController.activePage = .screenMap; controller.enter() }
52
+ )
53
+ case .docs:
54
+ SettingsContentView(
55
+ page: .docs,
56
+ prefs: Preferences.shared,
57
+ scanner: ProjectScanner.shared,
58
+ onBack: { windowController.activePage = .screenMap; controller.enter() }
59
+ )
60
+ }
61
+ }
62
+ }