From b7519f2f29942d6ebf5ebabba2242b1ea4a8e3dd Mon Sep 17 00:00:00 2001 From: Chris Fields Date: Sun, 1 Feb 2026 04:03:15 -0600 Subject: [PATCH 1/9] Add random theme button to workstream editor - Dice button picks a random theme from filtered list - Respects Dark/Light category filter when picking Co-Authored-By: Claude Opus 4.5 --- GhosttyThemePickerApp.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/GhosttyThemePickerApp.swift b/GhosttyThemePickerApp.swift index dce4801..3c3a290 100644 --- a/GhosttyThemePickerApp.swift +++ b/GhosttyThemePickerApp.swift @@ -830,6 +830,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") From 2770a508cd32837cb73c29c6206d040cd15278de Mon Sep 17 00:00:00 2001 From: Chris Fields Date: Sun, 1 Feb 2026 05:39:22 -0600 Subject: [PATCH 2/9] Add Window Switcher with proper Screen Recording permission handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a new Window Switcher feature (⌃⌥P hotkey) that displays all open Ghostty windows with their actual window names for quick navigation. Key changes: - Add WindowSwitcherPanel and WindowSwitcherView for window management - Implement permission checking with CGPreflightScreenCaptureAccess() before loading windows - Add helpful UI when Screen Recording permission is not granted - Provide "Open System Settings" and "Retry" buttons for easy permission management - Fix race condition where loadWindows() was called before permission was ready - Add workstream name mapping for better window identification - Use AppleScript to focus/raise selected windows The permission check prevents the previous issue where windows would show as "Window 1", "Window 2" instead of their actual names when permission wasn't fully initialized. Co-Authored-By: Claude Sonnet 4.5 --- GhosttyThemePickerApp.swift | 346 ++++++++++++++++++++++++++++++++++-- 1 file changed, 330 insertions(+), 16 deletions(-) diff --git a/GhosttyThemePickerApp.swift b/GhosttyThemePickerApp.swift index 3c3a290..7405393 100644 --- a/GhosttyThemePickerApp.swift +++ b/GhosttyThemePickerApp.swift @@ -45,46 +45,360 @@ 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 WindowSwitcherPanel { + static let shared = WindowSwitcherPanel() + private var panel: NSPanel? + + func show() { + if let existing = panel { + existing.close() + } + + let panel = NSPanel( + 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.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 + +struct GhosttyWindow: Identifiable { + let id: Int + let name: String + let axIndex: Int // Index for AppleScript (1-based) +} + +struct WindowSwitcherView: View { + @State private var windows: [GhosttyWindow] = [] + @State private var searchText: String = "" + @State private var selectedIndex: Int = 0 + @State private var hasScreenRecordingPermission: Bool = false + var themeManager: ThemeManager? + var onDismiss: (() -> Void)? + + init(themeManager: ThemeManager? = nil, onDismiss: (() -> Void)? = nil) { + self.themeManager = themeManager + self.onDismiss = onDismiss + } + + // Check if Screen Recording permission is granted + private func checkPermissions() { + hasScreenRecordingPermission = CGPreflightScreenCaptureAccess() + } + + // Find workstream name that matches a window title + func workstreamName(for windowTitle: String) -> String? { + guard let manager = themeManager else { return nil } + return manager.workstreams.first { ws in + ws.windowTitle == windowTitle + }?.name + } + + var filteredWindows: [GhosttyWindow] { + if searchText.isEmpty { + return windows + } + return windows.filter { window in + window.name.localizedCaseInsensitiveContains(searchText) || + (workstreamName(for: window.name)?.localizedCaseInsensitiveContains(searchText) ?? false) + } + } + + 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: $searchText) + .textFieldStyle(.roundedBorder) + .padding(.horizontal) + .padding(.top, 8) + + // Window list + if !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 hasScreenRecordingPermission { + loadWindows() + } + } label: { + HStack { + Image(systemName: "arrow.clockwise") + Text("Retry") + } + } + } + Spacer() + } + .frame(maxWidth: .infinity) + } else if filteredWindows.isEmpty { + VStack { + Spacer() + Text(windows.isEmpty ? "No Ghostty windows open" : "No matching windows") + .foregroundColor(.secondary) + Spacer() + } + .frame(maxWidth: .infinity) + } else { + ScrollView { + VStack(spacing: 4) { + ForEach(Array(filteredWindows.enumerated()), id: \.element.id) { index, window in + Button { + focusWindow(axIndex: window.axIndex) + onDismiss?() + } label: { + HStack { + Image(systemName: "terminal") + .foregroundColor(.accentColor) + if let wsName = workstreamName(for: window.name) { + VStack(alignment: .leading, spacing: 2) { + Text(wsName) + .fontWeight(.medium) + Text(window.name) + .font(.caption) + .foregroundColor(.secondary) + } + } else { + Text(window.name) + } + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(NSColor.controlBackgroundColor).opacity(0.5)) + .cornerRadius(6) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + .padding(.top, 8) + } + } + + Divider() + + // Footer + HStack { + Text("Press Esc to close") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text("\(windows.count) window\(windows.count == 1 ? "" : "s")") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(8) + } + .frame(width: 400, height: 300) + .onAppear { + checkPermissions() + if hasScreenRecordingPermission { + loadWindows() + } else { + // Request permission (will show system dialog) + CGRequestScreenCaptureAccess() + } + } + .onExitCommand { + onDismiss?() + } + } + + private func loadWindows() { + // Use CGWindowList API to get Ghostty windows + let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] + guard let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else { + return + } + + var ghosttyWindows: [GhosttyWindow] = [] + var windowIndex = 1 + + for window in windowList { + guard let ownerName = window["kCGWindowOwnerName"] as? String, + ownerName == "Ghostty" else { + continue + } + + let name = window["kCGWindowName"] as? String ?? "Window \(windowIndex)" + let windowNumber = window["kCGWindowNumber"] as? Int ?? windowIndex + + ghosttyWindows.append(GhosttyWindow(id: windowNumber, name: name, axIndex: windowIndex)) + windowIndex += 1 + } + + self.windows = ghosttyWindows + } + + private func focusWindow(axIndex: Int) { + let script = """ + tell application "System Events" + tell process "ghostty" + set frontmost to true + perform action "AXRaise" of window \(axIndex) + end tell + end tell + """ + + let process = Process() + let errorPipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", script] + process.standardOutput = FileHandle.nullDevice + process.standardError = errorPipe + + do { + try process.run() + process.waitUntilExit() + + // Check for errors + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + if let errorOutput = String(data: errorData, encoding: .utf8), !errorOutput.isEmpty { + print("Focus window error: \(errorOutput)") + } + } catch { + print("Failed to focus window: \(error)") + } } } From b5f4da06e6c7111ae0e4bdd0fdf3cab6724321e6 Mon Sep 17 00:00:00 2001 From: Chris Fields Date: Sun, 1 Feb 2026 05:41:49 -0600 Subject: [PATCH 3/9] Document Window Switcher feature and Screen Recording permission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Permissions & Privacy section explaining Screen Recording permission requirement - Document Window Switcher feature (⌃⌥P) in Features and Usage sections - Add ⌃⌥P to keyboard shortcuts table - Update tips for multiple Claude sessions to highlight Window Switcher - Clarify data collection policy: no analytics, no telemetry, all local storage Co-Authored-By: Claude Sonnet 4.5 --- README.md | 44 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 89b637d..b3482ee 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,23 @@ 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: + +- **Search windows** - Type to filter by window name or workstream name +- **Click to focus** - Select any window to bring it to the front +- **Shows actual names** - Displays the real window titles (e.g., "Frontend", "Backend API", "Claude Code") +- **Workstream mapping** - If a window matches a workstream title, shows both the workstream name and window title + +**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 lets you jump between 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 +139,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 +175,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 From 41d52415502e05c90654bd2ec391827e8d98b864 Mon Sep 17 00:00:00 2001 From: Chris Fields Date: Sun, 1 Feb 2026 07:55:13 -0600 Subject: [PATCH 4/9] Add keyboard navigation to Window Switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Arrow keys (↑↓) navigate through window list with wraparound - Enter key focuses selected window and closes panel - Visual highlight shows selected window with accent color - Auto-scroll keeps selected window centered in view - Custom KeyHandlingPanel intercepts events before TextField consumes them - WindowSwitcherViewModel manages state and keyboard event handling - Updated footer to display keyboard shortcuts - Added claude.md with development notes about Screen Recording permission resets Co-Authored-By: Claude Sonnet 4.5 --- GhosttyThemePickerApp.swift | 233 +++++++++++++++++++++++------------- claude.md | 62 ++++++++++ 2 files changed, 210 insertions(+), 85 deletions(-) create mode 100644 claude.md diff --git a/GhosttyThemePickerApp.swift b/GhosttyThemePickerApp.swift index 7405393..058a05b 100644 --- a/GhosttyThemePickerApp.swift +++ b/GhosttyThemePickerApp.swift @@ -112,16 +112,28 @@ class HotkeyManager: ObservableObject { // 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: NSPanel? + private var panel: KeyHandlingPanel? func show() { if let existing = panel { existing.close() } - let panel = NSPanel( + let panel = KeyHandlingPanel( contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), styleMask: [.titled, .closable, .fullSizeContentView, .nonactivatingPanel], backing: .buffered, @@ -136,7 +148,7 @@ class WindowSwitcherPanel { panel.isReleasedWhenClosed = false panel.backgroundColor = NSColor.windowBackgroundColor - let view = WindowSwitcherView(themeManager: HotkeyManager.instance?.themeManager) { + let view = WindowSwitcherView(themeManager: HotkeyManager.instance?.themeManager, panel: panel) { panel.close() } @@ -163,22 +175,87 @@ struct GhosttyWindow: Identifiable { let axIndex: Int // Index for AppleScript (1-based) } +class WindowSwitcherViewModel: ObservableObject { + @Published var windows: [GhosttyWindow] = [] + @Published var searchText: String = "" + @Published var selectedIndex: Int = 0 + @Published var hasScreenRecordingPermission: Bool = false + + var filteredWindows: [GhosttyWindow] { + if searchText.isEmpty { + return windows + } + return windows.filter { window in + window.name.localizedCaseInsensitiveContains(searchText) + } + } + + 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 + focusWindow(axIndex: filteredWindows[selectedIndex].axIndex) + onDismiss?() + return true + default: + return false + } + } + + func focusWindow(axIndex: Int) { + let script = """ + tell application "System Events" + tell process "ghostty" + set frontmost to true + perform action "AXRaise" of window \(axIndex) + end tell + end tell + """ + + let process = Process() + let errorPipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", script] + process.standardOutput = FileHandle.nullDevice + process.standardError = errorPipe + + do { + try process.run() + process.waitUntilExit() + + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + if let errorOutput = String(data: errorData, encoding: .utf8), !errorOutput.isEmpty { + print("Focus window error: \(errorOutput)") + } + } catch { + print("Failed to focus window: \(error)") + } + } +} + struct WindowSwitcherView: View { - @State private var windows: [GhosttyWindow] = [] - @State private var searchText: String = "" - @State private var selectedIndex: Int = 0 - @State private var hasScreenRecordingPermission: Bool = false + @StateObject private var viewModel = WindowSwitcherViewModel() var themeManager: ThemeManager? + weak var panel: KeyHandlingPanel? var onDismiss: (() -> Void)? - init(themeManager: ThemeManager? = nil, onDismiss: (() -> Void)? = nil) { + 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() { - hasScreenRecordingPermission = CGPreflightScreenCaptureAccess() + viewModel.hasScreenRecordingPermission = CGPreflightScreenCaptureAccess() } // Find workstream name that matches a window title @@ -189,16 +266,6 @@ struct WindowSwitcherView: View { }?.name } - var filteredWindows: [GhosttyWindow] { - if searchText.isEmpty { - return windows - } - return windows.filter { window in - window.name.localizedCaseInsensitiveContains(searchText) || - (workstreamName(for: window.name)?.localizedCaseInsensitiveContains(searchText) ?? false) - } - } - var body: some View { VStack(alignment: .leading, spacing: 0) { // Header @@ -218,13 +285,13 @@ struct WindowSwitcherView: View { Divider() // Search field - TextField("Search windows...", text: $searchText) + TextField("Search windows...", text: $viewModel.searchText) .textFieldStyle(.roundedBorder) .padding(.horizontal) .padding(.top, 8) // Window list - if !hasScreenRecordingPermission { + if !viewModel.hasScreenRecordingPermission { VStack(spacing: 16) { Spacer() Image(systemName: "eye.trianglebadge.exclamationmark") @@ -255,7 +322,7 @@ struct WindowSwitcherView: View { Button { checkPermissions() - if hasScreenRecordingPermission { + if viewModel.hasScreenRecordingPermission { loadWindows() } } label: { @@ -268,49 +335,61 @@ struct WindowSwitcherView: View { Spacer() } .frame(maxWidth: .infinity) - } else if filteredWindows.isEmpty { + } else if viewModel.filteredWindows.isEmpty { VStack { Spacer() - Text(windows.isEmpty ? "No Ghostty windows open" : "No matching windows") + Text(viewModel.windows.isEmpty ? "No Ghostty windows open" : "No matching windows") .foregroundColor(.secondary) Spacer() } .frame(maxWidth: .infinity) } else { - ScrollView { - VStack(spacing: 4) { - ForEach(Array(filteredWindows.enumerated()), id: \.element.id) { index, window in - Button { - focusWindow(axIndex: window.axIndex) - onDismiss?() - } label: { - HStack { - Image(systemName: "terminal") - .foregroundColor(.accentColor) - if let wsName = workstreamName(for: window.name) { - VStack(alignment: .leading, spacing: 2) { - Text(wsName) - .fontWeight(.medium) + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 4) { + ForEach(Array(viewModel.filteredWindows.enumerated()), id: \.element.id) { index, window in + Button { + viewModel.focusWindow(axIndex: window.axIndex) + onDismiss?() + } label: { + HStack { + Image(systemName: "terminal") + .foregroundColor(.accentColor) + if let wsName = workstreamName(for: window.name) { + VStack(alignment: .leading, spacing: 2) { + Text(wsName) + .fontWeight(.medium) + Text(window.name) + .font(.caption) + .foregroundColor(.secondary) + } + } else { Text(window.name) - .font(.caption) - .foregroundColor(.secondary) } - } else { - Text(window.name) + Spacer() } - Spacer() + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + index == viewModel.selectedIndex + ? Color.accentColor.opacity(0.2) + : Color(NSColor.controlBackgroundColor).opacity(0.5) + ) + .cornerRadius(6) } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(NSColor.controlBackgroundColor).opacity(0.5)) - .cornerRadius(6) + .buttonStyle(.plain) + .id(index) } - .buttonStyle(.plain) + } + .padding(.horizontal) + .padding(.top, 8) + } + .onChange(of: viewModel.selectedIndex) { newIndex in + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(newIndex, anchor: .center) } } - .padding(.horizontal) - .padding(.top, 8) } } @@ -318,11 +397,11 @@ struct WindowSwitcherView: View { // Footer HStack { - Text("Press Esc to close") + Text("↑↓ Navigate • Enter Select • Esc Close") .font(.caption) .foregroundColor(.secondary) Spacer() - Text("\(windows.count) window\(windows.count == 1 ? "" : "s")") + Text("\(viewModel.windows.count) window\(viewModel.windows.count == 1 ? "" : "s")") .font(.caption) .foregroundColor(.secondary) } @@ -331,12 +410,23 @@ struct WindowSwitcherView: View { .frame(width: 400, height: 300) .onAppear { checkPermissions() - if hasScreenRecordingPermission { + 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?() @@ -366,38 +456,11 @@ struct WindowSwitcherView: View { windowIndex += 1 } - self.windows = ghosttyWindows - } + viewModel.windows = ghosttyWindows - private func focusWindow(axIndex: Int) { - let script = """ - tell application "System Events" - tell process "ghostty" - set frontmost to true - perform action "AXRaise" of window \(axIndex) - end tell - end tell - """ - - let process = Process() - let errorPipe = Pipe() - - process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") - process.arguments = ["-e", script] - process.standardOutput = FileHandle.nullDevice - process.standardError = errorPipe - - do { - try process.run() - process.waitUntilExit() - - // Check for errors - let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() - if let errorOutput = String(data: errorData, encoding: .utf8), !errorOutput.isEmpty { - print("Focus window error: \(errorOutput)") - } - } catch { - print("Failed to focus window: \(error)") + // Reset selection if out of bounds + if viewModel.selectedIndex >= ghosttyWindows.count { + viewModel.selectedIndex = 0 } } } 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 From 80a6af01e857095bd5af1dd3121a3b998d543b0d Mon Sep 17 00:00:00 2001 From: Chris Fields Date: Sun, 1 Feb 2026 13:50:41 -0600 Subject: [PATCH 5/9] Fix window switcher focus for multiple Ghostty processes Previously, focusing a window would activate the "main" Ghostty app, which didn't work when each window runs as a separate process. Now: - Track each window's PID alongside its per-process index - Use NSRunningApplication to activate the specific process by PID - Use Accessibility API (AXRaise) to raise the specific window - Load windows via Accessibility API for accurate per-process indexing - Fall back to CGWindowList if Accessibility fails - Make process name comparison case-insensitive Co-Authored-By: Claude Opus 4.5 --- GhosttyThemePickerApp.swift | 178 ++++++++++++++++++++++++++++-------- 1 file changed, 140 insertions(+), 38 deletions(-) diff --git a/GhosttyThemePickerApp.swift b/GhosttyThemePickerApp.swift index 058a05b..922f521 100644 --- a/GhosttyThemePickerApp.swift +++ b/GhosttyThemePickerApp.swift @@ -1,5 +1,6 @@ import SwiftUI import Carbon +import ApplicationServices @main struct GhosttyThemePickerApp: App { @@ -172,7 +173,8 @@ class WindowSwitcherPanel { struct GhosttyWindow: Identifiable { let id: Int let name: String - let axIndex: Int // Index for AppleScript (1-based) + let axIndex: Int // Index for AppleScript (1-based, per-process) + let pid: pid_t // Process ID this window belongs to } class WindowSwitcherViewModel: ObservableObject { @@ -201,7 +203,8 @@ class WindowSwitcherViewModel: ObservableObject { selectedIndex = (selectedIndex - 1 + filteredWindows.count) % filteredWindows.count return true case 36, 76: // Enter/Return - focusWindow(axIndex: filteredWindows[selectedIndex].axIndex) + let window = filteredWindows[selectedIndex] + focusWindow(axIndex: window.axIndex, pid: window.pid) onDismiss?() return true default: @@ -209,35 +212,30 @@ class WindowSwitcherViewModel: ObservableObject { } } - func focusWindow(axIndex: Int) { - let script = """ - tell application "System Events" - tell process "ghostty" - set frontmost to true - perform action "AXRaise" of window \(axIndex) - end tell - end tell - """ - - let process = Process() - let errorPipe = Pipe() - - process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") - process.arguments = ["-e", script] - process.standardOutput = FileHandle.nullDevice - process.standardError = errorPipe - - do { - try process.run() - process.waitUntilExit() - - let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() - if let errorOutput = String(data: errorData, encoding: .utf8), !errorOutput.isEmpty { - print("Focus window error: \(errorOutput)") - } - } catch { - print("Failed to focus window: \(error)") + 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) } } @@ -349,7 +347,7 @@ struct WindowSwitcherView: View { VStack(spacing: 4) { ForEach(Array(viewModel.filteredWindows.enumerated()), id: \.element.id) { index, window in Button { - viewModel.focusWindow(axIndex: window.axIndex) + viewModel.focusWindow(axIndex: window.axIndex, pid: window.pid) onDismiss?() } label: { HStack { @@ -434,26 +432,113 @@ struct WindowSwitcherView: View { } private func loadWindows() { - // Use CGWindowList API to get Ghostty windows + // 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)") + + // Debug logging to file + let logPath = "/tmp/ghostty_picker_debug.log" + let timestamp = Date() + try? "[\(timestamp)] Found \(ghosttyApps.count) Ghostty process(es)\n".write(toFile: logPath, atomically: false, encoding: .utf8) + + 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))") + try? " PID \(pid): FAILED (error: \(result.rawValue))\n".appendToFile(atPath: logPath) + continue // Skip this process, try others + } + + print("Process PID \(pid) returned \(windows.count) window(s)") + try? " PID \(pid): \(windows.count) window(s)\n".appendToFile(atPath: logPath) + + // 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") + try? " TOTAL: \(ghosttyWindows.count) windows\n".appendToFile(atPath: logPath) + + // Only return true if we actually found windows + guard !ghosttyWindows.isEmpty else { + print("Accessibility API returned empty window list - using CGWindowList fallback") + return false + } + + viewModel.windows = ghosttyWindows + + // Reset selection if out of bounds + if viewModel.selectedIndex >= ghosttyWindows.count { + viewModel.selectedIndex = 0 + } + + print("Successfully loaded \(ghosttyWindows.count) windows via Accessibility API") + return true + } + + 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 windowIndex = 1 + var perProcessIndices: [pid_t: Int] = [:] // Track per-process window indices for window in windowList { guard let ownerName = window["kCGWindowOwnerName"] as? String, - ownerName == "Ghostty" else { + ownerName.lowercased() == "ghostty", + let ownerPID = window["kCGWindowOwnerPID"] as? Int else { continue } - let name = window["kCGWindowName"] as? String ?? "Window \(windowIndex)" - let windowNumber = window["kCGWindowNumber"] as? Int ?? windowIndex + 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: windowIndex)) - windowIndex += 1 + ghosttyWindows.append(GhosttyWindow(id: windowNumber, name: name, axIndex: perProcessIndex, pid: pid)) } viewModel.windows = ghosttyWindows @@ -1378,3 +1463,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() + } + } +} From c61388c12d33d7845fe7c3f3cd7a4a4a9635b32d Mon Sep 17 00:00:00 2001 From: Chris Fields Date: Sun, 1 Feb 2026 14:43:45 -0600 Subject: [PATCH 6/9] Add Claude waiting detection to Window Switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect and prioritize Ghostty windows where Claude is waiting for input: - Add ClaudeState enum (.waiting, .running, .working, .notRunning) - Detect Claude status via window title prefix (✳ = waiting, Braille = working) - Fall back to process tree scanning when title detection unavailable - Match windows to workstreams by PID (for app-launched windows) or by directory (for manually opened windows) - Sort windows by Claude state (waiting windows appear at top) - Remove --title from workstream launch to allow Claude dynamic titles - Add section headers and state icons to Window Switcher UI - Optimize process detection with single ps call and caching Co-Authored-By: Claude Opus 4.5 --- GhosttyThemePickerApp.swift | 385 +++++++++++++++++++++++++---- ThemeManager.swift | 48 +++- plans/claude-waiting-detection.md | 390 ++++++++++++++++++++++++++++++ 3 files changed, 772 insertions(+), 51 deletions(-) create mode 100644 plans/claude-waiting-detection.md diff --git a/GhosttyThemePickerApp.swift b/GhosttyThemePickerApp.swift index 922f521..cb990e4 100644 --- a/GhosttyThemePickerApp.swift +++ b/GhosttyThemePickerApp.swift @@ -170,11 +170,74 @@ class WindowSwitcherPanel { // 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 - let axIndex: Int // Index for AppleScript (1-based, per-process) - let pid: pid_t // Process ID this window belongs to + 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 { @@ -183,15 +246,166 @@ class WindowSwitcherViewModel: ObservableObject { @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] { - if searchText.isEmpty { - return windows + var result = windows + if !searchText.isEmpty { + result = result.filter { window in + window.name.localizedCaseInsensitiveContains(searchText) || + (window.workstreamName?.localizedCaseInsensitiveContains(searchText) ?? false) + } } - return windows.filter { window in - window.name.localizedCaseInsensitiveContains(searchText) + // 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) + + private var cachedProcessTree: [pid_t: (ppid: pid_t, comm: String)] = [:] + private var cachedClaudePids: Set = [] + private var cachedShellCwds: [pid_t: String] = [:] + + /// 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() + task.waitUntilExit() + } catch { + return + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + 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 { + return nil + } + + guard let shellPid = cachedProcessTree.first(where: { + $0.value.ppid == loginPid + })?.key else { + return nil + } + + // Check cache first + if let cached = cachedShellCwds[shellPid] { + return cached + } + + // Get cwd using lsof (only for shells we need) + let cwd = getCwd(of: shellPid) + 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/bin/lsof") + task.arguments = ["-a", "-p", "\(pid)", "-d", "cwd", "-F", "n"] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = FileHandle.nullDevice + + do { + try task.run() + task.waitUntilExit() + } catch { + return nil + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { return nil } + + 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 } @@ -256,14 +470,6 @@ struct WindowSwitcherView: View { viewModel.hasScreenRecordingPermission = CGPreflightScreenCaptureAccess() } - // Find workstream name that matches a window title - func workstreamName(for windowTitle: String) -> String? { - guard let manager = themeManager else { return nil } - return manager.workstreams.first { ws in - ws.windowTitle == windowTitle - }?.name - } - var body: some View { VStack(alignment: .leading, spacing: 0) { // Header @@ -346,35 +552,16 @@ struct WindowSwitcherView: View { 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: { - HStack { - Image(systemName: "terminal") - .foregroundColor(.accentColor) - if let wsName = workstreamName(for: window.name) { - VStack(alignment: .leading, spacing: 2) { - Text(wsName) - .fontWeight(.medium) - Text(window.name) - .font(.caption) - .foregroundColor(.secondary) - } - } else { - Text(window.name) - } - Spacer() - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - index == viewModel.selectedIndex - ? Color.accentColor.opacity(0.2) - : Color(NSColor.controlBackgroundColor).opacity(0.5) - ) - .cornerRadius(6) + windowRow(window: window, isSelected: index == viewModel.selectedIndex) } .buttonStyle(.plain) .id(index) @@ -407,6 +594,9 @@ struct WindowSwitcherView: View { } .frame(width: 400, height: 300) .onAppear { + // Connect viewModel to themeManager for workstream lookup + viewModel.themeManager = themeManager + checkPermissions() if viewModel.hasScreenRecordingPermission { loadWindows() @@ -431,6 +621,86 @@ struct WindowSwitcherView: View { } } + // 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() { @@ -454,11 +724,6 @@ struct WindowSwitcherView: View { print("Found \(ghosttyApps.count) Ghostty process(es)") - // Debug logging to file - let logPath = "/tmp/ghostty_picker_debug.log" - let timestamp = Date() - try? "[\(timestamp)] Found \(ghosttyApps.count) Ghostty process(es)\n".write(toFile: logPath, atomically: false, encoding: .utf8) - var ghosttyWindows: [GhosttyWindow] = [] // Query windows from each Ghostty process @@ -473,12 +738,10 @@ struct WindowSwitcherView: View { guard result == .success, let windows = windowsRef as? [AXUIElement] else { print("Failed to get accessibility windows for PID \(pid) (error: \(result.rawValue))") - try? " PID \(pid): FAILED (error: \(result.rawValue))\n".appendToFile(atPath: logPath) continue // Skip this process, try others } print("Process PID \(pid) returned \(windows.count) window(s)") - try? " PID \(pid): \(windows.count) window(s)\n".appendToFile(atPath: logPath) // Enumerate windows from this process (1-based index per process) for (perProcessIndex, windowElement) in windows.enumerated() { @@ -494,7 +757,6 @@ struct WindowSwitcherView: View { } print("Extracted \(ghosttyWindows.count) total Ghostty windows from Accessibility API") - try? " TOTAL: \(ghosttyWindows.count) windows\n".appendToFile(atPath: logPath) // Only return true if we actually found windows guard !ghosttyWindows.isEmpty else { @@ -502,6 +764,9 @@ struct WindowSwitcherView: View { return false } + // Enrich windows with workstream names, shell cwd, and Claude detection + ghosttyWindows = enrichWindows(ghosttyWindows) + viewModel.windows = ghosttyWindows // Reset selection if out of bounds @@ -513,6 +778,27 @@ struct WindowSwitcherView: View { return true } + /// Enrich windows with workstream names, shell cwd, and Claude process detection + private func enrichWindows(_ windows: [GhosttyWindow]) -> [GhosttyWindow] { + // Load process tree data once (single ps call) + viewModel.loadProcessCache() + + return windows.map { window 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 + } + } + private func loadWindowsViaCGWindowList() { // Use CGWindowList API to get Ghostty windows (fallback method) let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] @@ -541,6 +827,9 @@ struct WindowSwitcherView: View { ghosttyWindows.append(GhosttyWindow(id: windowNumber, name: name, axIndex: perProcessIndex, pid: pid)) } + // Enrich windows with workstream names, shell cwd, and Claude detection + ghosttyWindows = enrichWindows(ghosttyWindows) + viewModel.windows = ghosttyWindows // Reset selection if out of bounds diff --git a/ThemeManager.swift b/ThemeManager.swift index ae96a25..25ee55f 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,9 +154,10 @@ 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 { args.append("-e") @@ -172,6 +176,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 +327,37 @@ 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? { + // Check if we launched this window + if let name = launchedWindows[pid] { + return name + } + // Fall back to directory matching + if let cwd = shellCwd, let ws = workstreamForDirectory(cwd) { + return ws.name + } + 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/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 From 807276ce5bbf3fe66b663d4e7233fa58b6fb8048 Mon Sep 17 00:00:00 2001 From: Chris Fields Date: Sun, 1 Feb 2026 15:21:03 -0600 Subject: [PATCH 7/9] Fix window switcher performance and workstream command launch - Make window enrichment async so UI doesn't block - Fix workstream command launch by using interactive login shell (-ilc) to properly source .zshrc and resolve PATH for commands like 'claude' Co-Authored-By: Claude Opus 4.5 --- GhosttyThemePickerApp.swift | 49 +++++++++++++++++++++++-------------- ThemeManager.swift | 5 ++++ 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/GhosttyThemePickerApp.swift b/GhosttyThemePickerApp.swift index cb990e4..2f800e1 100644 --- a/GhosttyThemePickerApp.swift +++ b/GhosttyThemePickerApp.swift @@ -764,9 +764,7 @@ struct WindowSwitcherView: View { return false } - // Enrich windows with workstream names, shell cwd, and Claude detection - ghosttyWindows = enrichWindows(ghosttyWindows) - + // Show windows immediately (without enrichment) viewModel.windows = ghosttyWindows // Reset selection if out of bounds @@ -774,28 +772,40 @@ struct WindowSwitcherView: View { viewModel.selectedIndex = 0 } + // Enrich windows async so UI doesn't block + enrichWindowsAsync(ghosttyWindows) + print("Successfully loaded \(ghosttyWindows.count) windows via Accessibility API") return true } - /// Enrich windows with workstream names, shell cwd, and Claude process detection - private func enrichWindows(_ windows: [GhosttyWindow]) -> [GhosttyWindow] { - // Load process tree data once (single ps call) - viewModel.loadProcessCache() + /// Enrich windows with workstream names, shell cwd, and Claude process detection (async) + private func enrichWindowsAsync(_ windows: [GhosttyWindow]) { + DispatchQueue.global(qos: .userInitiated).async { [weak viewModel, weak themeManager] in + guard let viewModel = viewModel else { return } + + // Load process tree data once (single ps call) + viewModel.loadProcessCache() + + let enriched = windows.map { window -> GhosttyWindow in + var enriched = window - return windows.map { window in - var enriched = window + // Get shell working directory + enriched.shellCwd = viewModel.getShellCwd(ghosttyPid: window.pid) - // 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) - // 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) - // Check for Claude process - enriched.hasClaudeProcess = viewModel.hasClaudeProcess(ghosttyPid: window.pid) + return enriched + } - return enriched + // Update UI on main thread + DispatchQueue.main.async { + viewModel.windows = enriched + } } } @@ -827,15 +837,16 @@ struct WindowSwitcherView: View { ghosttyWindows.append(GhosttyWindow(id: windowNumber, name: name, axIndex: perProcessIndex, pid: pid)) } - // Enrich windows with workstream names, shell cwd, and Claude detection - ghosttyWindows = enrichWindows(ghosttyWindows) - + // 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) } } diff --git a/ThemeManager.swift b/ThemeManager.swift index 25ee55f..eeff1d8 100644 --- a/ThemeManager.swift +++ b/ThemeManager.swift @@ -160,7 +160,12 @@ class ThemeManager: ObservableObject { // 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) } From 7338431ad10e7de856c18afeef1997e5e26d4e8f Mon Sep 17 00:00:00 2001 From: Chris Fields Date: Sun, 1 Feb 2026 16:54:12 -0600 Subject: [PATCH 8/9] Fix workstream detection via directory matching - Fix lsof path (/usr/sbin/lsof not /usr/bin/lsof) - Fix pipe deadlock: read data before waitUntilExit() in getCwd() - Add debug logging for troubleshooting process detection - Workstream names now correctly matched by shell cwd to workstream directory Co-Authored-By: Claude Opus 4.5 --- GhosttyThemePickerApp.swift | 74 ++++++++++++++++++++++++++++++++----- ThemeManager.swift | 6 +++ 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/GhosttyThemePickerApp.swift b/GhosttyThemePickerApp.swift index 2f800e1..53d06f3 100644 --- a/GhosttyThemePickerApp.swift +++ b/GhosttyThemePickerApp.swift @@ -281,9 +281,10 @@ class WindowSwitcherViewModel: ObservableObject { // MARK: - Cached Process Data (for efficient lookups) - private var cachedProcessTree: [pid_t: (ppid: pid_t, comm: String)] = [:] + 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() { @@ -301,12 +302,14 @@ class WindowSwitcherViewModel: ObservableObject { do { try task.run() - task.waitUntilExit() } 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") { @@ -331,14 +334,18 @@ class WindowSwitcherViewModel: ObservableObject { 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 shellPid = cachedProcessTree.first(where: { + guard let shellEntry = cachedProcessTree.first(where: { $0.value.ppid == loginPid - })?.key else { + }) 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] { @@ -347,6 +354,7 @@ class WindowSwitcherViewModel: ObservableObject { // 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 } @@ -355,21 +363,30 @@ class WindowSwitcherViewModel: ObservableObject { private func getCwd(of pid: pid_t) -> String? { let task = Process() - task.executableURL = URL(fileURLWithPath: "/usr/bin/lsof") + 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 = FileHandle.nullDevice + task.standardError = errPipe do { try task.run() - task.waitUntilExit() } catch { + debugLog?("getCwd(\(pid)): failed to run lsof: \(error)") return nil } + // Read data BEFORE waiting to avoid deadlock let data = pipe.fileHandleForReading.readDataToEndOfFile() - guard let output = String(data: data, encoding: .utf8) else { return nil } + 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") { @@ -779,13 +796,46 @@ struct WindowSwitcherView: View { 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 - guard let viewModel = viewModel else { return } + 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 @@ -802,8 +852,14 @@ struct WindowSwitcherView: View { 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 } } diff --git a/ThemeManager.swift b/ThemeManager.swift index eeff1d8..f04a4cf 100644 --- a/ThemeManager.swift +++ b/ThemeManager.swift @@ -352,14 +352,20 @@ class ThemeManager: ObservableObject { /// 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 } From bea5916ef52a4aaeda6b097b48a1ad026df053de Mon Sep 17 00:00:00 2001 From: Chris Fields Date: Sun, 1 Feb 2026 16:57:08 -0600 Subject: [PATCH 9/9] Update README with Claude state detection feature Document the new Window Switcher capabilities: - Claude state detection (waiting/working/running) - Smart sorting with waiting windows at top - Workstream matching by directory - How Claude Code integration works Co-Authored-By: Claude Opus 4.5 --- README.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b3482ee..5c0304f 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,26 @@ Select an option to launch Ghostty, or press **Esc** to close. No need to click 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 -- **Shows actual names** - Displays the real window titles (e.g., "Frontend", "Backend API", "Claude Code") -- **Workstream mapping** - If a window matches a workstream title, shows both the workstream name and window title + +**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 @@ -67,7 +83,7 @@ Press **Control + Option + P** from anywhere to instantly see and switch between 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 lets you jump between them instantly without alt-tabbing through all your other apps. +**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