macOS: Run scripts using stdin rather than executing directly (#7654)

Fixes #7647

See #7647 for context. This commit works by extending the `input` work
introduced in #7652 to libghostty so that the macOS can take advantage
of it. At that point, it's just the macOS utilizing `input` in order to
set the command and `exit` up similar to Terminal and iTerm2.

This applies both to files opened directly by Ghostty as well as the App
Intent to run a command in a new terminal.
This commit is contained in:
Mitchell Hashimoto
2025-06-22 18:15:32 -07:00
committed by GitHub
6 changed files with 66 additions and 30 deletions

View File

@ -413,6 +413,7 @@ typedef struct {
const char* command; const char* command;
ghostty_env_var_s* env_vars; ghostty_env_var_s* env_vars;
size_t env_var_count; size_t env_var_count;
const char* initial_input;
} ghostty_surface_config_s; } ghostty_surface_config_s;
typedef struct { typedef struct {

View File

@ -384,10 +384,17 @@ class AppDelegate: NSObject,
config.workingDirectory = filename config.workingDirectory = filename
_ = TerminalController.newTab(ghostty, withBaseConfig: config) _ = TerminalController.newTab(ghostty, withBaseConfig: config)
} else { } else {
// When opening a file, open a new window with that file as the command, // When opening a file, we want to execute the file. To do this, we
// and its parent directory as the working directory. // don't override the command directly, because it won't load the
config.command = filename // profile/rc files for the shell, which is super important on macOS
// due to things like Homebrew. Instead, we set the command to
// `<filename>; exit` which is what Terminal and iTerm2 do.
config.initialInput = "\(filename); exit\n"
// Set the parent directory to our working directory so that relative
// paths in scripts work.
config.workingDirectory = (filename as NSString).deletingLastPathComponent config.workingDirectory = (filename as NSString).deletingLastPathComponent
_ = TerminalController.newWindow(ghostty, withBaseConfig: config) _ = TerminalController.newWindow(ghostty, withBaseConfig: config)
} }

View File

@ -45,7 +45,7 @@ func requestIntentPermission() async -> Bool {
PermissionRequest.show( PermissionRequest.show(
"org.mitchellh.ghostty.shortcutsPermission", "com.mitchellh.ghostty.shortcutsPermission",
message: "Allow Shortcuts to interact with Ghostty?", message: "Allow Shortcuts to interact with Ghostty?",
allowDuration: .forever, allowDuration: .forever,
rememberDuration: nil, rememberDuration: nil,

View File

@ -19,7 +19,7 @@ struct NewTerminalIntent: AppIntent {
@Parameter( @Parameter(
title: "Command", title: "Command",
description: "Command to execute instead of the default shell." description: "Command to execute within your configured shell.",
) )
var command: String? var command: String?
@ -60,7 +60,12 @@ struct NewTerminalIntent: AppIntent {
let ghostty = appDelegate.ghostty let ghostty = appDelegate.ghostty
var config = Ghostty.SurfaceConfiguration() var config = Ghostty.SurfaceConfiguration()
config.command = command
// We don't run command as "command" and instead use "initialInput" so
// that we can get all the login scripts to setup things like PATH.
if let command {
config.initialInput = "\(command); exit\n"
}
// If we were given a working directory then open that directory // If we were given a working directory then open that directory
if let url = workingDirectory?.fileURL { if let url = workingDirectory?.fileURL {

View File

@ -422,6 +422,9 @@ extension Ghostty {
/// Environment variables to set for the terminal /// Environment variables to set for the terminal
var environmentVariables: [String: String] = [:] var environmentVariables: [String: String] = [:]
/// Extra input to send as stdin
var initialInput: String? = nil
init() {} init() {}
init(from config: ghostty_surface_config_s) { init(from config: ghostty_surface_config_s) {
@ -450,13 +453,13 @@ extension Ghostty {
func withCValue<T>(view: SurfaceView, _ body: (inout ghostty_surface_config_s) throws -> T) rethrows -> T { func withCValue<T>(view: SurfaceView, _ body: (inout ghostty_surface_config_s) throws -> T) rethrows -> T {
var config = ghostty_surface_config_new() var config = ghostty_surface_config_new()
config.userdata = Unmanaged.passUnretained(view).toOpaque() config.userdata = Unmanaged.passUnretained(view).toOpaque()
#if os(macOS) #if os(macOS)
config.platform_tag = GHOSTTY_PLATFORM_MACOS config.platform_tag = GHOSTTY_PLATFORM_MACOS
config.platform = ghostty_platform_u(macos: ghostty_platform_macos_s( config.platform = ghostty_platform_u(macos: ghostty_platform_macos_s(
nsview: Unmanaged.passUnretained(view).toOpaque() nsview: Unmanaged.passUnretained(view).toOpaque()
)) ))
config.scale_factor = NSScreen.main!.backingScaleFactor config.scale_factor = NSScreen.main!.backingScaleFactor
#elseif os(iOS) #elseif os(iOS)
config.platform_tag = GHOSTTY_PLATFORM_IOS config.platform_tag = GHOSTTY_PLATFORM_IOS
config.platform = ghostty_platform_u(ios: ghostty_platform_ios_s( config.platform = ghostty_platform_u(ios: ghostty_platform_ios_s(
uiview: Unmanaged.passUnretained(view).toOpaque() uiview: Unmanaged.passUnretained(view).toOpaque()
@ -466,9 +469,9 @@ extension Ghostty {
// probably set this to some default, then modify the scale factor through // probably set this to some default, then modify the scale factor through
// libghostty APIs when a UIView is attached to a window/scene. TODO. // libghostty APIs when a UIView is attached to a window/scene. TODO.
config.scale_factor = UIScreen.main.scale config.scale_factor = UIScreen.main.scale
#else #else
#error("unsupported target") #error("unsupported target")
#endif #endif
// Zero is our default value that means to inherit the font size. // Zero is our default value that means to inherit the font size.
config.font_size = fontSize ?? 0 config.font_size = fontSize ?? 0
@ -480,27 +483,31 @@ extension Ghostty {
return try command.withCString { cCommand in return try command.withCString { cCommand in
config.command = cCommand config.command = cCommand
// Convert dictionary to arrays for easier processing return try initialInput.withCString { cInput in
let keys = Array(environmentVariables.keys) config.initial_input = cInput
let values = Array(environmentVariables.values)
// Create C strings for all keys and values // Convert dictionary to arrays for easier processing
return try keys.withCStrings { keyCStrings in let keys = Array(environmentVariables.keys)
return try values.withCStrings { valueCStrings in let values = Array(environmentVariables.values)
// Create array of ghostty_env_var_s
var envVars = Array<ghostty_env_var_s>()
envVars.reserveCapacity(environmentVariables.count)
for i in 0..<environmentVariables.count {
envVars.append(ghostty_env_var_s(
key: keyCStrings[i],
value: valueCStrings[i]
))
}
return try envVars.withUnsafeMutableBufferPointer { buffer in // Create C strings for all keys and values
config.env_vars = buffer.baseAddress return try keys.withCStrings { keyCStrings in
config.env_var_count = environmentVariables.count return try values.withCStrings { valueCStrings in
return try body(&config) // Create array of ghostty_env_var_s
var envVars = Array<ghostty_env_var_s>()
envVars.reserveCapacity(environmentVariables.count)
for i in 0..<environmentVariables.count {
envVars.append(ghostty_env_var_s(
key: keyCStrings[i],
value: valueCStrings[i]
))
}
return try envVars.withUnsafeMutableBufferPointer { buffer in
config.env_vars = buffer.baseAddress
config.env_var_count = environmentVariables.count
return try body(&config)
}
} }
} }
} }

View File

@ -430,6 +430,9 @@ pub const Surface = struct {
/// Extra environment variables to set for the surface. /// Extra environment variables to set for the surface.
env_vars: ?[*]EnvVar = null, env_vars: ?[*]EnvVar = null,
env_var_count: usize = 0, env_var_count: usize = 0,
/// Input to send to the command after it is started.
initial_input: ?[*:0]const u8 = null,
}; };
pub fn init(self: *Surface, app: *App, opts: Options) !void { pub fn init(self: *Surface, app: *App, opts: Options) !void {
@ -510,6 +513,19 @@ pub const Surface = struct {
} }
} }
// If we have an initial input then we set it.
if (opts.initial_input) |c_input| {
const alloc = config.arenaAlloc();
config.input.list.clearRetainingCapacity();
try config.input.list.append(
alloc,
.{ .raw = try alloc.dupeZ(u8, std.mem.sliceTo(
c_input,
0,
)) },
);
}
// Initialize our surface right away. We're given a view that is // Initialize our surface right away. We're given a view that is
// ready to use. // ready to use.
try self.core_surface.init( try self.core_surface.init(