diff --git a/Shellraiser.xcodeproj/project.pbxproj b/Shellraiser.xcodeproj/project.pbxproj index e2e6959..3ce8d67 100644 --- a/Shellraiser.xcodeproj/project.pbxproj +++ b/Shellraiser.xcodeproj/project.pbxproj @@ -57,8 +57,10 @@ A1000050 /* GitBranchResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000050; }; A1000051 /* WorkspaceManager+GitBranches.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000051; }; A1000052 /* SurfaceInputEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000052; }; - A1000053 /* SurfaceSearchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000053; }; - A1000054 /* TerminalSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000054; }; + A1000053 /* SurfaceProgressReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000053; }; + A1000054 /* SurfaceProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000054; }; + A1000055 /* SurfaceSearchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000055; }; + A1000056 /* TerminalSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000056; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -112,8 +114,10 @@ A2000050 /* GitBranchResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Infrastructure/Git/GitBranchResolver.swift; sourceTree = ""; }; A2000051 /* WorkspaceManager+GitBranches.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Services/Workspaces/WorkspaceManager+GitBranches.swift"; sourceTree = ""; }; A2000052 /* SurfaceInputEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/SurfaceInputEvent.swift; sourceTree = ""; }; - A2000053 /* SurfaceSearchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/SurfaceSearchState.swift; sourceTree = ""; }; - A2000054 /* TerminalSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Features/WorkspaceDetail/TerminalSearchOverlay.swift; sourceTree = ""; }; + A2000053 /* SurfaceProgressReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/SurfaceProgressReport.swift; sourceTree = ""; }; + A2000054 /* SurfaceProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Features/WorkspaceDetail/SurfaceProgressBar.swift; sourceTree = ""; }; + A2000055 /* SurfaceSearchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/SurfaceSearchState.swift; sourceTree = ""; }; + A2000056 /* TerminalSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Features/WorkspaceDetail/TerminalSearchOverlay.swift; sourceTree = ""; }; A3000001 /* Shellraiser.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Shellraiser.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -151,7 +155,8 @@ A2000018 /* TerminalPanelConfig.swift */, A2000019 /* SurfaceModel.swift */, A2000052 /* SurfaceInputEvent.swift */, - A2000053 /* SurfaceSearchState.swift */, + A2000053 /* SurfaceProgressReport.swift */, + A2000055 /* SurfaceSearchState.swift */, A2000020 /* PaneNodeModel.swift */, A2000041 /* PaneNodeModel+Operations.swift */, A2000021 /* WorkspaceModel.swift */, @@ -175,8 +180,9 @@ A2000037 /* PaneNodeView.swift */, A2000038 /* PaneSplitView.swift */, A2000039 /* PaneLeafView.swift */, - A2000054 /* TerminalSearchOverlay.swift */, + A2000056 /* TerminalSearchOverlay.swift */, A2000040 /* SurfaceTabButton.swift */, + A2000054 /* SurfaceProgressBar.swift */, A2000008 /* GhosttyTerminalView.swift */, A2000012 /* LibghosttySurfaceView.swift */, A2000013 /* GhosttyRuntime.swift */, @@ -291,7 +297,9 @@ A1000018 /* TerminalPanelConfig.swift in Sources */, A1000019 /* SurfaceModel.swift in Sources */, A1000052 /* SurfaceInputEvent.swift in Sources */, - A1000053 /* SurfaceSearchState.swift in Sources */, + A1000053 /* SurfaceProgressReport.swift in Sources */, + A1000054 /* SurfaceProgressBar.swift in Sources */, + A1000055 /* SurfaceSearchState.swift in Sources */, A1000020 /* PaneNodeModel.swift in Sources */, A1000041 /* PaneNodeModel+Operations.swift in Sources */, A1000021 /* WorkspaceModel.swift in Sources */, @@ -311,7 +319,7 @@ A1000037 /* PaneNodeView.swift in Sources */, A1000038 /* PaneSplitView.swift in Sources */, A1000039 /* PaneLeafView.swift in Sources */, - A1000054 /* TerminalSearchOverlay.swift in Sources */, + A1000056 /* TerminalSearchOverlay.swift in Sources */, A1000040 /* SurfaceTabButton.swift in Sources */, A1000008 /* GhosttyTerminalView.swift in Sources */, A1000009 /* WorkspaceCatalogManager.swift in Sources */, diff --git a/Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift b/Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift index f182ad9..fe3d0f9 100644 --- a/Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift +++ b/Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift @@ -17,7 +17,8 @@ protocol GhosttyTerminalHostView: GhosttyFocusableHost { onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, - onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void + onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void, + onProgressReport: @escaping (SurfaceProgressReport?) -> Void ) } @@ -79,6 +80,7 @@ struct GhosttyTerminalView: NSViewRepresentable { let onWorkingDirectoryChange: (String) -> Void let onChildExited: () -> Void let onPaneNavigationRequest: (PaneNodeModel.PaneFocusDirection) -> Void + let onProgressReport: (SurfaceProgressReport?) -> Void let onSearchStateChange: (SurfaceSearchState?) -> Void /// Builds the AppKit surface host. @@ -95,6 +97,7 @@ struct GhosttyTerminalView: NSViewRepresentable { onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, onPaneNavigationRequest: onPaneNavigationRequest, + onProgressReport: onProgressReport, onSearchStateChange: onSearchStateChange ) Self.syncContainerView( @@ -110,7 +113,8 @@ struct GhosttyTerminalView: NSViewRepresentable { onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, - onPaneNavigationRequest: onPaneNavigationRequest + onPaneNavigationRequest: onPaneNavigationRequest, + onProgressReport: onProgressReport ) return containerView #else @@ -134,6 +138,7 @@ struct GhosttyTerminalView: NSViewRepresentable { onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, onPaneNavigationRequest: onPaneNavigationRequest, + onProgressReport: onProgressReport, onSearchStateChange: onSearchStateChange ) Self.syncContainerView( @@ -149,7 +154,8 @@ struct GhosttyTerminalView: NSViewRepresentable { onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, - onPaneNavigationRequest: onPaneNavigationRequest + onPaneNavigationRequest: onPaneNavigationRequest, + onProgressReport: onProgressReport ) #endif } @@ -176,7 +182,8 @@ struct GhosttyTerminalView: NSViewRepresentable { onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, - onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void + onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void, + onProgressReport: @escaping (SurfaceProgressReport?) -> Void ) { if container.mountedSurfaceId != surface.id { if let mountedSurfaceId = container.mountedSurfaceId { @@ -198,7 +205,8 @@ struct GhosttyTerminalView: NSViewRepresentable { onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, - onPaneNavigationRequest: onPaneNavigationRequest + onPaneNavigationRequest: onPaneNavigationRequest, + onProgressReport: onProgressReport ) } @@ -225,7 +233,8 @@ struct GhosttyTerminalView: NSViewRepresentable { onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, - onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void + onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void, + onProgressReport: @escaping (SurfaceProgressReport?) -> Void ) { host.update( surfaceModel: surface, @@ -236,7 +245,8 @@ struct GhosttyTerminalView: NSViewRepresentable { onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, - onPaneNavigationRequest: onPaneNavigationRequest + onPaneNavigationRequest: onPaneNavigationRequest, + onProgressReport: onProgressReport ) runtime.setSurfaceFocus(surfaceId: surface.id, focused: isFocused) runtime.restorePendingFocusIfNeeded(surfaceId: surface.id, hostView: host) diff --git a/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift b/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift index 4abf501..41fe8ae 100644 --- a/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift +++ b/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift @@ -232,6 +232,13 @@ struct PaneLeafView: View { onPaneNavigationRequest: { direction in manager.focusAdjacentPane(from: activeSurface.id, direction: direction) }, + onProgressReport: { report in + manager.setProgressReport( + workspaceId: workspaceId, + surfaceId: activeSurface.id, + report: report + ) + }, onSearchStateChange: { state in searchState = state } @@ -255,6 +262,17 @@ struct PaneLeafView: View { .allowsHitTesting(false) } + if let activeSurface, + let report = manager.progressBySurfaceId[activeSurface.id] { + VStack { + SurfaceProgressBar(report: report) + .padding(.horizontal, 8) + .padding(.top, 6) + Spacer() + } + .allowsHitTesting(false) + } + if let searchState { TerminalSearchOverlay( searchState: searchState, diff --git a/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceProgressBar.swift b/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceProgressBar.swift new file mode 100644 index 0000000..51c0e7e --- /dev/null +++ b/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceProgressBar.swift @@ -0,0 +1,99 @@ +import SwiftUI + +/// 2px progress bar rendered at the top of a terminal surface pane. +/// +/// Renders a determinate fill for known percentages and a bouncing animation +/// for indeterminate states. Color reflects the report state: red for error, +/// orange for pause, accent for all others. +struct SurfaceProgressBar: View { + let report: SurfaceProgressReport + + private var color: Color { report.state.tintColor } + + /// Resolved percentage value, treating pause-without-progress as 100%. + private var progress: UInt8? { + if let v = report.progress { return v } + if report.state == .pause { return 100 } + return nil + } + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + if let progress { + Rectangle() + .fill(color) + .frame( + width: geometry.size.width * CGFloat(progress) / 100, + height: geometry.size.height + ) + .animation(.easeInOut(duration: 0.2), value: progress) + } else { + BouncingProgressBar(color: color) + } + } + } + .frame(height: 2) + .clipped() + .allowsHitTesting(false) + .accessibilityElement(children: .ignore) + .accessibilityAddTraits(.updatesFrequently) + .accessibilityLabel(accessibilityLabel) + .accessibilityValue(accessibilityValue) + } + + private var accessibilityLabel: String { + switch report.state { + case .error: return "Terminal progress - Error" + case .pause: return "Terminal progress - Paused" + case .indeterminate: return "Terminal progress - In progress" + default: return "Terminal progress" + } + } + + private var accessibilityValue: String { + if let progress { return "\(progress) percent complete" } + switch report.state { + case .error: return "Operation failed" + case .pause: return "Operation paused at completion" + case .indeterminate: return "Operation in progress" + default: return "Indeterminate progress" + } + } +} + +/// Bouncing animated bar used for indeterminate progress states. +private struct BouncingProgressBar: View { + let color: Color + @State private var position: CGFloat = 0 + + private let barWidthRatio: CGFloat = 0.25 + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Rectangle() + .fill(color.opacity(0.3)) + + Rectangle() + .fill(color) + .frame( + width: geometry.size.width * barWidthRatio, + height: geometry.size.height + ) + .offset(x: position * (geometry.size.width * (1 - barWidthRatio))) + } + } + .onAppear { + withAnimation( + .easeInOut(duration: 1.2) + .repeatForever(autoreverses: true) + ) { + position = 1 + } + } + .onDisappear { + position = 0 + } + } +} diff --git a/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceTabButton.swift b/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceTabButton.swift index 30d2045..3b09549 100644 --- a/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceTabButton.swift +++ b/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceTabButton.swift @@ -26,12 +26,21 @@ struct SurfaceTabButton: View { Color(nsColor: chromeStyle.foregroundColor).opacity(isSelected ? 0.76 : 0.55) } + /// Color for the tab status dot derived from progress state, completion, or default. + private var dotFill: AnyShapeStyle { + if let report = manager.progressBySurfaceId[surface.id] { + return AnyShapeStyle(report.state.tintColor) + } + if surface.hasPendingCompletion { return AnyShapeStyle(AppTheme.accentGradient) } + return AnyShapeStyle(Color.white.opacity(0.2)) + } + var body: some View { HStack(spacing: 6) { Button(action: onSelect) { HStack(spacing: 8) { Circle() - .fill(surface.hasPendingCompletion ? AnyShapeStyle(AppTheme.accentGradient) : AnyShapeStyle(Color.white.opacity(0.2))) + .fill(dotFill) .frame(width: 6, height: 6) Text(surface.title) diff --git a/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift b/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift index fdb6fae..cad9184 100644 --- a/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift +++ b/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift @@ -25,6 +25,8 @@ final class GhosttyRuntime { case title(String) case workingDirectory(String) case childExited + /// OSC 9;4 progress report; nil means the remove state. + case progressReport(SurfaceProgressReport?) } struct SurfaceCallbacks { @@ -32,6 +34,7 @@ final class GhosttyRuntime { let onTitleChange: (String) -> Void let onWorkingDirectoryChange: (String) -> Void let onChildExited: () -> Void + let onProgressReport: (SurfaceProgressReport?) -> Void let onSearchStateChange: (SurfaceSearchState?) -> Void } @@ -104,7 +107,8 @@ final class GhosttyRuntime { onIdleNotification: @escaping () -> Void, onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, - onChildExited: @escaping () -> Void + onChildExited: @escaping () -> Void, + onProgressReport: @escaping (SurfaceProgressReport?) -> Void ) { let existingSearch = callbacksBySurfaceId[surfaceId]?.onSearchStateChange ?? { _ in } registerSurfaceCallbacks( @@ -113,17 +117,19 @@ final class GhosttyRuntime { onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, + onProgressReport: onProgressReport, onSearchStateChange: existingSearch ) } - /// Registers all callbacks for a surface model identifier, including the search state callback. + /// Registers all callbacks for a surface model identifier, including progress and search state callbacks. func registerSurfaceCallbacks( surfaceId: UUID, onIdleNotification: @escaping () -> Void, onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, + onProgressReport: @escaping (SurfaceProgressReport?) -> Void, onSearchStateChange: @escaping (SurfaceSearchState?) -> Void ) { callbacksBySurfaceId[surfaceId] = SurfaceCallbacks( @@ -131,6 +137,7 @@ final class GhosttyRuntime { onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, + onProgressReport: onProgressReport, onSearchStateChange: onSearchStateChange ) } @@ -146,6 +153,7 @@ final class GhosttyRuntime { onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void, + onProgressReport: @escaping (SurfaceProgressReport?) -> Void, onSearchStateChange: @escaping (SurfaceSearchState?) -> Void ) -> LibghosttySurfaceView { registerSurfaceCallbacks( @@ -154,22 +162,12 @@ final class GhosttyRuntime { onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, + onProgressReport: onProgressReport, onSearchStateChange: onSearchStateChange ) releasedSurfaceIds.remove(surfaceModel.id) if let existing = hostViewsBySurfaceId[surfaceModel.id] { - existing.update( - surfaceModel: surfaceModel, - terminalConfig: terminalConfig, - onActivate: onActivate, - onIdleNotification: onIdleNotification, - onInput: onInput, - onTitleChange: onTitleChange, - onWorkingDirectoryChange: onWorkingDirectoryChange, - onChildExited: onChildExited, - onPaneNavigationRequest: onPaneNavigationRequest - ) return existing } @@ -182,7 +180,8 @@ final class GhosttyRuntime { onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, - onPaneNavigationRequest: onPaneNavigationRequest + onPaneNavigationRequest: onPaneNavigationRequest, + onProgressReport: onProgressReport ) hostViewsBySurfaceId[surfaceModel.id] = created mountedHostCountsBySurfaceId[surfaceModel.id] = 0 @@ -876,6 +875,32 @@ final class GhosttyRuntime { runtime.notifyAppearanceDidChange() } return true + case GHOSTTY_ACTION_PROGRESS_REPORT: + guard target.tag == GHOSTTY_TARGET_SURFACE, + let surface = target.target.surface else { return false } + let surfaceKey = UInt(bitPattern: surface) + let raw = action.action.progress_report + let clampedProgress: UInt8? = raw.progress >= 0 ? UInt8(min(raw.progress, 100)) : nil + let report: SurfaceProgressReport? + switch raw.state { + case GHOSTTY_PROGRESS_STATE_REMOVE: + report = nil + case GHOSTTY_PROGRESS_STATE_SET: + report = SurfaceProgressReport(state: .set, progress: clampedProgress) + case GHOSTTY_PROGRESS_STATE_ERROR: + report = SurfaceProgressReport(state: .error, progress: clampedProgress) + case GHOSTTY_PROGRESS_STATE_INDETERMINATE: + report = SurfaceProgressReport(state: .indeterminate, progress: clampedProgress) + case GHOSTTY_PROGRESS_STATE_PAUSE: + report = SurfaceProgressReport(state: .pause, progress: clampedProgress) + default: + return false + } + let capturedEvent: SurfaceActionEvent = .progressReport(report) + DispatchQueue.main.async { + runtime.handleAction(surfaceKey: surfaceKey, event: capturedEvent) + } + return true case GHOSTTY_ACTION_START_SEARCH: guard target.tag == GHOSTTY_TARGET_SURFACE, let surface = target.target.surface else { return false } @@ -1097,6 +1122,8 @@ final class GhosttyRuntime { callbacks.onWorkingDirectoryChange(workingDirectory) case .childExited: callbacks.onChildExited() + case .progressReport(let report): + callbacks.onProgressReport(report) } } diff --git a/Sources/Shellraiser/Infrastructure/Ghostty/LibghosttySurfaceView.swift b/Sources/Shellraiser/Infrastructure/Ghostty/LibghosttySurfaceView.swift index 227cccd..1531bc7 100644 --- a/Sources/Shellraiser/Infrastructure/Ghostty/LibghosttySurfaceView.swift +++ b/Sources/Shellraiser/Infrastructure/Ghostty/LibghosttySurfaceView.swift @@ -16,6 +16,7 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati private var onWorkingDirectoryChange: (String) -> Void private var onChildExited: () -> Void private var onPaneNavigationRequest: (PaneNodeModel.PaneFocusDirection) -> Void + private var onProgressReport: (SurfaceProgressReport?) -> Void private var markedText = NSMutableAttributedString() private var keyTextAccumulator: [String]? private var didInterpretCommand = false @@ -36,7 +37,8 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, - onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void + onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void, + onProgressReport: @escaping (SurfaceProgressReport?) -> Void ) { self.surfaceModel = surfaceModel self.terminalConfig = terminalConfig @@ -47,6 +49,7 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati self.onWorkingDirectoryChange = onWorkingDirectoryChange self.onChildExited = onChildExited self.onPaneNavigationRequest = onPaneNavigationRequest + self.onProgressReport = onProgressReport super.init(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) wantsLayer = true @@ -61,7 +64,8 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati onIdleNotification: onIdleNotification, onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, - onChildExited: onChildExited + onChildExited: onChildExited, + onProgressReport: onProgressReport ) surfaceHandle = GhosttyRuntime.shared.createSurface( for: self, @@ -430,7 +434,8 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, - onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void + onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void, + onProgressReport: @escaping (SurfaceProgressReport?) -> Void ) { self.surfaceModel = surfaceModel self.terminalConfig = terminalConfig @@ -441,13 +446,15 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati self.onWorkingDirectoryChange = onWorkingDirectoryChange self.onChildExited = onChildExited self.onPaneNavigationRequest = onPaneNavigationRequest + self.onProgressReport = onProgressReport GhosttyRuntime.shared.registerSurfaceCallbacks( surfaceId: surfaceModel.id, onIdleNotification: onIdleNotification, onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, - onChildExited: onChildExited + onChildExited: onChildExited, + onProgressReport: onProgressReport ) applyGhosttyBackgroundStyle() updateScaleAndSize() diff --git a/Sources/Shellraiser/Models/SurfaceProgressReport.swift b/Sources/Shellraiser/Models/SurfaceProgressReport.swift new file mode 100644 index 0000000..2aa4498 --- /dev/null +++ b/Sources/Shellraiser/Models/SurfaceProgressReport.swift @@ -0,0 +1,31 @@ +import SwiftUI + +/// Progress state reported by a terminal surface via OSC 9;4. +enum SurfaceProgressState: Equatable { + case set + case error + case indeterminate + case pause + + /// Tint color representing the progress state in UI elements. + var tintColor: Color { + switch self { + case .error: return .red + case .pause: return .orange + default: return .accentColor + } + } +} + +/// Progress report delivered by a terminal surface via OSC 9;4. +struct SurfaceProgressReport: Equatable { + /// Current progress state. + let state: SurfaceProgressState + /// Percentage completion (0–100), or nil when not quantified. + let progress: UInt8? + + init(state: SurfaceProgressState, progress: UInt8?) { + self.state = state + self.progress = progress.map { min($0, 100) } + } +} diff --git a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift index 014e61e..d330ed7 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift @@ -43,6 +43,7 @@ extension WorkspaceManager { clearBusySurface(surfaceId) clearLiveCodexSessionSurface(surfaceId) clearGitBranch(surfaceId: surfaceId) + clearProgressReport(surfaceId: surfaceId) if let workspace = workspace(id: workspaceId), let focusedSurfaceId = workspace.focusedSurfaceId ?? workspace.rootPane.firstActiveSurfaceId(), @@ -145,6 +146,49 @@ extension WorkspaceManager { markSurfaceBusy(surfaceId) } + /// Interval after which a progress report is automatically cleared if no update arrives. + private static let progressAutoClearInterval: TimeInterval = 15 + + /// Stores or removes an OSC 9;4 progress report for a surface, resetting the auto-clear timer. + func setProgressReport(workspaceId: UUID, surfaceId: UUID, report: SurfaceProgressReport?) { + guard let workspace = workspace(id: workspaceId), + self.surface(in: workspace.rootPane, surfaceId: surfaceId) != nil else { return } + if let report { + progressBySurfaceId[surfaceId] = report + progressClearTimers[surfaceId]?.invalidate() + let generation = (progressTimerGeneration[surfaceId] ?? 0) + 1 + progressTimerGeneration[surfaceId] = generation + let timer = Timer(timeInterval: Self.progressAutoClearInterval, repeats: false) { [weak self] _ in + Task { @MainActor [weak self] in + guard self?.progressTimerGeneration[surfaceId] == generation else { return } + self?.progressBySurfaceId.removeValue(forKey: surfaceId) + self?.progressClearTimers.removeValue(forKey: surfaceId) + self?.progressTimerGeneration.removeValue(forKey: surfaceId) + } + } + RunLoop.main.add(timer, forMode: .common) + progressClearTimers[surfaceId] = timer + } else { + clearProgressReport(surfaceId: surfaceId) + } + } + + /// Returns the progress report for the focused surface of a workspace, if any. + func focusedSurfaceProgress(workspaceId: UUID) -> SurfaceProgressReport? { + guard let workspace = workspace(id: workspaceId) else { return nil } + let surfaceId = workspace.focusedSurfaceId ?? workspace.rootPane.firstActiveSurfaceId() + guard let surfaceId else { return nil } + return progressBySurfaceId[surfaceId] + } + + /// Removes any stored progress state and cancels the auto-clear timer for a surface. + func clearProgressReport(surfaceId: UUID) { + progressBySurfaceId.removeValue(forKey: surfaceId) + progressClearTimers[surfaceId]?.invalidate() + progressClearTimers.removeValue(forKey: surfaceId) + progressTimerGeneration.removeValue(forKey: surfaceId) + } + /// Updates tab title using the current terminal title. func setSurfaceTitle(workspaceId: UUID, surfaceId: UUID, title: String) { surfaceManager.setSurfaceTitle( diff --git a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift index 8fb65b9..9c863b9 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift @@ -74,7 +74,11 @@ final class WorkspaceManager: ObservableObject { @Published var pendingWorkspaceRename: WorkspaceRenameRequest? @Published var gitStatesBySurfaceId: [UUID: ResolvedGitState] = [:] @Published var busySurfaceIds: Set = [] + @Published var progressBySurfaceId: [UUID: SurfaceProgressReport] = [:] var liveCodexSessionSurfaceIds: Set = [] + var progressClearTimers: [UUID: Timer] = [:] + /// Monotonically-increasing generation counter per surface; used to detect stale timer callbacks. + var progressTimerGeneration: [UUID: Int] = [:] let persistence: any WorkspacePersisting let workspaceCatalog: WorkspaceCatalogManager diff --git a/Tests/ShellraiserTests/GhosttyTerminalViewTests.swift b/Tests/ShellraiserTests/GhosttyTerminalViewTests.swift index 0c32a6d..d57dab7 100644 --- a/Tests/ShellraiserTests/GhosttyTerminalViewTests.swift +++ b/Tests/ShellraiserTests/GhosttyTerminalViewTests.swift @@ -29,7 +29,8 @@ final class GhosttyTerminalViewTests: XCTestCase { onTitleChange: { _ in }, onWorkingDirectoryChange: { _ in }, onChildExited: {}, - onPaneNavigationRequest: { _ in } + onPaneNavigationRequest: { _ in }, + onProgressReport: { _ in } ) XCTAssertEqual(host.updatedSurfaceIds, [surface.id]) @@ -39,6 +40,39 @@ final class GhosttyTerminalViewTests: XCTestCase { XCTAssertTrue(runtime.restoredHosts.first === host) } + /// Verifies syncHostView forwards the onProgressReport closure to the host view. + func testSyncHostViewForwardsProgressReportClosure() { + let runtime = MockGhosttyTerminalRuntime() + let host = MockGhosttyTerminalHostView() + let surface = SurfaceModel.makeDefault() + let config = TerminalPanelConfig( + workingDirectory: "/tmp", + shell: "/bin/zsh", + environment: [:] + ) + var receivedReport: SurfaceProgressReport?? + let expectedReport = SurfaceProgressReport(state: .set, progress: 42) + + GhosttyTerminalView.syncHostView( + host, + runtime: runtime, + surface: surface, + config: config, + isFocused: false, + onActivate: {}, + onIdleNotification: {}, + onInput: { _ in }, + onTitleChange: { _ in }, + onWorkingDirectoryChange: { _ in }, + onChildExited: {}, + onPaneNavigationRequest: { _ in }, + onProgressReport: { receivedReport = $0 } + ) + + host.progressReportHandler?(expectedReport) + XCTAssertEqual(receivedReport, .some(expectedReport)) + } + /// Verifies reparenting a shared host into a new wrapper keeps the host off the mount root. func testSyncContainerViewReparentsSharedHostWithoutDetachingCurrentMount() { let runtime = MockGhosttyTerminalRuntime() @@ -65,7 +99,8 @@ final class GhosttyTerminalViewTests: XCTestCase { onTitleChange: { _ in }, onWorkingDirectoryChange: { _ in }, onChildExited: {}, - onPaneNavigationRequest: { _ in } + onPaneNavigationRequest: { _ in }, + onProgressReport: { _ in } ) GhosttyTerminalView.syncContainerView( secondContainer, @@ -80,7 +115,8 @@ final class GhosttyTerminalViewTests: XCTestCase { onTitleChange: { _ in }, onWorkingDirectoryChange: { _ in }, onChildExited: {}, - onPaneNavigationRequest: { _ in } + onPaneNavigationRequest: { _ in }, + onProgressReport: { _ in } ) XCTAssertTrue(host.superview === secondContainer) @@ -118,7 +154,8 @@ final class GhosttyTerminalViewTests: XCTestCase { onTitleChange: { _ in }, onWorkingDirectoryChange: { _ in }, onChildExited: {}, - onPaneNavigationRequest: { _ in } + onPaneNavigationRequest: { _ in }, + onProgressReport: { _ in } ) GhosttyTerminalView.syncContainerView( container, @@ -133,7 +170,8 @@ final class GhosttyTerminalViewTests: XCTestCase { onTitleChange: { _ in }, onWorkingDirectoryChange: { _ in }, onChildExited: {}, - onPaneNavigationRequest: { _ in } + onPaneNavigationRequest: { _ in }, + onProgressReport: { _ in } ) XCTAssertTrue(firstHost.superview == nil) @@ -169,7 +207,8 @@ final class GhosttyTerminalViewTests: XCTestCase { onTitleChange: { _ in }, onWorkingDirectoryChange: { _ in }, onChildExited: {}, - onPaneNavigationRequest: { _ in } + onPaneNavigationRequest: { _ in }, + onProgressReport: { _ in } ) GhosttyTerminalView.dismantleContainerView(container, runtime: runtime) @@ -193,6 +232,8 @@ private final class MockGhosttyTerminalHostView: NSView, GhosttyTerminalHostView /// Supplies the responder target to make first responder. var firstResponderTarget: NSResponder { self } + private(set) var progressReportHandler: ((SurfaceProgressReport?) -> Void)? + /// Records host updates dispatched through the terminal view helper. func update( surfaceModel: SurfaceModel, @@ -203,7 +244,8 @@ private final class MockGhosttyTerminalHostView: NSView, GhosttyTerminalHostView onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, - onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void + onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void, + onProgressReport: @escaping (SurfaceProgressReport?) -> Void ) { _ = terminalConfig _ = onActivate @@ -213,6 +255,7 @@ private final class MockGhosttyTerminalHostView: NSView, GhosttyTerminalHostView workingDirectoryChangeHandler = onWorkingDirectoryChange _ = onChildExited _ = onPaneNavigationRequest + progressReportHandler = onProgressReport updatedSurfaceIds.append(surfaceModel.id) } }