mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-14 11:35:48 +00:00
shell-integration: initial nushell shell integration (#10274)
Nushell <https://www.nushell.sh/> is a modern interactive shell that provides many shell features out-of-the-box, like `title` support. Our shell integration therefore focuses on Ghostty-specific features like `sudo`. We use Nushell's module system to provide a `ghostty` module containing our shell integration features. This module is automatically loaded from $XDG_DATA_DIRS/nushell/vendor/autoload/ when `nushell` shell integration is enabled. Exported module functions need to be explicitly "used" before they're available to the interactive shell environment. We do that automatically by adding `--execute "use ghostty *"` to the `nu` command line. This imports all available functions, and individual shell features are runtime-guarded by the script code (using $GHOSTTY_SHELL_FEATURES). We can consider further refining this later. When automatic shell integration is disabled, users can still manually source and enable the shell integration module: source $GHOSTTY_RESOURCES_DIR/shell-integration/nushell/vendor/autoload/ghostty.nu use ghostty * This initial work implements our TERMINFO-aware `sudo` wrapper (via the `sudo` shell feature). Support for additional features, like `ssh-env` and `ssh-terminfo`, will follow (#9604).
This commit is contained in:
@@ -2667,7 +2667,7 @@ keybind: Keybinds = .{},
|
||||
///
|
||||
/// * `detect` - Detect the shell based on the filename.
|
||||
///
|
||||
/// * `bash`, `elvish`, `fish`, `zsh` - Use this specific shell injection scheme.
|
||||
/// * `bash`, `elvish`, `fish`, `nushell`, `zsh` - Use this specific shell injection scheme.
|
||||
///
|
||||
/// The default value is `detect`.
|
||||
@"shell-integration": ShellIntegration = .detect,
|
||||
@@ -8027,6 +8027,7 @@ pub const ShellIntegration = enum {
|
||||
bash,
|
||||
elvish,
|
||||
fish,
|
||||
nushell,
|
||||
zsh,
|
||||
};
|
||||
|
||||
|
||||
@@ -76,6 +76,24 @@ allowing us to automatically integrate with the shell. For details
|
||||
on the Fish startup process, see the
|
||||
[Fish documentation](https://fishshell.com/docs/current/language.html).
|
||||
|
||||
### Nushell
|
||||
|
||||
For [Nushell](https://www.nushell.sh/), Ghostty prepends to the
|
||||
`XDG_DATA_DIRS` directory, making the `ghostty` module available through
|
||||
Nushell's vendor autoload mechanism. Ghostty then automatically imports
|
||||
the module using the `-e "use ghostty *"` flag when starting Nushell.
|
||||
|
||||
Nushell provides many shell features itself, such as `title` and `cursor`,
|
||||
so our integration focuses on Ghostty-specific features like `sudo`.
|
||||
|
||||
The shell integration is automatically enabled when running Nushell in Ghostty,
|
||||
but you can also load it manually is shell integration is disabled:
|
||||
|
||||
```nushell
|
||||
source $GHOSTTY_RESOURCES_DIR/shell-integration/nushell/vendor/autoload/ghostty.nu
|
||||
use ghostty *
|
||||
```
|
||||
|
||||
### Zsh
|
||||
|
||||
Automatic [Zsh](https://www.zsh.org/) integration works by temporarily setting
|
||||
|
||||
35
src/shell-integration/nushell/vendor/autoload/ghostty.nu
vendored
Normal file
35
src/shell-integration/nushell/vendor/autoload/ghostty.nu
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# Ghostty shell integration
|
||||
export module ghostty {
|
||||
def has_feature [feature: string] {
|
||||
$feature in ($env.GHOSTTY_SHELL_FEATURES | default "" | split row ',')
|
||||
}
|
||||
|
||||
# Wrap `sudo` to preserve Ghostty's TERMINFO environment variable
|
||||
export def --wrapped sudo [
|
||||
...args # Arguments to pass to `sudo`
|
||||
] {
|
||||
mut sudo_args = $args
|
||||
|
||||
if (has_feature "sudo") {
|
||||
# Extract just the sudo options (before the command)
|
||||
let sudo_options = ($args | take until {|arg|
|
||||
not (($arg | str starts-with "-") or ($arg | str contains "="))
|
||||
})
|
||||
|
||||
# Prepend TERMINFO preservation flag if not using sudoedit
|
||||
if (not ("-e" in $sudo_options or "--edit" in $sudo_options)) {
|
||||
$sudo_args = ($args | prepend "--preserve-env=TERMINFO")
|
||||
}
|
||||
}
|
||||
|
||||
^sudo ...$sudo_args
|
||||
}
|
||||
}
|
||||
|
||||
# Clean up XDG_DATA_DIRS by removing GHOSTTY_SHELL_INTEGRATION_XDG_DIR
|
||||
if 'GHOSTTY_SHELL_INTEGRATION_XDG_DIR' in $env {
|
||||
if 'XDG_DATA_DIRS' in $env {
|
||||
$env.XDG_DATA_DIRS = ($env.XDG_DATA_DIRS | str replace $"($env.GHOSTTY_SHELL_INTEGRATION_XDG_DIR):" "")
|
||||
}
|
||||
hide-env GHOSTTY_SHELL_INTEGRATION_XDG_DIR
|
||||
}
|
||||
@@ -770,6 +770,7 @@ const Subprocess = struct {
|
||||
.bash => .bash,
|
||||
.elvish => .elvish,
|
||||
.fish => .fish,
|
||||
.nushell => .nushell,
|
||||
.zsh => .zsh,
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ pub const Shell = enum {
|
||||
bash,
|
||||
elvish,
|
||||
fish,
|
||||
nushell,
|
||||
zsh,
|
||||
};
|
||||
|
||||
@@ -57,7 +58,7 @@ pub fn setup(
|
||||
env,
|
||||
),
|
||||
|
||||
.elvish, .fish => try setupXdgDataDirs(
|
||||
.nushell => try setupNushell(
|
||||
alloc_arena,
|
||||
command,
|
||||
resource_dir,
|
||||
@@ -70,6 +71,11 @@ pub fn setup(
|
||||
resource_dir,
|
||||
env,
|
||||
),
|
||||
|
||||
.elvish, .fish => xdg: {
|
||||
if (!try setupXdgDataDirs(alloc_arena, resource_dir, env)) return null;
|
||||
break :xdg try command.clone(alloc_arena);
|
||||
},
|
||||
} orelse return null;
|
||||
|
||||
return .{
|
||||
@@ -153,6 +159,7 @@ fn detectShell(alloc: Allocator, command: config.Command) !?Shell {
|
||||
|
||||
if (std.mem.eql(u8, "elvish", exe)) return .elvish;
|
||||
if (std.mem.eql(u8, "fish", exe)) return .fish;
|
||||
if (std.mem.eql(u8, "nu", exe)) return .nushell;
|
||||
if (std.mem.eql(u8, "zsh", exe)) return .zsh;
|
||||
|
||||
return null;
|
||||
@@ -166,6 +173,7 @@ test detectShell {
|
||||
try testing.expectEqual(.bash, try detectShell(alloc, .{ .shell = "bash" }));
|
||||
try testing.expectEqual(.elvish, try detectShell(alloc, .{ .shell = "elvish" }));
|
||||
try testing.expectEqual(.fish, try detectShell(alloc, .{ .shell = "fish" }));
|
||||
try testing.expectEqual(.nushell, try detectShell(alloc, .{ .shell = "nu" }));
|
||||
try testing.expectEqual(.zsh, try detectShell(alloc, .{ .shell = "zsh" }));
|
||||
|
||||
if (comptime builtin.target.os.tag.isDarwin()) {
|
||||
@@ -373,11 +381,8 @@ fn setupBash(
|
||||
}
|
||||
}
|
||||
|
||||
// Get the command string from the builder, then copy it to the arena
|
||||
// allocator. The stackFallback allocator's memory becomes invalid after
|
||||
// this function returns, so we must copy to the arena.
|
||||
const cmd_str = try cmd.toOwnedSlice();
|
||||
return .{ .shell = try alloc.dupeZ(u8, cmd_str) };
|
||||
// Return a copy of our modified command line to use as the shell command.
|
||||
return .{ .shell = try alloc.dupeZ(u8, try cmd.toOwnedSlice()) };
|
||||
}
|
||||
|
||||
test "bash" {
|
||||
@@ -595,10 +600,9 @@ test "bash: missing resources" {
|
||||
/// from `XDG_DATA_DIRS` when integration is complete.
|
||||
fn setupXdgDataDirs(
|
||||
alloc: Allocator,
|
||||
command: config.Command,
|
||||
resource_dir: []const u8,
|
||||
env: *EnvMap,
|
||||
) !?config.Command {
|
||||
) !bool {
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
|
||||
// Get our path to the shell integration directory.
|
||||
@@ -609,7 +613,7 @@ fn setupXdgDataDirs(
|
||||
);
|
||||
var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch |err| {
|
||||
log.warn("unable to open {s}: {}", .{ integ_path, err });
|
||||
return null;
|
||||
return false;
|
||||
};
|
||||
integ_dir.close();
|
||||
|
||||
@@ -640,7 +644,7 @@ fn setupXdgDataDirs(
|
||||
),
|
||||
);
|
||||
|
||||
return try command.clone(alloc);
|
||||
return true;
|
||||
}
|
||||
|
||||
test "xdg: empty XDG_DATA_DIRS" {
|
||||
@@ -656,8 +660,7 @@ test "xdg: empty XDG_DATA_DIRS" {
|
||||
var env = EnvMap.init(alloc);
|
||||
defer env.deinit();
|
||||
|
||||
const command = try setupXdgDataDirs(alloc, .{ .shell = "xdg" }, res.path, &env);
|
||||
try testing.expectEqualStrings("xdg", command.?.shell);
|
||||
try testing.expect(try setupXdgDataDirs(alloc, res.path, &env));
|
||||
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
try testing.expectEqualStrings(
|
||||
@@ -685,8 +688,7 @@ test "xdg: existing XDG_DATA_DIRS" {
|
||||
|
||||
try env.put("XDG_DATA_DIRS", "/opt/share");
|
||||
|
||||
const command = try setupXdgDataDirs(alloc, .{ .shell = "xdg" }, res.path, &env);
|
||||
try testing.expectEqualStrings("xdg", command.?.shell);
|
||||
try testing.expect(try setupXdgDataDirs(alloc, res.path, &env));
|
||||
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
try testing.expectEqualStrings(
|
||||
@@ -714,7 +716,150 @@ test "xdg: missing resources" {
|
||||
var env = EnvMap.init(alloc);
|
||||
defer env.deinit();
|
||||
|
||||
try testing.expect(try setupXdgDataDirs(alloc, .{ .shell = "xdg" }, resources_dir, &env) == null);
|
||||
try testing.expect(!try setupXdgDataDirs(alloc, resources_dir, &env));
|
||||
try testing.expectEqual(0, env.count());
|
||||
}
|
||||
|
||||
/// Set up automatic Nushell shell integration. This works by adding our
|
||||
/// shell resource directory to the `XDG_DATA_DIRS` environment variable,
|
||||
/// which Nushell will use to load `nushell/vendor/autoload/ghostty.nu`.
|
||||
///
|
||||
/// We then add `--execute 'use ghostty ...'` to the nu command line to
|
||||
/// automatically enable our shelll features.
|
||||
fn setupNushell(
|
||||
alloc: Allocator,
|
||||
command: config.Command,
|
||||
resource_dir: []const u8,
|
||||
env: *EnvMap,
|
||||
) !?config.Command {
|
||||
// Add our XDG_DATA_DIRS entry (for nushell/vendor/autoload/). This
|
||||
// makes our 'ghostty' module automatically available, even if any
|
||||
// of the later checks abort the rest of our automatic integration.
|
||||
if (!try setupXdgDataDirs(alloc, resource_dir, env)) return null;
|
||||
|
||||
var stack_fallback = std.heap.stackFallback(4096, alloc);
|
||||
var cmd = internal_os.shell.ShellCommandBuilder.init(stack_fallback.get());
|
||||
defer cmd.deinit();
|
||||
|
||||
// Iterator that yields each argument in the original command line.
|
||||
// This will allocate once proportionate to the command line length.
|
||||
var iter = try command.argIterator(alloc);
|
||||
defer iter.deinit();
|
||||
|
||||
// Start accumulating arguments with the executable and initial flags.
|
||||
if (iter.next()) |exe| {
|
||||
try cmd.appendArg(exe);
|
||||
} else return null;
|
||||
|
||||
// Tell nu to immediately "use" all of the exported functions in our
|
||||
// 'ghostty' module.
|
||||
//
|
||||
// We can consider making this more specific based on the set of
|
||||
// enabled shell features (e.g. `use ghostty sudo`). At the moment,
|
||||
// shell features are all runtime-guarded in the nushell script.
|
||||
try cmd.appendArg("--execute 'use ghostty *'");
|
||||
|
||||
// Walk through the rest of the given arguments. If we see an option that
|
||||
// would require complex or unsupported integration behavior, we bail out
|
||||
// and skip loading our shell integration. Users can still manually source
|
||||
// the shell integration module.
|
||||
//
|
||||
// Unsupported options:
|
||||
// -c / --command -c is always non-interactive
|
||||
// --lsp --lsp starts the language server
|
||||
while (iter.next()) |arg| {
|
||||
if (std.mem.eql(u8, arg, "--command") or std.mem.eql(u8, arg, "--lsp")) {
|
||||
return null;
|
||||
} else if (arg.len > 1 and arg[0] == '-' and arg[1] != '-') {
|
||||
if (std.mem.indexOfScalar(u8, arg, 'c') != null) {
|
||||
return null;
|
||||
}
|
||||
try cmd.appendArg(arg);
|
||||
} else if (std.mem.eql(u8, arg, "-") or std.mem.eql(u8, arg, "--")) {
|
||||
// All remaining arguments should be passed directly to the shell
|
||||
// command. We shouldn't perform any further option processing.
|
||||
try cmd.appendArg(arg);
|
||||
while (iter.next()) |remaining_arg| {
|
||||
try cmd.appendArg(remaining_arg);
|
||||
}
|
||||
break;
|
||||
} else {
|
||||
try cmd.appendArg(arg);
|
||||
}
|
||||
}
|
||||
|
||||
// Return a copy of our modified command line to use as the shell command.
|
||||
return .{ .shell = try alloc.dupeZ(u8, try cmd.toOwnedSlice()) };
|
||||
}
|
||||
|
||||
test "nushell" {
|
||||
const testing = std.testing;
|
||||
var arena = ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const alloc = arena.allocator();
|
||||
|
||||
var res: TmpResourcesDir = try .init(alloc, .nushell);
|
||||
defer res.deinit();
|
||||
|
||||
var env = EnvMap.init(alloc);
|
||||
defer env.deinit();
|
||||
|
||||
const command = try setupNushell(alloc, .{ .shell = "nu" }, res.path, &env);
|
||||
try testing.expectEqualStrings("nu --execute 'use ghostty *'", command.?.shell);
|
||||
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
try testing.expectEqualStrings(
|
||||
try std.fmt.bufPrint(&path_buf, "{s}/shell-integration", .{res.path}),
|
||||
env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR").?,
|
||||
);
|
||||
try testing.expectStringStartsWith(
|
||||
env.get("XDG_DATA_DIRS").?,
|
||||
try std.fmt.bufPrint(&path_buf, "{s}/shell-integration", .{res.path}),
|
||||
);
|
||||
}
|
||||
|
||||
test "nushell: unsupported options" {
|
||||
const testing = std.testing;
|
||||
var arena = ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const alloc = arena.allocator();
|
||||
|
||||
var res: TmpResourcesDir = try .init(alloc, .nushell);
|
||||
defer res.deinit();
|
||||
|
||||
const cmdlines = [_][:0]const u8{
|
||||
"nu --command exit",
|
||||
"nu --lsp",
|
||||
"nu -c script.sh",
|
||||
"nu -ic script.sh",
|
||||
};
|
||||
|
||||
for (cmdlines) |cmdline| {
|
||||
var env = EnvMap.init(alloc);
|
||||
defer env.deinit();
|
||||
|
||||
try testing.expect(try setupNushell(alloc, .{ .shell = cmdline }, res.path, &env) == null);
|
||||
try testing.expect(env.get("XDG_DATA_DIRS") != null);
|
||||
try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR") != null);
|
||||
}
|
||||
}
|
||||
|
||||
test "nushell: missing resources" {
|
||||
const testing = std.testing;
|
||||
var arena = ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const alloc = arena.allocator();
|
||||
|
||||
var tmp_dir = testing.tmpDir(.{});
|
||||
defer tmp_dir.cleanup();
|
||||
|
||||
const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, ".");
|
||||
defer alloc.free(resources_dir);
|
||||
|
||||
var env = EnvMap.init(alloc);
|
||||
defer env.deinit();
|
||||
|
||||
try testing.expect(try setupNushell(alloc, .{ .shell = "nu" }, resources_dir, &env) == null);
|
||||
try testing.expectEqual(0, env.count());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user