@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.
- package/README.md +155 -0
- package/app/Lattices.app/Contents/Info.plist +24 -0
- package/app/Package.swift +13 -0
- package/app/Sources/AccessibilityTextExtractor.swift +111 -0
- package/app/Sources/ActionRow.swift +61 -0
- package/app/Sources/App.swift +10 -0
- package/app/Sources/AppDelegate.swift +242 -0
- package/app/Sources/AppShellView.swift +62 -0
- package/app/Sources/AppTypeClassifier.swift +70 -0
- package/app/Sources/AppWindowShell.swift +63 -0
- package/app/Sources/CheatSheetHUD.swift +332 -0
- package/app/Sources/CommandModeState.swift +1362 -0
- package/app/Sources/CommandModeView.swift +1405 -0
- package/app/Sources/CommandModeWindow.swift +192 -0
- package/app/Sources/CommandPaletteView.swift +307 -0
- package/app/Sources/CommandPaletteWindow.swift +134 -0
- package/app/Sources/DaemonProtocol.swift +101 -0
- package/app/Sources/DaemonServer.swift +414 -0
- package/app/Sources/DesktopModel.swift +149 -0
- package/app/Sources/DesktopModelTypes.swift +71 -0
- package/app/Sources/DiagnosticLog.swift +271 -0
- package/app/Sources/EventBus.swift +30 -0
- package/app/Sources/HotkeyManager.swift +254 -0
- package/app/Sources/HotkeyStore.swift +338 -0
- package/app/Sources/InventoryManager.swift +35 -0
- package/app/Sources/InventoryPath.swift +43 -0
- package/app/Sources/KeyRecorderView.swift +210 -0
- package/app/Sources/LatticesApi.swift +1234 -0
- package/app/Sources/LayerBezel.swift +203 -0
- package/app/Sources/MainView.swift +479 -0
- package/app/Sources/MainWindow.swift +83 -0
- package/app/Sources/OcrModel.swift +430 -0
- package/app/Sources/OcrStore.swift +329 -0
- package/app/Sources/OmniSearchState.swift +283 -0
- package/app/Sources/OmniSearchView.swift +288 -0
- package/app/Sources/OmniSearchWindow.swift +105 -0
- package/app/Sources/OrphanRow.swift +129 -0
- package/app/Sources/PaletteCommand.swift +419 -0
- package/app/Sources/PermissionChecker.swift +125 -0
- package/app/Sources/Preferences.swift +99 -0
- package/app/Sources/ProcessModel.swift +199 -0
- package/app/Sources/ProcessQuery.swift +151 -0
- package/app/Sources/Project.swift +28 -0
- package/app/Sources/ProjectRow.swift +368 -0
- package/app/Sources/ProjectScanner.swift +128 -0
- package/app/Sources/ScreenMapState.swift +2387 -0
- package/app/Sources/ScreenMapView.swift +2820 -0
- package/app/Sources/ScreenMapWindowController.swift +89 -0
- package/app/Sources/SessionManager.swift +72 -0
- package/app/Sources/SettingsView.swift +1064 -0
- package/app/Sources/SettingsWindow.swift +20 -0
- package/app/Sources/TabGroupRow.swift +178 -0
- package/app/Sources/Terminal.swift +259 -0
- package/app/Sources/TerminalQuery.swift +156 -0
- package/app/Sources/TerminalSynthesizer.swift +200 -0
- package/app/Sources/Theme.swift +163 -0
- package/app/Sources/TilePickerView.swift +209 -0
- package/app/Sources/TmuxModel.swift +53 -0
- package/app/Sources/TmuxQuery.swift +81 -0
- package/app/Sources/WindowTiler.swift +1778 -0
- package/app/Sources/WorkspaceManager.swift +575 -0
- package/bin/client.js +4 -0
- package/bin/daemon-client.js +187 -0
- package/bin/lattices-app.js +221 -0
- package/bin/lattices.js +1551 -0
- package/docs/api.md +924 -0
- package/docs/app.md +297 -0
- package/docs/concepts.md +135 -0
- package/docs/config.md +245 -0
- package/docs/layers.md +410 -0
- package/docs/ocr.md +185 -0
- package/docs/overview.md +94 -0
- package/docs/quickstart.md +75 -0
- 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
|
+
}
|