diff --git a/src/Surface.zig b/src/Surface.zig index 6005635d9..78363e87c 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -545,6 +545,7 @@ pub fn init( .env_override = config.env, .shell_integration = config.@"shell-integration", .shell_integration_features = config.@"shell-integration-features", + .ssh_integration = config.@"ssh-integration", .working_directory = config.@"working-directory", .resources_dir = global_state.resources_dir.host(), .term = config.term, diff --git a/src/config.zig b/src/config.zig index 7f390fb08..e34819fa1 100644 --- a/src/config.zig +++ b/src/config.zig @@ -33,6 +33,7 @@ pub const RepeatableStringMap = @import("config/RepeatableStringMap.zig"); pub const RepeatablePath = Config.RepeatablePath; pub const Path = Config.Path; pub const ShellIntegrationFeatures = Config.ShellIntegrationFeatures; +pub const SSHIntegration = Config.SSHIntegration; pub const WindowPaddingColor = Config.WindowPaddingColor; pub const BackgroundImagePosition = Config.BackgroundImagePosition; pub const BackgroundImageFit = Config.BackgroundImageFit; diff --git a/src/config/Config.zig b/src/config/Config.zig index be59ae94f..5fe950576 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -466,93 +466,6 @@ background: Color = .{ .r = 0x28, .g = 0x2C, .b = 0x34 }, /// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, -/// Background image for the terminal. -/// -/// This should be a path to a PNG or JPEG file, other image formats are -/// not yet supported. -/// -/// The background image is currently per-terminal, not per-window. If -/// you are a heavy split user, the background image will be repeated across -/// splits. A future improvement to Ghostty will address this. -/// -/// WARNING: Background images are currently duplicated in VRAM per-terminal. -/// For sufficiently large images, this could lead to a large increase in -/// memory usage (specifically VRAM usage). A future Ghostty improvement -/// will resolve this by sharing image textures across terminals. -@"background-image": ?Path = null, - -/// Background image opacity. -/// -/// This is relative to the value of `background-opacity`. -/// -/// A value of `1.0` (the default) will result in the background image being -/// placed on top of the general background color, and then the combined result -/// will be adjusted to the opacity specified by `background-opacity`. -/// -/// A value less than `1.0` will result in the background image being mixed -/// with the general background color before the combined result is adjusted -/// to the configured `background-opacity`. -/// -/// A value greater than `1.0` will result in the background image having a -/// higher opacity than the general background color. For instance, if the -/// configured `background-opacity` is `0.5` and `background-image-opacity` -/// is set to `1.5`, then the final opacity of the background image will be -/// `0.5 * 1.5 = 0.75`. -@"background-image-opacity": f32 = 1.0, - -/// Background image position. -/// -/// Valid values are: -/// * `top-left` -/// * `top-center` -/// * `top-right` -/// * `center-left` -/// * `center` -/// * `center-right` -/// * `bottom-left` -/// * `bottom-center` -/// * `bottom-right` -/// -/// The default value is `center`. -@"background-image-position": BackgroundImagePosition = .center, - -/// Background image fit. -/// -/// Valid values are: -/// -/// * `contain` -/// -/// Preserving the aspect ratio, scale the background image to the largest -/// size that can still be contained within the terminal, so that the whole -/// image is visible. -/// -/// * `cover` -/// -/// Preserving the aspect ratio, scale the background image to the smallest -/// size that can completely cover the terminal. This may result in one or -/// more edges of the image being clipped by the edge of the terminal. -/// -/// * `stretch` -/// -/// Stretch the background image to the full size of the terminal, without -/// preserving the aspect ratio. -/// -/// * `none` -/// -/// Don't scale the background image. -/// -/// The default value is `contain`. -@"background-image-fit": BackgroundImageFit = .contain, - -/// Whether to repeat the background image or not. -/// -/// If this is set to true, the background image will be repeated if there -/// would otherwise be blank space around it because it doesn't completely -/// fill the terminal area. -/// -/// The default value is `false`. -@"background-image-repeat": bool = false, - /// The foreground and background color for selection. If this is not set, then /// the selection color is just the inverted window background and foreground /// (note: not to be confused with the cell bg/fg). @@ -2062,27 +1975,14 @@ keybind: Keybinds = .{}, /// Example: `cursor`, `no-cursor`, `sudo`, `no-sudo`, `title`, `no-title` @"shell-integration-features": ShellIntegrationFeatures = .{}, -/// Custom entries into the command palette. +/// SSH integration level. This controls what level of SSH integration +/// is performed when using the ssh wrapper provided by shell integration. +/// Requires shell integration to be enabled to function. /// -/// Each entry requires the title, the corresponding action, and an optional -/// description. Each field should be prefixed with the field name, a colon -/// (`:`), and then the specified value. The syntax for actions is identical -/// to the one for keybind actions. Whitespace in between fields is ignored. +/// See SSHIntegration for available options. /// -/// ```ini -/// command-palette-entry = title:Reset Font Style, action:csi:0m -/// command-palette-entry = title:Crash on Main Thread,description:Causes a crash on the main (UI) thread.,action:crash:main -/// ``` -/// -/// By default, the command palette is preloaded with most actions that might -/// be useful in an interactive setting yet do not have easily accessible or -/// memorizable shortcuts. The default entries can be cleared by setting this -/// setting to an empty value: -/// -/// ```ini -/// command-palette-entry = -/// ``` -@"command-palette-entry": RepeatableCommand = .{}, +/// The default value is `off`. +@"ssh-integration": SSHIntegration = .off, /// Sets the reporting format for OSC sequences that request color information. /// Ghostty currently supports OSC 10 (foreground), OSC 11 (background), and @@ -2893,9 +2793,6 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { // Add our default keybindings try result.keybind.init(alloc); - // Add our default command palette entries - try result.@"command-palette-entry".init(alloc); - // Add our default link for URL detection try result.link.links.append(alloc, .{ .regex = url.regex, @@ -3410,15 +3307,6 @@ fn expandPaths(self: *Config, base: []const u8) !void { &self._diagnostics, ); }, - ?RepeatablePath, ?Path => { - if (@field(self, field.name)) |*path| { - try path.expand( - arena_alloc, - base, - &self._diagnostics, - ); - } - }, else => {}, } } @@ -5083,29 +4971,25 @@ pub const Keybinds = struct { .{ .key = .{ .unicode = 'w' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .close_tab = {} }, ); - try self.set.putFlags( + try self.set.put( alloc, .{ .key = .{ .physical = .arrow_left }, .mods = .{ .ctrl = true, .shift = true } }, .{ .previous_tab = {} }, - .{ .performable = true }, ); - try self.set.putFlags( + try self.set.put( alloc, .{ .key = .{ .physical = .arrow_right }, .mods = .{ .ctrl = true, .shift = true } }, .{ .next_tab = {} }, - .{ .performable = true }, ); - try self.set.putFlags( + try self.set.put( alloc, .{ .key = .{ .physical = .page_up }, .mods = .{ .ctrl = true } }, .{ .previous_tab = {} }, - .{ .performable = true }, ); - try self.set.putFlags( + try self.set.put( alloc, .{ .key = .{ .physical = .page_down }, .mods = .{ .ctrl = true } }, .{ .next_tab = {} }, - .{ .performable = true }, ); try self.set.put( alloc, @@ -5117,67 +5001,57 @@ pub const Keybinds = struct { .{ .key = .{ .unicode = 'e' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_split = .down }, ); - try self.set.putFlags( + try self.set.put( alloc, .{ .key = .{ .physical = .bracket_left }, .mods = .{ .ctrl = true, .super = true } }, .{ .goto_split = .previous }, - .{ .performable = true }, ); - try self.set.putFlags( + try self.set.put( alloc, .{ .key = .{ .physical = .bracket_right }, .mods = .{ .ctrl = true, .super = true } }, .{ .goto_split = .next }, - .{ .performable = true }, ); - try self.set.putFlags( + try self.set.put( alloc, .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .up }, - .{ .performable = true }, ); - try self.set.putFlags( + try self.set.put( alloc, .{ .key = .{ .physical = .arrow_down }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .down }, - .{ .performable = true }, ); - try self.set.putFlags( + try self.set.put( alloc, .{ .key = .{ .physical = .arrow_left }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .left }, - .{ .performable = true }, ); - try self.set.putFlags( + try self.set.put( alloc, .{ .key = .{ .physical = .arrow_right }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .right }, - .{ .performable = true }, ); // Resizing splits - try self.set.putFlags( + try self.set.put( alloc, .{ .key = .{ .physical = .arrow_up }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .up, 10 } }, - .{ .performable = true }, ); - try self.set.putFlags( + try self.set.put( alloc, .{ .key = .{ .physical = .arrow_down }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .down, 10 } }, - .{ .performable = true }, ); - try self.set.putFlags( + try self.set.put( alloc, .{ .key = .{ .physical = .arrow_left }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .left, 10 } }, - .{ .performable = true }, ); - try self.set.putFlags( + try self.set.put( alloc, .{ .key = .{ .physical = .arrow_right }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .right, 10 } }, - .{ .performable = true }, ); // Viewport scrolling @@ -5248,24 +5122,22 @@ pub const Keybinds = struct { const end: u21 = '8'; var i: u21 = start; while (i <= end) : (i += 1) { - try self.set.putFlags( + try self.set.put( alloc, .{ .key = .{ .unicode = i }, .mods = mods, }, .{ .goto_tab = (i - start) + 1 }, - .{ .performable = true }, ); } - try self.set.putFlags( + try self.set.put( alloc, .{ .key = .{ .unicode = '9' }, .mods = mods, }, .{ .last_tab = {} }, - .{ .performable = true }, ); } @@ -6235,147 +6107,32 @@ pub const ShellIntegrationFeatures = packed struct { title: bool = true, }; -pub const RepeatableCommand = struct { - value: std.ArrayListUnmanaged(inputpkg.Command) = .empty, +/// SSH integration levels for shell integration. +/// Controls how much SSH integration is performed when connecting to remote hosts. +/// +/// Allowable values are: +/// +/// * `off` - No SSH integration, use standard ssh command +/// +/// * `term_only` - Only fix TERM compatibility (xterm-ghostty -> xterm-256color) +/// +/// * `basic` - TERM fix + environment variable propagation +/// +/// * `full` - All features: TERM fix + env vars + terminfo installation + shell integration injection +/// +/// The default value is `off`. +pub const SSHIntegration = enum { + off, + term_only, + basic, + full, - pub fn init(self: *RepeatableCommand, alloc: Allocator) !void { - self.value = .empty; - try self.value.appendSlice(alloc, inputpkg.command.defaults); - } - - pub fn parseCLI( - self: *RepeatableCommand, - alloc: Allocator, - input_: ?[]const u8, + pub fn jsonStringify( + self: SSHIntegration, + options: std.json.StringifyOptions, + writer: anytype, ) !void { - // Unset or empty input clears the list - const input = input_ orelse ""; - if (input.len == 0) { - self.value.clearRetainingCapacity(); - return; - } - - const cmd = try cli.args.parseAutoStruct( - inputpkg.Command, - alloc, - input, - ); - try self.value.append(alloc, cmd); - } - - /// Deep copy of the struct. Required by Config. - pub fn clone(self: *const RepeatableCommand, alloc: Allocator) Allocator.Error!RepeatableCommand { - const value = try self.value.clone(alloc); - for (value.items) |*item| { - item.* = try item.clone(alloc); - } - - return .{ .value = value }; - } - - /// Compare if two of our value are equal. Required by Config. - pub fn equal(self: RepeatableCommand, other: RepeatableCommand) bool { - if (self.value.items.len != other.value.items.len) return false; - for (self.value.items, other.value.items) |a, b| { - if (!a.equal(b)) return false; - } - - return true; - } - - /// Used by Formatter - pub fn formatEntry(self: RepeatableCommand, formatter: anytype) !void { - if (self.value.items.len == 0) { - try formatter.formatEntry(void, {}); - return; - } - - var buf: [4096]u8 = undefined; - for (self.value.items) |item| { - const str = if (item.description.len > 0) std.fmt.bufPrint( - &buf, - "title:{s},description:{s},action:{}", - .{ item.title, item.description, item.action }, - ) else std.fmt.bufPrint( - &buf, - "title:{s},action:{}", - .{ item.title, item.action }, - ); - try formatter.formatEntry([]const u8, str catch return error.OutOfMemory); - } - } - - test "RepeatableCommand parseCLI" { - const testing = std.testing; - var arena = ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const alloc = arena.allocator(); - - var list: RepeatableCommand = .{}; - try list.parseCLI(alloc, "title:Foo,action:ignore"); - try list.parseCLI(alloc, "title:Bar,description:bobr,action:text:ale bydle"); - try list.parseCLI(alloc, "title:Quux,description:boo,action:increase_font_size:2.5"); - - try testing.expectEqual(@as(usize, 3), list.value.items.len); - - try testing.expectEqual(inputpkg.Binding.Action.ignore, list.value.items[0].action); - try testing.expectEqualStrings("Foo", list.value.items[0].title); - - try testing.expect(list.value.items[1].action == .text); - try testing.expectEqualStrings("ale bydle", list.value.items[1].action.text); - try testing.expectEqualStrings("Bar", list.value.items[1].title); - try testing.expectEqualStrings("bobr", list.value.items[1].description); - - try testing.expectEqual( - inputpkg.Binding.Action{ .increase_font_size = 2.5 }, - list.value.items[2].action, - ); - try testing.expectEqualStrings("Quux", list.value.items[2].title); - try testing.expectEqualStrings("boo", list.value.items[2].description); - - try list.parseCLI(alloc, ""); - try testing.expectEqual(@as(usize, 0), list.value.items.len); - } - - test "RepeatableCommand formatConfig empty" { - const testing = std.testing; - var buf = std.ArrayList(u8).init(testing.allocator); - defer buf.deinit(); - - var list: RepeatableCommand = .{}; - try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); - try std.testing.expectEqualSlices(u8, "a = \n", buf.items); - } - - test "RepeatableCommand formatConfig single item" { - const testing = std.testing; - var buf = std.ArrayList(u8).init(testing.allocator); - defer buf.deinit(); - - var arena = ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const alloc = arena.allocator(); - - var list: RepeatableCommand = .{}; - try list.parseCLI(alloc, "title:Bobr, action:text:Bober"); - try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); - try std.testing.expectEqualSlices(u8, "a = title:Bobr,action:text:Bober\n", buf.items); - } - - test "RepeatableCommand formatConfig multiple items" { - const testing = std.testing; - var buf = std.ArrayList(u8).init(testing.allocator); - defer buf.deinit(); - - var arena = ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const alloc = arena.allocator(); - - var list: RepeatableCommand = .{}; - try list.parseCLI(alloc, "title:Bobr, action:text:kurwa"); - try list.parseCLI(alloc, "title:Ja, description: pierdole, action:text:jakie bydle"); - try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); - try std.testing.expectEqualSlices(u8, "a = title:Bobr,action:text:kurwa\na = title:Ja,description:pierdole,action:text:jakie bydle\n", buf.items); + try std.json.stringify(@tagName(self), options, writer); } }; @@ -6834,28 +6591,6 @@ pub const AlphaBlending = enum { } }; -/// See background-image-position -pub const BackgroundImagePosition = enum { - @"top-left", - @"top-center", - @"top-right", - @"center-left", - @"center-center", - @"center-right", - @"bottom-left", - @"bottom-center", - @"bottom-right", - center, -}; - -/// See background-image-fit -pub const BackgroundImageFit = enum { - contain, - cover, - stretch, - none, -}; - /// See freetype-load-flag pub const FreetypeLoadFlags = packed struct { // The defaults here at the time of writing this match the defaults diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 0766198f9..f542261d4 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -15,8 +15,10 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -# We need to be in interactive mode to proceed. -if [[ "$-" != *i* ]] ; then builtin return; fi +# We need to be in interactive mode and we need to have the Ghostty +# resources dir set which also tells us we're running in Ghostty. +if [[ "$-" != *i* ]]; then builtin return; fi +if [ -z "$GHOSTTY_RESOURCES_DIR" ]; then builtin return; fi # When automatic shell integration is active, we were started in POSIX # mode and need to manually recreate the bash startup sequence. @@ -43,7 +45,10 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then if [[ $__ghostty_bash_flags != *"--noprofile"* ]]; then [ -r /etc/profile ] && builtin source "/etc/profile" for __ghostty_rcfile in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do - [ -r "$__ghostty_rcfile" ] && { builtin source "$__ghostty_rcfile"; break; } + [ -r "$__ghostty_rcfile" ] && { + builtin source "$__ghostty_rcfile" + break + } done fi else @@ -55,7 +60,10 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then # Void Linux uses /etc/bash/bashrc # Nixos uses /etc/bashrc for __ghostty_rcfile in /etc/bash.bashrc /etc/bash/bashrc /etc/bashrc; do - [ -r "$__ghostty_rcfile" ] && { builtin source "$__ghostty_rcfile"; break; } + [ -r "$__ghostty_rcfile" ] && { + builtin source "$__ghostty_rcfile" + break + } done if [[ -z "$GHOSTTY_BASH_RCFILE" ]]; then GHOSTTY_BASH_RCFILE="$HOME/.bashrc"; fi [ -r "$GHOSTTY_BASH_RCFILE" ] && builtin source "$GHOSTTY_BASH_RCFILE" @@ -88,15 +96,105 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then fi done if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then - builtin command sudo "$@"; + builtin command sudo "$@" else - builtin command sudo TERMINFO="$TERMINFO" "$@"; + builtin command sudo TERMINFO="$TERMINFO" "$@" fi } fi +if [[ -n "$GHOSTTY_SSH_INTEGRATION" && "$GHOSTTY_SSH_INTEGRATION" != "off" ]]; then + # Wrap `ssh` command to provide Ghostty SSH integration. + # + # This approach supports wrapping an `ssh` alias, but the alias definition + # must come _after_ this function is defined. Otherwise, the alias expansion + # will take precedence over this function, and it won't be wrapped. + function ssh { + case "$GHOSTTY_SSH_INTEGRATION" in + "term_only") + _ghostty_ssh_term_only "$@" + ;; + "basic") + _ghostty_ssh_basic "$@" + ;; + "full") + _ghostty_ssh_full "$@" + ;; + *) + # Unknown level, fall back to basic + _ghostty_ssh_basic "$@" + ;; + esac + } + + # Level: term_only - Just fix TERM compatibility + _ghostty_ssh_term_only() { + if [[ "$TERM" == "xterm-ghostty" ]]; then + TERM=xterm-256color command ssh "$@" + else + command ssh "$@" + fi + } + + # Level: basic - TERM fix + environment variable propagation + _ghostty_ssh_basic() { + local env_vars=() + + # Fix TERM compatibility + if [[ "$TERM" == "xterm-ghostty" ]]; then + env_vars+=("TERM=xterm-256color") + fi + + # Propagate Ghostty shell integration environment variables + [[ -n "$GHOSTTY_SHELL_FEATURES" ]] && env_vars+=("GHOSTTY_SHELL_FEATURES=$GHOSTTY_SHELL_FEATURES") + [[ -n "$GHOSTTY_RESOURCES_DIR" ]] && env_vars+=("GHOSTTY_RESOURCES_DIR=$GHOSTTY_RESOURCES_DIR") + + # Execute with environment variables if any were set + if [[ ${#env_vars[@]} -gt 0 ]]; then + env "${env_vars[@]}" ssh "$@" + else + builtin command ssh "$@" + fi + } + + # Level: full - All features + _ghostty_ssh_full() { + # Full integration: Two-step terminfo installation + if command -v infocmp >/dev/null 2>&1; then + echo "Installing Ghostty terminfo on remote host..." >&2 + + # Step 1: Install terminfo using the same approach that works manually + # This requires authentication but is quick and reliable + if infocmp -x xterm-ghostty 2>/dev/null | command ssh "$@" 'mkdir -p ~/.terminfo/x 2>/dev/null && tic -x -o ~/.terminfo /dev/stdin 2>/dev/null'; then + echo "Terminfo installed successfully. Connecting with full Ghostty support..." >&2 + + # Step 2: Connect with xterm-ghostty since we know terminfo is now available + local env_vars=() + + # Use xterm-ghostty since we just installed it + env_vars+=("TERM=xterm-ghostty") + + # Propagate Ghostty shell integration environment variables + [[ -n "$GHOSTTY_SHELL_INTEGRATION_NO_CURSOR" ]] && env_vars+=("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR=$GHOSTTY_SHELL_INTEGRATION_NO_CURSOR") + [[ -n "$GHOSTTY_SHELL_INTEGRATION_NO_SUDO" ]] && env_vars+=("GHOSTTY_SHELL_INTEGRATION_NO_SUDO=$GHOSTTY_SHELL_INTEGRATION_NO_SUDO") + [[ -n "$GHOSTTY_SHELL_INTEGRATION_NO_TITLE" ]] && env_vars+=("GHOSTTY_SHELL_INTEGRATION_NO_TITLE=$GHOSTTY_SHELL_INTEGRATION_NO_TITLE") + + # Normal SSH connection with Ghostty terminfo available + env "${env_vars[@]}" ssh "$@" + return 0 + else + echo "Terminfo installation failed. Using basic integration." >&2 + fi + fi + + # Fallback to basic integration + _ghostty_ssh_basic "$@" + } + +fi + # Import bash-preexec, safe to do multiple times -builtin source "$(dirname -- "${BASH_SOURCE[0]}")/bash-preexec.sh" +builtin source "$GHOSTTY_RESOURCES_DIR/shell-integration/bash/bash-preexec.sh" # This is set to 1 when we're executing a command so that we don't # send prompt marks multiple times. @@ -104,68 +202,68 @@ _ghostty_executing="" _ghostty_last_reported_cwd="" function __ghostty_precmd() { - local ret="$?" - if test "$_ghostty_executing" != "0"; then - _GHOSTTY_SAVE_PS0="$PS0" - _GHOSTTY_SAVE_PS1="$PS1" - _GHOSTTY_SAVE_PS2="$PS2" + local ret="$?" + if test "$_ghostty_executing" != "0"; then + _GHOSTTY_SAVE_PS0="$PS0" + _GHOSTTY_SAVE_PS1="$PS1" + _GHOSTTY_SAVE_PS2="$PS2" - # Marks - PS1=$PS1'\[\e]133;B\a\]' - PS2=$PS2'\[\e]133;B\a\]' + # Marks + PS1=$PS1'\[\e]133;B\a\]' + PS2=$PS2'\[\e]133;B\a\]' - # bash doesn't redraw the leading lines in a multiline prompt so - # mark the last line as a secondary prompt (k=s) to prevent the - # preceding lines from being erased by ghostty after a resize. - if [[ "${PS1}" == *"\n"* || "${PS1}" == *$'\n'* ]]; then - PS1=$PS1'\[\e]133;A;k=s\a\]' - fi - - # Cursor - if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then - PS1=$PS1'\[\e[5 q\]' - PS0=$PS0'\[\e[0 q\]' - fi - - # Title (working directory) - if [[ "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then - PS1=$PS1'\[\e]2;\w\a\]' - fi + # bash doesn't redraw the leading lines in a multiline prompt so + # mark the last line as a secondary prompt (k=s) to prevent the + # preceding lines from being erased by ghostty after a resize. + if [[ "${PS1}" == *"\n"* || "${PS1}" == *$'\n'* ]]; then + PS1=$PS1'\[\e]133;A;k=s\a\]' fi - if test "$_ghostty_executing" != ""; then - # End of current command. Report its status. - builtin printf "\e]133;D;%s;aid=%s\a" "$ret" "$BASHPID" + # Cursor + if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then + PS1=$PS1'\[\e[5 q\]' + PS0=$PS0'\[\e[0 q\]' fi - # unfortunately bash provides no hooks to detect cwd changes - # in particular this means cwd reporting will not happen for a - # command like cd /test && cat. PS0 is evaluated before cd is run. - if [[ "$_ghostty_last_reported_cwd" != "$PWD" ]]; then - _ghostty_last_reported_cwd="$PWD" - builtin printf "\e]7;kitty-shell-cwd://%s%s\a" "$HOSTNAME" "$PWD" + # Title (working directory) + if [[ "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then + PS1=$PS1'\[\e]2;\w\a\]' fi + fi - # Fresh line and start of prompt. - builtin printf "\e]133;A;aid=%s\a" "$BASHPID" - _ghostty_executing=0 + if test "$_ghostty_executing" != ""; then + # End of current command. Report its status. + builtin printf "\e]133;D;%s;aid=%s\a" "$ret" "$BASHPID" + fi + + # unfortunately bash provides no hooks to detect cwd changes + # in particular this means cwd reporting will not happen for a + # command like cd /test && cat. PS0 is evaluated before cd is run. + if [[ "$_ghostty_last_reported_cwd" != "$PWD" ]]; then + _ghostty_last_reported_cwd="$PWD" + builtin printf "\e]7;kitty-shell-cwd://%s%s\a" "$HOSTNAME" "$PWD" + fi + + # Fresh line and start of prompt. + builtin printf "\e]133;A;aid=%s\a" "$BASHPID" + _ghostty_executing=0 } function __ghostty_preexec() { - builtin local cmd="$1" + builtin local cmd="$1" - PS0="$_GHOSTTY_SAVE_PS0" - PS1="$_GHOSTTY_SAVE_PS1" - PS2="$_GHOSTTY_SAVE_PS2" + PS0="$_GHOSTTY_SAVE_PS0" + PS1="$_GHOSTTY_SAVE_PS1" + PS2="$_GHOSTTY_SAVE_PS2" - # Title (current command) - if [[ -n $cmd && "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then - builtin printf "\e]2;%s\a" "${cmd//[[:cntrl:]]}" - fi + # Title (current command) + if [[ -n $cmd && "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then + builtin printf "\e]2;%s\a" "${cmd//[[:cntrl:]]/}" + fi - # End of input, start of output. - builtin printf "\e]133;C;\a" - _ghostty_executing=1 + # End of input, start of output. + builtin printf "\e]133;C;\a" + _ghostty_executing=1 } preexec_functions+=(__ghostty_preexec) diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index a6d052a72..32f9ecbb6 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -98,6 +98,107 @@ (external sudo) $@args } + fn ssh-with-ghostty-integration {|@args| + if (and (has-env GHOSTTY_SSH_INTEGRATION) (not-eq "" $E:GHOSTTY_SSH_INTEGRATION) (not-eq "off" $E:GHOSTTY_SSH_INTEGRATION)) { + if (eq "term_only" $E:GHOSTTY_SSH_INTEGRATION) { + ssh-term-only $@args + } elif (eq "basic" $E:GHOSTTY_SSH_INTEGRATION) { + ssh-basic $@args + } elif (eq "full" $E:GHOSTTY_SSH_INTEGRATION) { + ssh-full $@args + } else { + # Unknown level, fall back to basic + ssh-basic $@args + } + } else { + (external ssh) $@args + } + } + + fn ssh-term-only {|@args| + # Level: term_only - Just fix TERM compatibility + if (eq "xterm-ghostty" $E:TERM) { + TERM=xterm-256color (external ssh) $@args + } else { + (external ssh) $@args + } + } + + fn ssh-basic {|@args| + # Level: basic - TERM fix + environment variable propagation + var env-vars = [] + + # Fix TERM compatibility + if (eq "xterm-ghostty" $E:TERM) { + set env-vars = [$@env-vars TERM=xterm-256color] + } + + # Propagate Ghostty shell integration environment variables + if (has-env GHOSTTY_SHELL_FEATURES) { + if (not-eq "" $E:GHOSTTY_SHELL_FEATURES) { + set env-vars = [$@env-vars GHOSTTY_SHELL_FEATURES=$E:GHOSTTY_SHELL_FEATURES] + } + } + + if (has-env GHOSTTY_RESOURCES_DIR) { + if (not-eq "" $E:GHOSTTY_RESOURCES_DIR) { + set env-vars = [$@env-vars GHOSTTY_RESOURCES_DIR=$E:GHOSTTY_RESOURCES_DIR] + } + } + + # Execute with environment variables if any were set + if (> (count $env-vars) 0) { + (external env) $@env-vars ssh $@args + } else { + (external ssh) $@args + } + } + + fn ghostty-ssh-full {|@args| + # Full integration: Two-step terminfo installation + if (has-external infocmp) { + echo "Installing Ghostty terminfo on remote host..." >&2 + + # Step 1: Install terminfo using the same approach that works manually + # This requires authentication but is quick and reliable + try { + infocmp -x xterm-ghostty 2>/dev/null | command ssh $@args 'mkdir -p ~/.terminfo/x 2>/dev/null && tic -x -o ~/.terminfo /dev/stdin 2>/dev/null' + echo "Terminfo installed successfully. Connecting with full Ghostty support..." >&2 + + # Step 2: Connect with xterm-ghostty since we know terminfo is now available + var env-vars = [] + + # Use xterm-ghostty since we just installed it + set env-vars = [$@env-vars TERM=xterm-ghostty] + + # Propagate Ghostty shell integration environment variables + if (has-env GHOSTTY_SHELL_INTEGRATION_NO_CURSOR) { + set env-vars = [$@env-vars GHOSTTY_SHELL_INTEGRATION_NO_CURSOR=$E:GHOSTTY_SHELL_INTEGRATION_NO_CURSOR] + } + if (has-env GHOSTTY_SHELL_INTEGRATION_NO_SUDO) { + set env-vars = [$@env-vars GHOSTTY_SHELL_INTEGRATION_NO_SUDO=$E:GHOSTTY_SHELL_INTEGRATION_NO_SUDO] + } + if (has-env GHOSTTY_SHELL_INTEGRATION_NO_TITLE) { + set env-vars = [$@env-vars GHOSTTY_SHELL_INTEGRATION_NO_TITLE=$E:GHOSTTY_SHELL_INTEGRATION_NO_TITLE] + } + + # Normal SSH connection with Ghostty terminfo available + env $@env-vars ssh $@args + return + } catch e { + echo "Terminfo installation failed. Using basic integration." >&2 + } + } + + # Fallback to basic integration + ghostty-ssh-basic $@args + } + + # Register SSH integration if enabled + if (and (has-env GHOSTTY_SSH_INTEGRATION) (not-eq "" $E:GHOSTTY_SSH_INTEGRATION) (not-eq "off" $E:GHOSTTY_SSH_INTEGRATION) (has-external ssh)) { + edit:add-var ssh~ $ssh-with-ghostty-integration~ + } + defer { mark-prompt-start report-pwd 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 index e7c264e1f..d03c98c7f 100644 --- a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +++ b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish @@ -63,14 +63,14 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" # When using sudo shell integration feature, ensure $TERMINFO is set # and `sudo` is not already a function or alias - if contains sudo $features; and test -n "$TERMINFO"; and test "file" = (type -t sudo 2> /dev/null; or echo "x") + if contains sudo $features; 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 function sudo -d "Wrap sudo to preserve terminfo" - set --function sudo_has_sudoedit_flags "no" + set --function sudo_has_sudoedit_flags no for arg in $argv # Check if argument is '-e' or '--edit' (sudoedit flags) - if string match -q -- "-e" "$arg"; or string match -q -- "--edit" "$arg" - set --function sudo_has_sudoedit_flags "yes" + if string match -q -- -e "$arg"; or string match -q -- --edit "$arg" + set --function sudo_has_sudoedit_flags yes break end # Check if argument is neither an option nor a key-value pair @@ -78,7 +78,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" break end end - if test "$sudo_has_sudoedit_flags" = "yes" + if test "$sudo_has_sudoedit_flags" = yes command sudo $argv else command sudo TERMINFO="$TERMINFO" $argv @@ -86,6 +86,99 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" end end + # SSH integration wrapper + if test -n "$GHOSTTY_SSH_INTEGRATION"; and test "$GHOSTTY_SSH_INTEGRATION" != off + function ssh -d "Wrap ssh to provide Ghostty SSH integration" + switch "$GHOSTTY_SSH_INTEGRATION" + case term_only + _ghostty_ssh_term_only $argv + case basic + _ghostty_ssh_basic $argv + case full + _ghostty_ssh_full $argv + case "*" + # Unknown level, fall back to basic + _ghostty_ssh_basic $argv + end + end + + # Level: term_only - Just fix TERM compatibility + function _ghostty_ssh_term_only -d "SSH with TERM compatibility fix" + if test "$TERM" = xterm-ghostty + TERM=xterm-256color command ssh $argv + else + command ssh $argv + end + end + + # Level: basic - TERM fix + environment variable propagation + function _ghostty_ssh_basic -d "SSH with TERM fix and environment propagation" + # Build environment variables to propagate + set --local env_vars + + # Fix TERM compatibility + if test "$TERM" = xterm-ghostty + set env_vars $env_vars TERM=xterm-256color + end + + # Propagate Ghostty shell integration environment variables + if test -n "$GHOSTTY_SHELL_FEATURES" + set env_vars $env_vars GHOSTTY_SHELL_FEATURES="$GHOSTTY_SHELL_FEATURES" + end + + if test -n "$GHOSTTY_RESOURCES_DIR" + set env_vars $env_vars GHOSTTY_RESOURCES_DIR="$GHOSTTY_RESOURCES_DIR" + end + + # Execute with environment variables if any were set + if test (count $env_vars) -gt 0 + env $env_vars ssh $argv + else + command ssh $argv + end + end + + # Level: full - All features + function _ghostty_ssh_full + # Full integration: Two-step terminfo installation + if command -v infocmp >/dev/null 2>&1 + echo "Installing Ghostty terminfo on remote host..." >&2 + + # Step 1: Install terminfo using the same approach that works manually + # This requires authentication but is quick and reliable + if infocmp -x xterm-ghostty 2>/dev/null | command ssh $argv 'mkdir -p ~/.terminfo/x 2>/dev/null && tic -x -o ~/.terminfo /dev/stdin 2>/dev/null' + echo "Terminfo installed successfully. Connecting with full Ghostty support..." >&2 + + # Step 2: Connect with xterm-ghostty since we know terminfo is now available + set -l env_vars + + # Use xterm-ghostty since we just installed it + set -a env_vars TERM=xterm-ghostty + + # Propagate Ghostty shell integration environment variables + if set -q GHOSTTY_SHELL_INTEGRATION_NO_CURSOR + set -a env_vars GHOSTTY_SHELL_INTEGRATION_NO_CURSOR=$GHOSTTY_SHELL_INTEGRATION_NO_CURSOR + end + if set -q GHOSTTY_SHELL_INTEGRATION_NO_SUDO + set -a env_vars GHOSTTY_SHELL_INTEGRATION_NO_SUDO=$GHOSTTY_SHELL_INTEGRATION_NO_SUDO + end + if set -q GHOSTTY_SHELL_INTEGRATION_NO_TITLE + set -a env_vars GHOSTTY_SHELL_INTEGRATION_NO_TITLE=$GHOSTTY_SHELL_INTEGRATION_NO_TITLE + end + + # Normal SSH connection with Ghostty terminfo available + env $env_vars ssh $argv + return 0 + else + echo "Terminfo installation failed. Using basic integration." >&2 + end + end + + # Fallback to basic integration + _ghostty_ssh_basic $argv + end + 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. diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index c1329683e..58838d1f0 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -243,6 +243,87 @@ _ghostty_deferred_init() { fi } fi + + # SSH + if [[ -n "$GHOSTTY_SSH_INTEGRATION" && "$GHOSTTY_SSH_INTEGRATION" != "off" ]]; then + # Wrap `ssh` command to provide Ghostty SSH integration + ssh() { + case "$GHOSTTY_SSH_INTEGRATION" in + "term_only") + _ghostty_ssh_term_only "$@" + ;; + "basic") + _ghostty_ssh_basic "$@" + ;; + "full") + _ghostty_ssh_full "$@" + ;; + *) + # Unknown level, fall back to basic + _ghostty_ssh_basic "$@" + ;; + esac + } + + # Level: term_only - Just fix TERM compatibility + _ghostty_ssh_term_only() { + if [[ "$TERM" == "xterm-ghostty" ]]; then + TERM=xterm-256color builtin command ssh "$@" + else + builtin command ssh "$@" + fi + } + + # Level: basic - TERM fix + environment variable propagation + _ghostty_ssh_basic() { + # Fix TERM compatibility and propagate key environment variables + if [[ "$TERM" == "xterm-ghostty" ]]; then + TERM=xterm-256color \ + GHOSTTY_SHELL_FEATURES="${GHOSTTY_SHELL_FEATURES}" \ + GHOSTTY_RESOURCES_DIR="${GHOSTTY_RESOURCES_DIR}" \ + builtin command ssh "$@" + else + GHOSTTY_SHELL_FEATURES="${GHOSTTY_SHELL_FEATURES}" \ + GHOSTTY_RESOURCES_DIR="${GHOSTTY_RESOURCES_DIR}" \ + builtin command ssh "$@" + fi + } + + # Level: full - All features + _ghostty_ssh_full() { + # Full integration: Two-step terminfo installation + if command -v infocmp >/dev/null 2>&1; then + echo "Installing Ghostty terminfo on remote host..." >&2 + + # Step 1: Install terminfo using the same approach that works manually + # This requires authentication but is quick and reliable + if infocmp -x xterm-ghostty 2>/dev/null | command ssh "$@" 'mkdir -p ~/.terminfo/x 2>/dev/null && tic -x -o ~/.terminfo /dev/stdin 2>/dev/null'; then + echo "Terminfo installed successfully. Connecting with full Ghostty support..." >&2 + + # Step 2: Connect with xterm-ghostty since we know terminfo is now available + local env_vars=() + + # Use xterm-ghostty since we just installed it + env_vars+=("TERM=xterm-ghostty") + + # Propagate Ghostty shell integration environment variables + [[ -n "$GHOSTTY_SHELL_INTEGRATION_NO_CURSOR" ]] && env_vars+=("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR=$GHOSTTY_SHELL_INTEGRATION_NO_CURSOR") + [[ -n "$GHOSTTY_SHELL_INTEGRATION_NO_SUDO" ]] && env_vars+=("GHOSTTY_SHELL_INTEGRATION_NO_SUDO=$GHOSTTY_SHELL_INTEGRATION_NO_SUDO") + [[ -n "$GHOSTTY_SHELL_INTEGRATION_NO_TITLE" ]] && env_vars+=("GHOSTTY_SHELL_INTEGRATION_NO_TITLE=$GHOSTTY_SHELL_INTEGRATION_NO_TITLE") + + # Normal SSH connection with Ghostty terminfo available + env "${env_vars[@]}" ssh "$@" + return 0 + else + echo "Terminfo installation failed. Using basic integration." >&2 + fi + fi + + # Fallback to basic integration + _ghostty_ssh_basic "$@" + } + + fi # 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 diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index aed7cefb6..9b4707db5 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -733,6 +733,7 @@ pub const Config = struct { env_override: configpkg.RepeatableStringMap = .{}, shell_integration: configpkg.Config.ShellIntegration = .detect, shell_integration_features: configpkg.Config.ShellIntegrationFeatures = .{}, + ssh_integration: configpkg.SSHIntegration, working_directory: ?[]const u8 = null, resources_dir: ?[]const u8, term: []const u8, @@ -937,6 +938,7 @@ const Subprocess = struct { &env, force, cfg.shell_integration_features, + cfg.ssh_integration, ) orelse { log.warn("shell could not be detected, no automatic shell integration will be injected", .{}); break :shell default_shell_command; diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index fb62327d3..fc3fbb63c 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -45,6 +45,7 @@ pub fn setup( env: *EnvMap, force_shell: ?Shell, features: config.ShellIntegrationFeatures, + ssh_integration: config.SSHIntegration, ) !?ShellIntegration { const exe = if (force_shell) |shell| switch (shell) { .bash => "bash", @@ -70,8 +71,9 @@ pub fn setup( exe, ); - // Setup our feature env vars + // Setup our feature env vars and SSH integration try setupFeatures(env, features); + try setupSSHIntegration(env, ssh_integration); return result; } @@ -159,6 +161,7 @@ test "force shell" { &env, shell, .{}, + .off, ); try testing.expectEqual(shell, result.?.shell); } @@ -224,6 +227,15 @@ test "setup features" { } } +pub fn setupSSHIntegration( + env: *EnvMap, + ssh_integration: config.SSHIntegration, +) !void { + if (ssh_integration != .off) { + try env.put("GHOSTTY_SSH_INTEGRATION", @tagName(ssh_integration)); + } +} + /// Setup the bash automatic shell integration. This works by /// starting bash in POSIX mode and using the ENV environment /// variable to load our bash integration script. This prevents