native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Dim inactive split panes (#250, #243) (#260)

Surface which pane has focus by dimming inactive splits with Ghostty's
own unfocused-split overlay semantics.

- WorktreeTerminalState exposes activeSurfaceID(for:) backed by the
single focusedSurfaceIdByTab source of truth. Click-path and explicit
focus paths (goto_split, split zoom, palette) both funnel through the
new recordActiveSurface choke point so two panes can't both claim
active and focus-loss can't wipe the remembered active pane.
- GhosttyRuntime reads unfocused-split-opacity and unfocused-split-fill
via ghostty_config_get, clamping the overlay opacity to [0, 1] and
returning nil (with a SupaLogger warning) when both the fill and
background keys are missing so the UI can skip the overlay rather
than silently paint it black.
- WorktreeTerminalTabsView threads the overlay config down to LeafView
and bumps a SwiftUI invalidation counter on
.ghosttyRuntimeConfigDidChange so live config reloads re-read the
values. Split divider uses the system separator color.
- SplitTreeTests locks in recordActiveSurface symmetry across the
click and goto_split paths, dedup via emitFocusChangedIfNeeded,
idempotence on the explicit path, and focus-loss as a no-op.

authored by

Stefano Bertagno and committed by
GitHub
4d19b068 6c807c63

+174 -15
+5
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 2 2 import Observation 3 3 import Sharing 4 4 import SupacodeSettingsShared 5 + import SwiftUI 5 6 6 7 private let terminalLogger = SupaLogger("Terminal") 7 8 ··· 416 417 417 418 func surfaceBackgroundOpacity() -> Double { 418 419 runtime.backgroundOpacity() 420 + } 421 + 422 + func unfocusedSplitOverlay() -> (fill: Color?, opacity: Double) { 423 + (runtime.unfocusedSplitFill(), runtime.unfocusedSplitOverlayOpacity()) 419 424 } 420 425 421 426 private func emit(_ event: TerminalClient.Event) {
+17 -7
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 1160 1160 } 1161 1161 view.onFocusChange = { [weak self, weak view] focused in 1162 1162 guard let self, let view, focused else { return } 1163 - self.focusedSurfaceIdByTab[tabId] = view.id 1164 - self.markNotificationsRead(forSurfaceID: view.id) 1165 - self.updateTabTitle(for: tabId) 1166 - self.emitFocusChangedIfNeeded(view.id) 1163 + self.recordActiveSurface(view, in: tabId) 1167 1164 self.emitTaskStatusIfChanged() 1168 1165 } 1169 1166 surfaces[view.id] = view ··· 1224 1221 1225 1222 private func focusSurface(_ surface: GhosttySurfaceView, in tabId: TerminalTabID) { 1226 1223 let previousSurface = focusedSurfaceIdByTab[tabId].flatMap { surfaces[$0] } 1224 + recordActiveSurface(surface, in: tabId) 1225 + guard tabId == tabManager.selectedTabId else { return } 1226 + let fromSurface = (previousSurface === surface) ? nil : previousSurface 1227 + GhosttySurfaceView.moveFocus(to: surface, from: fromSurface) 1228 + } 1229 + 1230 + // Single choke point for mutating the "active pane" of a tab. Reached both 1231 + // from explicit focus paths (programmatic focus, split navigation, zoom) 1232 + // and from AppKit responder changes when the user clicks a pane. 1233 + private func recordActiveSurface(_ surface: GhosttySurfaceView, in tabId: TerminalTabID) { 1227 1234 focusedSurfaceIdByTab[tabId] = surface.id 1228 1235 markNotificationsRead(forSurfaceID: surface.id) 1229 1236 updateTabTitle(for: tabId) 1230 - guard tabId == tabManager.selectedTabId else { return } 1231 - let fromSurface = (previousSurface === surface) ? nil : previousSurface 1232 - GhosttySurfaceView.moveFocus(to: surface, from: fromSurface) 1233 1237 emitFocusChangedIfNeeded(surface.id) 1238 + } 1239 + 1240 + // Single source of truth for the tab's active pane so the overlay renderer 1241 + // can't drift across surfaces. 1242 + func activeSurfaceID(for tabId: TerminalTabID) -> UUID? { 1243 + focusedSurfaceIdByTab[tabId] 1234 1244 } 1235 1245 1236 1246 /// Appends a notification from an agent hook on a specific surface.
+64 -7
supacode/Features/Terminal/Views/TerminalSplitTreeView.swift
··· 4 4 5 5 struct TerminalSplitTreeView: View { 6 6 let tree: SplitTree<GhosttySurfaceView> 7 + // Single source of truth for which pane is active in this tab. Any surface 8 + // whose id does not match this gets the unfocused-split dim overlay. 9 + let activeSurfaceID: UUID? 10 + // Supacode renders surfaces directly (no Ghostty SurfaceWrapper), so the 11 + // unfocused-pane dim overlay is applied here from the `unfocused-split-fill` 12 + // and `unfocused-split-opacity` config values. Fill is nil when the config 13 + // is unreadable; callers must skip the overlay in that case. 14 + let unfocusedSplitOverlay: (fill: Color?, opacity: Double) 7 15 let action: (Operation) -> Void 8 16 9 17 private static let dragType = UTType(exportedAs: "sh.supacode.ghosttySurfaceId") ··· 22 30 23 31 var body: some View { 24 32 if let node = tree.visibleNode { 25 - SubtreeView(node: node, isRoot: node == tree.root, action: action) 26 - .id(node.structuralIdentity) 33 + SubtreeView( 34 + node: node, 35 + isRoot: node == tree.root, 36 + activeSurfaceID: activeSurfaceID, 37 + unfocusedSplitOverlay: unfocusedSplitOverlay, 38 + action: action 39 + ) 40 + .id(node.structuralIdentity) 27 41 } 28 42 } 29 43 ··· 36 50 struct SubtreeView: View { 37 51 let node: SplitTree<GhosttySurfaceView>.Node 38 52 var isRoot: Bool = false 53 + let activeSurfaceID: UUID? 54 + let unfocusedSplitOverlay: (fill: Color?, opacity: Double) 39 55 let action: (Operation) -> Void 40 56 41 57 var body: some View { 42 58 switch node { 43 59 case .leaf(let leafView): 44 - LeafView(surfaceView: leafView, isSplit: !isRoot, action: action) 60 + LeafView( 61 + surfaceView: leafView, 62 + isSplit: !isRoot, 63 + activeSurfaceID: activeSurfaceID, 64 + unfocusedSplitOverlay: unfocusedSplitOverlay, 65 + action: action 66 + ) 45 67 case .split(let split): 46 68 let splitViewDirection: SplitView<SubtreeView, SubtreeView>.Direction = 47 69 switch split.direction { ··· 57 79 set: { 58 80 action(.resize(node: node, ratio: Double($0))) 59 81 }), 60 - dividerColor: .secondary, 82 + dividerColor: Color(nsColor: .separatorColor), 61 83 resizeIncrements: .init(width: 1, height: 1), 62 84 left: { 63 - SubtreeView(node: split.left, action: action) 85 + SubtreeView( 86 + node: split.left, 87 + activeSurfaceID: activeSurfaceID, 88 + unfocusedSplitOverlay: unfocusedSplitOverlay, 89 + action: action 90 + ) 64 91 }, 65 92 right: { 66 - SubtreeView(node: split.right, action: action) 93 + SubtreeView( 94 + node: split.right, 95 + activeSurfaceID: activeSurfaceID, 96 + unfocusedSplitOverlay: unfocusedSplitOverlay, 97 + action: action 98 + ) 67 99 }, 68 100 onEqualize: { 69 101 action(.equalize) ··· 76 108 struct LeafView: View { 77 109 let surfaceView: GhosttySurfaceView 78 110 let isSplit: Bool 111 + let activeSurfaceID: UUID? 112 + let unfocusedSplitOverlay: (fill: Color?, opacity: Double) 79 113 let action: (Operation) -> Void 80 114 81 115 @State private var dropState: DropState = .idle 116 + 117 + private var isDimmed: Bool { 118 + // During initialization activeSurfaceID is nil and nothing should be 119 + // dimmed. 120 + guard isSplit, let activeSurfaceID else { return false } 121 + return activeSurfaceID != surfaceView.id 122 + } 82 123 83 124 var body: some View { 84 125 GeometryReader { geometry in 85 126 GhosttyTerminalView(surfaceView: surfaceView) 86 127 .frame(maxWidth: .infinity, maxHeight: .infinity) 128 + .overlay { 129 + if isDimmed, let fill = unfocusedSplitOverlay.fill, unfocusedSplitOverlay.opacity > 0 { 130 + fill 131 + .opacity(unfocusedSplitOverlay.opacity) 132 + .allowsHitTesting(false) 133 + } 134 + } 87 135 .overlay(alignment: .top) { 88 136 GhosttySurfaceProgressOverlay(state: surfaceView.bridge.state) 89 137 } ··· 281 329 /// list of terminal panes to assistive technologies. 282 330 struct TerminalSplitTreeAXContainer: NSViewRepresentable { 283 331 let tree: SplitTree<GhosttySurfaceView> 332 + let activeSurfaceID: UUID? 333 + let unfocusedSplitOverlay: (fill: Color?, opacity: Double) 284 334 let action: (TerminalSplitTreeView.Operation) -> Void 285 335 286 336 func makeNSView(context: Context) -> TerminalSplitAXContainerView { ··· 289 339 290 340 func updateNSView(_ nsView: TerminalSplitAXContainerView, context: Context) { 291 341 nsView.update( 292 - rootView: AnyView(TerminalSplitTreeView(tree: tree, action: action)), 342 + rootView: AnyView( 343 + TerminalSplitTreeView( 344 + tree: tree, 345 + activeSurfaceID: activeSurfaceID, 346 + unfocusedSplitOverlay: unfocusedSplitOverlay, 347 + action: action 348 + ) 349 + ), 293 350 panes: tree.visibleLeaves() 294 351 ) 295 352 }
+14 -1
supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift
··· 8 8 let forceAutoFocus: Bool 9 9 let createTab: () -> Void 10 10 @State private var windowActivity = WindowActivityState.inactive 11 + // SwiftUI invalidation token. Runtime config values aren't Observable, so 12 + // we bump this counter on `.ghosttyRuntimeConfigDidChange` to force body 13 + // to re-read `manager.unfocusedSplitOverlay()` after a live reload. 14 + @State private var configReloadCounter = 0 11 15 12 16 var body: some View { 13 17 let state = manager.state(for: worktree) { shouldRunSetupScript } 18 + let _ = configReloadCounter 19 + let unfocusedSplitOverlay = manager.unfocusedSplitOverlay() 14 20 VStack(spacing: 0) { 15 21 if !state.shouldHideTabBar { 16 22 TerminalTabBarView( ··· 40 46 } 41 47 if let selectedId = state.tabManager.selectedTabId { 42 48 TerminalTabContentStack(tabs: state.tabManager.tabs, selectedTabId: selectedId) { tabId in 43 - TerminalSplitTreeAXContainer(tree: state.splitTree(for: tabId)) { operation in 49 + TerminalSplitTreeAXContainer( 50 + tree: state.splitTree(for: tabId), 51 + activeSurfaceID: state.activeSurfaceID(for: tabId), 52 + unfocusedSplitOverlay: unfocusedSplitOverlay 53 + ) { operation in 44 54 state.performSplitOperation(operation, in: tabId) 45 55 } 46 56 } ··· 69 79 } 70 80 let activity = resolvedWindowActivity 71 81 state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) 82 + } 83 + .onReceive(NotificationCenter.default.publisher(for: .ghosttyRuntimeConfigDidChange)) { _ in 84 + configReloadCounter &+= 1 72 85 } 73 86 } 74 87
+30
supacode/Infrastructure/Ghostty/GhosttyRuntime.swift
··· 576 576 return min(max(value, 0.001), 1) 577 577 } 578 578 579 + // The `unfocused-split-opacity` config value is the *visible* opacity of 580 + // the unfocused pane, so the dimming overlay uses `1 - value`. 581 + func unfocusedSplitOverlayOpacity() -> Double { 582 + guard let config else { return 0 } 583 + var value: Double = 0.85 584 + let key = "unfocused-split-opacity" 585 + _ = ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))) 586 + return min(max(1 - value, 0), 1) 587 + } 588 + 589 + // Returns nil when both `unfocused-split-fill` and `background` lookups 590 + // fail so the caller can distinguish "use default" from "render black", 591 + // matching the pattern used by `backgroundColorFromConfig`. 592 + func unfocusedSplitFill() -> Color? { 593 + guard let config else { return nil } 594 + var color = ghostty_config_color_s() 595 + let fillKey = "unfocused-split-fill" 596 + if ghostty_config_get(config, &color, fillKey, UInt(fillKey.lengthOfBytes(using: .utf8))) { 597 + return Color(nsColor: NSColor(ghostty: color)) 598 + } 599 + let bgKey = "background" 600 + if ghostty_config_get(config, &color, bgKey, UInt(bgKey.lengthOfBytes(using: .utf8))) { 601 + return Color(nsColor: NSColor(ghostty: color)) 602 + } 603 + Self.logger.warning( 604 + "Ghostty config missing both 'unfocused-split-fill' and 'background'; skipping overlay." 605 + ) 606 + return nil 607 + } 608 + 579 609 func backgroundColor() -> NSColor { 580 610 backgroundColorFromConfig() ?? NSColor.windowBackgroundColor 581 611 }
+44
supacodeTests/SplitTreeTests.swift
··· 90 90 #expect(visibleLeaves.count == 2) 91 91 } 92 92 93 + // Locks in that both the AppKit responder path (clicks -> onFocusChange) 94 + // and the explicit focus path (goto_split / focusSurface) route through 95 + // the same choke point and produce one focus-changed emission per real 96 + // transition. 97 + @Test func recordActiveSurfaceSymmetryAcrossClickAndGotoSplitPaths() throws { 98 + let fixture = makeWorktreeFixture(preserveZoomOnNavigation: false) 99 + let first = fixture.first 100 + let second = try #require(fixture.second) 101 + let state = fixture.state 102 + let tabId = fixture.tabId 103 + 104 + var emissions: [UUID] = [] 105 + state.onFocusChanged = { emissions.append($0) } 106 + 107 + // Creating the split already focused `second`, but `onFocusChanged` 108 + // wasn't wired yet; establish the baseline. 109 + #expect(state.activeSurfaceID(for: tabId) == second.id) 110 + 111 + // Simulates the AppKit responder path (a user clicking a pane). 112 + first.onFocusChange?(true) 113 + #expect(state.activeSurfaceID(for: tabId) == first.id) 114 + 115 + // Same surface reported twice should dedup. 116 + first.onFocusChange?(true) 117 + #expect(state.activeSurfaceID(for: tabId) == first.id) 118 + 119 + // Simulates the explicit focus path (keybinding / palette / goto_split). 120 + #expect(state.performSplitAction(.gotoSplit(direction: .next), for: first.id)) 121 + #expect(state.activeSurfaceID(for: tabId) == second.id) 122 + 123 + // Explicit-path idempotence: re-focusing the already-active surface 124 + // must not re-emit. 125 + #expect(state.focusSurface(id: second.id)) 126 + #expect(state.activeSurfaceID(for: tabId) == second.id) 127 + 128 + // Focus loss (e.g. window resign) must not wipe the active pane or emit 129 + // a stray focus-changed event — the overlay needs to remember the last 130 + // active surface across window key transitions. 131 + second.onFocusChange?(false) 132 + #expect(state.activeSurfaceID(for: tabId) == second.id) 133 + 134 + #expect(emissions == [first.id, second.id]) 135 + } 136 + 93 137 private func makeWorktreeFixture(preserveZoomOnNavigation: Bool) -> WorktreeFixture { 94 138 let state = WorktreeTerminalState( 95 139 runtime: GhosttyRuntime(),