@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,200 @@
1
+ import Foundation
2
+
3
+ // MARK: - Unified Output
4
+
5
+ struct TerminalInstance {
6
+ // Join key
7
+ let tty: String
8
+
9
+ // Tab info (from AppleScript)
10
+ let app: Terminal?
11
+ let windowIndex: Int?
12
+ let tabIndex: Int?
13
+ let isActiveTab: Bool
14
+ let tabTitle: String?
15
+ let terminalSessionId: String? // iTerm2 unique ID
16
+
17
+ // Process info (from ps)
18
+ let processes: [ProcessEntry]
19
+ let shellPid: Int?
20
+ let cwd: String?
21
+
22
+ // Tmux info
23
+ let tmuxSession: String?
24
+ let tmuxPaneId: String?
25
+
26
+ // Window info (from CGWindowList)
27
+ let windowId: UInt32?
28
+ let windowTitle: String?
29
+
30
+ // Computed
31
+ var hasClaude: Bool {
32
+ processes.contains { $0.comm == "claude" }
33
+ }
34
+
35
+ var displayName: String {
36
+ if let session = tmuxSession { return session }
37
+ if let title = tabTitle, !title.isEmpty { return title }
38
+ if let title = windowTitle, !title.isEmpty { return title }
39
+ return tty
40
+ }
41
+ }
42
+
43
+ // MARK: - Synthesizer
44
+
45
+ enum TerminalSynthesizer {
46
+
47
+ /// Pure-function merge: joins 5 slices by TTY into unified TerminalInstances.
48
+ ///
49
+ /// - Parameters:
50
+ /// - processTable: Full process table from ProcessQuery.snapshot()
51
+ /// - interesting: Filtered interesting processes
52
+ /// - tmuxSessions: Current tmux sessions with panes
53
+ /// - terminalTabs: AppleScript-enumerated tabs
54
+ /// - windows: CGWindowList entries
55
+ static func synthesize(
56
+ processTable: [Int: ProcessEntry],
57
+ interesting: [ProcessEntry],
58
+ tmuxSessions: [TmuxSession],
59
+ terminalTabs: [TerminalTab],
60
+ windows: [UInt32: WindowEntry]
61
+ ) -> [TerminalInstance] {
62
+
63
+ // 1. Single pass: index ALL processes by normalized TTY
64
+ // This avoids O(TTYs × processes) re-scans later.
65
+ var allProcessesByTTY: [String: [ProcessEntry]] = [:]
66
+ for entry in processTable.values {
67
+ let tty = TerminalQuery.normalizeTTY(entry.tty)
68
+ guard tty != "??" else { continue }
69
+ allProcessesByTTY[tty, default: []].append(entry)
70
+ }
71
+
72
+ // 2. Group interesting processes by TTY (subset of above)
73
+ var interestingByTTY: [String: [ProcessEntry]] = [:]
74
+ for entry in interesting {
75
+ let tty = TerminalQuery.normalizeTTY(entry.tty)
76
+ guard tty != "??" else { continue }
77
+ interestingByTTY[tty, default: []].append(entry)
78
+ }
79
+
80
+ // 3. Build tmux pane → TTY lookup
81
+ var tmuxByTTY: [String: (session: String, paneId: String)] = [:]
82
+ for session in tmuxSessions {
83
+ for pane in session.panes {
84
+ if let entry = processTable[pane.pid] {
85
+ let tty = TerminalQuery.normalizeTTY(entry.tty)
86
+ if tty != "??" {
87
+ tmuxByTTY[tty] = (session: session.name, paneId: pane.id)
88
+ }
89
+ }
90
+ }
91
+ }
92
+
93
+ // 4. Index terminal tabs by TTY
94
+ var tabByTTY: [String: TerminalTab] = [:]
95
+ for tab in terminalTabs {
96
+ tabByTTY[tab.tty] = tab
97
+ }
98
+
99
+ // 5. Collect all known TTYs (union of all maps)
100
+ var allTTYs = Set(interestingByTTY.keys)
101
+ allTTYs.formUnion(tmuxByTTY.keys)
102
+ allTTYs.formUnion(tabByTTY.keys)
103
+
104
+ // 6. Build window lookup for positional matching
105
+ let windowsByApp = buildWindowsByApp(windows)
106
+
107
+ // 7. For each TTY, merge all slices
108
+ var instances: [TerminalInstance] = []
109
+ for tty in allTTYs {
110
+ let procs = interestingByTTY[tty] ?? []
111
+ let tab = tabByTTY[tty]
112
+ let tmux = tmuxByTTY[tty]
113
+
114
+ // Shell PID: process on this TTY whose parent is NOT on this TTY
115
+ let ttyProcs = allProcessesByTTY[tty] ?? []
116
+ let ttyPids = Set(ttyProcs.map(\.pid))
117
+ let shellPid = ttyProcs.first { !ttyPids.contains($0.ppid) }?.pid
118
+
119
+ // CWD: deepest interesting process's cwd, or shell's cwd
120
+ let cwd = procs.last(where: { $0.cwd != nil })?.cwd
121
+ ?? (shellPid.flatMap { processTable[$0]?.cwd })
122
+
123
+ // Window: try lattices tag match first, then positional
124
+ let windowMatch = resolveWindow(
125
+ tmuxSession: tmux?.session,
126
+ tab: tab,
127
+ windowsByApp: windowsByApp,
128
+ allWindows: windows
129
+ )
130
+
131
+ instances.append(TerminalInstance(
132
+ tty: tty,
133
+ app: tab?.app,
134
+ windowIndex: tab?.windowIndex,
135
+ tabIndex: tab?.tabIndex,
136
+ isActiveTab: tab?.isActiveTab ?? false,
137
+ tabTitle: tab?.title,
138
+ terminalSessionId: tab?.sessionId,
139
+ processes: procs,
140
+ shellPid: shellPid,
141
+ cwd: cwd,
142
+ tmuxSession: tmux?.session,
143
+ tmuxPaneId: tmux?.paneId,
144
+ windowId: windowMatch?.wid,
145
+ windowTitle: windowMatch?.title
146
+ ))
147
+ }
148
+
149
+ // 7. Sort: Claude first, active tabs first, then by TTY
150
+ instances.sort { a, b in
151
+ if a.hasClaude != b.hasClaude { return a.hasClaude }
152
+ if a.isActiveTab != b.isActiveTab { return a.isActiveTab }
153
+ return a.tty < b.tty
154
+ }
155
+
156
+ return instances
157
+ }
158
+
159
+ // MARK: - Private Helpers
160
+
161
+ /// Group windows by app name for positional matching.
162
+ private static func buildWindowsByApp(_ windows: [UInt32: WindowEntry]) -> [String: [WindowEntry]] {
163
+ var result: [String: [WindowEntry]] = [:]
164
+ for w in windows.values {
165
+ result[w.app, default: []].append(w)
166
+ }
167
+ // Sort each app's windows for consistent positional matching
168
+ for key in result.keys {
169
+ result[key]?.sort { $0.wid < $1.wid }
170
+ }
171
+ return result
172
+ }
173
+
174
+ /// Resolve a window for this TTY. Try lattices tag match first, then positional.
175
+ private static func resolveWindow(
176
+ tmuxSession: String?,
177
+ tab: TerminalTab?,
178
+ windowsByApp: [String: [WindowEntry]],
179
+ allWindows: [UInt32: WindowEntry]
180
+ ) -> WindowEntry? {
181
+ // Strategy 1: lattices session tag match
182
+ if let session = tmuxSession {
183
+ let tag = Terminal.windowTag(for: session)
184
+ if let match = allWindows.values.first(where: { $0.title.contains(tag) }) {
185
+ return match
186
+ }
187
+ }
188
+
189
+ // Strategy 2: positional match by app + window index
190
+ if let tab = tab {
191
+ let appName = tab.app.rawValue
192
+ if let appWindows = windowsByApp[appName],
193
+ tab.windowIndex < appWindows.count {
194
+ return appWindows[tab.windowIndex]
195
+ }
196
+ }
197
+
198
+ return nil
199
+ }
200
+ }
@@ -0,0 +1,163 @@
1
+ import SwiftUI
2
+
3
+ // MARK: - Colors
4
+
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)
12
+
13
+ // Text
14
+ 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)
17
+
18
+ // Functional accents
19
+ static let running = Color(red: 0.20, green: 0.78, blue: 0.45) // Green
20
+ static let detach = Color(red: 0.96, green: 0.65, blue: 0.14) // Amber
21
+ static let kill = Color(red: 0.94, green: 0.30, blue: 0.35) // Red
22
+ static let launch = Color.white // Clean white
23
+ }
24
+
25
+ // MARK: - Typography
26
+
27
+ enum Typo {
28
+ private static let jetbrains = "JetBrains Mono"
29
+ private static let geist = "GeistMono Nerd Font"
30
+ private static let gohu = "GohuFontuni14 Nerd Font"
31
+
32
+ static func title(_ size: CGFloat = 15) -> Font {
33
+ .system(size: size, weight: .bold, design: .rounded)
34
+ }
35
+
36
+ static func heading(_ size: CGFloat = 13) -> Font {
37
+ .system(size: size, weight: .semibold, design: .rounded)
38
+ }
39
+
40
+ static func body(_ size: CGFloat = 12) -> Font {
41
+ .system(size: size, weight: .regular, design: .rounded)
42
+ }
43
+
44
+ static func caption(_ size: CGFloat = 10) -> Font {
45
+ .system(size: size, weight: .medium, design: .rounded)
46
+ }
47
+
48
+ static func mono(_ size: CGFloat = 11) -> Font {
49
+ .custom(jetbrains, size: size)
50
+ }
51
+
52
+ static func monoBold(_ size: CGFloat = 11) -> Font {
53
+ Font.custom(jetbrains, size: size).weight(.semibold)
54
+ }
55
+
56
+ static func geistMono(_ size: CGFloat = 11) -> Font {
57
+ .custom(geist, size: size)
58
+ }
59
+
60
+ static func geistMonoBold(_ size: CGFloat = 11) -> Font {
61
+ Font.custom(geist, size: size).weight(.medium)
62
+ }
63
+
64
+ static func pixel(_ size: CGFloat = 14) -> Font {
65
+ .custom(gohu, size: size)
66
+ }
67
+ }
68
+
69
+ // MARK: - Background
70
+
71
+ struct PanelBackground: View {
72
+ var body: some View {
73
+ Palette.bg
74
+ }
75
+ }
76
+
77
+ // MARK: - Reusable modifiers
78
+
79
+ struct GlassCard: ViewModifier {
80
+ var isHovered: Bool = false
81
+
82
+ func body(content: Content) -> some View {
83
+ content
84
+ .background(
85
+ RoundedRectangle(cornerRadius: 5)
86
+ .fill(isHovered ? Palette.surfaceHov : Palette.surface)
87
+ .overlay(
88
+ RoundedRectangle(cornerRadius: 5)
89
+ .strokeBorder(isHovered ? Palette.borderLit : Palette.border, lineWidth: 0.5)
90
+ )
91
+ )
92
+ }
93
+ }
94
+
95
+ struct LiquidGlassCard: ViewModifier {
96
+ func body(content: Content) -> some View {
97
+ content
98
+ .background(
99
+ ZStack {
100
+ // Base: translucent dark fill
101
+ RoundedRectangle(cornerRadius: 10)
102
+ .fill(Color.white.opacity(0.04))
103
+
104
+ // Subtle gradient: brighter at top edge for "glass reflection"
105
+ RoundedRectangle(cornerRadius: 10)
106
+ .fill(
107
+ LinearGradient(
108
+ colors: [Color.white.opacity(0.06), Color.clear],
109
+ startPoint: .top,
110
+ endPoint: .center
111
+ )
112
+ )
113
+
114
+ // Border: top-bright, bottom-dark for depth
115
+ RoundedRectangle(cornerRadius: 10)
116
+ .strokeBorder(
117
+ LinearGradient(
118
+ colors: [Color.white.opacity(0.12), Color.white.opacity(0.04)],
119
+ startPoint: .top,
120
+ endPoint: .bottom
121
+ ),
122
+ lineWidth: 0.5
123
+ )
124
+ }
125
+ )
126
+ .shadow(color: Color.black.opacity(0.2), radius: 8, y: 4)
127
+ }
128
+ }
129
+
130
+ struct AngularButton: ViewModifier {
131
+ let color: Color
132
+ var filled: Bool = true
133
+
134
+ func body(content: Content) -> some View {
135
+ content
136
+ .font(Typo.monoBold(10))
137
+ .foregroundColor(filled ? Palette.bg : color)
138
+ .padding(.horizontal, 8)
139
+ .padding(.vertical, 4)
140
+ .background(
141
+ RoundedRectangle(cornerRadius: 3)
142
+ .fill(filled ? color : color.opacity(0.10))
143
+ )
144
+ .overlay(
145
+ RoundedRectangle(cornerRadius: 3)
146
+ .strokeBorder(filled ? Color.clear : color.opacity(0.25), lineWidth: 0.5)
147
+ )
148
+ }
149
+ }
150
+
151
+ extension View {
152
+ func glassCard(hovered: Bool = false) -> some View {
153
+ modifier(GlassCard(isHovered: hovered))
154
+ }
155
+
156
+ func liquidGlass() -> some View {
157
+ modifier(LiquidGlassCard())
158
+ }
159
+
160
+ func angularButton(_ color: Color, filled: Bool = true) -> some View {
161
+ modifier(AngularButton(color: color, filled: filled))
162
+ }
163
+ }
@@ -0,0 +1,209 @@
1
+ import SwiftUI
2
+
3
+ struct TilePickerView: View {
4
+ let sessionName: String
5
+ let terminal: Terminal
6
+ let onSelect: (TilePosition) -> Void
7
+ let onGoToSpace: (Int) -> Void // space ID
8
+ let onDismiss: () -> Void
9
+
10
+ @State private var hoveredTile: TilePosition?
11
+ @State private var hoveredSpace: Int? // space ID
12
+ @State private var displaySpaces: [DisplaySpaces] = []
13
+ @State private var windowSpaceId: Int = 0
14
+ @State private var currentTile: TilePosition?
15
+
16
+ private let grid: [[TilePosition]] = [
17
+ [.topLeft, .topRight],
18
+ [.left, .right],
19
+ [.bottomLeft, .bottomRight],
20
+ ]
21
+
22
+ var body: some View {
23
+ VStack(spacing: 8) {
24
+ HStack {
25
+ Text("TILE WINDOW")
26
+ .font(Typo.pixel(12))
27
+ .foregroundColor(Palette.running)
28
+ Spacer()
29
+ Button(action: onDismiss) {
30
+ Image(systemName: "xmark")
31
+ .font(.system(size: 8, weight: .bold))
32
+ .foregroundColor(Palette.textDim)
33
+ .frame(width: 18, height: 18)
34
+ .background(
35
+ RoundedRectangle(cornerRadius: 3)
36
+ .fill(Palette.surface)
37
+ )
38
+ }
39
+ .buttonStyle(.plain)
40
+ }
41
+
42
+ // Tile grid
43
+ VStack(spacing: 3) {
44
+ ForEach(grid, id: \.first?.id) { row in
45
+ HStack(spacing: 3) {
46
+ ForEach(row) { tile in
47
+ tileCell(tile)
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ HStack(spacing: 3) {
54
+ tileWideCell(.maximize)
55
+ tileWideCell(.center)
56
+ }
57
+
58
+ // Spaces per display — navigate to space
59
+ ForEach(displaySpaces, id: \.displayIndex) { display in
60
+ if display.spaces.count > 1 || displaySpaces.count > 1 {
61
+ Rectangle()
62
+ .fill(Palette.border)
63
+ .frame(height: 0.5)
64
+ .padding(.vertical, 2)
65
+
66
+ HStack {
67
+ Text(displaySpaces.count > 1
68
+ ? "DISPLAY \(display.displayIndex + 1) SPACES"
69
+ : "GO TO SPACE")
70
+ .font(Typo.pixel(10))
71
+ .foregroundColor(Palette.textMuted)
72
+ Spacer()
73
+ if windowSpaceId > 0 {
74
+ let windowOnDisplay = display.spaces.contains { $0.id == windowSpaceId }
75
+ if windowOnDisplay {
76
+ Text("window here")
77
+ .font(Typo.mono(9))
78
+ .foregroundColor(Palette.running)
79
+ }
80
+ }
81
+ }
82
+
83
+ HStack(spacing: 3) {
84
+ ForEach(display.spaces) { space in
85
+ spaceCell(space: space)
86
+ }
87
+ }
88
+ }
89
+ }
90
+ }
91
+ .padding(12)
92
+ .background(
93
+ RoundedRectangle(cornerRadius: 6)
94
+ .fill(Palette.surface)
95
+ .overlay(
96
+ RoundedRectangle(cornerRadius: 6)
97
+ .strokeBorder(Palette.borderLit, lineWidth: 0.5)
98
+ )
99
+ )
100
+ .onAppear {
101
+ displaySpaces = WindowTiler.getDisplaySpaces()
102
+ // Find which space this session's window is on + current tile
103
+ if let info = WindowTiler.getWindowInfo(session: sessionName, terminal: terminal) {
104
+ if let spaceId = WindowTiler.getSpacesForWindow(info.wid).first {
105
+ windowSpaceId = spaceId
106
+ }
107
+ currentTile = info.tilePosition
108
+ }
109
+ }
110
+ }
111
+
112
+ private func tileCell(_ tile: TilePosition) -> some View {
113
+ let isCurrent = currentTile == tile
114
+ let isHovered = hoveredTile == tile
115
+ return Button {
116
+ onSelect(tile)
117
+ } label: {
118
+ Image(systemName: tile.icon)
119
+ .font(.system(size: 14))
120
+ .foregroundColor(isHovered ? Palette.running : isCurrent ? Palette.running.opacity(0.8) : Palette.textDim)
121
+ .frame(maxWidth: .infinity)
122
+ .frame(height: 32)
123
+ .background(
124
+ RoundedRectangle(cornerRadius: 4)
125
+ .fill(isHovered ? Palette.running.opacity(0.1) : isCurrent ? Palette.running.opacity(0.06) : Palette.bg)
126
+ .overlay(
127
+ RoundedRectangle(cornerRadius: 4)
128
+ .strokeBorder(
129
+ isHovered ? Palette.running.opacity(0.3) : isCurrent ? Palette.running.opacity(0.25) : Palette.border,
130
+ lineWidth: isCurrent ? 1 : 0.5
131
+ )
132
+ )
133
+ )
134
+ }
135
+ .buttonStyle(.plain)
136
+ .onHover { hoveredTile = $0 ? tile : nil }
137
+ }
138
+
139
+ private func tileWideCell(_ tile: TilePosition) -> some View {
140
+ let isCurrent = currentTile == tile
141
+ let isHovered = hoveredTile == tile
142
+ return Button {
143
+ onSelect(tile)
144
+ } label: {
145
+ HStack(spacing: 4) {
146
+ Image(systemName: tile.icon)
147
+ .font(.system(size: 12))
148
+ Text(tile.label)
149
+ .font(Typo.mono(10))
150
+ }
151
+ .foregroundColor(isHovered ? Palette.running : isCurrent ? Palette.running.opacity(0.8) : Palette.textDim)
152
+ .frame(maxWidth: .infinity)
153
+ .frame(height: 28)
154
+ .background(
155
+ RoundedRectangle(cornerRadius: 4)
156
+ .fill(isHovered ? Palette.running.opacity(0.1) : isCurrent ? Palette.running.opacity(0.06) : Palette.bg)
157
+ .overlay(
158
+ RoundedRectangle(cornerRadius: 4)
159
+ .strokeBorder(
160
+ isHovered ? Palette.running.opacity(0.3) : isCurrent ? Palette.running.opacity(0.25) : Palette.border,
161
+ lineWidth: isCurrent ? 1 : 0.5
162
+ )
163
+ )
164
+ )
165
+ }
166
+ .buttonStyle(.plain)
167
+ .onHover { hoveredTile = $0 ? tile : nil }
168
+ }
169
+
170
+ private func spaceCell(space: SpaceInfo) -> some View {
171
+ let hasWindow = space.id == windowSpaceId
172
+ return Button {
173
+ onGoToSpace(space.id)
174
+ } label: {
175
+ HStack(spacing: 4) {
176
+ Image(systemName: space.isCurrent ? "desktopcomputer" : hasWindow ? "macwindow" : "rectangle.on.rectangle")
177
+ .font(.system(size: 10))
178
+ Text("\(space.index)")
179
+ .font(Typo.monoBold(11))
180
+ }
181
+ .foregroundColor(
182
+ hoveredSpace == space.id ? Palette.running :
183
+ hasWindow ? Palette.running :
184
+ space.isCurrent ? Palette.text : Palette.textDim
185
+ )
186
+ .frame(maxWidth: .infinity)
187
+ .frame(height: 28)
188
+ .background(
189
+ RoundedRectangle(cornerRadius: 4)
190
+ .fill(
191
+ hoveredSpace == space.id ? Palette.running.opacity(0.1) :
192
+ hasWindow ? Palette.running.opacity(0.05) :
193
+ space.isCurrent ? Palette.bg.opacity(0.5) : Palette.bg
194
+ )
195
+ .overlay(
196
+ RoundedRectangle(cornerRadius: 4)
197
+ .strokeBorder(
198
+ hoveredSpace == space.id ? Palette.running.opacity(0.3) :
199
+ hasWindow ? Palette.running.opacity(0.3) :
200
+ space.isCurrent ? Palette.borderLit : Palette.border,
201
+ lineWidth: 0.5
202
+ )
203
+ )
204
+ )
205
+ }
206
+ .buttonStyle(.plain)
207
+ .onHover { hoveredSpace = $0 ? space.id : nil }
208
+ }
209
+ }
@@ -0,0 +1,53 @@
1
+ import Foundation
2
+
3
+ final class TmuxModel: ObservableObject {
4
+ static let shared = TmuxModel()
5
+
6
+ @Published private(set) var sessions: [TmuxSession] = []
7
+ private var timer: Timer?
8
+
9
+ func start(interval: TimeInterval = 3.0) {
10
+ guard timer == nil else { return }
11
+ DiagnosticLog.shared.info("TmuxModel: starting (interval=\(interval)s)")
12
+ poll()
13
+ timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
14
+ self?.poll()
15
+ }
16
+ }
17
+
18
+ func stop() {
19
+ timer?.invalidate()
20
+ timer = nil
21
+ }
22
+
23
+ func poll() {
24
+ let fresh = TmuxQuery.listSessions()
25
+ let changed = sessionsChanged(old: sessions, new: fresh)
26
+
27
+ DispatchQueue.main.async {
28
+ self.sessions = fresh
29
+ }
30
+
31
+ if changed {
32
+ EventBus.shared.post(.tmuxChanged(sessions: fresh))
33
+ }
34
+ }
35
+
36
+ func isRunning(_ name: String) -> Bool {
37
+ sessions.contains { $0.name == name }
38
+ }
39
+
40
+ private func sessionsChanged(old: [TmuxSession], new: [TmuxSession]) -> Bool {
41
+ guard old.count == new.count else { return true }
42
+ let oldNames = Set(old.map(\.name))
43
+ let newNames = Set(new.map(\.name))
44
+ if oldNames != newNames { return true }
45
+ // Check pane counts changed
46
+ for newSession in new {
47
+ guard let oldSession = old.first(where: { $0.name == newSession.name }) else { return true }
48
+ if oldSession.panes.count != newSession.panes.count { return true }
49
+ if oldSession.attached != newSession.attached { return true }
50
+ }
51
+ return false
52
+ }
53
+ }