diff --git a/GhosttyThemePickerApp.swift b/GhosttyThemePickerApp.swift index dce4801..53d06f3 100644 --- a/GhosttyThemePickerApp.swift +++ b/GhosttyThemePickerApp.swift @@ -1,5 +1,6 @@ import SwiftUI import Carbon +import ApplicationServices @main struct GhosttyThemePickerApp: App { @@ -45,46 +46,863 @@ struct GhosttyThemePickerApp: App { class HotkeyManager: ObservableObject { var themeManager: ThemeManager? - private var hotkeyRef: EventHotKeyRef? - private static var instance: HotkeyManager? + private var hotkeyRefG: EventHotKeyRef? + private var hotkeyRefP: EventHotKeyRef? + static var instance: HotkeyManager? func registerHotkey() { HotkeyManager.instance = self - // Register Control+Option+G using Carbon API - var hotKeyID = EventHotKeyID() - hotKeyID.signature = OSType(0x4754504B) // "GTPK" - GhosttyThemePickerKey - hotKeyID.id = 1 - - // G = keycode 5, Control+Option = controlKey + optionKey let modifiers: UInt32 = UInt32(controlKey | optionKey) var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) InstallEventHandler(GetApplicationEventTarget(), { (_, event, _) -> OSStatus in - HotkeyManager.instance?.handleHotkey() + var hotKeyID = EventHotKeyID() + GetEventParameter(event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID), nil, MemoryLayout.size, nil, &hotKeyID) + + if hotKeyID.id == 1 { + HotkeyManager.instance?.handleHotkeyG() + } else if hotKeyID.id == 2 { + HotkeyManager.instance?.handleHotkeyP() + } return noErr }, 1, &eventType, nil, nil) - let status = RegisterEventHotKey(5, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &hotkeyRef) + // Register Control+Option+G (Quick Launch) + var hotKeyIDG = EventHotKeyID() + hotKeyIDG.signature = OSType(0x4754504B) // "GTPK" + hotKeyIDG.id = 1 + let statusG = RegisterEventHotKey(5, modifiers, hotKeyIDG, GetApplicationEventTarget(), 0, &hotkeyRefG) // G = keycode 5 - if status == noErr { - print("Global hotkey registered: ⌃⌥G (Carbon)") - } else { - print("Failed to register hotkey: \(status)") + // Register Control+Option+P (Window Switcher) + var hotKeyIDP = EventHotKeyID() + hotKeyIDP.signature = OSType(0x4754504B) // "GTPK" + hotKeyIDP.id = 2 + let statusP = RegisterEventHotKey(35, modifiers, hotKeyIDP, GetApplicationEventTarget(), 0, &hotkeyRefP) // P = keycode 35 + + if statusG == noErr { + print("Global hotkey registered: ⌃⌥G (Quick Launch)") + } + if statusP == noErr { + print("Global hotkey registered: ⌃⌥P (Window Switcher)") } } - private func handleHotkey() { + private func handleHotkeyG() { DispatchQueue.main.async { QuickLaunchPanel.shared.show(themeManager: self.themeManager) } } + private func handleHotkeyP() { + DispatchQueue.main.async { + WindowSwitcherPanel.shared.show() + } + } + deinit { - if let ref = hotkeyRef { + if let ref = hotkeyRefG { UnregisterEventHotKey(ref) } + if let ref = hotkeyRefP { + UnregisterEventHotKey(ref) + } + } +} + +// MARK: - Window Switcher Panel + +class KeyHandlingPanel: NSPanel { + var keyHandler: ((NSEvent) -> Bool)? + + override func sendEvent(_ event: NSEvent) { + if event.type == .keyDown, let handler = keyHandler, handler(event) { + // Event was handled, don't pass it on + return + } + super.sendEvent(event) + } +} + +class WindowSwitcherPanel { + static let shared = WindowSwitcherPanel() + private var panel: KeyHandlingPanel? + + func show() { + if let existing = panel { + existing.close() + } + + let panel = KeyHandlingPanel( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled, .closable, .fullSizeContentView, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + panel.isMovableByWindowBackground = true + panel.level = .floating + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.isReleasedWhenClosed = false + panel.backgroundColor = NSColor.windowBackgroundColor + + let view = WindowSwitcherView(themeManager: HotkeyManager.instance?.themeManager, panel: panel) { + panel.close() + } + + panel.contentView = NSHostingView(rootView: view) + panel.center() + + panel.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + + self.panel = panel + } + + func close() { + panel?.close() + panel = nil + } +} + +// MARK: - Window Switcher View + +/// Represents Claude's state in a terminal window +enum ClaudeState: Int, Comparable { + case notRunning = 0 // No Claude in this window + case working = 1 // Claude is processing (spinner in title) + case running = 2 // Claude detected via process tree (can't determine exact state) + case waiting = 3 // Claude waiting for input (✳ in title) + + static func < (lhs: ClaudeState, rhs: ClaudeState) -> Bool { + lhs.rawValue < rhs.rawValue + } + + var icon: String { + switch self { + case .waiting: return "hourglass" + case .running: return "terminal" + case .working: return "gearshape" + case .notRunning: return "terminal" + } + } + + var label: String { + switch self { + case .waiting: return "Needs Input" + case .running: return "Claude" + case .working: return "Working" + case .notRunning: return "" + } + } +} + +struct GhosttyWindow: Identifiable { + let id: Int + let name: String // Window title (e.g., "✳ Claude Code" or "~/Projects") + let axIndex: Int // Index for AppleScript (1-based, per-process) + let pid: pid_t // Process ID this window belongs to + var workstreamName: String? // Matched workstream name (via PID cache or directory) + var shellCwd: String? // Current working directory of shell + var hasClaudeProcess: Bool = false // Whether a Claude process is running in this window + + /// Determine Claude's state based on window title and process detection + var claudeState: ClaudeState { + // Check title for exact state indicators + if let firstChar = name.first { + // ✳ (U+2733) = waiting for input + if firstChar == "✳" && name.contains("Claude") { + return .waiting + } + // Braille spinner characters = working + let spinnerChars: Set = ["⠁", "⠂", "⠄", "⠈", "⠐", "⠠", "⡀", "⢀"] + if spinnerChars.contains(firstChar) && name.contains("Claude") { + return .working + } + } + // Fall back to process detection + return hasClaudeProcess ? .running : .notRunning + } + + /// Display name: prefer workstream name, fall back to shortened path or title + var displayName: String { + if let ws = workstreamName { + return ws + } + // If title looks like a path, shorten it + if name.hasPrefix("/") || name.hasPrefix("~") { + return (name as NSString).lastPathComponent + } + return name + } +} + +class WindowSwitcherViewModel: ObservableObject { + @Published var windows: [GhosttyWindow] = [] + @Published var searchText: String = "" + @Published var selectedIndex: Int = 0 + @Published var hasScreenRecordingPermission: Bool = false + + weak var themeManager: ThemeManager? + + /// Windows filtered by search and sorted by Claude state (waiting first) + var filteredWindows: [GhosttyWindow] { + var result = windows + if !searchText.isEmpty { + result = result.filter { window in + window.name.localizedCaseInsensitiveContains(searchText) || + (window.workstreamName?.localizedCaseInsensitiveContains(searchText) ?? false) + } + } + // Sort by Claude state (waiting > running > working > notRunning) + return result.sorted { $0.claudeState > $1.claudeState } + } + + /// Group windows by Claude state for sectioned display + var groupedWindows: [(state: ClaudeState, windows: [GhosttyWindow])] { + let sorted = filteredWindows + var groups: [(ClaudeState, [GhosttyWindow])] = [] + + let waiting = sorted.filter { $0.claudeState == .waiting } + let running = sorted.filter { $0.claudeState == .running } + let working = sorted.filter { $0.claudeState == .working } + let other = sorted.filter { $0.claudeState == .notRunning } + + if !waiting.isEmpty { groups.append((.waiting, waiting)) } + if !running.isEmpty { groups.append((.running, running)) } + if !working.isEmpty { groups.append((.working, working)) } + if !other.isEmpty { groups.append((.notRunning, other)) } + + return groups + } + + // MARK: - Cached Process Data (for efficient lookups) + + var cachedProcessTree: [pid_t: (ppid: pid_t, comm: String)] = [:] // Internal for debug + private var cachedClaudePids: Set = [] + private var cachedShellCwds: [pid_t: String] = [:] + var debugLog: ((String) -> Void)? // Debug logging callback + + /// Load all process data in one shot for efficient lookups + func loadProcessCache() { + cachedProcessTree.removeAll() + cachedClaudePids.removeAll() + cachedShellCwds.removeAll() + + // Single ps call to get all process info + let task = Process() + task.executableURL = URL(fileURLWithPath: "/bin/ps") + task.arguments = ["-eo", "pid,ppid,comm"] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = FileHandle.nullDevice + + do { + try task.run() + } catch { + return + } + + // Read data before waiting (to avoid deadlock with large output) + let data = pipe.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() + + guard let output = String(data: data, encoding: .utf8) else { return } + + for line in output.components(separatedBy: "\n") { + let parts = line.trimmingCharacters(in: .whitespaces).split(separator: " ", maxSplits: 2) + guard parts.count >= 3, + let pid = pid_t(parts[0]), + let ppid = pid_t(parts[1]) else { continue } + let comm = String(parts[2]) + cachedProcessTree[pid] = (ppid: ppid, comm: comm) + + if comm.lowercased() == "claude" { + cachedClaudePids.insert(pid) + } + } + } + + // MARK: - Shell CWD Detection + + /// Get the current working directory of the shell running inside a Ghostty window + func getShellCwd(ghosttyPid: pid_t) -> String? { + // Find login -> shell chain using cached data + guard let loginPid = cachedProcessTree.first(where: { + $0.value.ppid == ghosttyPid && $0.value.comm.contains("login") + })?.key else { + debugLog?("getShellCwd(\(ghosttyPid)): no login found") + return nil + } + + guard let shellEntry = cachedProcessTree.first(where: { + $0.value.ppid == loginPid + }) else { + debugLog?("getShellCwd(\(ghosttyPid)): no shell found under login \(loginPid)") + return nil + } + let shellPid = shellEntry.key + debugLog?("getShellCwd(\(ghosttyPid)): found shell \(shellPid) (\(shellEntry.value.comm))") + + // Check cache first + if let cached = cachedShellCwds[shellPid] { + return cached + } + + // Get cwd using lsof (only for shells we need) + let cwd = getCwd(of: shellPid) + debugLog?("getShellCwd(\(ghosttyPid)): lsof returned \(cwd ?? "nil")") + if let cwd = cwd { + cachedShellCwds[shellPid] = cwd + } + return cwd + } + + private func getCwd(of pid: pid_t) -> String? { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof") + task.arguments = ["-a", "-p", "\(pid)", "-d", "cwd", "-F", "n"] + let pipe = Pipe() + let errPipe = Pipe() + task.standardOutput = pipe + task.standardError = errPipe + + do { + try task.run() + } catch { + debugLog?("getCwd(\(pid)): failed to run lsof: \(error)") + return nil + } + + // Read data BEFORE waiting to avoid deadlock + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let errData = errPipe.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() + + let exitCode = task.terminationStatus + let output = String(data: data, encoding: .utf8) ?? "" + let errOutput = String(data: errData, encoding: .utf8) ?? "" + + debugLog?("getCwd(\(pid)): exit=\(exitCode), stdout='\(output.prefix(100))', stderr='\(errOutput.prefix(100))'") + + for line in output.components(separatedBy: "\n") { + if line.hasPrefix("n") { + return String(line.dropFirst()) + } + } + return nil + } + + // MARK: - Claude Process Detection + + /// Check if a Claude process is running under the given Ghostty PID + func hasClaudeProcess(ghosttyPid: pid_t) -> Bool { + for claudePid in cachedClaudePids { + if traceToGhostty(from: claudePid) == ghosttyPid { + return true + } + } + return false + } + + private func traceToGhostty(from pid: pid_t) -> pid_t? { + var current = pid + for _ in 0..<15 { + guard let info = cachedProcessTree[current] else { return nil } + let parent = info.ppid + if parent <= 1 { return nil } + // Check if parent is a Ghostty window we know about + if windows.contains(where: { $0.pid == parent }) { + return parent + } + current = parent + } + return nil + } + + func handleKeyDown(_ event: NSEvent, onDismiss: (() -> Void)?) -> Bool { + guard !filteredWindows.isEmpty else { return false } + + switch Int(event.keyCode) { + case 125: // Down arrow + selectedIndex = (selectedIndex + 1) % filteredWindows.count + return true + case 126: // Up arrow + selectedIndex = (selectedIndex - 1 + filteredWindows.count) % filteredWindows.count + return true + case 36, 76: // Enter/Return + let window = filteredWindows[selectedIndex] + focusWindow(axIndex: window.axIndex, pid: window.pid) + onDismiss?() + return true + default: + return false + } + } + + func focusWindow(axIndex: Int, pid: pid_t) { + // Use NSRunningApplication to activate the specific process by PID + guard let app = NSRunningApplication(processIdentifier: pid) else { + print("Could not find application with PID \(pid)") + return + } + + // Activate the specific process + app.activate(options: [.activateIgnoringOtherApps]) + + // Use Accessibility API to raise the specific window + let appElement = AXUIElementCreateApplication(pid) + var windowsRef: CFTypeRef? + let result = AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowsRef) + + guard result == .success, + let windows = windowsRef as? [AXUIElement], + axIndex > 0 && axIndex <= windows.count else { + print("Could not get window \(axIndex) for PID \(pid)") + return + } + + let windowElement = windows[axIndex - 1] // axIndex is 1-based + AXUIElementPerformAction(windowElement, kAXRaiseAction as CFString) + } +} + +struct WindowSwitcherView: View { + @StateObject private var viewModel = WindowSwitcherViewModel() + var themeManager: ThemeManager? + weak var panel: KeyHandlingPanel? + var onDismiss: (() -> Void)? + + init(themeManager: ThemeManager? = nil, panel: KeyHandlingPanel? = nil, onDismiss: (() -> Void)? = nil) { + self.themeManager = themeManager + self.panel = panel + self.onDismiss = onDismiss + } + + // Check if Screen Recording permission is granted + private func checkPermissions() { + viewModel.hasScreenRecordingPermission = CGPreflightScreenCaptureAccess() + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack { + Image(systemName: "macwindow.on.rectangle") + .foregroundColor(.accentColor) + Text("Switch Window") + .font(.headline) + Spacer() + Text("⌃⌥P") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .background(Color(NSColor.controlBackgroundColor)) + + Divider() + + // Search field + TextField("Search windows...", text: $viewModel.searchText) + .textFieldStyle(.roundedBorder) + .padding(.horizontal) + .padding(.top, 8) + + // Window list + if !viewModel.hasScreenRecordingPermission { + VStack(spacing: 16) { + Spacer() + Image(systemName: "eye.trianglebadge.exclamationmark") + .font(.system(size: 40)) + .foregroundColor(.orange) + + Text("Screen Recording Permission Required") + .font(.headline) + + Text("Window names require Screen Recording permission to be displayed.") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + VStack(spacing: 8) { + Button { + // Open System Settings to Privacy & Security > Screen Recording + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { + NSWorkspace.shared.open(url) + } + } label: { + HStack { + Image(systemName: "gear") + Text("Open System Settings") + } + } + + Button { + checkPermissions() + if viewModel.hasScreenRecordingPermission { + loadWindows() + } + } label: { + HStack { + Image(systemName: "arrow.clockwise") + Text("Retry") + } + } + } + Spacer() + } + .frame(maxWidth: .infinity) + } else if viewModel.filteredWindows.isEmpty { + VStack { + Spacer() + Text(viewModel.windows.isEmpty ? "No Ghostty windows open" : "No matching windows") + .foregroundColor(.secondary) + Spacer() + } + .frame(maxWidth: .infinity) + } else { + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 4) { + ForEach(Array(viewModel.filteredWindows.enumerated()), id: \.element.id) { index, window in + // Section header when state changes + if index == 0 || viewModel.filteredWindows[index - 1].claudeState != window.claudeState { + sectionHeader(for: window.claudeState) + } + + Button { + viewModel.focusWindow(axIndex: window.axIndex, pid: window.pid) + onDismiss?() + } label: { + windowRow(window: window, isSelected: index == viewModel.selectedIndex) + } + .buttonStyle(.plain) + .id(index) + } + } + .padding(.horizontal) + .padding(.top, 8) + } + .onChange(of: viewModel.selectedIndex) { newIndex in + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(newIndex, anchor: .center) + } + } + } + } + + Divider() + + // Footer + HStack { + Text("↑↓ Navigate • Enter Select • Esc Close") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text("\(viewModel.windows.count) window\(viewModel.windows.count == 1 ? "" : "s")") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(8) + } + .frame(width: 400, height: 300) + .onAppear { + // Connect viewModel to themeManager for workstream lookup + viewModel.themeManager = themeManager + + checkPermissions() + if viewModel.hasScreenRecordingPermission { + loadWindows() + } else { + // Request permission (will show system dialog) + CGRequestScreenCaptureAccess() + } + + // Set up key handler on panel + panel?.keyHandler = { [weak viewModel, onDismiss] event in + viewModel?.handleKeyDown(event, onDismiss: onDismiss) ?? false + } + } + .onDisappear { + panel?.keyHandler = nil + } + .onChange(of: viewModel.searchText) { _ in + viewModel.selectedIndex = 0 + } + .onExitCommand { + onDismiss?() + } + } + + // MARK: - View Helpers + + @ViewBuilder + private func sectionHeader(for state: ClaudeState) -> some View { + if state != .notRunning { + HStack { + Image(systemName: state.icon) + .font(.caption) + Text(state.label) + .font(.caption) + .fontWeight(.semibold) + Spacer() + } + .foregroundColor(state == .waiting ? .orange : .secondary) + .padding(.horizontal, 4) + .padding(.top, index(of: state) > 0 ? 12 : 4) + .padding(.bottom, 4) + } + } + + private func index(of state: ClaudeState) -> Int { + let states: [ClaudeState] = [.waiting, .running, .working, .notRunning] + return states.firstIndex(of: state) ?? 0 + } + + @ViewBuilder + private func windowRow(window: GhosttyWindow, isSelected: Bool) -> some View { + HStack(spacing: 8) { + // State icon + Image(systemName: window.claudeState.icon) + .foregroundColor(iconColor(for: window.claudeState)) + .frame(width: 16) + + // Window info + VStack(alignment: .leading, spacing: 2) { + // Primary: workstream name or display name + Text(window.displayName) + .fontWeight(window.workstreamName != nil ? .medium : .regular) + + // Secondary: window title if different from display name + if window.workstreamName != nil { + Text(window.name) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + + Spacer() + + // Claude state badge for "running" (when we detect Claude but can't tell exact state) + if window.claudeState == .running { + Text("Claude") + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.accentColor.opacity(0.2)) + .cornerRadius(4) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + isSelected + ? Color.accentColor.opacity(0.2) + : Color(NSColor.controlBackgroundColor).opacity(0.5) + ) + .cornerRadius(6) + } + + private func iconColor(for state: ClaudeState) -> Color { + switch state { + case .waiting: return .orange + case .running: return .accentColor + case .working: return .blue + case .notRunning: return .secondary + } + } + + private func loadWindows() { + // Try to load windows using Accessibility API first (correct order for AppleScript) + if loadWindowsViaAccessibilityAPI() { + return + } + + // Fall back to CGWindowList API if Accessibility fails + print("Falling back to CGWindowList API") + loadWindowsViaCGWindowList() + } + + private func loadWindowsViaAccessibilityAPI() -> Bool { + // Get ALL Ghostty processes (not just the first one) + let runningApps = NSWorkspace.shared.runningApplications + let ghosttyApps = runningApps.filter { $0.localizedName?.lowercased() == "ghostty" } + + guard !ghosttyApps.isEmpty else { + print("No Ghostty processes found") + return false + } + + print("Found \(ghosttyApps.count) Ghostty process(es)") + + var ghosttyWindows: [GhosttyWindow] = [] + + // Query windows from each Ghostty process + for ghosttyApp in ghosttyApps { + let pid = ghosttyApp.processIdentifier + let appElement = AXUIElementCreateApplication(pid) + + // Query windows from this specific process + var windowsRef: CFTypeRef? + let result = AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowsRef) + + guard result == .success, + let windows = windowsRef as? [AXUIElement] else { + print("Failed to get accessibility windows for PID \(pid) (error: \(result.rawValue))") + continue // Skip this process, try others + } + + print("Process PID \(pid) returned \(windows.count) window(s)") + + // Enumerate windows from this process (1-based index per process) + for (perProcessIndex, windowElement) in windows.enumerated() { + // Get window title + var titleRef: CFTypeRef? + AXUIElementCopyAttributeValue(windowElement, kAXTitleAttribute as CFString, &titleRef) + let title = (titleRef as? String) ?? "Window \(ghosttyWindows.count + 1)" + + // Use a unique ID based on current count, but axIndex is per-process (1-based) + let id = ghosttyWindows.count + 1 + ghosttyWindows.append(GhosttyWindow(id: id, name: title, axIndex: perProcessIndex + 1, pid: pid)) + } + } + + print("Extracted \(ghosttyWindows.count) total Ghostty windows from Accessibility API") + + // Only return true if we actually found windows + guard !ghosttyWindows.isEmpty else { + print("Accessibility API returned empty window list - using CGWindowList fallback") + return false + } + + // Show windows immediately (without enrichment) + viewModel.windows = ghosttyWindows + + // Reset selection if out of bounds + if viewModel.selectedIndex >= ghosttyWindows.count { + viewModel.selectedIndex = 0 + } + + // Enrich windows async so UI doesn't block + enrichWindowsAsync(ghosttyWindows) + + print("Successfully loaded \(ghosttyWindows.count) windows via Accessibility API") + return true + } + + private func debugLog(_ msg: String) { + let log = "[\(Date())] \(msg)\n" + if let data = log.data(using: .utf8) { + if FileManager.default.fileExists(atPath: "/tmp/gtp_debug.log") { + if let handle = FileHandle(forWritingAtPath: "/tmp/gtp_debug.log") { + handle.seekToEndOfFile() + handle.write(data) + handle.closeFile() + } + } else { + FileManager.default.createFile(atPath: "/tmp/gtp_debug.log", contents: data) + } + } + } + + /// Enrich windows with workstream names, shell cwd, and Claude process detection (async) + private func enrichWindowsAsync(_ windows: [GhosttyWindow]) { + debugLog("enrichWindowsAsync called with \(windows.count) windows") + debugLog("themeManager is \(themeManager == nil ? "nil" : "set")") + + let logFunc = self.debugLog + DispatchQueue.global(qos: .userInitiated).async { [weak viewModel, weak themeManager] in + logFunc("Inside async block, viewModel=\(viewModel == nil ? "nil" : "set"), themeManager=\(themeManager == nil ? "nil" : "set")") + guard let viewModel = viewModel else { + logFunc("viewModel is nil, returning") + return + } + + // Set debug logging on viewModel + viewModel.debugLog = logFunc + + // Load process tree data once (single ps call) + viewModel.loadProcessCache() + logFunc("Process cache loaded with \(viewModel.cachedProcessTree.count) entries") + + // Debug: show relevant entries for our window PIDs + for window in windows { + let children = viewModel.cachedProcessTree.filter { $0.value.ppid == window.pid } + logFunc(" Children of PID \(window.pid): \(children.map { "(\($0.key): \($0.value.comm))" }.joined(separator: ", "))") + } + + let enriched = windows.map { window -> GhosttyWindow in + var enriched = window + + // Get shell working directory + enriched.shellCwd = viewModel.getShellCwd(ghosttyPid: window.pid) + + // Get workstream name (from PID cache or directory match) + enriched.workstreamName = themeManager?.workstreamNameForPID(window.pid, shellCwd: enriched.shellCwd) + + // Check for Claude process + enriched.hasClaudeProcess = viewModel.hasClaudeProcess(ghosttyPid: window.pid) + + return enriched + } + + logFunc("Enrichment complete. Windows with workstream names:") + for w in enriched { + logFunc(" PID \(w.pid): ws=\(w.workstreamName ?? "nil"), cwd=\(w.shellCwd ?? "nil")") + } + + // Update UI on main thread + DispatchQueue.main.async { + logFunc("Updating UI with enriched windows") + viewModel.windows = enriched + } + } + } + + private func loadWindowsViaCGWindowList() { + // Use CGWindowList API to get Ghostty windows (fallback method) + let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] + guard let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else { + return + } + + var ghosttyWindows: [GhosttyWindow] = [] + var perProcessIndices: [pid_t: Int] = [:] // Track per-process window indices + + for window in windowList { + guard let ownerName = window["kCGWindowOwnerName"] as? String, + ownerName.lowercased() == "ghostty", + let ownerPID = window["kCGWindowOwnerPID"] as? Int else { + continue + } + + let pid = pid_t(ownerPID) + let name = window["kCGWindowName"] as? String ?? "Window \(ghosttyWindows.count + 1)" + let windowNumber = window["kCGWindowNumber"] as? Int ?? ghosttyWindows.count + 1 + + // Track per-process window index (1-based) + let perProcessIndex = (perProcessIndices[pid] ?? 0) + 1 + perProcessIndices[pid] = perProcessIndex + + ghosttyWindows.append(GhosttyWindow(id: windowNumber, name: name, axIndex: perProcessIndex, pid: pid)) + } + + // Show windows immediately + viewModel.windows = ghosttyWindows + + // Reset selection if out of bounds + if viewModel.selectedIndex >= ghosttyWindows.count { + viewModel.selectedIndex = 0 + } + + // Enrich windows async + enrichWindowsAsync(ghosttyWindows) } } @@ -830,6 +1648,15 @@ struct WorkstreamEditorView: View { } .pickerStyle(.segmented) .frame(width: 150) + + Button { + if let randomTheme = filteredThemes.randomElement() { + selectedTheme = randomTheme + } + } label: { + Image(systemName: "dice") + } + .help("Pick random theme") } Text("\(filteredThemes.count) themes") @@ -992,3 +1819,20 @@ struct WorkstreamEditorView: View { onDismiss() } } + +// Helper extension for debug logging +extension String { + func appendToFile(atPath path: String) throws { + let fileManager = FileManager.default + if !fileManager.fileExists(atPath: path) { + try self.write(toFile: path, atomically: true, encoding: .utf8) + } else { + let fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: path)) + fileHandle.seekToEndOfFile() + if let data = self.data(using: .utf8) { + fileHandle.write(data) + } + fileHandle.closeFile() + } + } +} diff --git a/README.md b/README.md index 89b637d..5c0304f 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,21 @@ A great way to run multiple [Claude Code](https://github.com/anthropics/claude-c [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) ![macOS 13+](https://img.shields.io/badge/macOS-13%2B-blue) +## Permissions & Privacy + +This app requests the following macOS permissions: + +| Permission | Why We Need It | How We Use It | +|------------|----------------|---------------| +| **Screen Recording** | To read window names from Ghostty | The Window Switcher feature (⌃⌥P) uses macOS's CGWindowList API to display the actual names of your open Ghostty windows. Without this permission, windows would show as "Window 1", "Window 2" instead of their actual titles. **We do not record, capture, or store any screen content.** This permission only allows us to read window metadata (names and positions) from the system. | + +**Data Collection:** This app does not collect, transmit, or share any data. All settings (themes, workstreams, favorites) are stored locally on your Mac using macOS UserDefaults. No analytics, no telemetry, no network requests (except to run `ghostty +list-themes` locally). + +**Open Source:** The complete source code is available in this repository. You can review exactly what the app does and build it yourself if preferred. + ## Features +- **Window Switcher** - Press ⌃⌥P from anywhere to quickly switch between open Ghostty windows - **Global Hotkey** - Press ⌃⌥G from anywhere to open Quick Launch panel - **Random Theme** - Launch Ghostty with a random theme (⌘R) - automatically avoids recent themes - **Workstreams** - Named presets with themes, directories, commands, and auto-launch @@ -39,6 +52,39 @@ Press **Control + Option + G** from anywhere to instantly open the Quick Launch Select an option to launch Ghostty, or press **Esc** to close. No need to click the menu bar! +### Window Switcher (⌃⌥P) + +Press **Control + Option + P** from anywhere to instantly see and switch between all your open Ghostty windows: + +- **Claude state detection** - Windows where Claude is waiting for input (showing `✳`) appear at the top +- **Smart sorting** - Windows sorted by: Needs Input → Claude Running → Working → Other +- **Workstream matching** - Windows automatically matched to workstreams by working directory +- **Search windows** - Type to filter by window name or workstream name +- **Click to focus** - Select any window to bring it to the front + +**Claude Code Integration:** + +The Window Switcher detects Claude Code's state by reading the window title: +- `✳ Claude Code` = **Needs Input** (sorted to top with hourglass icon) +- `⠐ Claude Code` (spinner) = **Working** (shown with gear icon) +- Claude process detected = **Claude** badge (when title detection unavailable) + +This works automatically when Claude Code sets dynamic window titles. Workstreams launched from this app don't use `--title`, allowing Claude to control the title and show its status. + +**Workstream Detection:** + +Windows are matched to workstreams in two ways: +1. **By PID** - Windows launched from this app are tracked automatically +2. **By directory** - Windows opened manually are matched by their working directory to workstream configurations + +**First time setup:** The Window Switcher requires **Screen Recording** permission to read window names from macOS. When you first press ⌃⌥P, you'll see a permission prompt with: +- **Open System Settings** button - Takes you directly to Privacy & Security settings +- **Retry** button - Re-checks permission after you grant it + +This permission only allows the app to read window metadata (names and positions). We do not record, capture, or store any screen content. + +**Perfect for multiple Claude sessions:** When running several Claude Code workstreams, the Window Switcher shows which sessions need your attention, letting you jump to them instantly without alt-tabbing through all your other apps. + ### Workstreams Workstreams are saved presets for different projects or tasks. Perfect for running multiple Claude Code sessions with distinct visual identities. @@ -109,6 +155,7 @@ Great for sharing setups with teammates or backing up your configuration. | Shortcut | Action | |----------|--------| | ⌃⌥G | Open Quick Launch panel (global - works from any app) | +| ⌃⌥P | Open Window Switcher (global - works from any app) | | ⌘R | Launch with random theme (from menu) | | ⌘, | Manage Workstreams | | ⌘Q | Quit | @@ -144,12 +191,13 @@ Note: Splits are created within Ghostty, not via CLI options. Each split shares ### Tips for Multiple Claude Sessions -1. **Distinct themes are automatic** - Random Theme automatically excludes your last 5 themes, so each new session looks different -2. **Set window titles** - Include the project name in the title for easy switching -3. **Use the command field** - Set `claude` to auto-start Claude Code when the terminal opens -4. **Organize by project** - Create one workstream per project with its directory pre-configured -5. **Use splits for related work** - Within a single themed window, use ⌘D to split for related tasks (e.g., running tests while coding) -6. **Auto-launch your daily setup** - Enable auto-launch on your most-used workstreams to open them automatically when the app starts +1. **Use the Window Switcher (⌃⌥P)** - Instantly jump between your Claude sessions without alt-tabbing through all your apps +2. **Distinct themes are automatic** - Random Theme automatically excludes your last 5 themes, so each new session looks different +3. **Set window titles** - Include the project name in the title for easy identification in the Window Switcher +4. **Use the command field** - Set `claude` to auto-start Claude Code when the terminal opens +5. **Organize by project** - Create one workstream per project with its directory pre-configured +6. **Use splits for related work** - Within a single themed window, use ⌘D to split for related tasks (e.g., running tests while coding) +7. **Auto-launch your daily setup** - Enable auto-launch on your most-used workstreams to open them automatically when the app starts ## Installation diff --git a/ThemeManager.swift b/ThemeManager.swift index ae96a25..f04a4cf 100644 --- a/ThemeManager.swift +++ b/ThemeManager.swift @@ -42,6 +42,9 @@ class ThemeManager: ObservableObject { @Published var workstreams: [Workstream] = [] @Published var lastSelectedTheme: String? + // Cache of launched window PIDs -> workstream names (for window switcher) + @Published var launchedWindows: [pid_t: String] = [:] + private let maxRecentThemes = 5 private let recentThemesKey = "RecentThemes" private let favoriteThemesKey = "FavoriteThemes" @@ -151,12 +154,18 @@ class ThemeManager: ObservableObject { args.append("--working-directory=\(dir)") } - if let title = workstream.windowTitle, !title.isEmpty { - args.append("--title=\(title)") - } + // NOTE: We intentionally do NOT set --title here anymore. + // This allows Claude Code to set dynamic window titles with status indicators + // (✳ for waiting, spinner for working). The workstream name is tracked via PID. + // Legacy windowTitle field is preserved for backwards compatibility but not used. if let cmd = workstream.command, !cmd.isEmpty { + // Wrap command in interactive login shell so PATH is resolved correctly + // Ghostty's -e passes directly to login which doesn't resolve PATH + // -i = interactive (sources .zshrc), -l = login (sources .zprofile) args.append("-e") + args.append("/bin/zsh") + args.append("-ilc") args.append(cmd) } @@ -172,6 +181,13 @@ class ThemeManager: ObservableObject { do { try process.run() + + // Store PID -> workstream name mapping for window switcher + let pid = process.processIdentifier + DispatchQueue.main.async { + self.launchedWindows[pid] = workstream.name + } + addToRecentThemes(workstream.theme) lastSelectedTheme = workstream.theme } catch { @@ -316,6 +332,43 @@ class ThemeManager: ObservableObject { } } + /// Find a workstream that matches the given directory path. + /// Used to identify windows not launched by the app (e.g., opened manually). + func workstreamForDirectory(_ directory: String) -> Workstream? { + // Exact match first + if let ws = workstreams.first(where: { $0.directory == directory }) { + return ws + } + // Check if directory is a subdirectory of a workstream's directory + for ws in workstreams { + guard let wsDir = ws.directory, !wsDir.isEmpty else { continue } + if directory.hasPrefix(wsDir + "/") { + return ws + } + } + return nil + } + + /// Get workstream name for a Ghostty PID. + /// First checks launched windows cache, then falls back to directory matching. + func workstreamNameForPID(_ pid: pid_t, shellCwd: String?) -> String? { + print("DEBUG: Looking up PID \(pid), shellCwd: \(shellCwd ?? "nil")") + print("DEBUG: launchedWindows keys: \(launchedWindows.keys.map { $0 })") + + // Check if we launched this window + if let name = launchedWindows[pid] { + print("DEBUG: Found in launchedWindows: \(name)") + return name + } + // Fall back to directory matching + if let cwd = shellCwd, let ws = workstreamForDirectory(cwd) { + print("DEBUG: Matched by directory: \(ws.name)") + return ws.name + } + print("DEBUG: No match found") + return nil + } + private func loadWorkstreams() { if let data = UserDefaults.standard.data(forKey: workstreamsKey), let decoded = try? JSONDecoder().decode([Workstream].self, from: data) { diff --git a/claude.md b/claude.md new file mode 100644 index 0000000..9cabdbc --- /dev/null +++ b/claude.md @@ -0,0 +1,62 @@ +# GhosttyThemePicker - Development Notes + +## Known Issues + +### Screen Recording Permission Reset on Rebuild + +**Issue**: Every time the app is rebuilt during development, macOS invalidates the Screen Recording permission and it needs to be removed and re-added. + +**Why This Happens**: +- When Xcode builds the app, it generates a new code signature +- macOS tracks permissions by app bundle identifier + code signature +- A changed signature = macOS treats it as a "different" app +- The old permission entry becomes stale and needs to be cleared + +**Workaround**: +1. Open **System Settings** → **Privacy & Security** → **Screen Recording** +2. Remove the old `GhosttyThemePicker` entry (if present) +3. Launch the rebuilt app +4. Grant Screen Recording permission when prompted +5. Restart the app if needed + +**Permanent Solution for Development**: +To avoid this issue during development, you can: +- Use a stable Development certificate in Xcode's signing settings +- Add `com.apple.security.temporary-exception.apple-events` entitlement (though this doesn't fully solve Screen Recording) +- Sign the app with a Developer ID certificate (requires Apple Developer Program membership) + +**Note**: This only affects development builds. Release builds signed with a proper Developer ID certificate won't have this issue. + +## Features + +### Window Switcher (⌃⌥P) +- **Arrow Keys (↑↓)**: Navigate through Ghostty windows +- **Enter**: Focus selected window and close panel +- **Esc**: Close panel without focusing +- **Search**: Type to filter windows by name +- **Visual Highlight**: Selected window shows blue accent background + +### Quick Launch (⌃⌥G) +- Launch workstreams, favorites, or random themes +- Keyboard-driven workflow + +## Architecture Notes + +### Keyboard Navigation Implementation +- Uses custom `KeyHandlingPanel` (subclass of `NSPanel`) to intercept key events at the panel level +- This ensures arrow keys are captured even when the search TextField has focus +- `WindowSwitcherViewModel` is an `ObservableObject` that manages state and handles key events +- Panel's `sendEvent(_:)` override intercepts `.keyDown` events before they reach SwiftUI's responder chain + +### Key Event Flow +1. User presses key → NSPanel receives event +2. `KeyHandlingPanel.sendEvent(_:)` intercepts event +3. Calls `keyHandler` closure (set by view) +4. Closure calls `viewModel.handleKeyDown(_:onDismiss:)` +5. If handled (arrow/enter), returns `true` to consume event +6. If not handled, passes to `super.sendEvent(_:)` for normal processing + +This approach works better than `NSEvent.addLocalMonitorForEvents` because: +- Local monitors see events AFTER focused views process them +- TextField consumes arrow keys for cursor movement before monitor sees them +- Panel-level interception happens BEFORE responder chain processing diff --git a/plans/claude-waiting-detection.md b/plans/claude-waiting-detection.md new file mode 100644 index 0000000..c44f9a2 --- /dev/null +++ b/plans/claude-waiting-detection.md @@ -0,0 +1,390 @@ +# Detect Claude Waiting for Input in Ghostty Windows + +## Goal +Surface Ghostty windows where Claude is waiting for user input at the top of the Window Switcher (⌃⌥P), making it easy to quickly respond to Claude. + +## Current State +- Window Switcher lists all Ghostty windows via Accessibility API +- Shows window titles (which Ghostty sets to current directory or custom title) +- Can match windows to workstreams by `windowTitle` +- Requires Screen Recording permission + +## Detection Approaches + +### Option 1: Parse Window/Tab Title (Simplest) +**How it works**: Claude Code sets specific terminal titles when in different states. + +**Pros**: +- Already have access to window titles via Accessibility API +- No additional permissions needed +- Fast and lightweight + +**Cons**: +- Requires Claude Code to set a specific title (need to verify behavior) +- May not work if user has custom shell prompt that overrides title + +**Investigation needed**: +- [ ] Check what title Claude Code sets when waiting for input +- [ ] Test with `echo -ne "\033]0;Claude Waiting\007"` to see if Ghostty respects it + +--- + +### Option 2: Screen Content OCR (Most Reliable) +**How it works**: Capture window content, OCR it, look for Claude's prompt pattern. + +**Pros**: +- Works regardless of title settings +- Can detect exact prompt state (e.g., `>` character at end of output) +- Already have Screen Recording permission + +**Cons**: +- Performance cost (screenshot + OCR per window) +- Need Vision framework or third-party OCR +- May be slow with many windows + +**Implementation sketch**: +```swift +func hasClaudeWaitingPrompt(windowElement: AXUIElement) -> Bool { + // 1. Get window bounds + // 2. Capture CGWindowListCreateImage for that rect + // 3. Run VNRecognizeTextRequest on image + // 4. Check if last line matches Claude prompt pattern +} +``` + +--- + +### Option 3: Process Tree Analysis (Medium Complexity) +**How it works**: Find Claude processes, trace parent PIDs back to Ghostty windows. + +**Pros**: +- Detects Claude running (vs other programs) +- Could detect idle vs busy state via process state + +**Cons**: +- Doesn't distinguish "waiting for input" from "running" +- Complex PID → window mapping +- Claude subprocess might not be visible if running inside container/shell + +**Implementation sketch**: +```bash +# Find claude processes +ps aux | grep -E 'claude|node.*claude' +# Get parent PID chain to find terminal +``` + +--- + +### Option 4: Terminal Content via Accessibility (Ideal if Possible) +**How it works**: Query Ghostty's text content via AXUIElement. + +**Pros**: +- Direct access to terminal text +- No screenshot/OCR needed +- Real-time + +**Cons**: +- Ghostty may not expose terminal content via Accessibility +- Need to investigate AXUIElement attributes available + +**Investigation needed**: +- [ ] Query all AXUIElement attributes on Ghostty window +- [ ] Check for AXValue, AXText, or similar containing terminal content + +--- + +### Option 5: Claude Code Status File/Socket (Most Elegant) +**How it works**: Claude Code writes state to a known location, app reads it. + +**Pros**: +- Explicit, reliable signal +- No heuristics or parsing +- Could include rich metadata (session ID, last prompt, etc.) + +**Cons**: +- Requires Claude Code to support this (feature request) +- Not available today + +**Potential file location**: +- `~/.claude/status/.json` +- `/tmp/claude-code-.status` + +--- + +## Recommended Approach + +### Phase 1: Window Title Detection (Ship First) +1. Investigate what titles Claude Code sets in different states +2. Add `isClaudeWaiting` computed property to `GhosttyWindow` +3. Sort windows with Claude waiting at top +4. Add visual indicator (icon or badge) + +### Phase 2: Fallback to OCR (If Title Unreliable) +1. For windows without detectable Claude title, optionally OCR +2. Cache results to avoid repeated scanning +3. Add refresh button to re-scan + +### Phase 3: Request Claude Code Feature +1. File issue requesting explicit "waiting for input" signal +2. Could be env var, file, or accessibility label + +--- + +## UI Changes to Window Switcher + +``` +┌─────────────────────────────────────────────┐ +│ 🪟 Switch Window ⌃⌥P │ +├─────────────────────────────────────────────┤ +│ [Search windows...] │ +├─────────────────────────────────────────────┤ +│ ── Claude Waiting ── │ +│ ⏳ 🖥️ claude-code ~/projects/foo │ ← Blue highlight, waiting icon +│ ⏳ 🖥️ DevWorkstream │ +├─────────────────────────────────────────────┤ +│ ── Other Windows ── │ +│ 🖥️ ~/projects/bar │ +│ 🖥️ npm running... │ +├─────────────────────────────────────────────┤ +│ ↑↓ Navigate • Enter Select • Esc Close │ +└─────────────────────────────────────────────┘ +``` + +--- + +## Experiment Results (2024) + +### Finding 1: Claude Code Uses Window Title for State! + +Claude Code sets the terminal window title with a prefix character indicating state: + +| Character | Unicode | Meaning | +|-----------|---------|---------| +| `✳` | U+2733 (Eight Spoked Asterisk) | **Waiting for input** | +| `⠐⠂⠁⠄⠈⠠⡀⢀` | U+2810, U+2802, etc. (Braille) | **Working** (animated spinner) | + +**Example titles observed:** +- `✳ Claude Code` - idle, waiting for user input +- `⠐ Claude Code` - actively processing + +### Finding 2: CGWindowList API Works + +Can reliably get window titles via `CGWindowListCopyWindowInfo`: +```swift +let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] +let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) +// Filter for Ghostty, read kCGWindowName +``` + +### Finding 3: Accessibility API Needs App Permission + +Command-line swift can't access Accessibility API, but the app already has this working via `AXUIElementCopyAttributeValue` with `kAXTitleAttribute`. + +### Finding 4: Custom Window Titles Block Claude's Title Updates + +Ghostty's `--title` option sets a **fixed** title that ignores escape sequences. Workstreams using `windowTitle` won't show Claude's `✳` prefix. + +**Ghostty docs confirm:** +> "This will force the title of the window to be this title at all times and Ghostty will ignore any set title escape sequences programs may send." + +### Finding 5: Process Tree Detection Works! + +Can trace Claude processes back to their parent Ghostty window PID: + +``` +Claude → Ghostty Mapping: + Claude 76316 → Ghostty 76312 ("L1-Backend") + Claude 61852 → Ghostty 61850 ("Frontend") + Claude 20772 → Ghostty 20118 ("✳ Claude Code") + ... +``` + +**Method:** +1. Get all Ghostty window PIDs via `CGWindowListCopyWindowInfo` +2. Find all `claude` processes via `ps` +3. Walk up parent PID chain using `sysctl(KERN_PROC_PID)` +4. Stop when we find a Ghostty PID + +**Limitation:** Can detect that Claude is **running** in a window, but cannot distinguish "waiting for input" from "processing" for windows with custom titles. + +--- + +## Implementation Plan + +### Phase 1: Full Detection (Title + Process Tree + Directory Matching) + +**Strategy:** +1. **Remove `--title` from workstream launches** - Let Claude control the title +2. **Track launched windows by PID** - Store PID → workstream name at launch +3. **Match orphan windows by directory** - Get shell cwd, match to workstream.directory +4. **Detect Claude status from title** - `✳` = waiting, Braille = working + +**Data model:** +```swift +enum ClaudeState { + case notRunning // No Claude in this window + case running // Claude detected via process tree (can't tell state) + case waiting // Claude waiting for input (✳ detected in title) + case working // Claude processing (spinner in title) +} + +struct GhosttyWindow: Identifiable { + let id: Int + let name: String // Window title (e.g., "✳ Claude Code") + let axIndex: Int + let pid: pid_t + var workstreamName: String? // Matched workstream (via PID cache or directory) + var shellCwd: String? // Current working directory of shell + var hasClaudeProcess: Bool = false + + var claudeState: ClaudeState { + // Check title for exact state (works when no --title override) + if let firstChar = name.first { + if firstChar == "✳" && name.contains("Claude") { + return .waiting + } + let spinnerChars: Set = ["⠁", "⠂", "⠄", "⠈", "⠐", "⠠", "⡀", "⢀"] + if spinnerChars.contains(firstChar) && name.contains("Claude") { + return .working + } + } + // Fall back to process detection + return hasClaudeProcess ? .running : .notRunning + } + + var displayName: String { + // Prefer workstream name, fall back to title + workstreamName ?? name + } +} +``` + +**Workstream matching (for windows not launched by app):** +```swift +func matchWorkstreamByDirectory(cwd: String, workstreams: [Workstream]) -> String? { + // Exact match + if let ws = workstreams.first(where: { $0.directory == cwd }) { + return ws.name + } + // Subdirectory match (window in child of workstream dir) + if let ws = workstreams.first(where: { + guard let dir = $0.directory else { return false } + return cwd.hasPrefix(dir + "/") + }) { + return ws.name + } + return nil +} +``` + +**Getting shell cwd:** +```swift +func getShellCwd(ghosttyPid: pid_t) -> String? { + // 1. Find login child: ps -eo pid,ppid,comm | grep login + // 2. Find shell under login + // 3. Get cwd: lsof -a -p -d cwd -F n + // 4. Parse output for "n/path/to/dir" +} +``` + +**Window loading flow:** +```swift +func loadWindows() { + // 1. Get all Ghostty windows via CGWindowList/Accessibility API + // 2. For each window: + // a. Check launchedWindowsCache[pid] for workstream name + // b. If not found, get shell cwd and match to workstream.directory + // c. Scan process tree for claude child + // 3. Sort by claudeState priority +} +``` + +**Sorting priority:** +1. `.waiting` - Claude needs your input (highest) +2. `.running` - Claude detected but state unknown +3. `.working` - Claude processing (spinner) +4. `.notRunning` - No Claude + +**UI Display:** +``` +┌─────────────────────────────────────────────────┐ +│ ── Needs Input ── │ +│ ⏳ Frontend ✳ Claude Code │ +│ ⏳ L1-Backend ✳ Claude Code │ +├─────────────────────────────────────────────────┤ +│ ── Working ── │ +│ ⚙️ Voicescribe ⠐ Claude Code │ +├─────────────────────────────────────────────────┤ +│ ── Other ── │ +│ job-workers ~/projects/workers │ +│ (unknown) ~/Desktop │ +└─────────────────────────────────────────────────┘ +``` + +**UI Indicators:** +| State | Icon | Shows | +|-------|------|-------| +| waiting | ⏳ | `[Workstream] ✳ Claude Code` | +| running | 🤖 | `[Workstream] (Claude)` | +| working | ⚙️ | `[Workstream] ⠐ Claude Code` | +| notRunning | - | `[Workstream] [cwd or title]` | + +--- + +## Questions Resolved + +1. **What title does Claude Code set when waiting?** + - ✅ `✳ Claude Code` when waiting, Braille spinner when working + +2. **Does custom title break detection?** + - ✅ Yes - Ghostty's `--title` sets a fixed title, ignores escape sequences + - Workaround: Use process tree detection for windows with custom titles + +3. **Can we map Claude processes to windows?** + - ✅ Yes - Walk parent PID chain from claude process to Ghostty PID + +4. **Can we detect "waiting" for custom-titled windows?** + - ❌ No - Process state (sleeping vs running) isn't reliable + - Accept this limitation: show "Claude running" instead of exact state + +--- + +## Next Steps + +- [x] Experiment: Check Claude Code window title behavior +- [x] Experiment: Verify custom titles block title updates +- [x] Experiment: Implement process tree detection +- [x] Experiment: Implement directory → workstream matching + +### Implementation Tasks + +1. **Modify workstream launch** (ThemeManager.swift) + - [x] Remove `--title` argument from `launchWorkstream()` + - [x] Store launched PID in a cache: `launchedWindows[pid] = workstreamName` + - [ ] Persist cache (or rebuild on app launch) - skipped for now, cache resets on app restart + +2. **Add directory matching** (ThemeManager.swift + WindowSwitcherViewModel) + - [x] Implement `getShellCwd(ghosttyPid:)` using lsof + - [x] Implement `workstreamForDirectory()` and `workstreamNameForPID()` + +3. **Update GhosttyWindow model** (GhosttyThemePickerApp.swift) + - [x] Add `ClaudeState` enum with `.notRunning`, `.working`, `.running`, `.waiting` + - [x] Add `workstreamName`, `shellCwd`, `hasClaudeProcess` fields + - [x] Add `claudeState` computed property (checks title for ✳/spinner, falls back to process detection) + - [x] Add `displayName` computed property + +4. **Update window loading** (GhosttyThemePickerApp.swift) + - [x] Add `enrichWindows()` to populate workstream names and Claude detection + - [x] Add process tree scan for Claude detection (`hasClaudeProcess()`, `traceToGhostty()`) + - [x] Sort windows by claudeState priority (waiting > running > working > notRunning) + +5. **Update UI** (GhosttyThemePickerApp.swift) + - [x] Add section headers (Needs Input / Claude / Working) + - [x] Add state icons (hourglass, terminal, gearshape) + - [x] Show `[Workstream] [Title]` format with badge for "running" state + +6. **Testing** + - [ ] Test with windows launched from app (PID cache) + - [ ] Test with windows opened manually (directory matching) + - [ ] Test with Claude in various states + - [ ] Test with non-Claude windows