shell-integration: switch to $GHOSTTY_SHELL_FEATURES (#6871)

This change consolidates all three opt-out shell integration environment
variables into a single opt-in $GHOSTTY_SHELL_FEATURES variable. Its
value is a comma-delimited list of the enabled shell feature names (e.g.
"cursor,title").

$GHOSTTY_SHELL_FEATURES is set at runtime and automatically added to the
shell environment. Its value is based on the shell-integration-features
configuration option.

$GHOSTTY_SHELL_FEATURES is only set when at least one shell feature is
enabled. It won't be set when 'shell-integration-features = false'.

$GHOSTTY_SHELL_FEATURES lists only the enabled shell feature names. We
could have alternatively gone in the opposite direction and listed the
disabled features, letting the scripts assume each feature is on by
default like we did before, but I think this explicit approach is a
little safer and easier to reason about / debug.

It also doesn't support the "no-" negation prefix used by the config
system (e.g. "cursor,no-title"). This simplifies the implementation
requirements of our (multiple) shell integration scripts, and because
$GHOSTTY_SHELL_FEATURES is derived from shell-integration-features, the
user-facing configuration interface retains that expressiveness.

$GHOSTTY_SHELL_FEATURES is intended to primarily be an internal concern:
an interface between the runtime and our shell integration scripts. It
could be used by people with particular use cases who want to manually
source those scripts, but that isn't the intended audience.

... and because the previous $GHOSTTY_SHELL_INTEGRATION_NO_* variables
were also meant to be an internal concern, this change does not include
backwards compatibility support for those names.

One last advantage of a using a single $GHOSTTY_SHELL_FEATURES variable
is that it can be easily forwarded to e.g. ssh sessions or other shell
environments.

See: #5070
pull/6998/head
Mitchell Hashimoto 2025-04-04 17:05:37 -04:00 committed by GitHub
commit 60da3cf6a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 41 additions and 38 deletions

View File

@ -70,7 +70,7 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then
fi fi
# Sudo # Sudo
if [[ "$GHOSTTY_SHELL_INTEGRATION_NO_SUDO" != "1" && -n "$TERMINFO" ]]; then if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then
# Wrap `sudo` command to ensure Ghostty terminfo is preserved. # Wrap `sudo` command to ensure Ghostty terminfo is preserved.
# #
# This approach supports wrapping a `sudo` alias, but the alias definition # This approach supports wrapping a `sudo` alias, but the alias definition
@ -124,13 +124,13 @@ function __ghostty_precmd() {
fi fi
# Cursor # Cursor
if test "$GHOSTTY_SHELL_INTEGRATION_NO_CURSOR" != "1"; then if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then
PS1=$PS1'\[\e[5 q\]' PS1=$PS1'\[\e[5 q\]'
PS0=$PS0'\[\e[0 q\]' PS0=$PS0'\[\e[0 q\]'
fi fi
# Title (working directory) # Title (working directory)
if [[ "$GHOSTTY_SHELL_INTEGRATION_NO_TITLE" != 1 ]]; then if [[ "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then
PS1=$PS1'\[\e]2;\w\a\]' PS1=$PS1'\[\e]2;\w\a\]'
fi fi
fi fi
@ -161,7 +161,7 @@ function __ghostty_preexec() {
PS2="$_GHOSTTY_SAVE_PS2" PS2="$_GHOSTTY_SAVE_PS2"
# Title (current command) # Title (current command)
if [[ -n $cmd && "$GHOSTTY_SHELL_INTEGRATION_NO_TITLE" != 1 ]]; then if [[ -n $cmd && "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then
builtin printf "\e]2;%s\a" "${cmd//[[:cntrl:]]}" builtin printf "\e]2;%s\a" "${cmd//[[:cntrl:]]}"
fi fi

View File

@ -36,6 +36,8 @@
} }
{ {
use str
# helper used by `mark-*` functions # helper used by `mark-*` functions
fn set-prompt-state {|new| set-env __ghostty_prompt_state $new } fn set-prompt-state {|new| set-env __ghostty_prompt_state $new }
@ -104,20 +106,18 @@
set edit:after-readline = (conj $edit:after-readline $mark-output-start~) set edit:after-readline = (conj $edit:after-readline $mark-output-start~)
set edit:after-command = (conj $edit:after-command $mark-output-end~) set edit:after-command = (conj $edit:after-command $mark-output-end~)
var no-title = (eq 1 $E:GHOSTTY_SHELL_INTEGRATION_NO_TITLE) var features = [(str:split ',' $E:GHOSTTY_SHELL_FEATURES)]
var no-cursor = (eq 1 $E:GHOSTTY_SHELL_INTEGRATION_NO_CURSOR)
var no-sudo = (eq 1 $E:GHOSTTY_SHELL_INTEGRATION_NO_SUDO)
if (not $no-title) { if (has-value $features title) {
set after-chdir = (conj $after-chdir {|_| report-pwd }) set after-chdir = (conj $after-chdir {|_| report-pwd })
} }
if (not $no-cursor) { if (has-value $features cursor) {
fn beam { printf "\e[5 q" } fn beam { printf "\e[5 q" }
fn block { printf "\e[0 q" } fn block { printf "\e[0 q" }
set edit:before-readline = (conj $edit:before-readline $beam~) set edit:before-readline = (conj $edit:before-readline $beam~)
set edit:after-readline = (conj $edit:after-readline {|_| block }) set edit:after-readline = (conj $edit:after-readline {|_| block })
} }
if (and (not $no-sudo) (not-eq "" $E:TERMINFO) (has-external sudo)) { if (and (has-value $features sudo) (not-eq "" $E:TERMINFO) (has-external sudo)) {
edit:add-var sudo~ $sudo-with-terminfo~ edit:add-var sudo~ $sudo-with-terminfo~
} }
} }

View File

@ -49,10 +49,9 @@ status --is-interactive || ghostty_exit
function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
functions -e __ghostty_setup functions -e __ghostty_setup
# Check if we are setting cursors set --local features (string split , $GHOSTTY_SHELL_FEATURES)
set --local no_cursor "$GHOSTTY_SHELL_INTEGRATION_NO_CURSOR"
if test -z $no_cursor if contains cursor $features
# Change the cursor to a beam on prompt. # Change the cursor to a beam on prompt.
function __ghostty_set_cursor_beam --on-event fish_prompt -d "Set cursor shape" function __ghostty_set_cursor_beam --on-event fish_prompt -d "Set cursor shape"
echo -en "\e[5 q" echo -en "\e[5 q"
@ -62,13 +61,9 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
end end
end end
# Check if we are setting sudo
set --local no_sudo "$GHOSTTY_SHELL_INTEGRATION_NO_SUDO"
# When using sudo shell integration feature, ensure $TERMINFO is set # When using sudo shell integration feature, ensure $TERMINFO is set
# and `sudo` is not already a function or alias # and `sudo` is not already a function or alias
if test -z $no_sudo if contains sudo $features and test -n "$TERMINFO"; and test "file" = (type -t sudo 2> /dev/null; or echo "x")
and test -n "$TERMINFO"; and test "file" = (type -t sudo 2> /dev/null; or echo "x")
# Wrap `sudo` command to ensure Ghostty terminfo is preserved # Wrap `sudo` command to ensure Ghostty terminfo is preserved
function sudo -d "Wrap sudo to preserve terminfo" function sudo -d "Wrap sudo to preserve terminfo"
set --function sudo_has_sudoedit_flags "no" set --function sudo_has_sudoedit_flags "no"
@ -125,7 +120,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
set --global fish_handle_reflow 1 set --global fish_handle_reflow 1
# Initial calls for first prompt # Initial calls for first prompt
if test -z $no_cursor if contains cursor $features
__ghostty_set_cursor_beam __ghostty_set_cursor_beam
end end
__ghostty_mark_prompt_start __ghostty_mark_prompt_start

View File

@ -194,7 +194,7 @@ _ghostty_deferred_init() {
_ghostty_report_pwd" _ghostty_report_pwd"
_ghostty_report_pwd _ghostty_report_pwd
if [[ "$GHOSTTY_SHELL_INTEGRATION_NO_TITLE" != 1 ]]; then if [[ "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then
# Enable terminal title changes. # Enable terminal title changes.
functions[_ghostty_precmd]+=" functions[_ghostty_precmd]+="
builtin print -rnu $_ghostty_fd \$'\\e]2;'\"\${(%):-%(4~|…/%3~|%~)}\"\$'\\a'" builtin print -rnu $_ghostty_fd \$'\\e]2;'\"\${(%):-%(4~|…/%3~|%~)}\"\$'\\a'"
@ -202,7 +202,7 @@ _ghostty_deferred_init() {
builtin print -rnu $_ghostty_fd \$'\\e]2;'\"\${(V)1}\"\$'\\a'" builtin print -rnu $_ghostty_fd \$'\\e]2;'\"\${(V)1}\"\$'\\a'"
fi fi
if [[ "$GHOSTTY_SHELL_INTEGRATION_NO_CURSOR" != 1 ]]; then if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then
# Enable cursor shape changes depending on the current keymap. # Enable cursor shape changes depending on the current keymap.
# This implementation leaks blinking block cursor into external commands # This implementation leaks blinking block cursor into external commands
# executed from zle. For example, users of fzf-based widgets may find # executed from zle. For example, users of fzf-based widgets may find
@ -221,7 +221,7 @@ _ghostty_deferred_init() {
fi fi
# Sudo # Sudo
if [[ "$GHOSTTY_SHELL_INTEGRATION_NO_SUDO" != "1" ]] && [[ -n "$TERMINFO" ]]; then if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* ]] && [[ -n "$TERMINFO" ]]; then
# Wrap `sudo` command to ensure Ghostty terminfo is preserved # Wrap `sudo` command to ensure Ghostty terminfo is preserved
sudo() { sudo() {
builtin local sudo_has_sudoedit_flags="no" builtin local sudo_has_sudoedit_flags="no"

View File

@ -30,7 +30,7 @@ pub const ShellIntegration = struct {
command: []const u8, command: []const u8,
}; };
/// Setup the command execution environment for automatic /// Set up the command execution environment for automatic
/// integrated shell integration and return a ShellIntegration /// integrated shell integration and return a ShellIntegration
/// struct describing the integration. If integration fails /// struct describing the integration. If integration fails
/// (shell type couldn't be detected, etc.), this will return null. /// (shell type couldn't be detected, etc.), this will return null.
@ -144,15 +144,29 @@ test "force shell" {
} }
} }
/// Setup shell integration feature environment variables without /// Set up the shell integration features environment variable.
/// performing full shell integration setup.
pub fn setupFeatures( pub fn setupFeatures(
env: *EnvMap, env: *EnvMap,
features: config.ShellIntegrationFeatures, features: config.ShellIntegrationFeatures,
) !void { ) !void {
if (!features.cursor) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR", "1"); const fields = @typeInfo(@TypeOf(features)).@"struct".fields;
if (!features.sudo) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_SUDO", "1"); const capacity: usize = capacity: {
if (!features.title) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_TITLE", "1"); comptime var n: usize = fields.len - 1; // commas
inline for (fields) |field| n += field.name.len;
break :capacity n;
};
var buffer = try std.BoundedArray(u8, capacity).init(0);
inline for (fields) |field| {
if (@field(features, field.name)) {
if (buffer.len > 0) try buffer.append(',');
try buffer.appendSlice(field.name);
}
}
if (buffer.len > 0) {
try env.put("GHOSTTY_SHELL_FEATURES", buffer.slice());
}
} }
test "setup features" { test "setup features" {
@ -162,15 +176,13 @@ test "setup features" {
defer arena.deinit(); defer arena.deinit();
const alloc = arena.allocator(); const alloc = arena.allocator();
// Test: all features enabled (no environment variables should be set) // Test: all features enabled
{ {
var env = EnvMap.init(alloc); var env = EnvMap.init(alloc);
defer env.deinit(); defer env.deinit();
try setupFeatures(&env, .{ .cursor = true, .sudo = true, .title = true }); try setupFeatures(&env, .{ .cursor = true, .sudo = true, .title = true });
try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR") == null); try testing.expectEqualStrings("cursor,sudo,title", env.get("GHOSTTY_SHELL_FEATURES").?);
try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_NO_SUDO") == null);
try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_NO_TITLE") == null);
} }
// Test: all features disabled // Test: all features disabled
@ -179,9 +191,7 @@ test "setup features" {
defer env.deinit(); defer env.deinit();
try setupFeatures(&env, .{ .cursor = false, .sudo = false, .title = false }); try setupFeatures(&env, .{ .cursor = false, .sudo = false, .title = false });
try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR").?); try testing.expect(env.get("GHOSTTY_SHELL_FEATURES") == null);
try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_SUDO").?);
try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_TITLE").?);
} }
// Test: mixed features // Test: mixed features
@ -190,9 +200,7 @@ test "setup features" {
defer env.deinit(); defer env.deinit();
try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false }); try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false });
try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR").?); try testing.expectEqualStrings("sudo", env.get("GHOSTTY_SHELL_FEATURES").?);
try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_NO_SUDO") == null);
try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_TITLE").?);
} }
} }