Skip to content
This repository was archived by the owner on Nov 17, 2025. It is now read-only.

Commit cd8e573

Browse files
committed
Add support for iterm
1 parent 489f552 commit cd8e573

7 files changed

Lines changed: 345 additions & 4 deletions

File tree

CodeLooper.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@
205205
B4EF67DB32B4713E51AD4972 /* GeneralSettingsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6CB723D91D9348E2086AAD /* GeneralSettingsTab.swift */; };
206206
B5F52A6677D9BC895B34A60B /* QueryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C40C39CC2F895E1A45E415B /* QueryManager.swift */; };
207207
B64065FF2DB310363F17F357 /* AISettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2322F598135E442321A408B0 /* AISettingsView.swift */; };
208+
B8E51A81C6B99B427F084FCC /* ITermAppleScriptHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51772CC8CF625D8BA2502A4D /* ITermAppleScriptHelper.swift */; };
208209
BC0A1686EEAA49A0E2E1D5E4 /* ProcessMonitorService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D6CAF9E9B702E02DFDBFEB2 /* ProcessMonitorService.swift */; };
209210
BDA9C21C437EE2BB5BDE4630 /* AXpector in Frameworks */ = {isa = PBXBuildFile; productRef = C82404169CD50D644E1ED271 /* AXpector */; };
210211
BDFA492781204ACB586E5163 /* Demark in Frameworks */ = {isa = PBXBuildFile; productRef = 8EAC0DEA71BA55AC629B56ED /* Demark */; };
@@ -413,6 +414,7 @@
413414
50A25A5FB23DB0E34CBBC220 /* MainInputFieldHeuristic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainInputFieldHeuristic.swift; sourceTree = "<group>"; };
414415
5106D3073FAE7B4DC45FE4CF /* NativePopoverController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePopoverController.swift; sourceTree = "<group>"; };
415416
5174C00218163417DA60504F /* AsyncPatterns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncPatterns.swift; sourceTree = "<group>"; };
417+
51772CC8CF625D8BA2502A4D /* ITermAppleScriptHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ITermAppleScriptHelper.swift; sourceTree = "<group>"; };
416418
521C37D802EABF317FFA3944 /* AppIconStateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconStateController.swift; sourceTree = "<group>"; };
417419
5223F335B0AAADC0E755A7DC /* ConnectionErrorIndicatorHeuristic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionErrorIndicatorHeuristic.swift; sourceTree = "<group>"; };
418420
531FE09FAC2B42748DE1EEF5 /* ClaudeTerminalTitleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeTerminalTitleManager.swift; sourceTree = "<group>"; };
@@ -1336,6 +1338,7 @@
13361338
children = (
13371339
458A7357AE49C33B120A678C /* CursorAppLifecycleManager.swift */,
13381340
3FD09182D0EEA303D40F5CFC /* CursorWindowManager.swift */,
1341+
51772CC8CF625D8BA2502A4D /* ITermAppleScriptHelper.swift */,
13391342
);
13401343
path = Infrastructure;
13411344
sourceTree = "<group>";
@@ -1804,6 +1807,7 @@
18041807
36AC393507E97BD2C7F3D0DE /* TTYWindowMappingService.swift in Sources */,
18051808
FD1A41D5B07CFB152A30F973 /* CursorAppLifecycleManager.swift in Sources */,
18061809
7B69C358953A327727D855D2 /* CursorWindowManager.swift in Sources */,
1810+
B8E51A81C6B99B427F084FCC /* ITermAppleScriptHelper.swift in Sources */,
18071811
3944BC08C53F8A9532580BA0 /* AllPermissionsView.swift in Sources */,
18081812
EAD2792BDE3E13E195ECC83F /* AutomationPermissionsView.swift in Sources */,
18091813
9DC624C8FA666B3A80C48E90 /* AccessibilityStepView.swift in Sources */,

Features/Monitoring/Domain/Services/ClaudeMonitorService.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public final class ClaudeMonitorService: ObservableObject, Sendable, Loggable {
2626
// MARK: - Configuration
2727

2828
private struct Configuration {
29-
static let monitoringInterval: TimeInterval = 5.0
29+
static let monitoringInterval: TimeInterval = 1.0
3030
static let maxRetries = 3
3131
static let retryDelay: TimeInterval = 1.0
3232
}

Features/Monitoring/Domain/Services/ClaudeStatusExtractor.swift

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,20 +86,32 @@ final class ClaudeStatusExtractor: ObservableObject, Loggable {
8686
/// Robust window cache to prevent duplicate matches
8787
private let windowCache = WindowCache()
8888

89+
/// iTerm helper for AppleScript-based text extraction
90+
private let iTermHelper = ITermAppleScriptHelper()
91+
8992
// MARK: - Public API
9093

9194
/// Extract Claude status for a specific instance using multiple methods
9295
func extractStatus(for instance: ClaudeInstance) async -> ClaudeActivity {
9396
logger.debug("Extracting Claude status for PID \(instance.pid) in \(instance.folderName)")
9497

95-
// Method 1: Try accessibility API first (fastest and most reliable)
98+
// Method 1: Try iTerm AppleScript for iTerm windows (most reliable for iTerm)
99+
if iTermHelper.isITermRunning() {
100+
if let statusText = await extractViaITermAppleScript(instance: instance) {
101+
let activity = ClaudeActivity(text: statusText)
102+
logger.info("Extracted status via iTerm AppleScript for \(instance.folderName): '\(activity.text)' (type: \(activity.type))")
103+
return activity
104+
}
105+
}
106+
107+
// Method 2: Try accessibility API (fastest and most reliable for other terminals)
96108
if let statusText = await extractViaAccessibility(instance: instance) {
97109
let activity = ClaudeActivity(text: statusText)
98110
logger.info("Extracted status via accessibility for \(instance.folderName): '\(activity.text)' (type: \(activity.type))")
99111
return activity
100112
}
101113

102-
// Method 2: Try modern ScreenCaptureKit with OCR fallback
114+
// Method 3: Try modern ScreenCaptureKit with OCR fallback
103115
if let statusText = await extractViaScreenCapture(instance: instance) {
104116
let activity = ClaudeActivity(text: statusText)
105117
logger.info("Extracted status via OCR for \(instance.folderName): '\(activity.text)' (type: \(activity.type))")
@@ -116,6 +128,60 @@ final class ClaudeStatusExtractor: ObservableObject, Loggable {
116128
windowCache.clear()
117129
}
118130

131+
// MARK: - iTerm AppleScript-based Extraction
132+
133+
private func extractViaITermAppleScript(instance: ClaudeInstance) async -> String? {
134+
logger.debug("Attempting iTerm AppleScript extraction for \(instance.folderName)")
135+
136+
// First try to find by TTY if available
137+
if !instance.ttyPath.isEmpty {
138+
if let content = await iTermHelper.getITermSessionByTTY(instance.ttyPath) {
139+
logger.debug("Found iTerm session by TTY for \(instance.folderName)")
140+
return parseClaudeStatus(from: content)
141+
}
142+
}
143+
144+
// Fall back to directory matching
145+
if let content = await iTermHelper.getITermSessionByDirectory(instance.folderName) {
146+
logger.debug("Found iTerm session by directory for \(instance.folderName)")
147+
return parseClaudeStatus(from: content)
148+
}
149+
150+
// If no direct match, try all sessions and match
151+
let sessions = await iTermHelper.getAllITermSessions()
152+
for session in sessions {
153+
// Check if this session matches our instance
154+
if sessionMatchesInstance(session: session, instance: instance) {
155+
logger.debug("Found matching iTerm session for \(instance.folderName)")
156+
return parseClaudeStatus(from: session.content)
157+
}
158+
}
159+
160+
return nil
161+
}
162+
163+
private func sessionMatchesInstance(session: (sessionId: String, tty: String, content: String), instance: ClaudeInstance) -> Bool {
164+
// Check TTY match
165+
if !instance.ttyPath.isEmpty {
166+
let ttyName = URL(fileURLWithPath: instance.ttyPath).lastPathComponent
167+
if session.tty.contains(ttyName) {
168+
return true
169+
}
170+
}
171+
172+
// Check content for Claude indicators and instance directory
173+
let content = session.content.lowercased()
174+
if content.contains("claude") && content.contains("esc to interrupt") {
175+
// Check if working directory is in the content
176+
if content.contains(instance.workingDirectory.lowercased()) ||
177+
content.contains(instance.folderName.lowercased()) {
178+
return true
179+
}
180+
}
181+
182+
return false
183+
}
184+
119185
// MARK: - Accessibility-based Extraction
120186

121187
private nonisolated func extractViaAccessibility(instance: ClaudeInstance) async -> String? {

Features/Monitoring/Domain/Services/ClaudeTerminalTitleManager.swift

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import Darwin
88
@MainActor
99
final class ClaudeTerminalTitleManager: Loggable {
1010

11+
private let iTermHelper = ITermAppleScriptHelper()
12+
1113
// MARK: - Configuration
1214

1315
private struct Configuration {
@@ -41,6 +43,15 @@ final class ClaudeTerminalTitleManager: Loggable {
4143

4244
let title = buildTitle(for: instance)
4345

46+
// Check if this is an iTerm session
47+
if iTermHelper.isITermRunning() {
48+
// Try to update iTerm title via AppleScript
49+
if await updateITermTitle(title: title, instance: instance) {
50+
logger.info("✅ Updated iTerm title for \(instance.folderName) via AppleScript")
51+
return
52+
}
53+
}
54+
4455
// First try to find the window using the shared service
4556
if TTYWindowMappingService.shared.findWindowForTTY(instance.ttyPath) != nil {
4657
logger.debug("Found window via TTYWindowMappingService for TTY \(instance.ttyPath)")
@@ -117,6 +128,50 @@ final class ClaudeTerminalTitleManager: Loggable {
117128
logger.error("Failed to write title to TTY \(ttyPath): \(error)")
118129
}
119130
}
131+
132+
// MARK: - iTerm Support
133+
134+
private func updateITermTitle(title: String, instance: ClaudeInstance) async -> Bool {
135+
let ttyName = URL(fileURLWithPath: instance.ttyPath).lastPathComponent
136+
137+
let script = """
138+
tell application "iTerm"
139+
repeat with w in windows
140+
tell w
141+
repeat with t in tabs
142+
tell t
143+
repeat with s in sessions
144+
tell s
145+
try
146+
if tty contains "\(ttyName)" then
147+
set name to "\(title)"
148+
return true
149+
end if
150+
end try
151+
end tell
152+
end repeat
153+
end tell
154+
end repeat
155+
end tell
156+
end repeat
157+
return false
158+
end tell
159+
"""
160+
161+
do {
162+
var error: NSDictionary?
163+
let appleScript = NSAppleScript(source: script)
164+
let result = appleScript?.executeAndReturnError(&error)
165+
166+
if let error = error {
167+
let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error"
168+
logger.error("Failed to update iTerm title: \(errorMessage)")
169+
return false
170+
}
171+
172+
return result?.booleanValue ?? false
173+
}
174+
}
120175
}
121176

122177
// MARK: - Title Format Extensions

Features/Monitoring/Domain/Services/TTYWindowMappingService.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ final class TTYWindowMappingService: Loggable {
2020

2121
static let shared = TTYWindowMappingService()
2222

23+
private let iTermHelper = ITermAppleScriptHelper()
24+
2325
private init() {}
2426

2527
// MARK: - Public API
@@ -142,6 +144,11 @@ final class TTYWindowMappingService: Loggable {
142144

143145
logger.debug("Checking \(windows.count) windows in \(app.bundleIdentifier ?? "unknown") for TTY \(ttyName)")
144146

147+
// Special handling for iTerm2
148+
if app.bundleIdentifier == "com.googlecode.iterm2" {
149+
return findITermWindowForTTY(windows: windows, ttyName: ttyName)
150+
}
151+
145152
// For each window, check if it's associated with our TTY
146153
for window in windows {
147154
// Different terminals expose TTY info differently
@@ -255,4 +262,27 @@ final class TTYWindowMappingService: Loggable {
255262

256263
return nil
257264
}
265+
266+
/// Find iTerm window that contains a session with the given TTY
267+
private func findITermWindowForTTY(windows: [Element], ttyName: String) -> Element? {
268+
logger.debug("Using iTerm-specific logic to find window for TTY \(ttyName)")
269+
270+
// For synchronous context, we'll use a different approach
271+
// Check if any window title contains the TTY name
272+
for window in windows {
273+
if let title = window.title(), title.contains(ttyName) {
274+
logger.debug("Found iTerm window by title containing TTY \(ttyName)")
275+
return window
276+
}
277+
}
278+
279+
// In iTerm, windows typically don't show TTY in title, so return the first window
280+
// iTerm usually has one main window with multiple tabs/sessions
281+
if let firstWindow = windows.first {
282+
logger.debug("Returning first iTerm window as likely container for TTY \(ttyName)")
283+
return firstWindow
284+
}
285+
286+
return nil
287+
}
258288
}

0 commit comments

Comments
 (0)