diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 17c66bab..198efbc5 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -878,11 +878,11 @@ final class WorktreeTerminalState { return formatCommandInput(script) } + // Env vars are injected into the surface's shell process via + // `GhosttySurfaceView(environment:)`, so scripts no longer need a shell + // export prefix. private func formatCommandInput(_ script: String) -> String? { - makeCommandInput( - script: script, - environmentExportPrefix: worktree.scriptEnvironmentExportPrefix - ) + makeCommandInput(script: script) } private func runScriptInput(_ script: String) -> String? { @@ -894,10 +894,7 @@ final class WorktreeTerminalState { // Without this, the interactive shell stays alive after the script finishes // and GHOSTTY_ACTION_SHOW_CHILD_EXITED never fires for completion detection. private func blockingScriptInput(_ script: String) -> String? { - makeBlockingScriptInput( - script: script, - environmentExportPrefix: worktree.scriptEnvironmentExportPrefix - ) + makeBlockingScriptInput(script: script) } private func setRunScriptTabId(_ tabId: TerminalTabID?) { @@ -927,7 +924,8 @@ final class WorktreeTerminalState { workingDirectory: workingDirectoryOverride ?? inherited.workingDirectory ?? worktree.workingDirectory, initialInput: initialInput, fontSize: resolvedFontSize, - context: context + context: context, + environment: worktree.scriptEnvironment ) // Sending a no-op font size action marks the Ghostty surface as // "font_size_adjusted", which prevents config reloads (triggered by @@ -1696,26 +1694,13 @@ extension WorktreeTerminalState { } } -nonisolated func makeCommandInput( - script: String, - environmentExportPrefix: String -) -> String? { +nonisolated func makeCommandInput(script: String) -> String? { let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } - return environmentExportPrefix + trimmed + "\n" + return trimmed + "\n" } -nonisolated func makeBlockingScriptInput( - script: String, - environmentExportPrefix: String -) -> String? { - guard - let input = makeCommandInput( - script: script, - environmentExportPrefix: environmentExportPrefix - ) - else { - return nil - } +nonisolated func makeBlockingScriptInput(script: String) -> String? { + guard let input = makeCommandInput(script: script) else { return nil } return input + "exit\n" } diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index db322468..832e59f5 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -108,6 +108,9 @@ final class GhosttySurfaceView: NSView, Identifiable { private var surfaceRef: GhosttyRuntime.SurfaceReference? private let workingDirectoryCString: UnsafeMutablePointer? private let initialInputCString: UnsafeMutablePointer? + private let envVarCStrings: [UnsafeMutablePointer] + private let envVarEntries: UnsafeMutablePointer? + private let envVarCount: Int private let fontSize: Float32 private let context: ghostty_surface_context_e private let skipsSurfaceCreationForTesting: Bool @@ -222,6 +225,7 @@ final class GhosttySurfaceView: NSView, Identifiable { initialInput: String? = nil, fontSize: Float32? = nil, context: ghostty_surface_context_e, + environment: [String: String] = [:], skipsSurfaceCreationForTesting: Bool = false ) { self.runtime = runtime @@ -242,6 +246,32 @@ final class GhosttySurfaceView: NSView, Identifiable { } else { initialInputCString = nil } + let sortedEnv = environment.sorted { $0.key < $1.key } + var allocatedStrings: [UnsafeMutablePointer] = [] + allocatedStrings.reserveCapacity(sortedEnv.count * 2) + for (key, value) in sortedEnv { + guard let keyPtr = key.withCString({ strdup($0) }), + let valuePtr = value.withCString({ strdup($0) }) + else { continue } + allocatedStrings.append(keyPtr) + allocatedStrings.append(valuePtr) + } + envVarCStrings = allocatedStrings + let pairCount = allocatedStrings.count / 2 + if pairCount > 0 { + let entries = UnsafeMutablePointer.allocate(capacity: pairCount) + for index in 0.. 0 { + config.env_vars = envVarEntries + config.env_var_count = envVarCount + } surface = ghostty_surface_new(app, &config) bridge.surface = surface occlusionState.reset() diff --git a/supacodeTests/WorktreeEnvironmentTests.swift b/supacodeTests/WorktreeEnvironmentTests.swift index a13f31c6..d6d4972c 100644 --- a/supacodeTests/WorktreeEnvironmentTests.swift +++ b/supacodeTests/WorktreeEnvironmentTests.swift @@ -63,20 +63,11 @@ struct WorktreeEnvironmentTests { } @Test func blockingScriptInputUsesPortableBareExit() { - let worktree = Worktree( - id: "/tmp/repo/wt-1", - name: "feature-branch", - detail: "detail", - workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), - repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), - ) - let input = makeBlockingScriptInput( script: """ docker compose down codex exec "test" - """, - environmentExportPrefix: worktree.scriptEnvironmentExportPrefix + """ ) #expect(input?.contains("docker compose down\ncodex exec \"test\"\nexit\n") == true) @@ -84,12 +75,14 @@ struct WorktreeEnvironmentTests { #expect(input?.contains("exit $?") == false) } + @Test func commandInputDoesNotPrependEnvExports() { + // Environment variables are injected via ghostty_surface_config.env_vars + // now, so the shell input itself must stay free of `export` prefixes. + let input = makeCommandInput(script: "make build") + #expect(input == "make build\n") + } + @Test func blockingScriptInputReturnsNilForWhitespaceOnlyScripts() { - #expect( - makeBlockingScriptInput( - script: " \n ", - environmentExportPrefix: "export PROWL_ROOT_PATH='/tmp/repo'\n" - ) == nil - ) + #expect(makeBlockingScriptInput(script: " \n ") == nil) } }