Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions panel/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2860,28 +2860,37 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
// A blocking permission opened via 'O' (approve:false, "Open editor")
// must stay in the panel so the user can still resolve it (Dismiss →
// deny); removing it would orphan the hook for ~550s with no recovery
// path. Approvals — and any event without a pending FIFO — are removed
// as before. Stay on the panel if other events remain; otherwise close
// so the system frontmost reverts naturally and the approval keystroke
// lands in the target app's key window (see comment above re: hiding).
// path. Approvals — and any event without a pending FIFO — are removed.
if sendApproval || event.fifoPath == nil {
store.remove(id: event.id)
if store.events.isEmpty { hidePanel() }
}

// Approve a blocking permission by writing "allow" to its FIFO; the agent
// then skips its own prompt. Deny is the Dismiss gesture (see
// dismissSelected), NOT this path — so 'O' (approve:false, "Open editor")
// falls through to focusing the editor without resolving the decision.
// then skips its own prompt. This path doesn't hand focus to the editor,
// so keep the panel up while other prompts remain (lets the user triage
// a queue of approvals), closing only once the list empties. Deny is the
// Dismiss gesture (see dismissSelected), NOT this path.
if sendApproval, let fifo = event.fifoPath {
if store.events.isEmpty { hidePanel() }
DispatchQueue.global(qos: .userInitiated).async {
Self.writeFIFO(fifo, "allow")
}
return
}

// Every remaining path hands focus to the source app — either jumping
// to the editor ('O' / a stop event) or sending the approval keystroke
// into its key window. Hide the panel first, unconditionally: our panel
// is floating, pinned, and joins all spaces, so leaving it up keeps
// StackNudge frontmost and the jump silently appears not to happen when
// other events are still listed. Matches focusSelectedSession and the
// banner-click path. The 0.15s settle lets our deactivation land before
// the target is raised (without it an approval keystroke can hit our
// own process instead of the target's key window).
guard let bundleID = event.bundleID else { return }
hidePanel()
DispatchQueue.global(qos: .userInitiated).async {
Thread.sleep(forTimeInterval: 0.15)
AppActivator.activate(
bundleID: bundleID,
windowTitle: event.windowTitle,
Expand Down Expand Up @@ -2979,6 +2988,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
let ipcHook = VSCodeIntegration.isVSCodeHosted(session.terminalApp) ? session.tabId : nil
let sessionID = session.terminalApp?.contains("iTerm") == true ? session.tabId : nil
DispatchQueue.global(qos: .userInitiated).async {
// Settle after hidePanel() above so StackNudge has resigned
// frontmost before we raise the target — mirrors actOnSelected
// and the banner-click path.
Thread.sleep(forTimeInterval: 0.15)
AppActivator.activate(
bundleID: bundleID,
windowTitle: session.projectName,
Expand Down
26 changes: 25 additions & 1 deletion shared/AppActivator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,14 @@ struct AppActivator {
delay 0.4
do shell script "\(envPrefix)'\(escapedCLI)' --reuse-window '\(escapedPath)'"
""")?.executeAndReturnError(&err)
logScriptError(err, "editor-reuse-window")

// Step 2: set frontmost (requires Automation for System Events)
var err2: NSDictionary?
NSAppleScript(source: """
tell application "System Events" to set frontmost of process "\(procName)" to true
""")?.executeAndReturnError(&err2)
logScriptError(err2, "editor-set-frontmost")

// Step 2.5: AX-raise the specific window. --reuse-window routes
// the open request to the right window's IPC server (when
Expand Down Expand Up @@ -111,6 +113,7 @@ struct AppActivator {
end tell
end tell
""")?.executeAndReturnError(&err3)
logScriptError(err3, "editor-send-enter")
}
return
}
Expand Down Expand Up @@ -177,6 +180,7 @@ struct AppActivator {
end tell
end tell
""")?.executeAndReturnError(&err)
logScriptError(err, "approve-keystroke")
}
}

Expand Down Expand Up @@ -332,6 +336,7 @@ struct AppActivator {
"""
var err: NSDictionary?
NSAppleScript(source: script)?.executeAndReturnError(&err)
logScriptError(err, "ghostty-tab")
}

// MARK: - iTerm2 (AppleScript bridge)
Expand Down Expand Up @@ -375,10 +380,29 @@ struct AppActivator {
"""
var err: NSDictionary?
let result = NSAppleScript(source: script)?.executeAndReturnError(&err)
guard err == nil else { return false }
guard err == nil else {
logScriptError(err, "iterm2-session")
return false
}
return result?.stringValue == "matched"
}

// MARK: - Diagnostics

// AppleScript failures here are almost always a missing Automation grant:
// macOS wipes the grant whenever the app's cdhash changes (every ad-hoc
// rebuild), and the call then silently no-ops so focus never moves — the
// exact "Open editor did nothing" symptom. Swallowing the error dict hides
// it; surface it to stderr when panel debugging is on so the next
// occurrence is diagnosable. No-op unless STACKNUDGE_PANEL_DEBUG is set.
private static func logScriptError(_ err: NSDictionary?, _ context: String) {
guard let err,
ProcessInfo.processInfo.environment["STACKNUDGE_PANEL_DEBUG"] != nil
else { return }
FileHandle.standardError.write(Data(
"AppActivator[\(context)]: AppleScript error: \(err)\n".utf8))
}

// MARK: - AX tab switching (standalone terminal apps)

// For tabbed terminals where the OS-window is already frontmost but
Expand Down