diff --git a/README.md b/README.md index b1e787076..6650165a0 100644 --- a/README.md +++ b/README.md @@ -121,15 +121,18 @@ Ghostty supports some features that require shell integration. I am aiming to support many of the features that [Kitty supports for shell integration](https://sw.kovidgoyal.net/kitty/shell-integration/). -To enable this functionality, I recommend sourcing Kitty's shell integration -files directly for your shell configuration when running Ghostty. For -example, for fish, [source this file](https://github.com/kovidgoyal/kitty/blob/master/shell-integration/fish/vendor_conf.d/kitty-shell-integration.fish). +Ghostty will automatically inject the shell integration code for `zsh` and +`fish`. Other shells are not supported. You can also manually load them +in many cases (see `src/shell-integration`). **If you want to disable this feature,** +set `shell-integration = none` in your configuration file. The currently support shell integration features in Ghostty: * We do not confirm close for windows where the cursor is at a prompt. * New terminals start in the working directory of the previously focused terminal. * The cursor at the prompt is turned into a bar. + * The `scroll_to_prompt` keybinding can be used to scroll the terminal window + forward and back through prompts. ## Roadmap and Status diff --git a/build.zig b/build.zig index bedb87de9..7c6571b43 100644 --- a/build.zig +++ b/build.zig @@ -269,6 +269,28 @@ pub fn build(b: *std.Build) !void { } } + // Shell-integration + { + const install = b.addInstallDirectory(.{ + .source_dir = .{ .path = "src/shell-integration" }, + .install_dir = .{ .custom = "share" }, + .install_subdir = "shell-integration", + .exclude_extensions = &.{".md"}, + }); + b.getInstallStep().dependOn(&install.step); + + if (target.isDarwin()) { + const mac_install = b.addInstallDirectory(options: { + var copy = install.options; + copy.install_dir = .{ + .custom = "Ghostty.app/Contents/Resources", + }; + break :options copy; + }); + b.getInstallStep().dependOn(&mac_install.step); + } + } + // Terminfo { // Encode our terminfo diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 3a9c27f72..2b29f105b 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A535B9D9299C569B0017E2E4 /* ErrorView.swift */; }; + A545D1A22A5772CE006E0AE4 /* shell-integration in Resources */ = {isa = PBXBuildFile; fileRef = A545D1A12A5772CE006E0AE4 /* shell-integration */; }; A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.swift */; }; A55B7BB629B6F47F0055DE60 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB529B6F47F0055DE60 /* AppState.swift */; }; A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; }; @@ -27,6 +28,7 @@ /* Begin PBXFileReference section */ A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; + A545D1A12A5772CE006E0AE4 /* shell-integration */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "shell-integration"; path = "../zig-out/share/shell-integration"; sourceTree = ""; }; A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; A55B7BB529B6F47F0055DE60 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; @@ -90,6 +92,7 @@ A5A1F8862A489D7400D1E8BC /* Resources */ = { isa = PBXGroup; children = ( + A545D1A12A5772CE006E0AE4 /* shell-integration */, A5A1F8842A489D6800D1E8BC /* terminfo */, ); name = Resources; @@ -198,6 +201,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + A545D1A22A5772CE006E0AE4 /* shell-integration in Resources */, A5A1F8852A489D6800D1E8BC /* terminfo in Resources */, A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */, ); diff --git a/src/cli_args.zig b/src/cli_args.zig index 8afb35e65..ce5ccfbf8 100644 --- a/src/cli_args.zig +++ b/src/cli_args.zig @@ -91,26 +91,37 @@ fn parseIntoField( .Optional => |opt| opt.child, else => field.type, }; - const fieldInfo = @typeInfo(Field); // If we are a struct and have parseCLI, we call that and use // that to set the value. - if (fieldInfo == .Struct and @hasDecl(Field, "parseCLI")) { - const fnInfo = @typeInfo(@TypeOf(Field.parseCLI)).Fn; - switch (fnInfo.params.len) { - // 1 arg = (input) => output - 1 => @field(dst, field.name) = try Field.parseCLI(value), + switch (@typeInfo(Field)) { + .Struct => if (@hasDecl(Field, "parseCLI")) { + const fnInfo = @typeInfo(@TypeOf(Field.parseCLI)).Fn; + switch (fnInfo.params.len) { + // 1 arg = (input) => output + 1 => @field(dst, field.name) = try Field.parseCLI(value), - // 2 arg = (self, input) => void - 2 => try @field(dst, field.name).parseCLI(value), + // 2 arg = (self, input) => void + 2 => try @field(dst, field.name).parseCLI(value), - // 3 arg = (self, alloc, input) => void - 3 => try @field(dst, field.name).parseCLI(alloc, value), + // 3 arg = (self, alloc, input) => void + 3 => try @field(dst, field.name).parseCLI(alloc, value), - else => @compileError("parseCLI invalid argument count"), - } + else => @compileError("parseCLI invalid argument count"), + } - return; + return; + }, + + .Enum => { + @field(dst, field.name) = std.meta.stringToEnum( + Field, + value orelse return error.ValueRequired, + ) orelse return error.InvalidValue; + return; + }, + + else => {}, } // No parseCLI, magic the value based on the type @@ -317,6 +328,21 @@ test "parseIntoField: floats" { try testing.expectEqual(@as(f64, 1.0), data.f64); } +test "parseIntoField: enums" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const Enum = enum { one, two, three }; + var data: struct { + v: Enum, + } = undefined; + + try parseIntoField(@TypeOf(data), alloc, &data, "v", "two"); + try testing.expectEqual(Enum.two, data.v); +} + test "parseIntoField: optional field" { const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); diff --git a/src/config.zig b/src/config.zig index a54c5e062..feeea8cd2 100644 --- a/src/config.zig +++ b/src/config.zig @@ -197,10 +197,32 @@ pub const Config = struct { /// Additional configuration files to read. @"config-file": RepeatableString = .{}, - // Confirms that a surface should be closed before closing it. This defaults - // to true. If set to false, surfaces will close without any confirmation. + /// Confirms that a surface should be closed before closing it. This defaults + /// to true. If set to false, surfaces will close without any confirmation. @"confirm-close-surface": bool = true, + /// Whether to enable shell integration auto-injection or not. Shell + /// integration greatly enhances the terminal experience by enabling + /// a number of features: + /// + /// * Working directory reporting so new tabs, splits inherit the + /// previous terminal's working directory. + /// * Prompt marking that enables the "scroll_to_prompt" keybinding. + /// * If you're sitting at a prompt, closing a terminal will not ask + /// for confirmation. + /// * Resizing the window with a complex prompt usually paints much + /// better. + /// + /// Allowable values are: + /// + /// * "none" - Do not do any automatic injection. You can still manually + /// configure your shell to enable the integration. + /// * "detect" - Detect the shell based on the filename. + /// * "fish", "zsh" - Use this specific shell injection scheme. + /// + /// The default value is "detect". + @"shell-integration": ShellIntegration = .detect, + /// This is set by the CLI parser for deinit. _arena: ?ArenaAllocator = null, @@ -796,6 +818,7 @@ pub const Config = struct { inline .Bool, .Int, .Float, + .Enum, => return src, .Optional => |info| return try cloneValue( @@ -1209,6 +1232,13 @@ pub const Keybinds = struct { } }; +pub const ShellIntegration = enum { + none, + detect, + fish, + zsh, +}; + // Wasm API. pub const Wasm = if (!builtin.target.isWasm()) struct {} else struct { const wasm = @import("os/wasm.zig"); diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md new file mode 100644 index 000000000..46c3659ee --- /dev/null +++ b/src/shell-integration/README.md @@ -0,0 +1,20 @@ +# Shell Integration Code + +This is the shell-specific shell-integration code that is +used for the shell-integration feature set that Ghostty +supports. + +This README is meant as developer documentation and not as +user documentation. For user documentation, see the main +README. + +## Implementation Details + +### Fish + +For [Fish](https://fishshell.com/), Ghostty prepends to the +`XDG_DATA_DIRS` directory. Fish automatically loads configuration +files in `/fish/vendor_conf.d/*.fish` on startup, +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). diff --git a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish new file mode 100755 index 000000000..1b5ec5e25 --- /dev/null +++ b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish @@ -0,0 +1,101 @@ +#!/bin/fish +# +# This shell script aims to be written in a way where it can't really fail +# or all failure scenarios are handled, so that we never leave the shell in +# a weird state. If you find a way to break this, please report a bug! + +function ghostty_restore_xdg_data_dir -d "restore the original XDG_DATA_DIR value" + # If we don't have our own data dir then we don't need to do anything. + if not set -q GHOSTTY_FISH_XDG_DIR + return + end + + # If the data dir isn't set at all then we don't need to do anything. + if not set -q XDG_DATA_DIRS + return + end + + # We need to do this so that XDG_DATA_DIRS turns into an array. + set --function --path xdg_data_dirs "$XDG_DATA_DIRS" + + # If our data dir is in the list then remove it. + if set --function index (contains --index "$GHOSTTY_FISH_XDG_DIR" $xdg_data_dirs) + set --erase --function xdg_data_dirs[$index] + end + + # Re-export our data dir + if set -q xdg_data_dirs[1] + set --global --export --unpath XDG_DATA_DIRS "$xdg_data_dirs" + else + set --erase --global XDG_DATA_DIRS + end + + set --erase GHOSTTY_FISH_XDG_DIR +end + +function ghostty_exit -d "exit the shell integration setup" + functions -e ghostty_restore_xdg_data_dir + functions -e ghostty_exit + exit 0 +end + +# We always try to restore the XDG data dir +ghostty_restore_xdg_data_dir + +# If we aren't interactive or we've already run, don't run. +status --is-interactive || ghostty_exit + +# We do the full setup on the first prompt render. We do this so that other +# shell integrations that setup the prompt and modify things are able to run +# first. We want to run _last_. +function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" + functions -e __ghostty_setup + + # Change the cursor to a beam on prompt. + function __ghostty_set_cursor_beam --on-event fish_prompt -d "Set cursor shape" + echo -en "\e[5 q" + end + function __ghostty_reset_cursor --on-event fish_preexec -d "Reset cursor shape" + echo -en "\e[0 q" + end + + # Setup prompt marking + function __ghostty_mark_prompt_start --on-event fish_prompt --on-event fish_cancel --on-event fish_posterror + # If we never got the output end event, then we need to send it now. + if test "$__ghostty_prompt_state" != prompt-start + echo -en "\e]133;D\a" + end + + set --global __ghostty_prompt_state prompt-start + echo -en "\e]133;A\a" + end + + function __ghostty_mark_output_start --on-event fish_preexec + set --global __ghostty_prompt_state pre-exec + echo -en "\e]133;C\a" + end + + function __ghostty_mark_output_end --on-event fish_postexec + set --global __ghostty_prompt_state post-exec + echo -en "\e]133;D;$status\a" + end + + # Report pwd. This is actually built-in to fish but only for terminals + # that match an allowlist and that isn't us. + function __update_cwd_osc --on-variable PWD -d 'Notify capable terminals when $PWD changes' + if status --is-command-substitution || set -q INSIDE_EMACS + return + end + printf \e\]7\;file://%s%s\a $hostname (string escape --style=url $PWD) + end + + # Enable fish to handle reflow because Ghostty clears the prompt on resize. + set --global fish_handle_reflow 1 + + # Initial calls for first prompt + __ghostty_set_cursor_beam + __ghostty_mark_prompt_start + __update_cwd_osc +end + +ghostty_exit diff --git a/src/shell-integration/zsh/.zshenv b/src/shell-integration/zsh/.zshenv new file mode 100644 index 000000000..7fbfad659 --- /dev/null +++ b/src/shell-integration/zsh/.zshenv @@ -0,0 +1,60 @@ +# Based on (started as) a copy of Kitty's zsh integration. Kitty is +# distributed under GPLv3, so this file is also distributed under GPLv3. +# The license header is reproduced below: +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# This file can get sourced with aliases enabled. To avoid alias expansion +# we quote everything that can be quoted. Some aliases will still break us +# though. + +# Restore the original ZDOTDIR value. +if [[ -n "${GHOSTTY_ZSH_ZDOTDIR+X}" ]]; then + 'builtin' 'export' ZDOTDIR="$GHOSTTY_ZSH_ZDOTDIR" + 'builtin' 'unset' 'GHOSTTY_ZSH_ZDOTDIR' +else + 'builtin' 'unset' 'ZDOTDIR' +fi + +# Use try-always to have the right error code. +{ + # Zsh treats empty $ZDOTDIR as if it was "/". We do the same. + # + # Source the user's zshenv before sourcing ghostty.zsh because the former + # might set fpath and other things without which ghostty.zsh won't work. + # + # Use typeset in case we are in a function with warn_create_global in + # effect. Unlikely but better safe than sorry. + 'builtin' 'typeset' _ghostty_file=${ZDOTDIR-~}"/.zshenv" + # Zsh ignores unreadable rc files. We do the same. + # Zsh ignores rc files that are directories, and so does source. + [[ ! -r "$_ghostty_file" ]] || 'builtin' 'source' '--' "$_ghostty_file" +} always { + if [[ -o 'interactive' ]]; then + 'builtin' 'autoload' '--' 'is-at-least' + 'is-at-least' "5.1" || { + builtin echo "ZSH ${ZSH_VERSION} is too old for ghostty shell integration" > /dev/stderr + return + } + # ${(%):-%x} is the path to the current file. + # On top of it we add :A:h to get the directory. + 'builtin' 'typeset' _ghostty_file="${${(%):-%x}:A:h}"/ghostty-integration + if [[ -r "$_ghostty_file" ]]; then + 'builtin' 'autoload' '-Uz' '--' "$_ghostty_file" + "${_ghostty_file:t}" + 'builtin' 'unfunction' '--' "${_ghostty_file:t}" + fi + fi + 'builtin' 'unset' '_ghostty_file' +} diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration new file mode 100755 index 000000000..32cddaeb8 --- /dev/null +++ b/src/shell-integration/zsh/ghostty-integration @@ -0,0 +1,280 @@ +#!/bin/zsh +# +# Based on (started as) a copy of Kitty's zsh integration. Kitty is +# distributed under GPLv3, so this file is also distributed under GPLv3. +# The license header is reproduced below: +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# +# Enables integration between zsh and ghostty. +# +# This is an autoloadable function. It's invoked automatically in shells +# directly spawned by Ghostty but not in any other shells. For example, running +# `exec zsh`, `sudo -E zsh`, `tmux`, or plain `zsh` will create a shell where +# ghostty-integration won't automatically run. Zsh users who want integration with +# Ghostty in all shells should add the following lines to their .zshrc: +# +# if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then +# autoload -Uz -- "$GHOSTTY_RESOURCSE_DIR"/shell-integration/zsh/ghostty-integration +# ghostty-integration +# unfunction ghostty-integration +# fi +# +# Implementation note: We can assume that alias expansion is disabled in this +# file, so no need to quote defensively. We still have to defensively prefix all +# builtins with `builtin` to avoid accidentally invoking user-defined functions. +# We avoid `function` reserved word as an additional defensive measure. + +builtin emulate -L zsh -o no_warn_create_global -o no_aliases + +[[ -o interactive ]] || builtin return 0 # non-interactive shell +(( ! $+_ghostty_state )) || builtin return 0 # already initialized + +# 0: no OSC 133 [AC] marks have been written yet. +# 1: the last written OSC 133 C has not been closed with D yet. +# 2: none of the above. +builtin typeset -gi _ghostty_state + +# Attempt to create a writable file descriptor to the TTY so that we can print +# to the TTY later even when STDOUT is redirected. This code is fairly subtle. +# +# - It's tempting to do `[[ -t 1 ]] && exec {_ghostty_state}>&1` but we cannot do this +# because it'll create a file descriptor >= 10 without O_CLOEXEC. This file +# descriptor will leak to child processes. +# - If we do `exec {3}>&1`, the file descriptor won't leak to the child processes +# but it'll still leak if the current process is replaced with another. In +# addition, it'll break user code that relies on fd 3 being available. +# - Zsh doesn't expose dup3, which would have allowed us to copy STDOUT with +# O_CLOEXEC. The only way to create a file descriptor with O_CLOEXEC is via +# sysopen. +# - `zmodload zsh/system` and `sysopen -o cloexec -wu _ghostty_fd -- /dev/tty` can +# fail with an error message to STDERR (the latter can happen even if /dev/tty +# is writable), hence the redirection of STDERR. We do it for the whole block +# for performance reasons (redirections are slow). +# - We must open the file descriptor right here rather than in _ghostty_deferred_init +# because there are broken zsh plugins out there that run `exec {fd}< <(cmd)` +# and then close the file descriptor more than once while suppressing errors. +# This could end up closing our file descriptor if we opened it in +# _ghostty_deferred_init. +typeset -gi _ghostty_fd +{ + builtin zmodload zsh/system && (( $+builtins[sysopen] )) && { + { [[ -w $TTY ]] && builtin sysopen -o cloexec -wu _ghostty_fd -- $TTY } || + { [[ -w /dev/tty ]] && builtin sysopen -o cloexec -wu _ghostty_fd -- /dev/tty } + } +} 2>/dev/null || (( _ghostty_fd = 1 )) + +# Defer initialization so that other zsh init files can be configure +# the integration. +builtin typeset -ag precmd_functions +precmd_functions+=(_ghostty_deferred_init) + +_ghostty_deferred_init() { + builtin emulate -L zsh -o no_warn_create_global -o no_aliases + + # The directory where ghostty-integration is located: /../shell-integration/zsh. + builtin local self_dir="${functions_source[_ghostty_deferred_init]:A:h}" + + # Enable semantic markup with OSC 133. + _ghostty_precmd() { + builtin local -i cmd_status=$? + builtin emulate -L zsh -o no_warn_create_global -o no_aliases + + # Don't write OSC 133 D when our precmd handler is invoked from zle. + # Some plugins do that to update prompt on cd. + if ! builtin zle; then + # This code works incorrectly in the presence of a precmd or chpwd + # hook that prints. For example, sindresorhus/pure prints an empty + # line on precmd and marlonrichert/zsh-snap prints $PWD on chpwd. + # We'll end up writing our OSC 133 D mark too late. + # + # Another failure mode is when the output of a command doesn't end + # with LF and prompst_sp is set (it is by default). In this case + # we'll incorrectly state that '%' from prompt_sp is a part of the + # command's output. + if (( _ghostty_state == 1 )); then + # The last written OSC 133 C has not been closed with D yet. + # Close it and supply status. + builtin print -nu $_ghostty_fd '\e]133;D;'$cmd_status'\a' + (( _ghostty_state = 2 )) + elif (( _ghostty_state == 2 )); then + # There might be an unclosed OSC 133 C. Close that. + builtin print -nu $_ghostty_fd '\e]133;D\a' + fi + fi + + builtin local mark1=$'%{\e]133;A\a%}' + if [[ -o prompt_percent ]]; then + builtin typeset -g precmd_functions + if [[ ${precmd_functions[-1]} == _ghostty_precmd ]]; then + # This is the best case for us: we can add our marks to PS1 and + # PS2. This way our marks will be printed whenever zsh + # redisplays prompt: on reset-prompt, on SIGWINCH, and on + # SIGCHLD if notify is set. Themes that update prompt + # asynchronously from a `zle -F` handler might still remove our + # marks. Oh well. + builtin local mark2=$'%{\e]133;A;k=s\a%}' + # Add marks conditionally to avoid a situation where we have + # several marks in place. These conditions can have false + # positives and false negatives though. + # + # - False positive (with prompt_percent): PS1="%(?.$mark1.)" + # - False negative (with prompt_subst): PS1='$mark1' + [[ $PS1 == *$mark1* ]] || PS1=${mark1}${PS1} + # PS2 mark is needed when clearing the prompt on resize + [[ $PS2 == *$mark2* ]] || PS2=${mark2}${PS2} + (( _ghostty_state = 2 )) + else + # If our precmd hook is not the last, we cannot rely on prompt + # changes to stick, so we don't even try. At least we can move + # our hook to the end to have better luck next time. If there is + # another piece of code that wants to take this privileged + # position, this won't work well. We'll break them as much as + # they are breaking us. + precmd_functions=(${precmd_functions:#_ghostty_precmd} _ghostty_precmd) + # Plugins that invoke precmd hooks from zle do that before zle + # is trashed. This means that the cursor is in the middle of + # BUFFER and we cannot print our mark there. Prompt might + # already have a mark, so the following reset-prompt will write + # it. If it doesn't, there is nothing we can do. + if ! builtin zle; then + builtin print -rnu $_ghostty_fd -- $mark1[3,-3] + (( _ghostty_state = 2 )) + fi + fi + elif ! builtin zle; then + # Without prompt_percent we cannot patch prompt. Just print the + # mark, except when we are invoked from zle. In the latter case we + # cannot do anything. + builtin print -rnu $_ghostty_fd -- $mark1[3,-3] + (( _ghostty_state = 2 )) + fi + } + + _ghostty_preexec() { + builtin emulate -L zsh -o no_warn_create_global -o no_aliases + + # This can potentially break user prompt. Oh well. The robustness of + # this code can be improved in the case prompt_subst is set because + # it'll allow us distinguish (not perfectly but close enough) between + # our own prompt, user prompt, and our own prompt with user additions on + # top. We cannot force prompt_subst on the user though, so we would + # still need this code for the no_prompt_subst case. + PS1=${PS1//$'%{\e]133;A\a%}'} + PS2=${PS2//$'%{\e]133;A;k=s\a%}'} + + # This will work incorrectly in the presence of a preexec hook that + # prints. For example, if MichaelAquilina/zsh-you-should-use installs + # its preexec hook before us, we'll incorrectly mark its output as + # belonging to the command (as if the user typed it into zle) rather + # than command output. + builtin print -nu $_ghostty_fd '\e]133;C\a' + (( _ghostty_state = 1 )) + } + + # Enable reporting current working dir to terminal. Ghostty supports + # the kitty-shell-cwd format. + _ghostty_report_pwd() { builtin print -nu $_ghostty_fd '\e]7;kitty-shell-cwd://'"$HOST""$PWD"'\a'; } + chpwd_functions=(${chpwd_functions[@]} "_ghostty_report_pwd") + # An executed program could change cwd and report the changed cwd, so also report cwd at each new prompt + # as in this case chpwd_functions is insufficient. chpwd_functions is still needed for things like: cd x && something + functions[_ghostty_precmd]+=" + _ghostty_report_pwd" + _ghostty_report_pwd + + # Enable cursor shape changes depending on the current keymap. + # This implementation leaks blinking block cursor into external commands + # executed from zle. For example, users of fzf-based widgets may find + # themselves with a blinking block cursor within fzf. + _ghostty_zle_line_init _ghostty_zle_line_finish _ghostty_zle_keymap_select() { + case ${KEYMAP-} in + # Blinking block cursor. + vicmd|visual) builtin print -nu "$_ghostty_fd" '\e[1 q';; + # Blinking bar cursor. + *) builtin print -nu "$_ghostty_fd" '\e[5 q';; + esac + } + # Restore the blinking default shape before executing an external command + functions[_ghostty_preexec]+=" + builtin print -rnu $_ghostty_fd \$'\\e[0 q'" + + # Some zsh users manually run `source ~/.zshrc` in order to apply rc file + # changes to the current shell. This is a terrible practice that breaks many + # things, including our shell integration. For example, Oh My Zsh and Prezto + # (both very popular among zsh users) will remove zle-line-init and + # zle-line-finish hooks if .zshrc is manually sourced. Prezto will also remove + # zle-keymap-select. + # + # Another common (and much more robust) way to apply rc file changes to the + # current shell is `exec zsh`. This will remove our integration from the shell + # unless it's explicitly invoked from .zshrc. This is not an issue with + # `exec zsh` but rather with our implementation of automatic shell integration. + + # In the ideal world we would use add-zle-hook-widget to hook zle-line-init + # and similar widget. This breaks user configs though, so we have do this + # horrible thing instead. + builtin local hook func widget orig_widget flag + for hook in line-init line-finish keymap-select; do + func=_ghostty_zle_${hook/-/_} + (( $+functions[$func] )) || builtin continue + widget=zle-$hook + if [[ $widgets[$widget] == user:azhw:* && + $+functions[add-zle-hook-widget] -eq 1 ]]; then + # If the widget is already hooked by add-zle-hook-widget at the top + # level, add our hook at the end. We MUST do it this way. We cannot + # just wrap the widget ourselves in this case because it would + # trigger bugs in add-zle-hook-widget. + add-zle-hook-widget $hook $func + else + if (( $+widgets[$widget] )); then + # There is a widget but it's not from add-zle-hook-widget. We + # can rename the original widget, install our own and invoke + # the original when we are called. + # + # Note: The leading dot is to work around bugs in + # zsh-syntax-highlighting. + orig_widget=._ghostty_orig_$widget + builtin zle -A $widget $orig_widget + if [[ $widgets[$widget] == user:* ]]; then + # No -w here to preserve $WIDGET within the original widget. + flag= + else + flag=w + fi + functions[$func]+=" + builtin zle $orig_widget -N$flag -- \"\$@\"" + fi + builtin zle -N $widget $func + fi + done + + if (( $+functions[_ghostty_preexec] )); then + builtin typeset -ag preexec_functions + preexec_functions+=(_ghostty_preexec) + fi + + builtin typeset -ag precmd_functions + if (( $+functions[_ghostty_precmd] )); then + precmd_functions=(${precmd_functions:/_ghostty_deferred_init/_ghostty_precmd}) + _ghostty_precmd + else + precmd_functions=(${precmd_functions:#_ghostty_deferred_init}) + fi + + # Unfunction _ghostty_deferred_init to save memory. Don't unfunction + # ghostty-integration though because decent public functions aren't supposed to + # to unfunction themselves when invoked. Unfunctioning is done by calling code. + builtin unfunction _ghostty_deferred_init +} diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index d6a9dafa5..29d716bb8 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -21,6 +21,7 @@ const apprt = @import("../apprt.zig"); const fastmem = @import("../fastmem.zig"); const internal_os = @import("../os/main.zig"); const configpkg = @import("../config.zig"); +const shell_integration = @import("shell_integration.zig"); const log = std.log.scoped(.io_exec); @@ -526,13 +527,20 @@ const Subprocess = struct { }; errdefer env.deinit(); + // Get our bundled resources directory, if it exists. We use this + // for terminfo, shell-integration, etc. + const resources_dir = try resourcesDir(alloc); + if (resources_dir) |dir| { + try env.put("GHOSTTY_RESOURCES_DIR", dir); + } + // Set our TERM var. This is a bit complicated because we want to use // the ghostty TERM value but we want to only do that if we have // ghostty in the TERMINFO database. // // For now, we just look up a bundled dir but in the future we should // also load the terminfo database and look for it. - if (try terminfoDir(alloc)) |dir| { + if (try terminfoDir(alloc, resources_dir)) |dir| { try env.put("TERM", "xterm-ghostty"); try env.put("COLORTERM", "truecolor"); try env.put("TERMINFO", dir); @@ -590,11 +598,40 @@ const Subprocess = struct { else null; + // The execution path + const final_path = if (internal_os.isFlatpak()) args[0] else path; + + // Setup our shell integration, if we can. + const shell_integrated: ?shell_integration.Shell = shell: { + const force: ?shell_integration.Shell = switch (opts.full_config.@"shell-integration") { + .none => break :shell null, + .detect => null, + .fish => .fish, + .zsh => .zsh, + }; + + const dir = resources_dir orelse break :shell null; + break :shell try shell_integration.setup( + dir, + final_path, + &env, + force, + ); + }; + if (shell_integrated) |shell| { + log.info( + "shell integration automatically injected shell={}", + .{shell}, + ); + } else if (opts.full_config.@"shell-integration" != .none) { + log.warn("shell could not be detected, no automatic shell integration will be injected", .{}); + } + return .{ .arena = arena, .env = env, .cwd = cwd, - .path = if (internal_os.isFlatpak()) args[0] else path, + .path = final_path, .args = args, .grid_size = opts.grid_size, .screen_size = opts.screen_size, @@ -802,7 +839,17 @@ const Subprocess = struct { /// Gets the directory to the terminfo database, if it can be detected. /// The memory returned can't be easily freed so the alloc should be /// an arena or something similar. - fn terminfoDir(alloc: Allocator) !?[]const u8 { + fn terminfoDir(alloc: Allocator, base: ?[]const u8) !?[]const u8 { + const dir = base orelse return null; + return try tryDir(alloc, dir, "terminfo"); + } + + /// Gets the directory to the bundled resources directory, if it + /// exists (not all platforms or packages have it). + /// + /// The memory returned can't be easily freed so the alloc should be + /// an arena or something similar. + fn resourcesDir(alloc: Allocator) !?[]const u8 { // We only support Mac lookups right now because the terminfo // DB can be embedded directly in the App bundle. if (comptime !builtin.target.isDarwin()) return null; @@ -815,24 +862,33 @@ const Subprocess = struct { // bundle as we expect it. while (std.fs.path.dirname(exe)) |dir| { exe = dir; - - var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - const path = try std.fmt.bufPrint( - &buf, - "{s}/Contents/Resources/terminfo", - .{dir}, - ); - - if (std.fs.accessAbsolute(path, .{})) { - return try alloc.dupe(u8, path); - } else |_| { - // Folder doesn't exist. If a different error happens its okay - // we just ignore it and move on. + if (try tryDir(alloc, dir, "Contents/Resources")) |v| { + return v; } } return null; } + + /// Little helper to check if the "base/sub" directory exists and + /// if so, duplicate the path and return it. + fn tryDir( + alloc: Allocator, + base: []const u8, + sub: []const u8, + ) !?[]const u8 { + var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const path = try std.fmt.bufPrint(&buf, "{s}/{s}", .{ base, sub }); + + if (std.fs.accessAbsolute(path, .{})) { + return try alloc.dupe(u8, path); + } else |_| { + // Folder doesn't exist. If a different error happens its okay + // we just ignore it and move on. + } + + return null; + } }; /// The read thread sits in a loop doing the following pseudo code: diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig new file mode 100644 index 000000000..6f8fe3220 --- /dev/null +++ b/src/termio/shell_integration.zig @@ -0,0 +1,104 @@ +const std = @import("std"); +const EnvMap = std.process.EnvMap; + +const log = std.log.scoped(.shell_integration); + +/// Shell types we support +pub const Shell = enum { + fish, + zsh, +}; + +/// Setup the command execution environment for automatic +/// integrated shell integration. This returns true if shell +/// integration was successful. False could mean many things: +/// the shell type wasn't detected, etc. +pub fn setup( + resource_dir: []const u8, + command_path: []const u8, + env: *EnvMap, + force_shell: ?Shell, +) !?Shell { + const exe = if (force_shell) |shell| switch (shell) { + .fish => "/fish", + .zsh => "/zsh", + } else std.fs.path.basename(command_path); + + if (std.mem.eql(u8, "fish", exe)) { + try setupFish(resource_dir, env); + return .fish; + } + + if (std.mem.eql(u8, "zsh", exe)) { + try setupZsh(resource_dir, env); + return .zsh; + } + + return null; +} + +/// Setup the fish automatic shell integration. This works by +/// modify XDG_DATA_DIRS to include the resource directory. +/// Fish will automatically load configuration in XDG_DATA_DIRS +/// "fish/vendor_conf.d/*.fish". +fn setupFish( + resource_dir: []const u8, + env: *EnvMap, +) !void { + var path_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + + // Get our path to the shell integration directory. + const integ_dir = try std.fmt.bufPrint( + &path_buf, + "{s}/shell-integration", + .{resource_dir}, + ); + + // Set an env var so we can remove this from XDG_DATA_DIRS later. + // This happens in the shell integration config itself. We do this + // so that our modifications don't interfere with other commands. + try env.put("GHOSTTY_FISH_XDG_DIR", integ_dir); + + if (env.get("XDG_DATA_DIRS")) |old| { + // We have an old value, We need to prepend our value to it. + + // We use a 4K buffer to hold our XDG_DATA_DIR value. The stack + // on macOS is at least 512K and Linux is 8MB or more. So this + // should fit. If the user has a XDG_DATA_DIR value that is longer + // than this then it will fail... and we will cross that bridge + // when we actually get there. This avoids us needing an allocator. + var buf: [4096]u8 = undefined; + const prepended = try std.fmt.bufPrint( + &buf, + "{s}{c}{s}", + .{ integ_dir, std.fs.path.delimiter, old }, + ); + + try env.put("XDG_DATA_DIRS", prepended); + } else { + // No XDG_DATA_DIRS set, we just set it our desired value. + try env.put("XDG_DATA_DIRS", integ_dir); + } +} + +/// Setup the zsh automatic shell integration. This works by setting +/// ZDOTDIR to our resources dir so that zsh will load our config. This +/// config then loads the true user config. +fn setupZsh( + resource_dir: []const u8, + env: *EnvMap, +) !void { + // Preserve the old zdotdir value so we can recover it. + if (env.get("ZDOTDIR")) |old| { + try env.put("GHOSTTY_ZSH_ZDOTDIR", old); + } + + // Set our new ZDOTDIR + var path_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const integ_dir = try std.fmt.bufPrint( + &path_buf, + "{s}/shell-integration/zsh", + .{resource_dir}, + ); + try env.put("ZDOTDIR", integ_dir); +}