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;
ghostty_env_var_s* env_vars;
size_t env_var_count;
const char* initial_input;
} ghostty_surface_config_s;
typedef struct {

View File

@ -384,10 +384,17 @@ class AppDelegate: NSObject,
config.workingDirectory = filename
_ = TerminalController.newTab(ghostty, withBaseConfig: config)
} else {
// When opening a file, open a new window with that file as the command,
// and its parent directory as the working directory.
config.command = filename
// When opening a file, we want to execute the file. To do this, we
// don't override the command directly, because it won't load the
// 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
_ = TerminalController.newWindow(ghostty, withBaseConfig: config)
}

View File

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

View File

@ -19,7 +19,7 @@ struct NewTerminalIntent: AppIntent {
@Parameter(
title: "Command",
description: "Command to execute instead of the default shell."
description: "Command to execute within your configured shell.",
)
var command: String?
@ -60,7 +60,12 @@ struct NewTerminalIntent: AppIntent {
let ghostty = appDelegate.ghostty
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 let url = workingDirectory?.fileURL {

View File

@ -422,6 +422,9 @@ extension Ghostty {
/// Environment variables to set for the terminal
var environmentVariables: [String: String] = [:]
/// Extra input to send as stdin
var initialInput: String? = nil
init() {}
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 {
var config = ghostty_surface_config_new()
config.userdata = Unmanaged.passUnretained(view).toOpaque()
#if os(macOS)
#if os(macOS)
config.platform_tag = GHOSTTY_PLATFORM_MACOS
config.platform = ghostty_platform_u(macos: ghostty_platform_macos_s(
nsview: Unmanaged.passUnretained(view).toOpaque()
))
config.scale_factor = NSScreen.main!.backingScaleFactor
#elseif os(iOS)
#elseif os(iOS)
config.platform_tag = GHOSTTY_PLATFORM_IOS
config.platform = ghostty_platform_u(ios: ghostty_platform_ios_s(
uiview: Unmanaged.passUnretained(view).toOpaque()
@ -466,9 +469,9 @@ extension Ghostty {
// probably set this to some default, then modify the scale factor through
// libghostty APIs when a UIView is attached to a window/scene. TODO.
config.scale_factor = UIScreen.main.scale
#else
#error("unsupported target")
#endif
#else
#error("unsupported target")
#endif
// Zero is our default value that means to inherit the font size.
config.font_size = fontSize ?? 0
@ -480,6 +483,9 @@ extension Ghostty {
return try command.withCString { cCommand in
config.command = cCommand
return try initialInput.withCString { cInput in
config.initial_input = cInput
// Convert dictionary to arrays for easier processing
let keys = Array(environmentVariables.keys)
let values = Array(environmentVariables.values)
@ -508,6 +514,7 @@ extension Ghostty {
}
}
}
}
#if canImport(AppKit)
/// When changing the split state, or going full screen (native or non), the terminal view

View File

@ -430,6 +430,9 @@ pub const Surface = struct {
/// Extra environment variables to set for the surface.
env_vars: ?[*]EnvVar = null,
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 {
@ -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
// ready to use.
try self.core_surface.init(