native macOS codings agent orchestrator
6
fork

Configure Feed

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

Inhibit command on script and generalize single-tab bar hiding (#220)

Replace tmux-specific tab bar hiding with a general "hide tab bar for
single tab" setting. Pass an explicit command and environment variables
through to GhosttySurfaceView so blocking scripts can launch with a
controlled shell. Add a copy-to-clipboard button for script variable
keys in repository settings.

authored by

Stefano Bertagno and committed by
GitHub
301cf398 dc8eb02e

+87 -57
+1 -1
supacode/Clients/Terminal/TerminalClient.swift
··· 23 23 case prune(Set<Worktree.ID>) 24 24 case setNotificationsEnabled(Bool) 25 25 case setSelectedWorktreeID(Worktree.ID?) 26 - case refreshTmuxTabBarVisibility 26 + case refreshTabBarVisibility 27 27 } 28 28 29 29 enum Event: Equatable {
+1 -1
supacode/Features/App/Reducer/AppFeature.swift
··· 307 307 await terminalClient.send(.setNotificationsEnabled(settings.inAppNotificationsEnabled)) 308 308 }, 309 309 .run { _ in 310 - await terminalClient.send(.refreshTmuxTabBarVisibility) 310 + await terminalClient.send(.refreshTabBarVisibility) 311 311 }, 312 312 .run { _ in 313 313 await worktreeInfoWatcher.send(
+17 -15
supacode/Features/Settings/Models/GlobalSettings.swift
··· 50 50 var pullRequestMergeStrategy: PullRequestMergeStrategy 51 51 var terminalThemeSyncEnabled: Bool 52 52 var restoreTerminalLayoutEnabled: Bool 53 - var hideTmuxTabBar: Bool 53 + var hideSingleTabBar: Bool 54 54 var autoDeleteArchivedWorktreesAfterDays: AutoDeletePeriod? 55 55 var shortcutOverrides: [AppShortcutID: AppShortcutOverride] 56 56 ··· 77 77 pullRequestMergeStrategy: .merge, 78 78 terminalThemeSyncEnabled: false, 79 79 restoreTerminalLayoutEnabled: false, 80 - hideTmuxTabBar: false, 80 + hideSingleTabBar: false, 81 81 defaultWorktreeBaseDirectoryPath: nil, 82 82 autoDeleteArchivedWorktreesAfterDays: nil, 83 83 shortcutOverrides: [:] ··· 106 106 pullRequestMergeStrategy: PullRequestMergeStrategy = .merge, 107 107 terminalThemeSyncEnabled: Bool = false, 108 108 restoreTerminalLayoutEnabled: Bool = false, 109 - hideTmuxTabBar: Bool = false, 109 + hideSingleTabBar: Bool = false, 110 110 defaultWorktreeBaseDirectoryPath: String? = nil, 111 111 autoDeleteArchivedWorktreesAfterDays: AutoDeletePeriod? = nil, 112 112 shortcutOverrides: [AppShortcutID: AppShortcutOverride] = [:] ··· 133 133 self.pullRequestMergeStrategy = pullRequestMergeStrategy 134 134 self.terminalThemeSyncEnabled = terminalThemeSyncEnabled 135 135 self.restoreTerminalLayoutEnabled = restoreTerminalLayoutEnabled 136 - self.hideTmuxTabBar = hideTmuxTabBar 136 + self.hideSingleTabBar = hideSingleTabBar 137 137 self.defaultWorktreeBaseDirectoryPath = defaultWorktreeBaseDirectoryPath 138 138 self.autoDeleteArchivedWorktreesAfterDays = autoDeleteArchivedWorktreesAfterDays 139 139 self.shortcutOverrides = shortcutOverrides 140 140 } 141 141 142 + /// Keys for reading renamed settings fields that no longer 143 + /// match the auto-synthesized CodingKeys. 144 + private struct LegacyCodingKey: CodingKey { 145 + var stringValue: String 146 + init?(stringValue: String) { self.stringValue = stringValue } 147 + var intValue: Int? { nil } 148 + init?(intValue: Int) { nil } 149 + } 150 + 142 151 init(from decoder: any Decoder) throws { 143 152 let container = try decoder.container(keyedBy: CodingKeys.self) 153 + let legacy = try decoder.container(keyedBy: LegacyCodingKey.self) 144 154 appearanceMode = try container.decode(AppearanceMode.self, forKey: .appearanceMode) 145 155 defaultEditorID = 146 156 try container.decodeIfPresent(String.self, forKey: .defaultEditorID) ··· 184 194 if let action = try? container.decodeIfPresent(MergedWorktreeAction.self, forKey: .mergedWorktreeAction) { 185 195 mergedWorktreeAction = action 186 196 } else { 187 - // Legacy migration. 188 - struct LegacyCodingKey: CodingKey { 189 - var stringValue: String 190 - init?(stringValue: String) { self.stringValue = stringValue } 191 - var intValue: Int? { nil } 192 - init?(intValue: Int) { nil } 193 - } 194 - let legacy = try decoder.container(keyedBy: LegacyCodingKey.self) 195 197 if let legacyBool = try legacy.decodeIfPresent( 196 198 Bool.self, 197 199 forKey: LegacyCodingKey(stringValue: "automaticallyArchiveMergedWorktrees")! ··· 222 224 restoreTerminalLayoutEnabled = 223 225 try container.decodeIfPresent(Bool.self, forKey: .restoreTerminalLayoutEnabled) 224 226 ?? Self.default.restoreTerminalLayoutEnabled 225 - hideTmuxTabBar = 226 - try container.decodeIfPresent(Bool.self, forKey: .hideTmuxTabBar) 227 - ?? Self.default.hideTmuxTabBar 227 + hideSingleTabBar = 228 + try container.decodeIfPresent(Bool.self, forKey: .hideSingleTabBar) 229 + ?? Self.default.hideSingleTabBar 228 230 defaultWorktreeBaseDirectoryPath = 229 231 try container.decodeIfPresent(String.self, forKey: .defaultWorktreeBaseDirectoryPath) 230 232 ?? Self.default.defaultWorktreeBaseDirectoryPath
+4 -4
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 28 28 var pullRequestMergeStrategy: PullRequestMergeStrategy 29 29 var terminalThemeSyncEnabled: Bool 30 30 var restoreTerminalLayoutEnabled: Bool 31 - var hideTmuxTabBar: Bool 31 + var hideSingleTabBar: Bool 32 32 var defaultWorktreeBaseDirectoryPath: String 33 33 var autoDeleteArchivedWorktreesAfterDays: AutoDeletePeriod? 34 34 var shortcutOverrides: [AppShortcutID: AppShortcutOverride] ··· 63 63 pullRequestMergeStrategy = settings.pullRequestMergeStrategy 64 64 terminalThemeSyncEnabled = settings.terminalThemeSyncEnabled 65 65 restoreTerminalLayoutEnabled = settings.restoreTerminalLayoutEnabled 66 - hideTmuxTabBar = settings.hideTmuxTabBar 66 + hideSingleTabBar = settings.hideSingleTabBar 67 67 autoDeleteArchivedWorktreesAfterDays = settings.autoDeleteArchivedWorktreesAfterDays 68 68 shortcutOverrides = settings.shortcutOverrides 69 69 defaultWorktreeBaseDirectoryPath = ··· 94 94 pullRequestMergeStrategy: pullRequestMergeStrategy, 95 95 terminalThemeSyncEnabled: terminalThemeSyncEnabled, 96 96 restoreTerminalLayoutEnabled: restoreTerminalLayoutEnabled, 97 - hideTmuxTabBar: hideTmuxTabBar, 97 + hideSingleTabBar: hideSingleTabBar, 98 98 defaultWorktreeBaseDirectoryPath: SupacodePaths.normalizedWorktreeBaseDirectoryPath( 99 99 defaultWorktreeBaseDirectoryPath 100 100 ), ··· 185 185 state.pullRequestMergeStrategy = normalizedSettings.pullRequestMergeStrategy 186 186 state.terminalThemeSyncEnabled = normalizedSettings.terminalThemeSyncEnabled 187 187 state.restoreTerminalLayoutEnabled = normalizedSettings.restoreTerminalLayoutEnabled 188 - state.hideTmuxTabBar = normalizedSettings.hideTmuxTabBar 188 + state.hideSingleTabBar = normalizedSettings.hideSingleTabBar 189 189 state.autoDeleteArchivedWorktreesAfterDays = normalizedSettings.autoDeleteArchivedWorktreesAfterDays 190 190 state.shortcutOverrides = normalizedSettings.shortcutOverrides 191 191 state.defaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath ?? ""
+3 -3
supacode/Features/Settings/Views/AppearanceSettingsView.swift
··· 77 77 Text("Changes to Analytics require Supacode to restart before they take effect.") 78 78 } 79 79 Section("Advanced") { 80 - Toggle(isOn: $store.hideTmuxTabBar) { 81 - Text("Hide Tab Bar for `tmux`") 82 - Text("Applies when `tmux` is the only running tab.") 80 + Toggle(isOn: $store.hideSingleTabBar) { 81 + Text("Hide Tab Bar for Single Tab") 82 + Text("Automatically hides the tab bar when only one tab is open.") 83 83 } 84 84 } 85 85 }
+8 -4
supacode/Features/Settings/Views/RepositorySettingsView.swift
··· 100 100 ) 101 101 ScriptEnvironmentRow( 102 102 name: "SUPACODE_ROOT_PATH", 103 - value: store.rootURL.path(percentEncoded: false), 104 103 description: "Path to the repository root." 105 104 ) 106 105 } ··· 168 167 169 168 private struct ScriptEnvironmentRow: View { 170 169 let name: String 171 - var value: String? 172 170 let description: String 173 171 174 172 var body: some View { 175 173 LabeledContent { 176 - if let value { 177 - Text(value).monospaced() 174 + Button { 175 + NSPasteboard.general.clearContents() 176 + NSPasteboard.general.setString(name, forType: .string) 177 + } label: { 178 + Image(systemName: "doc.on.doc") 179 + .accessibilityLabel("Copy variable key") 178 180 } 181 + .buttonStyle(.borderless) 182 + .help("Copy variable key.") 179 183 } label: { 180 184 Text(name).monospaced() 181 185 Text(description)
+2 -2
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 94 94 prune(keeping: ids) 95 95 case .setNotificationsEnabled(let enabled): 96 96 setNotificationsEnabled(enabled) 97 - case .refreshTmuxTabBarVisibility: 97 + case .refreshTabBarVisibility: 98 98 for state in states.values { 99 - state.refreshTmuxTabBarVisibility() 99 + state.refreshTabBarVisibility() 100 100 } 101 101 case .setSelectedWorktreeID(let id): 102 102 guard id != selectedWorktreeID else { return }
+16 -26
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 25 25 private var surfaces: [UUID: GhosttySurfaceView] = [:] 26 26 private var focusedSurfaceIdByTab: [TerminalTabID: UUID] = [:] 27 27 var tabIsRunningById: [TerminalTabID: Bool] = [:] 28 - private var tmuxActiveTabIds: Set<TerminalTabID> = [] 29 28 private(set) var shouldHideTabBar = false 30 29 private var blockingScripts: [TerminalTabID: BlockingScriptKind] = [:] 31 30 private var blockingScriptLaunchDirectories: [TerminalTabID: URL] = [:] ··· 62 61 wrappedValue: RepositorySettings.default, 63 62 .repositorySettings(worktree.repositoryRootURL) 64 63 ) 64 + // Pre-hide the tab bar before the first tab is created to 65 + // avoid a visible flash. updateShouldHideTabBar() handles 66 + // the steady state once tabs exist. 67 + @Shared(.settingsFile) var settingsFile 68 + self.shouldHideTabBar = settingsFile.global.hideSingleTabBar 65 69 } 66 70 67 71 var taskStatus: WorktreeTaskStatus { ··· 72 76 blockingScripts.values.contains(kind) 73 77 } 74 78 75 - private func setTmuxActive(_ active: Bool, for tabId: TerminalTabID) { 76 - if active { 77 - tmuxActiveTabIds.insert(tabId) 78 - } else { 79 - tmuxActiveTabIds.remove(tabId) 80 - } 81 - updateShouldHideTabBar() 82 - } 83 - 84 79 private func updateShouldHideTabBar() { 85 80 @Shared(.settingsFile) var settingsFile 86 81 shouldHideTabBar = 87 - settingsFile.global.hideTmuxTabBar 82 + settingsFile.global.hideSingleTabBar 88 83 && tabManager.tabs.count == 1 89 - && (tabManager.tabs.first.map { tmuxActiveTabIds.contains($0.id) } ?? false) 90 84 } 91 85 92 - func refreshTmuxTabBarVisibility() { 86 + func refreshTabBarVisibility() { 93 87 updateShouldHideTabBar() 94 88 } 95 89 96 - private func titleIndicatesTmux(_ title: String) -> Bool { 97 - let trimmed = title.trimmingCharacters(in: .whitespaces) 98 - return trimmed == "tmux" || trimmed.hasPrefix("tmux ") 99 - } 100 - 101 90 func ensureInitialTab(focusing: Bool) { 102 91 guard tabManager.tabs.isEmpty else { return } 103 92 guard !isEnsuringInitialTab else { return } ··· 161 150 title: title, 162 151 icon: "terminal", 163 152 isTitleLocked: false, 153 + command: nil, 164 154 initialInput: resolvedInput, 165 155 focusing: focusing, 166 156 inheritingFromSurfaceId: resolvedInheritanceSurfaceId, ··· 207 197 icon: kind.tabIcon, 208 198 isTitleLocked: true, 209 199 tintColor: kind.tabColor, 200 + command: defaultShellPath(), 210 201 initialInput: launch.commandInput, 211 202 focusing: true, 212 203 inheritingFromSurfaceId: currentFocusedSurfaceId(), ··· 232 223 let icon: String? 233 224 let isTitleLocked: Bool 234 225 var tintColor: TerminalTabTintColor? 226 + let command: String? 235 227 let initialInput: String? 236 228 let focusing: Bool 237 229 let inheritingFromSurfaceId: UUID? ··· 248 240 let tree = splitTree( 249 241 for: tabId, 250 242 inheritingFromSurfaceId: creation.inheritingFromSurfaceId, 243 + command: creation.command, 251 244 initialInput: creation.initialInput, 252 245 context: creation.context 253 246 ) ··· 389 382 func closeTab(_ tabId: TerminalTabID) { 390 383 let closedBlockingKind = blockingScripts.removeValue(forKey: tabId) 391 384 cleanupBlockingScriptLaunchDirectory(for: tabId) 392 - tmuxActiveTabIds.remove(tabId) 393 385 // Clear lingering tab tracking for completed or non-blocking tabs. 394 386 for (kind, tracked) in lastBlockingScriptTabByKind where tracked == tabId { 395 387 lastBlockingScriptTabByKind.removeValue(forKey: kind) ··· 436 428 func splitTree( 437 429 for tabId: TerminalTabID, 438 430 inheritingFromSurfaceId: UUID? = nil, 431 + command: String? = nil, 439 432 initialInput: String? = nil, 440 433 context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_TAB 441 434 ) -> SplitTree<GhosttySurfaceView> { ··· 444 437 } 445 438 let surface = createSurface( 446 439 tabId: tabId, 440 + command: command, 447 441 initialInput: initialInput, 448 442 inheritingFromSurfaceId: inheritingFromSurfaceId, 449 443 context: context ··· 916 910 917 911 private func createSurface( 918 912 tabId: TerminalTabID, 913 + command: String? = nil, 919 914 initialInput: String?, 920 915 workingDirectoryOverride: URL? = nil, 921 916 inheritingFromSurfaceId: UUID?, ··· 925 920 let view = GhosttySurfaceView( 926 921 runtime: runtime, 927 922 workingDirectory: workingDirectoryOverride ?? inherited.workingDirectory ?? worktree.workingDirectory, 923 + command: command, 928 924 initialInput: initialInput, 925 + environmentVariables: worktree.scriptEnvironment, 929 926 fontSize: inherited.fontSize, 930 927 context: context 931 928 ) ··· 933 930 guard let self, let view else { return } 934 931 if self.focusedSurfaceIdByTab[tabId] == view.id { 935 932 self.tabManager.updateTitle(tabId, title: title) 936 - } 937 - if self.titleIndicatesTmux(title) { 938 - self.setTmuxActive(true, for: tabId) 939 933 } 940 934 } 941 935 view.bridge.onSplitAction = { [weak self, weak view] action in ··· 966 960 } 967 961 view.bridge.onCommandFinished = { [weak self] exitCode in 968 962 guard let self else { return } 969 - if self.tmuxActiveTabIds.contains(tabId) { 970 - self.setTmuxActive(false, for: tabId) 971 - } 972 963 self.handleBlockingScriptCommandFinished(tabId: tabId, exitCode: exitCode) 973 964 } 974 965 view.bridge.onChildExited = { [weak self] exitCode in ··· 1227 1218 trees.removeValue(forKey: tabId) 1228 1219 focusedSurfaceIdByTab.removeValue(forKey: tabId) 1229 1220 cleanupBlockingScriptLaunchDirectory(for: tabId) 1230 - tmuxActiveTabIds.remove(tabId) 1231 1221 tabManager.closeTab(tabId) 1232 1222 updateShouldHideTabBar() 1233 1223 if let kind = blockingScripts.removeValue(forKey: tabId) {
+35 -1
supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
··· 71 71 private(set) var surface: ghostty_surface_t? 72 72 private var surfaceRef: GhosttyRuntime.SurfaceReference? 73 73 private let workingDirectoryCString: UnsafeMutablePointer<CChar>? 74 + private let commandCString: UnsafeMutablePointer<CChar>? 74 75 private let initialInputCString: UnsafeMutablePointer<CChar>? 76 + private let environmentVariables: [String: String] 75 77 private let fontSize: Float32 76 78 private let context: ghostty_surface_context_e 77 79 private var trackingArea: NSTrackingArea? ··· 174 176 init( 175 177 runtime: GhosttyRuntime, 176 178 workingDirectory: URL?, 179 + command: String? = nil, 177 180 initialInput: String? = nil, 181 + environmentVariables: [String: String] = [:], 178 182 fontSize: Float32? = nil, 179 183 context: ghostty_surface_context_e 180 184 ) { ··· 182 186 self.bridge = GhosttySurfaceBridge() 183 187 self.fontSize = fontSize ?? 0 184 188 self.context = context 189 + self.environmentVariables = environmentVariables 185 190 if let workingDirectory { 186 191 let path = Self.normalizedWorkingDirectoryPath( 187 192 workingDirectory.path(percentEncoded: false) ··· 189 194 workingDirectoryCString = path.withCString { strdup($0) } 190 195 } else { 191 196 workingDirectoryCString = nil 197 + } 198 + if let command { 199 + commandCString = command.withCString { strdup($0) } 200 + } else { 201 + commandCString = nil 192 202 } 193 203 if let initialInput { 194 204 initialInputCString = initialInput.withCString { strdup($0) } ··· 226 236 closeSurface() 227 237 if let workingDirectoryCString { 228 238 free(workingDirectoryCString) 239 + } 240 + if let commandCString { 241 + free(commandCString) 229 242 } 230 243 if let initialInputCString { 231 244 free(initialInputCString) ··· 870 883 config.scale_factor = backingScaleFactor() 871 884 config.font_size = fontSize 872 885 config.working_directory = workingDirectoryCString.map { UnsafePointer($0) } 886 + config.command = commandCString.map { UnsafePointer($0) } 873 887 config.initial_input = initialInputCString.map { UnsafePointer($0) } 874 888 config.context = context 875 - surface = ghostty_surface_new(app, &config) 889 + // Ghostty copies env vars into its arena allocator, so 890 + // the C strings only need to live through this call. 891 + var envVars = environmentVariables.map { key, value in 892 + ghostty_env_var_s( 893 + key: key.withCString { strdup($0)! }, 894 + value: value.withCString { strdup($0)! } 895 + ) 896 + } 897 + defer { 898 + for envVar in envVars { 899 + free(.init(mutating: envVar.key)) 900 + free(.init(mutating: envVar.value)) 901 + } 902 + } 903 + envVars.withUnsafeMutableBufferPointer { buffer in 904 + if let baseAddress = buffer.baseAddress { 905 + config.env_vars = baseAddress 906 + config.env_var_count = buffer.count 907 + } 908 + surface = ghostty_surface_new(app, &config) 909 + } 876 910 bridge.surface = surface 877 911 lastOcclusion = nil 878 912 lastSurfaceFocus = nil