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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p
## [Unreleased]

### Fixed
- Restoring a damaged session no longer crashes when two saved panels share an ID, cancelling a system shutdown no longer leaves the next snapshot marked as a clean quit, and the configurable review shortcut works again.
- Browser automation now returns stable workspace and surface references, while obsolete native dialog state and macOS 11 compatibility branches have been removed. CI also fails clearly when a matching prebuilt GhosttyKit checksum is unavailable instead of silently compiling a different dependency from source.
- Closing a terminal tab or a workspace now actually ends the session. Anything running in it, an agent included, was being kept alive in the background after the tab disappeared: invisible, still using memory, and unable to talk back to the app. Quitting still preserves your sessions so they come back on the next launch.
- The app no longer freezes on the first launch after an update while it is restoring your terminals. Restoring a session with a long transcript could wedge the whole app: no window, no input, and force-quitting was the only way out, which lost every session you had open. Long transcripts also come back more smoothly now, instead of stalling the window until they finish.
- `programa` commands typed inside a restored terminal work again after an app update. They were being refused with "Access denied", which silently cut off any agent running in that pane until you opened a fresh one.
Expand All @@ -17,6 +19,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p
- A restart after an update no longer kills every terminal when the new app comes up faster than the background session-holder notices the old one is gone. The app now waits out that window instead of giving up, escrow sockets no longer leak into shell processes (which silently delayed that detection), and a session that falls back anyway keeps its reattach records on disk while its process is still alive instead of deleting them.

### Changed
- Terminal output subscriptions now take one bounded snapshot per surface and publish only the changed suffix, reducing main-thread work and memory churn for automation clients watching busy terminals.
- Less background churn under agent load: repeated identical progress and port reports no longer redraw workspaces, moving the mouse across a window no longer re-renders its chrome, and scrolling no longer builds debug strings that get thrown away.
- Closing a browser tab now clears its leftover automation state (scripts, dialog queues, download logs), and a browser download wait that times out no longer risks corrupting a file handle.
- The CLI now reconnects automatically instead of exiting when the app restarts (for example after an auto-update) or after an hour of inactivity; use `--no-reconnect` on `watch-events` if you want the old exit-on-disconnect behavior for scripts.
Expand Down
36 changes: 29 additions & 7 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1004,6 +1004,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
}
private var didHandleExplicitOpenIntentAtStartup = false
private var isTerminatingApp = false
private var isAwaitingPowerOffTermination = false
// Set to true when the user has already confirmed quit via the warning dialog,
// so applicationShouldTerminate does not show a second alert.
private var isQuitWarningConfirmed = false
Expand Down Expand Up @@ -1453,6 +1454,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
#endif

func applicationDidBecomeActive(_ notification: Notification) {
// `willPowerOffNotification` has no matching cancellation notification. If macOS
// becomes active again before termination, the shutdown was cancelled: resume
// autosave/session machinery and replace the provisional snapshot with a normal one.
if isAwaitingPowerOffTermination {
isAwaitingPowerOffTermination = false
isTerminatingApp = false
SessionMachineryGate.isApplicationTerminating = false
_ = saveSessionSnapshot(includeScrollback: false)
}
guard let notificationStore else { return }
notificationStore.handleApplicationDidBecomeActive()
guard let tabManager else { return }
Expand All @@ -1470,7 +1480,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
isTerminatingApp = true
SessionMachineryGate.isApplicationTerminating = true
_ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false, cleanShutdown: true)
// A warning dialog can still cancel this termination request. The final
// `applicationWillTerminate` callback is the only point that records a clean exit.
_ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false)

// Tagged DEV builds are ephemeral, skip quit confirmation entirely.
if SocketControlSettings.isTaggedDevBuild() {
Expand Down Expand Up @@ -1523,6 +1535,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
}

func applicationWillTerminate(_ notification: Notification) {
isAwaitingPowerOffTermination = false
isTerminatingApp = true
SessionMachineryGate.isApplicationTerminating = true
_ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false, cleanShutdown: true)
Expand Down Expand Up @@ -2162,8 +2175,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
) { [weak self] _ in
Task { @MainActor [weak self] in
guard let self else { return }
self.isAwaitingPowerOffTermination = true
self.isTerminatingApp = true
_ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false, cleanShutdown: true)
SessionMachineryGate.isApplicationTerminating = true
// `willPowerOff` can still be cancelled. Only applicationWillTerminate may
// label a snapshot as a clean shutdown.
_ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false)
}
}
lifecycleSnapshotObservers.append(powerOffObserver)
Expand Down Expand Up @@ -6597,7 +6614,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
]

private static let appShortcutPrecedenceOrderAfterLegacyTabNavigation: [KeyboardShortcutSettings.Action] = [
.newSurface, .openBrowser, .focusBrowserAddressBar, .browserBack, .browserForward, .browserReload,
.newSurface, .openBrowser, .openReview, .focusBrowserAddressBar, .browserBack, .browserForward, .browserReload,
.toggleBrowserDeveloperTools, .showBrowserJavaScriptConsole, .toggleReactGrab, .browserZoomIn,
.browserZoomOut, .browserZoomReset, .find, .findNext, .findPrevious, .hideFind, .useSelectionForFind,
.reopenClosedBrowserPanel,
Expand Down Expand Up @@ -6779,10 +6796,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
case .reopenClosedBrowserPanel:
return handleReopenClosedBrowserPanelShortcutAction(event: event)
case .openReview:
// Only reachable via the command palette registry (see ContentView.swift's
// "palette.openReviewPanel" command) -- there is no app-level shortcut-monitor entry
// for it, matching the old if-chain, which never checked this action either.
return nil
return handleOpenReviewShortcutAction(event: event)
}
}

Expand All @@ -6793,6 +6807,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
return true
}

private func handleOpenReviewShortcutAction(event: NSEvent) -> Bool? {
guard matchConfiguredShortcut(event: event, action: .openReview) else { return nil }
guard let workspace = tabManager?.selectedWorkspace,
let focusedPanelId = workspace.focusedPanelId else { return true }
_ = workspace.newReviewSplit(from: focusedPanelId, orientation: .horizontal, focus: true)
return true
}

private func handleGoToWorkspaceShortcutAction(
event: NSEvent,
commandPaletteTargetWindow: NSWindow?,
Expand Down
102 changes: 12 additions & 90 deletions Sources/TerminalController+BrowserAutomation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ extension TerminalController {
) -> V2JavaScriptResult {
let timeoutSeconds = max(0.01, timeout)
let evaluator: (@escaping (Any?, String?) -> Void) -> Void = { finish in
if preferAsync, #available(macOS 11.0, *) {
if preferAsync {
webView.callAsyncJavaScript(script, arguments: [:], in: nil, in: contentWorld) { result in
switch result {
case .success(let value):
Expand Down Expand Up @@ -404,25 +404,15 @@ extension TerminalController {
return await __programaEvalInFrame();
"""

var rawResult: V2JavaScriptResult
if #available(macOS 11.0, *) {
rawResult = v2RunJavaScript(
webView,
script: asyncFunctionBody,
timeout: timeout,
preferAsync: true,
contentWorld: .page
)
} else {
let evaluateFallback = """
(async () => {
\(asyncFunctionBody)
})()
"""
rawResult = v2RunJavaScript(webView, script: evaluateFallback, timeout: timeout, contentWorld: .page)
}
var rawResult = v2RunJavaScript(
webView,
script: asyncFunctionBody,
timeout: timeout,
preferAsync: true,
contentWorld: .page
)

if !useEval, case .failure(let pageMessage) = rawResult, #available(macOS 11.0, *) {
if !useEval, case .failure(let pageMessage) = rawResult {
let isolatedResult = v2RunJavaScript(
webView,
script: asyncFunctionBody,
Expand Down Expand Up @@ -469,75 +459,6 @@ extension TerminalController {
v2BrowserUnsupportedNetworkRequestsBySurface[surfaceId] = logs
}

func v2BrowserPendingDialogs(surfaceId: UUID) -> [[String: Any]] {
let queue = v2BrowserDialogQueueBySurface[surfaceId] ?? []
return queue.enumerated().map { index, d in
[
"index": index,
"type": d.type,
"message": d.message,
"default_text": v2OrNull(d.defaultText)
]
}
}

func enqueueBrowserDialog(
surfaceId: UUID,
type: String,
message: String,
defaultText: String?,
responder: @escaping (_ accept: Bool, _ text: String?) -> Void
) {
var queue = v2BrowserDialogQueueBySurface[surfaceId] ?? []
queue.append(V2BrowserPendingDialog(type: type, message: message, defaultText: defaultText, responder: responder))
if queue.count > 16 {
// Keep bounded memory while preserving FIFO semantics for newest entries.
queue.removeFirst(queue.count - 16)
}
v2BrowserDialogQueueBySurface[surfaceId] = queue
}

func v2BrowserPopDialog(surfaceId: UUID) -> V2BrowserPendingDialog? {
var queue = v2BrowserDialogQueueBySurface[surfaceId] ?? []
guard !queue.isEmpty else { return nil }
let first = queue.removeFirst()
v2BrowserDialogQueueBySurface[surfaceId] = queue
return first
}

func v2BrowserEnsureInitScriptsApplied(surfaceId: UUID, browserPanel: BrowserPanel) {
let scripts = v2BrowserInitScriptsBySurface[surfaceId] ?? []
let styles = v2BrowserInitStylesBySurface[surfaceId] ?? []
guard !scripts.isEmpty || !styles.isEmpty else { return }

let injector = """
(() => {
window.__programaInitScriptsApplied = window.__programaInitScriptsApplied || { scripts: [], styles: [] };
return true;
})()
"""
_ = v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: injector)

for script in scripts {
_ = v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script)
}
for css in styles {
let cssLiteral = v2JSONLiteral(css)
let styleScript = """
(() => {
const id = 'cmux-init-style-' + btoa(unescape(encodeURIComponent(\(cssLiteral)))).replace(/=+$/g, '');
if (document.getElementById(id)) return true;
const el = document.createElement('style');
el.id = id;
el.textContent = String(\(cssLiteral));
(document.head || document.documentElement || document.body).appendChild(el);
return true;
})()
"""
_ = v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: styleScript)
}
}

private func v2PNGData(from image: NSImage) -> Data? {
guard let tiff = image.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff) else { return nil }
Expand Down Expand Up @@ -2075,7 +1996,9 @@ extension TerminalController {
let browserPanel = ws.browserPanel(for: surfaceId) else { return }
result = .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"url": browserPanel.currentURL?.absoluteString ?? ""
])
}
Expand Down Expand Up @@ -2748,8 +2671,7 @@ extension TerminalController {
guard let dict = value as? [String: Any],
let ok = dict["ok"] as? Bool,
ok else {
let pending = v2BrowserPendingDialogs(surfaceId: surfaceId)
return .err(code: "not_found", message: "No pending dialog", data: ["pending": pending])
return .err(code: "not_found", message: "No pending dialog", data: ["pending": []])
}

return .ok([
Expand Down
67 changes: 49 additions & 18 deletions Sources/TerminalController+Subscriptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -213,9 +213,9 @@ final class SocketEventBroadcaster: @unchecked Sendable {

private let lock = NSLock()
private var subscriptions: [UUID: EventSubscription] = [:]
/// Last-seen full text length per watched surface, used to compute the "new tail" diff for
/// `output` events. Cleared when the last subscriber watching a surface unregisters.
private var lastOutputLength: [UUID: Int] = [:]
/// Last bounded viewport snapshot per watched surface, used to compute the newly appended
/// tail even when the viewport scrolls and its overall length stays constant.
private var lastOutputText: [UUID: String] = [:]

func register(_ subscription: EventSubscription) {
lock.lock()
Expand All @@ -227,7 +227,7 @@ final class SocketEventBroadcaster: @unchecked Sendable {
lock.lock()
subscriptions.removeValue(forKey: subscription.id)
let stillWatched = Set(subscriptions.values.flatMap { $0.outputSurfaceIds })
lastOutputLength = lastOutputLength.filter { stillWatched.contains($0.key) }
lastOutputText = lastOutputText.filter { stillWatched.contains($0.key) }
lock.unlock()
}

Expand Down Expand Up @@ -282,22 +282,44 @@ final class SocketEventBroadcaster: @unchecked Sendable {
guard !subs.isEmpty else { return }

lock.lock()
let previousLength = lastOutputLength[surfaceId] ?? fullText.count
lastOutputLength[surfaceId] = fullText.count
let previousText = lastOutputText.updateValue(fullText, forKey: surfaceId)
lock.unlock()

guard fullText.count > previousLength else { return }
// Text can also shrink/scroll between ticks (e.g. clear screen); in that case there is
// no well-defined "tail" to report, so this tick is skipped rather than guessing.
let tailStart = fullText.index(fullText.startIndex, offsetBy: previousLength)
let tail = String(fullText[tailStart...].suffix(4000))
guard !tail.isEmpty else { return }
guard let previousText, previousText != fullText else { return }
let tail: String
if fullText.hasPrefix(previousText) {
tail = String(fullText.dropFirst(previousText.count))
} else {
// A bounded viewport commonly shifts by whole lines. Find the largest suffix of
// the previous viewport that is still the prefix of the new one and emit only the
// rows after it. This keeps output flowing after the viewport reaches full height.
let previousLines = previousText.split(separator: "\n", omittingEmptySubsequences: false)
let currentLines = fullText.split(separator: "\n", omittingEmptySubsequences: false)
var overlap = min(previousLines.count, currentLines.count)
while overlap > 0,
!previousLines.suffix(overlap).elementsEqual(currentLines.prefix(overlap)) {
overlap -= 1
}
if overlap > 0 {
tail = currentLines.dropFirst(overlap).joined(separator: "\n")
} else {
// The cursor can extend the current last line without adding a row. Preserve
// that appended suffix when both snapshots still share a character prefix.
let commonPrefixCount = zip(previousText, fullText).prefix { pair in
pair.0 == pair.1
}.count
guard commonPrefixCount > 0, fullText.count > commonPrefixCount else { return }
tail = String(fullText.dropFirst(commonPrefixCount))
}
}
let boundedTail = String(tail.suffix(4000))
guard !boundedTail.isEmpty else { return }

let frame: [String: Any] = [
"event": "output",
"workspace_id": workspaceId.uuidString,
"surface_id": surfaceId.uuidString,
"text": tail,
"text": boundedTail,
"ts": Date().timeIntervalSince1970
]
for sub in subs { sub.enqueue(frame) }
Expand Down Expand Up @@ -407,16 +429,25 @@ extension TerminalController {
let surfaceIds = SocketEventBroadcaster.shared.watchedOutputSurfaceIds()
guard !surfaceIds.isEmpty else { return }

for surfaceId in surfaceIds {
v2MainSync {
let snapshots: [(workspaceId: UUID, surfaceId: UUID, text: String)] = v2MainSync {
surfaceIds.compactMap { surfaceId in
guard let located = AppDelegate.shared?.locateSurface(surfaceId: surfaceId),
let ws = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }),
let terminalPanel = ws.panels[surfaceId] as? TerminalPanel,
let text = self.v2SurfaceWaitReadText(terminalPanel: terminalPanel, lineLimit: 4000) else {
return
// Output polling needs only a bounded current viewport. Pattern waits
// retain the full scrollback path separately.
let text = self.readTerminalText(terminalPanel: terminalPanel) else {
return nil
}
SocketEventBroadcaster.shared.publishOutputIfChanged(workspaceId: ws.id, surfaceId: surfaceId, fullText: text)
return (workspaceId: ws.id, surfaceId: surfaceId, text: text)
}
}
for snapshot in snapshots {
SocketEventBroadcaster.shared.publishOutputIfChanged(
workspaceId: snapshot.workspaceId,
surfaceId: snapshot.surfaceId,
fullText: snapshot.text
)
}
}
}
Loading
Loading