@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,20 @@
1
+ import AppKit
2
+
3
+ /// Thin redirect — Settings is now a page inside the unified app window.
4
+ final class SettingsWindowController {
5
+ static let shared = SettingsWindowController()
6
+
7
+ var isVisible: Bool { ScreenMapWindowController.shared.isVisible }
8
+
9
+ func toggle() {
10
+ if isVisible { close() } else { show() }
11
+ }
12
+
13
+ func show() {
14
+ ScreenMapWindowController.shared.showPage(.settings)
15
+ }
16
+
17
+ func close() {
18
+ ScreenMapWindowController.shared.close()
19
+ }
20
+ }
@@ -0,0 +1,178 @@
1
+ import SwiftUI
2
+
3
+ struct TabGroupRow: View {
4
+ let group: TabGroup
5
+ @ObservedObject var workspace: WorkspaceManager
6
+
7
+ @State private var isHovered = false
8
+ @State private var isExpanded = false
9
+
10
+ private var isRunning: Bool { workspace.isGroupRunning(group) }
11
+
12
+ var body: some View {
13
+ VStack(spacing: 0) {
14
+ // Header row
15
+ HStack(spacing: 10) {
16
+ // Status bar
17
+ RoundedRectangle(cornerRadius: 1)
18
+ .fill(isRunning ? Palette.running : Palette.border)
19
+ .frame(width: 3, height: 32)
20
+
21
+ // Expand chevron
22
+ Button {
23
+ withAnimation(.easeOut(duration: 0.15)) { isExpanded.toggle() }
24
+ } label: {
25
+ Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
26
+ .font(.system(size: 9, weight: .semibold))
27
+ .foregroundColor(Palette.textMuted)
28
+ .frame(width: 14)
29
+ }
30
+ .buttonStyle(.plain)
31
+
32
+ // Info
33
+ VStack(alignment: .leading, spacing: 3) {
34
+ HStack(spacing: 6) {
35
+ Text(group.label)
36
+ .font(Typo.heading(13))
37
+ .foregroundColor(Palette.text)
38
+ .lineLimit(1)
39
+
40
+ Text("\(group.tabs.count) tabs")
41
+ .font(Typo.mono(9))
42
+ .foregroundColor(Palette.textMuted)
43
+ .padding(.horizontal, 5)
44
+ .padding(.vertical, 1)
45
+ .background(
46
+ RoundedRectangle(cornerRadius: 3)
47
+ .fill(Palette.surface)
48
+ )
49
+ }
50
+
51
+ Text(group.tabs.map { $0.label ?? ($0.path as NSString).lastPathComponent }.joined(separator: " \u{00B7} "))
52
+ .font(Typo.mono(10))
53
+ .foregroundColor(Palette.textMuted)
54
+ .lineLimit(1)
55
+ }
56
+
57
+ Spacer()
58
+
59
+ // Actions
60
+ HStack(spacing: 4) {
61
+ if isRunning {
62
+ Button {
63
+ workspace.killGroup(group)
64
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
65
+ ProjectScanner.shared.refreshStatus()
66
+ }
67
+ } label: {
68
+ Text("Kill")
69
+ .angularButton(Palette.kill, filled: false)
70
+ }
71
+ .buttonStyle(.plain)
72
+ }
73
+
74
+ Button {
75
+ if isRunning {
76
+ // Focus the first tab's session
77
+ if let firstTab = group.tabs.first {
78
+ let session = WorkspaceManager.sessionName(for: firstTab.path)
79
+ let terminal = Preferences.shared.terminal
80
+ terminal.focusOrAttach(session: session)
81
+ }
82
+ } else {
83
+ workspace.launchGroup(group)
84
+ }
85
+ } label: {
86
+ Text(isRunning ? "Attach" : "Launch")
87
+ .angularButton(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
+ // Expanded tab list
97
+ if isExpanded {
98
+ VStack(spacing: 2) {
99
+ ForEach(Array(group.tabs.enumerated()), id: \.offset) { idx, tab in
100
+ tabRow(tab: tab, index: idx)
101
+ }
102
+ }
103
+ .padding(.leading, 36)
104
+ .padding(.trailing, 10)
105
+ .padding(.vertical, 4)
106
+ .transition(.opacity.combined(with: .move(edge: .top)))
107
+ }
108
+ }
109
+ .contentShape(Rectangle())
110
+ .onHover { isHovered = $0 }
111
+ .contextMenu {
112
+ if isRunning {
113
+ Button("Attach") {
114
+ if let firstTab = group.tabs.first {
115
+ let session = WorkspaceManager.sessionName(for: firstTab.path)
116
+ let terminal = Preferences.shared.terminal
117
+ terminal.focusOrAttach(session: session)
118
+ }
119
+ }
120
+ Divider()
121
+ ForEach(Array(group.tabs.enumerated()), id: \.offset) { idx, tab in
122
+ Button("Go to: \(tab.label ?? (tab.path as NSString).lastPathComponent)") {
123
+ workspace.focusTab(group: group, tabIndex: idx)
124
+ }
125
+ }
126
+ Divider()
127
+ Button("Kill Group") {
128
+ workspace.killGroup(group)
129
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
130
+ ProjectScanner.shared.refreshStatus()
131
+ }
132
+ }
133
+ } else {
134
+ Button("Launch") {
135
+ workspace.launchGroup(group)
136
+ }
137
+ }
138
+ }
139
+ }
140
+
141
+ private func tabRow(tab: TabGroupTab, index: Int) -> some View {
142
+ HStack(spacing: 8) {
143
+ Image(systemName: "rectangle.topthird.inset.filled")
144
+ .font(.system(size: 9))
145
+ .foregroundColor(isRunning ? Palette.running.opacity(0.7) : Palette.textMuted)
146
+
147
+ Text(tab.label ?? (tab.path as NSString).lastPathComponent)
148
+ .font(Typo.mono(11))
149
+ .foregroundColor(Palette.text)
150
+ .lineLimit(1)
151
+
152
+ Spacer()
153
+
154
+ if isRunning {
155
+ Button {
156
+ workspace.focusTab(group: group, tabIndex: index)
157
+ } label: {
158
+ Text("Go")
159
+ .font(Typo.mono(9))
160
+ .foregroundColor(Palette.textDim)
161
+ .padding(.horizontal, 6)
162
+ .padding(.vertical, 2)
163
+ .background(
164
+ RoundedRectangle(cornerRadius: 3)
165
+ .fill(Palette.surface)
166
+ .overlay(
167
+ RoundedRectangle(cornerRadius: 3)
168
+ .strokeBorder(Palette.border, lineWidth: 0.5)
169
+ )
170
+ )
171
+ }
172
+ .buttonStyle(.plain)
173
+ }
174
+ }
175
+ .padding(.horizontal, 8)
176
+ .padding(.vertical, 4)
177
+ }
178
+ }
@@ -0,0 +1,259 @@
1
+ import AppKit
2
+
3
+ enum Terminal: String, CaseIterable, Identifiable {
4
+ case terminal = "Terminal"
5
+ case iterm2 = "iTerm2"
6
+ case warp = "Warp"
7
+ case ghostty = "Ghostty"
8
+ case kitty = "Kitty"
9
+ case alacritty = "Alacritty"
10
+
11
+ var id: String { rawValue }
12
+
13
+ var bundleId: String {
14
+ switch self {
15
+ case .terminal: return "com.apple.Terminal"
16
+ case .iterm2: return "com.googlecode.iterm2"
17
+ case .warp: return "dev.warp.Warp-Stable"
18
+ case .ghostty: return "com.mitchellh.ghostty"
19
+ case .kitty: return "net.kovidgoyal.kitty"
20
+ case .alacritty: return "org.alacritty"
21
+ }
22
+ }
23
+
24
+ var isInstalled: Bool {
25
+ NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) != nil
26
+ }
27
+
28
+ static var installed: [Terminal] {
29
+ allCases.filter(\.isInstalled)
30
+ }
31
+
32
+ /// Launch a command in this terminal
33
+ func launch(command: String, in directory: String) {
34
+ // Use single quotes for the shell command to avoid AppleScript escaping issues
35
+ let dir = directory.replacingOccurrences(of: "'", with: "'\\''")
36
+ let cmd = command.replacingOccurrences(of: "'", with: "'\\''")
37
+ let fullCmd = "cd '\(dir)' && \(cmd)"
38
+
39
+ switch self {
40
+ case .terminal:
41
+ runOsascript(
42
+ "tell application \"Terminal\"",
43
+ "activate",
44
+ "do script \"\(fullCmd)\"",
45
+ "end tell"
46
+ )
47
+
48
+ case .iterm2:
49
+ runOsascript(
50
+ "tell application \"iTerm2\"",
51
+ "activate",
52
+ "set newWindow to (create window with default profile)",
53
+ "tell current session of newWindow",
54
+ "write text \"\(fullCmd)\"",
55
+ "end tell",
56
+ "end tell"
57
+ )
58
+
59
+ case .warp:
60
+ let task = Process()
61
+ task.executableURL = URL(fileURLWithPath: "/usr/bin/open")
62
+ task.arguments = ["-a", "Warp", directory]
63
+ try? task.run()
64
+ task.waitUntilExit()
65
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
66
+ runOsascript(
67
+ "tell application \"System Events\"",
68
+ "tell process \"Warp\"",
69
+ "keystroke \"\(cmd)\"",
70
+ "keystroke return",
71
+ "end tell",
72
+ "end tell"
73
+ )
74
+ }
75
+
76
+ case .ghostty:
77
+ let task = Process()
78
+ task.executableURL = URL(fileURLWithPath: "/usr/bin/open")
79
+ task.arguments = ["-a", "Ghostty"]
80
+ task.environment = ["GHOSTTY_SHELL_COMMAND": fullCmd]
81
+ try? task.run()
82
+
83
+ case .kitty:
84
+ if let appUrl = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) {
85
+ let kittyBin = appUrl.appendingPathComponent("Contents/MacOS/kitty").path
86
+ let task = Process()
87
+ task.executableURL = URL(fileURLWithPath: kittyBin)
88
+ task.arguments = ["--single-instance", "--directory", directory, "sh", "-c", command]
89
+ try? task.run()
90
+ }
91
+
92
+ case .alacritty:
93
+ if let appUrl = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) {
94
+ let bin = appUrl.appendingPathComponent("Contents/MacOS/alacritty").path
95
+ let task = Process()
96
+ task.executableURL = URL(fileURLWithPath: bin)
97
+ task.arguments = ["--working-directory", directory, "-e", "sh", "-c", command]
98
+ try? task.run()
99
+ }
100
+ }
101
+ }
102
+
103
+ /// Launch a command in a new tab of the current terminal window
104
+ func launchTab(command: String, in directory: String, tabName: String? = nil) {
105
+ let dir = directory.replacingOccurrences(of: "'", with: "'\\''")
106
+ let cmd = command.replacingOccurrences(of: "'", with: "'\\''")
107
+ let fullCmd = "cd '\(dir)' && \(cmd)"
108
+
109
+ switch self {
110
+ case .iterm2:
111
+ var lines = [
112
+ "tell application \"iTerm2\"",
113
+ "activate",
114
+ "if (count of windows) = 0 then",
115
+ " create window with default profile",
116
+ "else",
117
+ " tell current window to create tab with default profile",
118
+ "end if",
119
+ "tell current session of current tab of current window",
120
+ " write text \"\(fullCmd)\"",
121
+ ]
122
+ if let name = tabName {
123
+ let escaped = name.replacingOccurrences(of: "\"", with: "\\\"")
124
+ lines.append(" set name to \"\(escaped)\"")
125
+ }
126
+ lines.append("end tell")
127
+ lines.append("end tell")
128
+ runOsascriptLines(lines)
129
+
130
+ case .terminal:
131
+ var lines = [
132
+ "tell application \"Terminal\"",
133
+ "activate",
134
+ "if (count of windows) = 0 then",
135
+ " do script \"\(fullCmd)\"",
136
+ "else",
137
+ " do script \"\(fullCmd)\" in front window",
138
+ "end if",
139
+ ]
140
+ if let name = tabName {
141
+ let escaped = name.replacingOccurrences(of: "\"", with: "\\\"")
142
+ lines.append("set custom title of selected tab of front window to \"\(escaped)\"")
143
+ }
144
+ lines.append("end tell")
145
+ runOsascriptLines(lines)
146
+
147
+ default:
148
+ // Terminals without AppleScript tab support: fall back to new window
149
+ launch(command: command, in: directory)
150
+ }
151
+ }
152
+
153
+ /// Rename the current/frontmost tab in this terminal
154
+ func nameTab(_ name: String) {
155
+ let escaped = name.replacingOccurrences(of: "\"", with: "\\\"")
156
+ switch self {
157
+ case .iterm2:
158
+ runOsascript(
159
+ "tell application \"iTerm2\"",
160
+ "tell current session of current tab of current window",
161
+ "set name to \"\(escaped)\"",
162
+ "end tell",
163
+ "end tell"
164
+ )
165
+ case .terminal:
166
+ runOsascript(
167
+ "tell application \"Terminal\"",
168
+ "set custom title of selected tab of front window to \"\(escaped)\"",
169
+ "end tell"
170
+ )
171
+ default:
172
+ break
173
+ }
174
+ }
175
+
176
+ /// The tag we put in the terminal window title via tmux set-titles
177
+ static func windowTag(for session: String) -> String {
178
+ "[lattices:\(session)]"
179
+ }
180
+
181
+ /// Find and focus the existing terminal window by its [lattices:name] tag, or open a new attach
182
+ func focusOrAttach(session: String) {
183
+ let tag = Terminal.windowTag(for: session)
184
+
185
+ switch self {
186
+ case .terminal:
187
+ runOsascript(
188
+ "tell application \"Terminal\"",
189
+ "activate",
190
+ "set found to false",
191
+ "repeat with w in windows",
192
+ " if name of w contains \"\(tag)\" then",
193
+ " set index of w to 1",
194
+ " set found to true",
195
+ " exit repeat",
196
+ " end if",
197
+ "end repeat",
198
+ "if not found then do script \"tmux attach -t \(session)\"",
199
+ "end tell"
200
+ )
201
+
202
+ case .iterm2:
203
+ // Search through all sessions in all tabs of all windows
204
+ runOsascript(
205
+ "tell application \"iTerm2\"",
206
+ "activate",
207
+ "set found to false",
208
+ "repeat with w in windows",
209
+ " repeat with t in tabs of w",
210
+ " repeat with s in sessions of t",
211
+ " if name of s contains \"\(tag)\" then",
212
+ " select w",
213
+ " tell w to set current tab to t",
214
+ " set found to true",
215
+ " exit repeat",
216
+ " end if",
217
+ " end repeat",
218
+ " if found then exit repeat",
219
+ " end repeat",
220
+ " if found then exit repeat",
221
+ "end repeat",
222
+ "if not found then",
223
+ " if (count of windows) = 0 then",
224
+ " create window with default profile",
225
+ " else",
226
+ " tell current window to create tab with default profile",
227
+ " end if",
228
+ " tell current session of current tab of current window",
229
+ " write text \"tmux attach -t \(session)\"",
230
+ " end tell",
231
+ "end if",
232
+ "end tell"
233
+ )
234
+
235
+ default:
236
+ // For terminals without good AppleScript support, just activate and attach
237
+ let task = Process()
238
+ task.executableURL = URL(fileURLWithPath: "/usr/bin/open")
239
+ task.arguments = ["-a", rawValue]
240
+ try? task.run()
241
+ }
242
+ }
243
+ }
244
+
245
+ /// Run an AppleScript by joining lines into a single -e script block
246
+ private func runOsascript(_ lines: String...) {
247
+ runOsascriptLines(lines)
248
+ }
249
+
250
+ /// Run an AppleScript from an array of lines
251
+ private func runOsascriptLines(_ lines: [String]) {
252
+ let script = lines.joined(separator: "\n")
253
+ let task = Process()
254
+ task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
255
+ task.arguments = ["-e", script]
256
+ task.standardOutput = FileHandle.nullDevice
257
+ task.standardError = FileHandle.nullDevice
258
+ try? task.run()
259
+ }
@@ -0,0 +1,156 @@
1
+ import AppKit
2
+
3
+ // MARK: - Data Model
4
+
5
+ struct TerminalTab {
6
+ let app: Terminal
7
+ let windowIndex: Int
8
+ let tabIndex: Int
9
+ let tty: String // normalized: "ttys003"
10
+ let isActiveTab: Bool
11
+ let title: String
12
+ let sessionId: String? // iTerm2 unique ID only
13
+ }
14
+
15
+ // MARK: - Query
16
+
17
+ enum TerminalQuery {
18
+
19
+ /// Normalize TTY strings: strip "/dev/" prefix if present.
20
+ /// iTerm2 returns "/dev/ttys003", Terminal.app and `ps` return "ttys003".
21
+ static func normalizeTTY(_ raw: String) -> String {
22
+ if raw.hasPrefix("/dev/") {
23
+ return String(raw.dropFirst(5))
24
+ }
25
+ return raw
26
+ }
27
+
28
+ /// Query all running terminal emulators for tab info.
29
+ /// Only queries apps that are currently running (won't auto-launch).
30
+ static func queryAll() -> [TerminalTab] {
31
+ var results: [TerminalTab] = []
32
+ if isAppRunning("iTerm2") {
33
+ results.append(contentsOf: queryITerm2())
34
+ }
35
+ if isAppRunning("Terminal") {
36
+ results.append(contentsOf: queryTerminalApp())
37
+ }
38
+ // Future: queryWarp(), queryGhostty(), etc.
39
+ return results
40
+ }
41
+
42
+ // MARK: - iTerm2
43
+
44
+ static func queryITerm2() -> [TerminalTab] {
45
+ let script = """
46
+ tell application "iTerm2"
47
+ set output to ""
48
+ set winIdx to 0
49
+ repeat with w in windows
50
+ set tabIdx to 0
51
+ repeat with t in tabs of w
52
+ repeat with s in sessions of t
53
+ set output to output & winIdx & "\t" & tabIdx & "\t" & (tty of s) & "\t" & (name of s) & "\t" & (unique ID of s) & linefeed
54
+ end repeat
55
+ set tabIdx to tabIdx + 1
56
+ end repeat
57
+ set winIdx to winIdx + 1
58
+ end repeat
59
+ return output
60
+ end tell
61
+ """
62
+
63
+ let raw = osascript(script)
64
+ guard !raw.isEmpty else { return [] }
65
+
66
+ var tabs: [TerminalTab] = []
67
+ for line in raw.split(separator: "\n", omittingEmptySubsequences: true) {
68
+ let cols = line.split(separator: "\t", maxSplits: 4, omittingEmptySubsequences: false)
69
+ guard cols.count >= 5 else { continue }
70
+
71
+ guard let winIdx = Int(cols[0]),
72
+ let tabIdx = Int(cols[1]) else { continue }
73
+
74
+ let tty = normalizeTTY(String(cols[2]))
75
+ guard tty.hasPrefix("ttys") else { continue }
76
+
77
+ let title = String(cols[3])
78
+ let sessionId = String(cols[4])
79
+
80
+ tabs.append(TerminalTab(
81
+ app: .iterm2,
82
+ windowIndex: winIdx,
83
+ tabIndex: tabIdx,
84
+ tty: tty,
85
+ isActiveTab: false, // iTerm2 doesn't expose this easily in a single call
86
+ title: title,
87
+ sessionId: sessionId
88
+ ))
89
+ }
90
+ return tabs
91
+ }
92
+
93
+ // MARK: - Terminal.app
94
+
95
+ static func queryTerminalApp() -> [TerminalTab] {
96
+ let script = """
97
+ tell application "Terminal"
98
+ set output to ""
99
+ set winIdx to 0
100
+ repeat with w in windows
101
+ set selTab to selected tab of w
102
+ set tabIdx to 0
103
+ repeat with t in tabs of w
104
+ set isSel to (t = selTab)
105
+ set output to output & winIdx & "\t" & tabIdx & "\t" & (tty of t) & "\t" & (custom title of t) & "\t" & isSel & linefeed
106
+ set tabIdx to tabIdx + 1
107
+ end repeat
108
+ set winIdx to winIdx + 1
109
+ end repeat
110
+ return output
111
+ end tell
112
+ """
113
+
114
+ let raw = osascript(script)
115
+ guard !raw.isEmpty else { return [] }
116
+
117
+ var tabs: [TerminalTab] = []
118
+ for line in raw.split(separator: "\n", omittingEmptySubsequences: true) {
119
+ let cols = line.split(separator: "\t", maxSplits: 4, omittingEmptySubsequences: false)
120
+ guard cols.count >= 5 else { continue }
121
+
122
+ guard let winIdx = Int(cols[0]),
123
+ let tabIdx = Int(cols[1]) else { continue }
124
+
125
+ let tty = normalizeTTY(String(cols[2]))
126
+ guard tty.hasPrefix("ttys") else { continue }
127
+
128
+ let title = String(cols[3])
129
+ let isActive = String(cols[4]).lowercased() == "true"
130
+
131
+ tabs.append(TerminalTab(
132
+ app: .terminal,
133
+ windowIndex: winIdx,
134
+ tabIndex: tabIdx,
135
+ tty: tty,
136
+ isActiveTab: isActive,
137
+ title: title,
138
+ sessionId: nil
139
+ ))
140
+ }
141
+ return tabs
142
+ }
143
+
144
+ // MARK: - Helpers
145
+
146
+ /// Check if a named app is already running (prevents AppleScript from auto-launching it).
147
+ private static func isAppRunning(_ name: String) -> Bool {
148
+ NSWorkspace.shared.runningApplications.contains { $0.localizedName == name }
149
+ }
150
+
151
+ /// Run an AppleScript and capture stdout.
152
+ /// Uses ProcessQuery.shell to avoid Process.waitUntilExit() deadlocks on macOS 26.
153
+ private static func osascript(_ source: String) -> String {
154
+ ProcessQuery.shell(["/usr/bin/osascript", "-e", source])
155
+ }
156
+ }