@arach/lattices 0.1.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 (64) hide show
  1. package/README.md +157 -0
  2. package/app/Lattices.app/Contents/Info.plist +24 -0
  3. package/app/Package.swift +13 -0
  4. package/app/Sources/App.swift +49 -0
  5. package/app/Sources/AppDelegate.swift +104 -0
  6. package/app/Sources/AppShellView.swift +62 -0
  7. package/app/Sources/AppTypeClassifier.swift +70 -0
  8. package/app/Sources/AppWindowShell.swift +63 -0
  9. package/app/Sources/CheatSheetHUD.swift +331 -0
  10. package/app/Sources/CommandModeState.swift +1341 -0
  11. package/app/Sources/CommandModeView.swift +1380 -0
  12. package/app/Sources/CommandModeWindow.swift +192 -0
  13. package/app/Sources/CommandPaletteView.swift +307 -0
  14. package/app/Sources/CommandPaletteWindow.swift +134 -0
  15. package/app/Sources/DaemonProtocol.swift +101 -0
  16. package/app/Sources/DaemonServer.swift +406 -0
  17. package/app/Sources/DesktopModel.swift +121 -0
  18. package/app/Sources/DesktopModelTypes.swift +71 -0
  19. package/app/Sources/DiagnosticLog.swift +253 -0
  20. package/app/Sources/EventBus.swift +29 -0
  21. package/app/Sources/HotkeyManager.swift +249 -0
  22. package/app/Sources/HotkeyStore.swift +330 -0
  23. package/app/Sources/InventoryManager.swift +35 -0
  24. package/app/Sources/InventoryPath.swift +43 -0
  25. package/app/Sources/KeyRecorderView.swift +210 -0
  26. package/app/Sources/LatticesApi.swift +915 -0
  27. package/app/Sources/MainView.swift +507 -0
  28. package/app/Sources/MainWindow.swift +70 -0
  29. package/app/Sources/OrphanRow.swift +129 -0
  30. package/app/Sources/PaletteCommand.swift +409 -0
  31. package/app/Sources/PermissionChecker.swift +115 -0
  32. package/app/Sources/Preferences.swift +48 -0
  33. package/app/Sources/ProcessModel.swift +199 -0
  34. package/app/Sources/ProcessQuery.swift +151 -0
  35. package/app/Sources/Project.swift +28 -0
  36. package/app/Sources/ProjectRow.swift +368 -0
  37. package/app/Sources/ProjectScanner.swift +121 -0
  38. package/app/Sources/ScreenMapState.swift +2397 -0
  39. package/app/Sources/ScreenMapView.swift +2817 -0
  40. package/app/Sources/ScreenMapWindowController.swift +89 -0
  41. package/app/Sources/SessionManager.swift +72 -0
  42. package/app/Sources/SettingsView.swift +641 -0
  43. package/app/Sources/SettingsWindow.swift +20 -0
  44. package/app/Sources/TabGroupRow.swift +178 -0
  45. package/app/Sources/Terminal.swift +259 -0
  46. package/app/Sources/TerminalQuery.swift +156 -0
  47. package/app/Sources/TerminalSynthesizer.swift +200 -0
  48. package/app/Sources/Theme.swift +124 -0
  49. package/app/Sources/TilePickerView.swift +209 -0
  50. package/app/Sources/TmuxModel.swift +53 -0
  51. package/app/Sources/TmuxQuery.swift +81 -0
  52. package/app/Sources/WindowTiler.swift +1752 -0
  53. package/app/Sources/WorkspaceManager.swift +434 -0
  54. package/bin/daemon-client.js +187 -0
  55. package/bin/lattices-app.js +205 -0
  56. package/bin/lattices.js +1295 -0
  57. package/docs/api.md +707 -0
  58. package/docs/app.md +250 -0
  59. package/docs/concepts.md +225 -0
  60. package/docs/config.md +234 -0
  61. package/docs/layers.md +317 -0
  62. package/docs/overview.md +74 -0
  63. package/docs/quickstart.md +82 -0
  64. package/package.json +38 -0
@@ -0,0 +1,507 @@
1
+ import SwiftUI
2
+
3
+ struct MainView: View {
4
+ @ObservedObject var scanner: ProjectScanner
5
+ @StateObject private var prefs = Preferences.shared
6
+ @StateObject private var permChecker = PermissionChecker.shared
7
+ @ObservedObject private var workspace = WorkspaceManager.shared
8
+ @StateObject private var inventory = InventoryManager.shared
9
+ @State private var searchText = ""
10
+ @State private var hasCheckedSetup = false
11
+ @State private var bannerDismissed = false
12
+ @State private var orphanSectionCollapsed = true
13
+ private var filtered: [Project] {
14
+ if searchText.isEmpty { return scanner.projects }
15
+ return scanner.projects.filter {
16
+ $0.name.localizedCaseInsensitiveContains(searchText)
17
+ }
18
+ }
19
+
20
+ private var filteredOrphans: [TmuxSession] {
21
+ if searchText.isEmpty { return inventory.orphans }
22
+ return inventory.orphans.filter {
23
+ $0.name.localizedCaseInsensitiveContains(searchText)
24
+ }
25
+ }
26
+
27
+ private var needsSetup: Bool { prefs.scanRoot.isEmpty }
28
+ private var runningCount: Int { scanner.projects.filter(\.isRunning).count }
29
+
30
+ var body: some View {
31
+ VStack(spacing: 0) {
32
+ mainContent
33
+ }
34
+ .frame(minWidth: 380, idealWidth: 380, maxWidth: 600, minHeight: 460, idealHeight: 460, maxHeight: .infinity)
35
+ .background(PanelBackground())
36
+ .preferredColorScheme(.dark)
37
+ .onAppear {
38
+ if needsSetup && !hasCheckedSetup {
39
+ hasCheckedSetup = true
40
+ SettingsWindowController.shared.show()
41
+ }
42
+ scanner.updateRoot(prefs.scanRoot)
43
+ scanner.scan()
44
+ inventory.refresh()
45
+ permChecker.check()
46
+ bannerDismissed = false
47
+ }
48
+ }
49
+
50
+ private var mainContent: some View {
51
+ VStack(spacing: 0) {
52
+ // Title bar
53
+ HStack {
54
+ Text("lattices")
55
+ .font(Typo.title())
56
+ .foregroundColor(Palette.text)
57
+
58
+ if runningCount > 0 || !inventory.orphans.isEmpty {
59
+ let total = runningCount + inventory.orphans.count
60
+ Text("\(total) session\(total == 1 ? "" : "s")")
61
+ .font(Typo.mono(10))
62
+ .foregroundColor(Palette.running)
63
+ .padding(.leading, 4)
64
+ } else {
65
+ Text("None")
66
+ .font(Typo.mono(10))
67
+ .foregroundColor(Palette.textMuted)
68
+ .padding(.leading, 4)
69
+ }
70
+
71
+ Spacer()
72
+
73
+ headerButton(icon: "arrow.up.left.and.arrow.down.right") {
74
+ // Dismiss the MenuBarExtra panel immediately
75
+ for window in NSApp.windows {
76
+ if window is NSPanel, window.isVisible,
77
+ !CommandPaletteWindow.shared.isVisible || window.frame.width < 500 {
78
+ // MenuBarExtra panels are small (~380px); command palette is 540px
79
+ if window.frame.width <= 400 {
80
+ window.orderOut(nil)
81
+ }
82
+ }
83
+ }
84
+ MainWindow.shared.show()
85
+ }
86
+ headerButton(icon: "arrow.clockwise") { scanner.scan(); inventory.refresh() }
87
+ }
88
+ .padding(.horizontal, 18)
89
+ .padding(.top, 14)
90
+ .padding(.bottom, 10)
91
+
92
+ // Layer switcher
93
+ if let config = workspace.config, let layers = config.layers, layers.count > 1 {
94
+ layerBar(config: config)
95
+ }
96
+
97
+ // Search
98
+ HStack(spacing: 8) {
99
+ Image(systemName: "magnifyingglass")
100
+ .foregroundColor(Palette.textMuted)
101
+ .font(.system(size: 11))
102
+ TextField("Search projects...", text: $searchText)
103
+ .textFieldStyle(.plain)
104
+ .font(Typo.body(13))
105
+ .foregroundColor(Palette.text)
106
+ if !searchText.isEmpty {
107
+ Button { searchText = "" } label: {
108
+ Image(systemName: "xmark.circle.fill")
109
+ .foregroundColor(Palette.textMuted)
110
+ .font(.system(size: 11))
111
+ }
112
+ .buttonStyle(.plain)
113
+ }
114
+ }
115
+ .padding(.horizontal, 12)
116
+ .padding(.vertical, 8)
117
+ .background(
118
+ RoundedRectangle(cornerRadius: 4)
119
+ .fill(Palette.surface)
120
+ )
121
+ .padding(.horizontal, 14)
122
+ .padding(.bottom, 10)
123
+
124
+ // Permission banner
125
+ if !permChecker.allGranted && !bannerDismissed {
126
+ permissionBanner
127
+ }
128
+
129
+ Rectangle()
130
+ .fill(Palette.border)
131
+ .frame(height: 0.5)
132
+
133
+ // List
134
+ if filtered.isEmpty && (workspace.config?.groups ?? []).isEmpty {
135
+ Spacer()
136
+ emptyState
137
+ Spacer()
138
+ } else {
139
+ ScrollView {
140
+ LazyVStack(spacing: 4) {
141
+ // Tab groups section
142
+ if let groups = workspace.config?.groups, !groups.isEmpty, searchText.isEmpty {
143
+ ForEach(groups) { group in
144
+ TabGroupRow(group: group, workspace: workspace)
145
+ }
146
+
147
+ if !filtered.isEmpty {
148
+ Rectangle()
149
+ .fill(Palette.border)
150
+ .frame(height: 0.5)
151
+ .padding(.vertical, 4)
152
+ }
153
+ }
154
+
155
+ // Projects
156
+ ForEach(filtered) { project in
157
+ ProjectRow(project: project) {
158
+ SessionManager.launch(project: project)
159
+ } onDetach: {
160
+ SessionManager.detach(project: project)
161
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
162
+ scanner.refreshStatus()
163
+ }
164
+ } onKill: {
165
+ SessionManager.kill(project: project)
166
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
167
+ scanner.refreshStatus()
168
+ }
169
+ } onSync: {
170
+ SessionManager.sync(project: project)
171
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
172
+ scanner.refreshStatus()
173
+ }
174
+ } onRestart: { paneName in
175
+ SessionManager.restart(project: project, paneName: paneName)
176
+ }
177
+ }
178
+
179
+ // Orphan sessions
180
+ if !filteredOrphans.isEmpty {
181
+ orphanSection
182
+ }
183
+ }
184
+ .padding(.horizontal, 10)
185
+ .padding(.vertical, 8)
186
+ }
187
+ }
188
+
189
+ Rectangle()
190
+ .fill(Palette.border)
191
+ .frame(height: 0.5)
192
+
193
+ // Status bar
194
+ statusBar
195
+ }
196
+ }
197
+
198
+ // MARK: - Orphan section
199
+
200
+ private var orphanSection: some View {
201
+ VStack(spacing: 4) {
202
+ Rectangle()
203
+ .fill(Palette.border)
204
+ .frame(height: 0.5)
205
+ .padding(.vertical, 4)
206
+
207
+ // Section header
208
+ Button {
209
+ withAnimation(.easeOut(duration: 0.15)) { orphanSectionCollapsed.toggle() }
210
+ } label: {
211
+ HStack(spacing: 6) {
212
+ Image(systemName: orphanSectionCollapsed ? "chevron.right" : "chevron.down")
213
+ .font(.system(size: 9, weight: .semibold))
214
+ .foregroundColor(Palette.textMuted)
215
+
216
+ Text("Unmanaged Sessions")
217
+ .font(Typo.caption(10))
218
+ .foregroundColor(Palette.textMuted)
219
+
220
+ Text("\(filteredOrphans.count)")
221
+ .font(Typo.mono(9))
222
+ .foregroundColor(Palette.detach)
223
+ .padding(.horizontal, 5)
224
+ .padding(.vertical, 1)
225
+ .background(
226
+ RoundedRectangle(cornerRadius: 3)
227
+ .fill(Palette.detach.opacity(0.12))
228
+ )
229
+
230
+ Spacer()
231
+ }
232
+ }
233
+ .buttonStyle(.plain)
234
+ .padding(.horizontal, 4)
235
+
236
+ if !orphanSectionCollapsed {
237
+ ForEach(filteredOrphans) { session in
238
+ OrphanRow(
239
+ session: session,
240
+ onAttach: {
241
+ let terminal = Preferences.shared.terminal
242
+ terminal.focusOrAttach(session: session.name)
243
+ },
244
+ onKill: {
245
+ SessionManager.killByName(session.name)
246
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
247
+ inventory.refresh()
248
+ }
249
+ }
250
+ )
251
+ }
252
+ }
253
+ }
254
+ }
255
+
256
+ // MARK: - Status bar
257
+
258
+ private var statusBar: some View {
259
+ HStack(spacing: 0) {
260
+ // Settings button
261
+ Button { SettingsWindowController.shared.show() } label: {
262
+ Image(systemName: "gearshape")
263
+ .font(.system(size: 10, weight: .medium))
264
+ .foregroundColor(Palette.textMuted)
265
+ }
266
+ .buttonStyle(.plain)
267
+ .help("Settings")
268
+
269
+ // Diagnostics toggle
270
+ Button { DiagnosticWindow.shared.toggle() } label: {
271
+ HStack(spacing: 3) {
272
+ Image(systemName: "stethoscope")
273
+ .font(.system(size: 10, weight: .medium))
274
+ .foregroundColor(DiagnosticWindow.shared.isVisible ? Palette.running : Palette.textMuted)
275
+ if !permChecker.allGranted {
276
+ Circle()
277
+ .fill(Palette.detach)
278
+ .frame(width: 5, height: 5)
279
+ }
280
+ }
281
+ }
282
+ .buttonStyle(.plain)
283
+ .help(!permChecker.allGranted ? "Permissions missing — open diagnostics" : "Toggle diagnostics")
284
+
285
+ Rectangle()
286
+ .fill(Palette.border)
287
+ .frame(width: 0.5, height: 12)
288
+ .padding(.horizontal, 8)
289
+
290
+ // Config summary — keys dim, values white
291
+ statusLine
292
+
293
+ Spacer()
294
+
295
+ // Palette hint
296
+ Text("\u{2318}\u{21E7}M")
297
+ .font(Typo.mono(9))
298
+ .foregroundColor(Palette.textMuted)
299
+ .help("Command palette (Cmd+Shift+M)")
300
+
301
+ Rectangle()
302
+ .fill(Palette.border)
303
+ .frame(width: 0.5, height: 12)
304
+ .padding(.horizontal, 6)
305
+
306
+ // Quit
307
+ Button { NSApp.terminate(nil) } label: {
308
+ Image(systemName: "power")
309
+ .font(.system(size: 9, weight: .medium))
310
+ .foregroundColor(Palette.textMuted)
311
+ }
312
+ .buttonStyle(.plain)
313
+ .help("Quit lattices")
314
+ }
315
+ .padding(.horizontal, 14)
316
+ .padding(.vertical, 7)
317
+ .background(Palette.surface.opacity(0.4))
318
+ }
319
+
320
+ private var statusLine: some View {
321
+ HStack(spacing: 3) {
322
+ statusPair("terminal", prefs.terminal.rawValue.lowercased())
323
+ statusDot
324
+ statusPair("mode", prefs.mode.rawValue)
325
+ statusDot
326
+ statusPair("home", "~/\((prefs.scanRoot as NSString).lastPathComponent)")
327
+ }
328
+ }
329
+
330
+ private func statusPair(_ key: String, _ value: String) -> some View {
331
+ HStack(spacing: 3) {
332
+ Text(key + ":")
333
+ .font(Typo.mono(9))
334
+ .foregroundColor(Palette.textMuted)
335
+ Text(value)
336
+ .font(Typo.mono(9))
337
+ .foregroundColor(Palette.text)
338
+ }
339
+ }
340
+
341
+ private var statusDot: some View {
342
+ Circle()
343
+ .fill(Palette.textMuted)
344
+ .frame(width: 2, height: 2)
345
+ .padding(.horizontal, 4)
346
+ }
347
+
348
+ // MARK: - Empty state
349
+
350
+ private var emptyState: some View {
351
+ VStack(spacing: 14) {
352
+ Image(systemName: "terminal")
353
+ .font(.system(size: 28, weight: .light))
354
+ .foregroundColor(Palette.textMuted)
355
+
356
+ Text("No projects yet")
357
+ .font(Typo.heading(14))
358
+ .foregroundColor(Palette.textDim)
359
+
360
+ Text("Run lattices init in a project\nto add it here")
361
+ .font(Typo.mono(11))
362
+ .foregroundColor(Palette.textMuted)
363
+ .multilineTextAlignment(.center)
364
+ .lineSpacing(3)
365
+ }
366
+ }
367
+
368
+ // MARK: - Permission banner
369
+
370
+ private var permissionBanner: some View {
371
+ VStack(alignment: .leading, spacing: 6) {
372
+ HStack {
373
+ Image(systemName: "exclamationmark.triangle.fill")
374
+ .font(.system(size: 10))
375
+ .foregroundColor(Palette.detach)
376
+ Text("PERMISSIONS NEEDED")
377
+ .font(Typo.monoBold(10))
378
+ .foregroundColor(Palette.detach)
379
+ Spacer()
380
+ Button { bannerDismissed = true } label: {
381
+ Image(systemName: "xmark")
382
+ .font(.system(size: 8, weight: .bold))
383
+ .foregroundColor(Palette.textMuted)
384
+ }
385
+ .buttonStyle(.plain)
386
+ }
387
+
388
+ permissionRow("Accessibility", granted: permChecker.accessibility) {
389
+ permChecker.requestAccessibility()
390
+ }
391
+ permissionRow("Screen Recording", granted: permChecker.screenRecording) {
392
+ permChecker.requestScreenRecording()
393
+ }
394
+
395
+ Text("Click a row to request access.")
396
+ .font(Typo.mono(9))
397
+ .foregroundColor(Palette.textMuted)
398
+ }
399
+ .padding(12)
400
+ .background(
401
+ RoundedRectangle(cornerRadius: 5)
402
+ .fill(Palette.detach.opacity(0.08))
403
+ .overlay(
404
+ RoundedRectangle(cornerRadius: 5)
405
+ .strokeBorder(Palette.detach.opacity(0.20), lineWidth: 0.5)
406
+ )
407
+ )
408
+ .padding(.horizontal, 14)
409
+ .padding(.bottom, 10)
410
+ }
411
+
412
+ private func permissionRow(_ name: String, granted: Bool, open: @escaping () -> Void) -> some View {
413
+ Button(action: { if !granted { open() } }) {
414
+ HStack(spacing: 6) {
415
+ Image(systemName: granted ? "checkmark.circle.fill" : "circle")
416
+ .font(.system(size: 10))
417
+ .foregroundColor(granted ? Palette.running : Palette.detach)
418
+ Text(name)
419
+ .font(Typo.mono(10))
420
+ .foregroundColor(Palette.text)
421
+ Spacer()
422
+ if granted {
423
+ Text("granted")
424
+ .font(Typo.mono(9))
425
+ .foregroundColor(Palette.running)
426
+ } else {
427
+ HStack(spacing: 4) {
428
+ Text("not set")
429
+ .font(Typo.mono(9))
430
+ .foregroundColor(Palette.detach)
431
+ Image(systemName: "arrow.up.forward.square")
432
+ .font(.system(size: 9))
433
+ .foregroundColor(Palette.detach)
434
+ }
435
+ }
436
+ }
437
+ .padding(.vertical, 4)
438
+ .padding(.horizontal, 8)
439
+ .background(
440
+ RoundedRectangle(cornerRadius: 4)
441
+ .fill(granted ? Color.clear : Palette.detach.opacity(0.06))
442
+ )
443
+ }
444
+ .buttonStyle(.plain)
445
+ .disabled(granted)
446
+ }
447
+
448
+ // MARK: - Layer Bar
449
+
450
+ private func layerBar(config: WorkspaceConfig) -> some View {
451
+ HStack(spacing: 6) {
452
+ ForEach(Array((config.layers ?? []).enumerated()), id: \.element.id) { i, layer in
453
+ let isActive = i == workspace.activeLayerIndex
454
+ let counts = workspace.layerRunningCount(index: i)
455
+ Button {
456
+ workspace.tileLayer(index: i)
457
+ } label: {
458
+ VStack(spacing: 2) {
459
+ HStack(spacing: 5) {
460
+ Circle()
461
+ .fill(isActive ? Palette.running : Palette.textMuted.opacity(0.4))
462
+ .frame(width: 6, height: 6)
463
+ Text(layer.label)
464
+ .font(Typo.mono(11))
465
+ .foregroundColor(isActive ? Palette.text : Palette.textDim)
466
+ if counts.total > 0 {
467
+ Text("\(counts.running)/\(counts.total)")
468
+ .font(Typo.mono(8))
469
+ .foregroundColor(counts.running > 0 ? Palette.running : Palette.textMuted)
470
+ }
471
+ }
472
+ Text("\u{2325}\(i + 1)")
473
+ .font(Typo.mono(8))
474
+ .foregroundColor(Palette.textMuted.opacity(0.6))
475
+ }
476
+ .padding(.horizontal, 10)
477
+ .padding(.vertical, 5)
478
+ .background(
479
+ RoundedRectangle(cornerRadius: 5)
480
+ .fill(isActive ? Palette.running.opacity(0.1) : Color.clear)
481
+ )
482
+ .overlay(
483
+ RoundedRectangle(cornerRadius: 5)
484
+ .strokeBorder(isActive ? Palette.running.opacity(0.3) : Palette.border, lineWidth: 0.5)
485
+ )
486
+ }
487
+ .buttonStyle(.plain)
488
+ .disabled(workspace.isSwitching)
489
+ }
490
+ Spacer()
491
+ }
492
+ .padding(.horizontal, 14)
493
+ .padding(.bottom, 8)
494
+ }
495
+
496
+ // MARK: - Helpers
497
+
498
+ private func headerButton(icon: String, action: @escaping () -> Void) -> some View {
499
+ Button(action: action) {
500
+ Image(systemName: icon)
501
+ .font(.system(size: 12, weight: .medium))
502
+ .foregroundColor(Palette.textDim)
503
+ .frame(width: 28, height: 28)
504
+ }
505
+ .buttonStyle(.plain)
506
+ }
507
+ }
@@ -0,0 +1,70 @@
1
+ import AppKit
2
+ import SwiftUI
3
+
4
+ /// Manages the main lattices window as a standalone NSWindow.
5
+ /// Menu bar icon toggles this window open/closed.
6
+ final class MainWindow {
7
+ static let shared = MainWindow()
8
+
9
+ private var window: NSWindow?
10
+
11
+ var isVisible: Bool { window?.isVisible ?? false }
12
+
13
+ func toggle() {
14
+ if let w = window, w.isVisible {
15
+ w.orderOut(nil)
16
+ AppDelegate.updateActivationPolicy()
17
+ } else {
18
+ show()
19
+ }
20
+ }
21
+
22
+ func show() {
23
+ if let existing = window {
24
+ existing.makeKeyAndOrderFront(nil)
25
+ NSApp.activate(ignoringOtherApps: true)
26
+ return
27
+ }
28
+
29
+ let view = MainView(scanner: ProjectScanner.shared)
30
+ .preferredColorScheme(.dark)
31
+
32
+ let hostingView = NSHostingView(rootView: view)
33
+ hostingView.frame = NSRect(x: 0, y: 0, width: 380, height: 460)
34
+
35
+ let w = NSWindow(
36
+ contentRect: NSRect(x: 0, y: 0, width: 380, height: 460),
37
+ styleMask: [.titled, .closable, .resizable, .miniaturizable],
38
+ backing: .buffered,
39
+ defer: false
40
+ )
41
+ w.contentView = hostingView
42
+ w.title = "lattices"
43
+ w.titlebarAppearsTransparent = true
44
+ w.titleVisibility = .hidden
45
+ w.isReleasedWhenClosed = false
46
+ w.backgroundColor = NSColor(red: 0.11, green: 0.11, blue: 0.12, alpha: 1.0)
47
+ w.appearance = NSAppearance(named: .darkAqua)
48
+ w.minSize = NSSize(width: 340, height: 380)
49
+ w.maxSize = NSSize(width: 600, height: 800)
50
+
51
+ // Position near top-right of screen (close to menu bar area)
52
+ if let screen = NSScreen.main {
53
+ let visibleFrame = screen.visibleFrame
54
+ let x = visibleFrame.maxX - 380 - 20
55
+ let y = visibleFrame.maxY - 460 - 10
56
+ w.setFrameOrigin(NSPoint(x: x, y: y))
57
+ }
58
+
59
+ w.makeKeyAndOrderFront(nil)
60
+ NSApp.activate(ignoringOtherApps: true)
61
+
62
+ window = w
63
+ AppDelegate.updateActivationPolicy()
64
+ }
65
+
66
+ func close() {
67
+ window?.orderOut(nil)
68
+ AppDelegate.updateActivationPolicy()
69
+ }
70
+ }
@@ -0,0 +1,129 @@
1
+ import SwiftUI
2
+
3
+ struct OrphanRow: View {
4
+ let session: TmuxSession
5
+ var onAttach: () -> Void
6
+ var onKill: () -> Void
7
+
8
+ @State private var isHovered = false
9
+ @State private var isExpanded = false
10
+
11
+ private var commandSummary: String {
12
+ let commands = session.panes
13
+ .map(\.currentCommand)
14
+ .filter { !$0.isEmpty }
15
+ let unique = commands.count <= 3 ? commands : Array(commands.prefix(3)) + ["..."]
16
+ return "\(session.panes.count) pane\(session.panes.count == 1 ? "" : "s") \u{2014} \(unique.joined(separator: ", "))"
17
+ }
18
+
19
+ var body: some View {
20
+ VStack(spacing: 0) {
21
+ // Header row
22
+ HStack(spacing: 10) {
23
+ // Status bar — amber for orphan
24
+ RoundedRectangle(cornerRadius: 1)
25
+ .fill(Palette.detach)
26
+ .frame(width: 3, height: 32)
27
+
28
+ // Expand chevron
29
+ Button {
30
+ withAnimation(.easeOut(duration: 0.15)) { isExpanded.toggle() }
31
+ } label: {
32
+ Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
33
+ .font(.system(size: 9, weight: .semibold))
34
+ .foregroundColor(Palette.textMuted)
35
+ .frame(width: 14)
36
+ }
37
+ .buttonStyle(.plain)
38
+
39
+ // Info
40
+ VStack(alignment: .leading, spacing: 3) {
41
+ HStack(spacing: 6) {
42
+ Text(session.name)
43
+ .font(Typo.heading(13))
44
+ .foregroundColor(Palette.text)
45
+ .lineLimit(1)
46
+
47
+ if session.attached {
48
+ Text("attached")
49
+ .font(Typo.mono(9))
50
+ .foregroundColor(Palette.detach)
51
+ .padding(.horizontal, 5)
52
+ .padding(.vertical, 1)
53
+ .background(
54
+ RoundedRectangle(cornerRadius: 3)
55
+ .fill(Palette.detach.opacity(0.12))
56
+ )
57
+ }
58
+ }
59
+
60
+ Text(commandSummary)
61
+ .font(Typo.mono(10))
62
+ .foregroundColor(Palette.textMuted)
63
+ .lineLimit(1)
64
+ }
65
+
66
+ Spacer()
67
+
68
+ // Actions
69
+ HStack(spacing: 4) {
70
+ Button(action: onKill) {
71
+ Text("Kill")
72
+ .angularButton(Palette.kill, filled: false)
73
+ }
74
+ .buttonStyle(.plain)
75
+
76
+ Button(action: onAttach) {
77
+ Text("Attach")
78
+ .angularButton(Palette.running)
79
+ }
80
+ .buttonStyle(.plain)
81
+ }
82
+ }
83
+ .padding(.horizontal, 10)
84
+ .padding(.vertical, 8)
85
+ .glassCard(hovered: isHovered)
86
+
87
+ // Expanded pane list
88
+ if isExpanded {
89
+ VStack(spacing: 2) {
90
+ ForEach(session.panes) { pane in
91
+ paneRow(pane)
92
+ }
93
+ }
94
+ .padding(.leading, 36)
95
+ .padding(.trailing, 10)
96
+ .padding(.vertical, 4)
97
+ .transition(.opacity.combined(with: .move(edge: .top)))
98
+ }
99
+ }
100
+ .contentShape(Rectangle())
101
+ .onHover { isHovered = $0 }
102
+ .contextMenu {
103
+ Button("Attach") { onAttach() }
104
+ Divider()
105
+ Button("Kill Session") { onKill() }
106
+ }
107
+ }
108
+
109
+ private func paneRow(_ pane: TmuxPane) -> some View {
110
+ HStack(spacing: 8) {
111
+ Circle()
112
+ .fill(pane.isActive ? Palette.detach.opacity(0.7) : Palette.textMuted)
113
+ .frame(width: 5, height: 5)
114
+
115
+ Text(pane.title.isEmpty ? pane.currentCommand : pane.title)
116
+ .font(Typo.mono(11))
117
+ .foregroundColor(Palette.text)
118
+ .lineLimit(1)
119
+
120
+ Spacer()
121
+
122
+ Text(pane.currentCommand)
123
+ .font(Typo.mono(9))
124
+ .foregroundColor(Palette.textDim)
125
+ }
126
+ .padding(.horizontal, 8)
127
+ .padding(.vertical, 4)
128
+ }
129
+ }