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
22 changes: 22 additions & 0 deletions Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ protocol GhosttyTerminalHostView: GhosttyFocusableHost {
protocol GhosttyTerminalRuntimeControlling: AnyObject {
func attachHost(surfaceId: UUID)
func detachHost(surfaceId: UUID)
/// Returns the number of SwiftUI wrapper containers that currently have this surface mounted.
func mountedHostCount(surfaceId: UUID) -> Int
func setSurfaceFocus(surfaceId: UUID, focused: Bool)
func setSurfaceOcclusion(surfaceId: UUID, occluded: Bool)
func restorePendingFocusIfNeeded(surfaceId: UUID, hostView: any GhosttyFocusableHost)
}

Expand Down Expand Up @@ -73,6 +76,9 @@ struct GhosttyTerminalView: NSViewRepresentable {
let surface: SurfaceModel
let config: TerminalPanelConfig
let isFocused: Bool
/// Whether this surface's workspace is the currently selected one.
/// Non-selected workspaces are occluded so libghostty stops their render loops.
let isWorkspaceSelected: Bool
let onActivate: () -> Void
let onIdleNotification: () -> Void
let onInput: (SurfaceInputEvent) -> Void
Expand Down Expand Up @@ -109,6 +115,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
surface: surface,
config: config,
isFocused: isFocused,
isWorkspaceSelected: isWorkspaceSelected,
onActivate: onActivate,
onIdleNotification: onIdleNotification,
onInput: onInput,
Expand Down Expand Up @@ -151,6 +158,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
surface: surface,
config: config,
isFocused: isFocused,
isWorkspaceSelected: isWorkspaceSelected,
onActivate: onActivate,
onIdleNotification: onIdleNotification,
onInput: onInput,
Expand Down Expand Up @@ -179,6 +187,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
surface: SurfaceModel,
config: TerminalPanelConfig,
isFocused: Bool,
isWorkspaceSelected: Bool,
onActivate: @escaping () -> Void,
onIdleNotification: @escaping () -> Void,
onInput: @escaping (SurfaceInputEvent) -> Void,
Expand All @@ -191,11 +200,19 @@ struct GhosttyTerminalView: NSViewRepresentable {
if container.mountedSurfaceId != surface.id {
if let mountedSurfaceId = container.mountedSurfaceId {
runtime.detachHost(surfaceId: mountedSurfaceId)
// Only occlude when this was the last remaining mount for the surface.
// In reparent scenarios another container may still show it visibly.
if runtime.mountedHostCount(surfaceId: mountedSurfaceId) == 0 {
runtime.setSurfaceOcclusion(surfaceId: mountedSurfaceId, occluded: true)
}
}
runtime.attachHost(surfaceId: surface.id)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

container.mountHostView(host, surfaceId: surface.id)
// Apply occlusion on every sync so workspace-selection changes take effect
// even when the mounted surface has not changed.
runtime.setSurfaceOcclusion(surfaceId: surface.id, occluded: !isWorkspaceSelected)
syncHostView(
host,
runtime: runtime,
Expand All @@ -220,6 +237,11 @@ struct GhosttyTerminalView: NSViewRepresentable {
) {
guard let surfaceId = container.mountedSurfaceId else { return }
runtime.detachHost(surfaceId: surfaceId)
// Only occlude when this was the last remaining mount for the surface.
// In reparent scenarios another container may still show it visibly.
if runtime.mountedHostCount(surfaceId: surfaceId) == 0 {
runtime.setSurfaceOcclusion(surfaceId: surfaceId, occluded: true)
}
container.clearMountedSurface()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ struct PaneLeafView: View {
surface: activeSurface,
config: activeSurface.terminalConfig,
isFocused: isFocusedPane,
isWorkspaceSelected: manager.window.selectedWorkspaceId == workspaceId,
onActivate: {
manager.activateSurface(workspaceId: workspaceId, paneId: leaf.id, surfaceId: activeSurface.id)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,19 @@ final class AgentCompletionEventMonitor: AgentActivityEventMonitoring {
? lines.map(String.init)
: lines.dropLast().map(String.init)

for line in completeLines where !line.isEmpty {
guard let event = AgentActivityEvent.parse(line) else { continue }
CompletionDebugLogger.log(
"event runtime=\(event.agentType.rawValue) phase=\(event.phase.rawValue) surface=\(event.surfaceId.uuidString)"
)
Task { @MainActor in
self.onEvent?(event)
let events = completeLines.compactMap { line -> AgentActivityEvent? in
guard !line.isEmpty else { return nil }
return AgentActivityEvent.parse(line)
}
if !events.isEmpty {
Task { @MainActor [weak self] in
guard let self else { return }
for event in events {
CompletionDebugLogger.log(
"event runtime=\(event.agentType.rawValue) phase=\(event.phase.rawValue) surface=\(event.surfaceId.uuidString)"
)
onEvent?(event)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -437,8 +437,10 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting {
[ "$latest_timestamp" = "$candidate_timestamp" ]
}

while :; do
iteration=0
while [ "$iteration" -lt 300 ]; do
[ -f "$stamp_file" ] || exit 0
iteration=$((iteration + 1))

while IFS= read -r session_file; do
[ -f "$session_file" ] || continue
Expand Down
14 changes: 14 additions & 0 deletions Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ final class GhosttyRuntime {
mountedHostCountsBySurfaceId[surfaceId, default: 0] += 1
}

/// Returns the current number of SwiftUI wrapper containers that have this surface mounted.
func mountedHostCount(surfaceId: UUID) -> Int {
mountedHostCountsBySurfaceId[surfaceId, default: 0]
}

/// Marks a host view as detached and schedules delayed cleanup.
func detachHost(surfaceId: UUID) {
let current = mountedHostCountsBySurfaceId[surfaceId, default: 0]
Expand Down Expand Up @@ -343,6 +348,15 @@ final class GhosttyRuntime {
ghostty_surface_set_focus(surface, focused)
}

/// Notifies libghostty whether a surface is occluded (not visible).
///
/// `ghostty_surface_set_occlusion` takes `true` when the surface IS visible,
/// so we invert the `occluded` flag.
func setSurfaceOcclusion(surfaceId: UUID, occluded: Bool) {
guard let surface = surfaceHandlesById[surfaceId] else { return }
ghostty_surface_set_occlusion(surface, !occluded)
}

/// Moves first-responder focus to the host view that owns a surface id.
func focusSurfaceHost(surfaceId: UUID) {
pendingFocusedSurfaceId = surfaceId
Expand Down
22 changes: 12 additions & 10 deletions Sources/Shellraiser/Services/Persistence/WorkspacePersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ final class WorkspacePersistence: WorkspacePersisting {
private let fileManager = FileManager.default
private let logsErrors: Bool
private let workspaceFileURL: URL
private let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return encoder
}()

/// Returns the directory containing the persisted workspace file.
var directoryURL: URL {
Expand Down Expand Up @@ -146,10 +151,7 @@ final class WorkspacePersistence: WorkspacePersisting {
withIntermediateDirectories: true
)

let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(workspaces)

try data.write(to: workspaceFileURL, options: .atomic)
} catch {
if logsErrors {
Expand Down Expand Up @@ -185,20 +187,20 @@ final class CoalescingWorkspacePersistence: WorkspacePersisting {

/// Stores the latest snapshot and resets the debounce timer.
func save(_ workspaces: [WorkspaceModel]) {
coordinationQueue.sync {
pendingWorkspaces = workspaces
saveWorkItem?.cancel()
coordinationQueue.async {
self.pendingWorkspaces = workspaces
self.saveWorkItem?.cancel()

guard debounceInterval > 0 else {
persistPendingWorkspaces()
guard self.debounceInterval > 0 else {
self.persistPendingWorkspaces()
return
}

let workItem = DispatchWorkItem { [weak self] in
self?.persistPendingWorkspaces()
}
saveWorkItem = workItem
coordinationQueue.asyncAfter(deadline: .now() + debounceInterval, execute: workItem)
self.saveWorkItem = workItem
self.coordinationQueue.asyncAfter(deadline: .now() + self.debounceInterval, execute: workItem)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,27 +29,41 @@ extension WorkspaceManager {
}

/// Refreshes the resolved Git state for a surface working directory.
///
/// Cancels any in-flight task for the same surface before spawning a replacement.
@discardableResult
func refreshGitBranch(workspaceId: UUID, surfaceId: UUID, workingDirectory: String) -> Task<Void, Never> {
gitBranchTasks[surfaceId]?.cancel()

let requestedWorkingDirectory = workingDirectory
let gitStateResolver = self.gitStateResolver

return Task.detached(priority: .utility) {
let task = Task.detached(priority: .utility) { [weak self] in
let gitState = gitStateResolver(requestedWorkingDirectory)
await MainActor.run {
guard let workspace = self.workspace(id: workspaceId),
let surface = self.surface(in: workspace.rootPane, surfaceId: surfaceId),
guard !Task.isCancelled else { return }
await MainActor.run { [weak self] in
guard let self else { return }
// Re-check after the actor hop: a replacement task may have cancelled
// this one between the pre-hop check and the write below.
guard !Task.isCancelled else { return }
guard let workspace = workspace(id: workspaceId),
let surface = surface(in: workspace.rootPane, surfaceId: surfaceId),
surface.terminalConfig.workingDirectory == requestedWorkingDirectory else {
return
}

self.gitStatesBySurfaceId[surfaceId] = gitState
gitStatesBySurfaceId[surfaceId] = gitState
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

gitBranchTasks[surfaceId] = task
return task
}

/// Removes cached Git state for a surface that is no longer present.
func clearGitBranch(surfaceId: UUID) {
gitBranchTasks[surfaceId]?.cancel()
gitBranchTasks.removeValue(forKey: surfaceId)
gitStatesBySurfaceId.removeValue(forKey: surfaceId)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ extension WorkspaceManager {
completionNotifications.removeNotifications(for: $0)
GhosttyRuntime.shared.releaseSurface(surfaceId: $0)
clearGitBranch(surfaceId: $0)
clearProgressReport(surfaceId: $0)
}
updateDockBadge()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ final class WorkspaceManager: ObservableObject {
var progressClearTimers: [UUID: Timer] = [:]
/// Monotonically-increasing generation counter per surface; used to detect stale timer callbacks.
var progressTimerGeneration: [UUID: Int] = [:]
var gitBranchTasks: [UUID: Task<Void, Never>] = [:]

let persistence: any WorkspacePersisting
let workspaceCatalog: WorkspaceCatalogManager
Expand Down
6 changes: 3 additions & 3 deletions Tests/ShellraiserTests/AgentRuntimeBridgeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,9 @@ final class AgentRuntimeBridgeTests: XCTestCase {
XCTAssertFalse(codexWrapperContents.contains("surface_matches_current_codex_session"))
XCTAssertTrue(codexWrapperContents.contains("normalize_codex_session_timestamp"))
XCTAssertTrue(codexWrapperContents.contains("timestamp_is_at_or_after"))
XCTAssertTrue(codexWrapperContents.contains("while :; do"))
XCTAssertFalse(codexWrapperContents.contains("while [ \"$attempts\" -lt 40 ]; do"))
XCTAssertFalse(codexWrapperContents.contains("attempts=$((attempts + 1))"))
XCTAssertTrue(codexWrapperContents.contains("while [ \"$iteration\" -lt 300 ]; do"))
XCTAssertTrue(codexWrapperContents.contains("iteration=$((iteration + 1))"))
XCTAssertFalse(codexWrapperContents.contains("while :; do"))
XCTAssertTrue(codexWrapperContents.contains("printf '%-9.9s'"))
XCTAssertTrue(codexWrapperContents.contains("monitor_pid=\"$!\""))
XCTAssertTrue(codexWrapperContents.contains("rm -f \"$stamp_file\""))
Expand Down
Loading
Loading