From 142e07c502dfbb274892cd5aa640ade31264183d Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Fri, 13 Jun 2025 16:11:46 -0700 Subject: [PATCH 01/52] feat: add SSH integration wrapper for shell integration - Implements opt-in SSH wrapper following sudo pattern - Supports term_only, basic, and full integration levels - Fixes xterm-ghostty TERM compatibility on remote systems - Propagates shell integration environment variables - Allows for automatic installation of terminfo if desired - Addresses GitHub discussions #5892 and #4156 --- src/Surface.zig | 1 + src/config.zig | 1 + src/config/Config.zig | 357 +++--------------- src/shell-integration/bash/ghostty.bash | 208 +++++++--- .../elvish/lib/ghostty-integration.elv | 101 +++++ .../ghostty-shell-integration.fish | 103 ++++- src/shell-integration/zsh/ghostty-integration | 81 ++++ src/termio/Exec.zig | 2 + src/termio/shell_integration.zig | 14 +- 9 files changed, 496 insertions(+), 372 deletions(-) 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 From 8f93d8fe030a3c14ec8ab358a974f70beb0995c4 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Fri, 13 Jun 2025 17:03:20 -0700 Subject: [PATCH 02/52] fix: use kebab-case for ssh-integration enum values --- src/config/Config.zig | 4 ++-- src/shell-integration/bash/ghostty.bash | 8 ++++---- .../elvish/lib/ghostty-integration.elv | 4 ++-- .../ghostty-shell-integration.fish | 20 +++++++++---------- src/shell-integration/zsh/ghostty-integration | 8 ++++---- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 5fe950576..7f5e35d0d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6114,7 +6114,7 @@ pub const ShellIntegrationFeatures = packed struct { /// /// * `off` - No SSH integration, use standard ssh command /// -/// * `term_only` - Only fix TERM compatibility (xterm-ghostty -> xterm-256color) +/// * `term-only` - Only fix TERM compatibility (xterm-ghostty -> xterm-256color) /// /// * `basic` - TERM fix + environment variable propagation /// @@ -6123,7 +6123,7 @@ pub const ShellIntegrationFeatures = packed struct { /// The default value is `off`. pub const SSHIntegration = enum { off, - term_only, + @"term-only", basic, full, diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index f542261d4..349f65f1f 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -111,8 +111,8 @@ if [[ -n "$GHOSTTY_SSH_INTEGRATION" && "$GHOSTTY_SSH_INTEGRATION" != "off" ]]; t # 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 "$@" + "term-only") + _ghostty_ssh_term-only "$@" ;; "basic") _ghostty_ssh_basic "$@" @@ -127,8 +127,8 @@ if [[ -n "$GHOSTTY_SSH_INTEGRATION" && "$GHOSTTY_SSH_INTEGRATION" != "off" ]]; t esac } - # Level: term_only - Just fix TERM compatibility - _ghostty_ssh_term_only() { + # Level: term-only - Just fix TERM compatibility + _ghostty_ssh_term-only() { if [[ "$TERM" == "xterm-ghostty" ]]; then TERM=xterm-256color command ssh "$@" else diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 32f9ecbb6..257cd7ba6 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -100,7 +100,7 @@ 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) { + if (eq "term-only" $E:GHOSTTY_SSH_INTEGRATION) { ssh-term-only $@args } elif (eq "basic" $E:GHOSTTY_SSH_INTEGRATION) { ssh-basic $@args @@ -116,7 +116,7 @@ } fn ssh-term-only {|@args| - # Level: term_only - Just fix TERM compatibility + # Level: term-only - Just fix TERM compatibility if (eq "xterm-ghostty" $E:TERM) { TERM=xterm-256color (external ssh) $@args } else { 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 d03c98c7f..f899764de 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 @@ -90,8 +90,8 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" 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 term-only + _ghostty_ssh_term-only $argv case basic _ghostty_ssh_basic $argv case full @@ -102,8 +102,8 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" end end - # Level: term_only - Just fix TERM compatibility - function _ghostty_ssh_term_only -d "SSH with TERM compatibility fix" + # 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 @@ -143,18 +143,18 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" # 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 @@ -165,7 +165,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" 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 @@ -173,7 +173,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" echo "Terminfo installation failed. Using basic integration." >&2 end end - + # Fallback to basic integration _ghostty_ssh_basic $argv end diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 58838d1f0..903b06dac 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -249,8 +249,8 @@ _ghostty_deferred_init() { # Wrap `ssh` command to provide Ghostty SSH integration ssh() { case "$GHOSTTY_SSH_INTEGRATION" in - "term_only") - _ghostty_ssh_term_only "$@" + "term-only") + _ghostty_ssh_term-only "$@" ;; "basic") _ghostty_ssh_basic "$@" @@ -265,8 +265,8 @@ _ghostty_deferred_init() { esac } - # Level: term_only - Just fix TERM compatibility - _ghostty_ssh_term_only() { + # Level: term-only - Just fix TERM compatibility + _ghostty_ssh_term-only() { if [[ "$TERM" == "xterm-ghostty" ]]; then TERM=xterm-256color builtin command ssh "$@" else From 34af3ffbaf56edef6b5b8b4d1d268f96f44135a5 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Fri, 13 Jun 2025 17:08:44 -0700 Subject: [PATCH 03/52] docs: inline ssh-integration documentation instead of referencing enum --- src/config/Config.zig | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 7f5e35d0d..edf743cce 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1975,11 +1975,18 @@ keybind: Keybinds = .{}, /// Example: `cursor`, `no-cursor`, `sudo`, `no-sudo`, `title`, `no-title` @"shell-integration-features": ShellIntegrationFeatures = .{}, -/// 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. +/// SSH integration levels for shell integration. +/// Controls how much SSH integration is performed when connecting to remote hosts. /// -/// See SSHIntegration for available options. +/// 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 /// /// The default value is `off`. @"ssh-integration": SSHIntegration = .off, From 2babdb458fa936908b0bfe1e288aaff5e9892ce5 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Mon, 16 Jun 2025 09:53:22 -0700 Subject: [PATCH 04/52] refactor: simplify ssh integration environment variable checks --- src/shell-integration/bash/ghostty.bash | 2 +- src/shell-integration/elvish/lib/ghostty-integration.elv | 4 ++-- .../fish/vendor_conf.d/ghostty-shell-integration.fish | 2 +- src/shell-integration/zsh/ghostty-integration | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 349f65f1f..6915a3f13 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -103,7 +103,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then } fi -if [[ -n "$GHOSTTY_SSH_INTEGRATION" && "$GHOSTTY_SSH_INTEGRATION" != "off" ]]; then +if [[ -n "$GHOSTTY_SSH_INTEGRATION" ]]; then # Wrap `ssh` command to provide Ghostty SSH integration. # # This approach supports wrapping an `ssh` alias, but the alias definition diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 257cd7ba6..5c901cbb3 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -99,7 +99,7 @@ } 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 (has-env GHOSTTY_SSH_INTEGRATION) { if (eq "term-only" $E:GHOSTTY_SSH_INTEGRATION) { ssh-term-only $@args } elif (eq "basic" $E:GHOSTTY_SSH_INTEGRATION) { @@ -195,7 +195,7 @@ } # 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)) { + if (and (has-env GHOSTTY_SSH_INTEGRATION) (has-external ssh)) { edit:add-var ssh~ $ssh-with-ghostty-integration~ } 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 f899764de..482b03cf9 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 @@ -87,7 +87,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" end # SSH integration wrapper - if test -n "$GHOSTTY_SSH_INTEGRATION"; and test "$GHOSTTY_SSH_INTEGRATION" != off + if test -n "$GHOSTTY_SSH_INTEGRATION" function ssh -d "Wrap ssh to provide Ghostty SSH integration" switch "$GHOSTTY_SSH_INTEGRATION" case term-only diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 903b06dac..6bfc6ffd2 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -245,7 +245,7 @@ _ghostty_deferred_init() { fi # SSH - if [[ -n "$GHOSTTY_SSH_INTEGRATION" && "$GHOSTTY_SSH_INTEGRATION" != "off" ]]; then + if [[ -n "$GHOSTTY_SSH_INTEGRATION" ]]; then # Wrap `ssh` command to provide Ghostty SSH integration ssh() { case "$GHOSTTY_SSH_INTEGRATION" in From 842ced9212e4e5d678e78e8a4c036cf9b4c8c68a Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Mon, 16 Jun 2025 13:16:35 -0700 Subject: [PATCH 05/52] bash: preserve mixed indentation in SSH integration changes Preserves existing mixed indentation in ghostty.bash to minimize diff noise per maintainer feedback. --- src/shell-integration/bash/ghostty.bash | 98 ++++++++++++------------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 6915a3f13..5c8fd959f 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -103,6 +103,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then } fi +# SSH if [[ -n "$GHOSTTY_SSH_INTEGRATION" ]]; then # Wrap `ssh` command to provide Ghostty SSH integration. # @@ -190,7 +191,6 @@ if [[ -n "$GHOSTTY_SSH_INTEGRATION" ]]; then # Fallback to basic integration _ghostty_ssh_basic "$@" } - fi # Import bash-preexec, safe to do multiple times @@ -202,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\]' + # 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 fi - # Cursor - if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then - PS1=$PS1'\[\e[5 q\]' - PS0=$PS0'\[\e[0 q\]' + if test "$_ghostty_executing" != ""; then + # End of current command. Report its status. + builtin printf "\e]133;D;%s;aid=%s\a" "$ret" "$BASHPID" fi - # Title (working directory) - if [[ "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then - PS1=$PS1'\[\e]2;\w\a\]' + # 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 - fi - 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 + # 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) From c70643404cf2da95036700a16da73c57a75006ab Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Mon, 16 Jun 2025 13:46:13 -0700 Subject: [PATCH 06/52] bash: revert all formatting changes Keeps only functional additions for SSH integration wrapper, preserving original line breaks and indentation to minimize diff noise per maintainer feedback. --- src/shell-integration/bash/ghostty.bash | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 5c8fd959f..a630b2dec 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -17,7 +17,7 @@ # 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 [[ "$-" != *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 @@ -45,10 +45,7 @@ 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 @@ -60,10 +57,7 @@ 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" @@ -96,9 +90,9 @@ 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 From b07b3e4608cbbdd5a9134e2cbec8c69d1612501c Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Mon, 16 Jun 2025 13:51:13 -0700 Subject: [PATCH 07/52] fish: revert all formatting changes Keeps only functional additions for SSH integration wrapper, preserving original line breaks and indentation to minimize diff noise per maintainer feedback. --- .../fish/vendor_conf.d/ghostty-shell-integration.fish | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 482b03cf9..1f4f9832d 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 From 2e9a0e92db644957a95ff5624262d9ed6e360495 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Mon, 16 Jun 2025 15:58:48 -0700 Subject: [PATCH 08/52] fix: clean up SSH environment variable propagation --- src/shell-integration/bash/ghostty.bash | 4 +--- .../elvish/lib/ghostty-integration.elv | 12 ++++-------- .../vendor_conf.d/ghostty-shell-integration.fish | 14 ++------------ src/shell-integration/zsh/ghostty-integration | 6 +----- 4 files changed, 8 insertions(+), 28 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index a630b2dec..9200509da 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -170,9 +170,7 @@ if [[ -n "$GHOSTTY_SSH_INTEGRATION" ]]; then 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") + [[ -n "$GHOSTTY_SHELL_FEATURES" ]] && env_vars+=("GHOSTTY_SHELL_FEATURES=$GHOSTTY_SHELL_FEATURES") # Normal SSH connection with Ghostty terminfo available env "${env_vars[@]}" ssh "$@" diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 5c901cbb3..63c1253bc 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -172,14 +172,10 @@ 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] + 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] + } } # Normal SSH connection with Ghostty terminfo available 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 1f4f9832d..e0644b8c6 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 @@ -126,10 +126,6 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" 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 @@ -156,14 +152,8 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" 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 + if test -n "$GHOSTTY_SHELL_FEATURES" + set env_vars $env_vars GHOSTTY_SHELL_FEATURES="$GHOSTTY_SHELL_FEATURES" end # Normal SSH connection with Ghostty terminfo available diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 6bfc6ffd2..0ffb283b8 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -280,11 +280,9 @@ _ghostty_deferred_init() { 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 } @@ -307,9 +305,7 @@ _ghostty_deferred_init() { 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") + [[ -n "$GHOSTTY_SHELL_FEATURES" ]] && env_vars+=("GHOSTTY_SHELL_FEATURES=$GHOSTTY_SHELL_FEATURES") # Normal SSH connection with Ghostty terminfo available env "${env_vars[@]}" ssh "$@" From 050cb3bfecbb2dc6c946b39ab670c560b2de3e0b Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Mon, 16 Jun 2025 15:59:27 -0700 Subject: [PATCH 09/52] fix: remove unnecessary jsonStringify method --- src/config/Config.zig | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index edf743cce..6ca7470d0 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6133,14 +6133,6 @@ pub const SSHIntegration = enum { @"term-only", basic, full, - - pub fn jsonStringify( - self: SSHIntegration, - options: std.json.StringifyOptions, - writer: anytype, - ) !void { - try std.json.stringify(@tagName(self), options, writer); - } }; /// OSC 4, 10, 11, and 12 default color reporting format. From 4206ab121053870c424b69107e4f46b2866c1a3b Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Mon, 16 Jun 2025 16:04:37 -0700 Subject: [PATCH 10/52] fix: use idiomatic Fish shell syntax in SSH integration - Use `set --append` for array operations - Use `type -q` for command existence checks --- .../fish/vendor_conf.d/ghostty-shell-integration.fish | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 e0644b8c6..4ceb5324a 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 @@ -118,12 +118,12 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" # Fix TERM compatibility if test "$TERM" = xterm-ghostty - set env_vars $env_vars TERM=xterm-256color + set --append 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" + set --append env_vars GHOSTTY_SHELL_FEATURES="$GHOSTTY_SHELL_FEATURES" end # Execute with environment variables if any were set @@ -137,7 +137,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" # Level: full - All features function _ghostty_ssh_full # Full integration: Two-step terminfo installation - if command -v infocmp >/dev/null 2>&1 + if type -q infocmp echo "Installing Ghostty terminfo on remote host..." >&2 # Step 1: Install terminfo using the same approach that works manually @@ -153,7 +153,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" # Propagate Ghostty shell integration environment variables if test -n "$GHOSTTY_SHELL_FEATURES" - set env_vars $env_vars GHOSTTY_SHELL_FEATURES="$GHOSTTY_SHELL_FEATURES" + set --append env_vars GHOSTTY_SHELL_FEATURES="$GHOSTTY_SHELL_FEATURES" end # Normal SSH connection with Ghostty terminfo available From 8fafd5ace11411a9a54e0dd13cb0085430a75e1c Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Mon, 16 Jun 2025 16:19:14 -0700 Subject: [PATCH 11/52] docs: expand SSH integration configuration documentation Add detailed explanations of shell function behavior, TERM compatibility trade-offs, environment variable propagation, and authentication requirements per maintainer feedback. --- src/config/Config.zig | 42 ++++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 6ca7470d0..589347c8b 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1975,18 +1975,32 @@ keybind: Keybinds = .{}, /// Example: `cursor`, `no-cursor`, `sudo`, `no-sudo`, `title`, `no-title` @"shell-integration-features": ShellIntegrationFeatures = .{}, -/// SSH integration levels for shell integration. -/// Controls how much SSH integration is performed when connecting to remote hosts. +/// SSH integration configuration for shell integration. +/// +/// When enabled (any value other than `off`), Ghostty replaces the `ssh` command +/// with a shell function to provide enhanced terminal compatibility and feature +/// propagation when connecting to remote hosts. Users can verify this by running +/// `type ssh` which will show "ssh is a shell function" instead of the binary path. /// /// Allowable values are: /// -/// * `off` - No SSH integration, use standard ssh command +/// * `off` - No SSH integration, use standard ssh binary /// -/// * `term-only` - Only fix TERM compatibility (xterm-ghostty -> xterm-256color) +/// * `term-only` - Automatically converts TERM from `xterm-ghostty` to `xterm-256color` +/// when connecting to remote hosts. This prevents "unknown terminal type" errors +/// on systems that lack Ghostty's terminfo entry, but sacrifices Ghostty-specific +/// terminal features like enhanced cursor reporting and shell integration markers. +/// See: https://ghostty.org/docs/help/terminfo /// -/// * `basic` - TERM fix + environment variable propagation +/// * `basic` - TERM compatibility fix plus environment variable propagation. +/// Forwards `GHOSTTY_SHELL_FEATURES` to enable shell integration features on +/// remote systems that have Ghostty installed and configured. /// -/// * `full` - All features: TERM fix + env vars + terminfo installation +/// * `full` - All basic features plus automatic terminfo installation. Attempts +/// to install Ghostty's terminfo entry on the remote host using `infocmp` and `tic`, +/// then connects with full `xterm-ghostty` support. Requires two SSH authentications +/// (one for installation, one for the session) but enables complete Ghostty +/// terminal functionality on the remote system. /// /// The default value is `off`. @"ssh-integration": SSHIntegration = .off, @@ -6114,20 +6128,7 @@ pub const ShellIntegrationFeatures = packed struct { title: bool = true, }; -/// 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`. +/// See ssh-integration pub const SSHIntegration = enum { off, @"term-only", @@ -7678,3 +7679,4 @@ test "theme specifying light/dark sets theme usage in conditional state" { try testing.expect(cfg._conditional_set.contains(.theme)); } } + From af28763a34ae8976b0b294d7c3c7a9cb79a8be14 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Mon, 16 Jun 2025 17:11:09 -0700 Subject: [PATCH 12/52] fix: trailing newline in Config.zig --- src/config/Config.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 589347c8b..4cc87ae04 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -7679,4 +7679,3 @@ test "theme specifying light/dark sets theme usage in conditional state" { try testing.expect(cfg._conditional_set.contains(.theme)); } } - From 80475e1d17fdc4e7368899a183c891548c021ece Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Tue, 17 Jun 2025 14:52:37 -0700 Subject: [PATCH 13/52] fix: critical elvish syntax errors for environment variables --- src/shell-integration/elvish/lib/ghostty-integration.elv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 63c1253bc..ef34d97b8 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -118,7 +118,7 @@ fn ssh-term-only {|@args| # Level: term-only - Just fix TERM compatibility if (eq "xterm-ghostty" $E:TERM) { - TERM=xterm-256color (external ssh) $@args + (external env) TERM=xterm-256color ssh $@args } else { (external ssh) $@args } @@ -179,7 +179,7 @@ } # Normal SSH connection with Ghostty terminfo available - env $@env-vars ssh $@args + (external env) $@env-vars ssh $@args return } catch e { echo "Terminfo installation failed. Using basic integration." >&2 From fb8f6c77dd49d57ec22e9119ed08ea7e4f974a1c Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Tue, 17 Jun 2025 15:26:00 -0700 Subject: [PATCH 14/52] fix: remove dangling resources_dir var --- src/shell-integration/elvish/lib/ghostty-integration.elv | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index ef34d97b8..7611d6745 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -140,12 +140,6 @@ } } - 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 From 3319b2b6eda6804e01e0b2d7a1e13028c3e0197a Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Tue, 17 Jun 2025 15:30:11 -0700 Subject: [PATCH 15/52] docs: added full stop for consistency --- src/config/Config.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 4cc87ae04..0743dc4cf 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1984,7 +1984,7 @@ keybind: Keybinds = .{}, /// /// Allowable values are: /// -/// * `off` - No SSH integration, use standard ssh binary +/// * `off` - No SSH integration, use standard ssh binary. /// /// * `term-only` - Automatically converts TERM from `xterm-ghostty` to `xterm-256color` /// when connecting to remote hosts. This prevents "unknown terminal type" errors From 2ddcf2fffe8e5359b065f7d3d258fe3af7b23015 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Tue, 17 Jun 2025 15:32:15 -0700 Subject: [PATCH 16/52] fix: remove resources_dir var, add builtin prefix for consistency --- src/shell-integration/bash/ghostty.bash | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 9200509da..cab4bc01e 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -142,7 +142,6 @@ if [[ -n "$GHOSTTY_SSH_INTEGRATION" ]]; then # 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 @@ -174,7 +173,7 @@ if [[ -n "$GHOSTTY_SSH_INTEGRATION" ]]; then # Normal SSH connection with Ghostty terminfo available env "${env_vars[@]}" ssh "$@" - return 0 + builtin return 0 else echo "Terminfo installation failed. Using basic integration." >&2 fi From 995fb09813ce75809bbd1beff769fd1bfd6501f8 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Tue, 17 Jun 2025 15:48:39 -0700 Subject: [PATCH 17/52] fix: add builtin prefix for safety and consistency --- src/shell-integration/bash/ghostty.bash | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index cab4bc01e..b40bf3816 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -125,9 +125,9 @@ if [[ -n "$GHOSTTY_SSH_INTEGRATION" ]]; then # Level: term-only - Just fix TERM compatibility _ghostty_ssh_term-only() { if [[ "$TERM" == "xterm-ghostty" ]]; then - TERM=xterm-256color command ssh "$@" + TERM=xterm-256color builtin command ssh "$@" else - command ssh "$@" + builtin command ssh "$@" fi } @@ -154,12 +154,12 @@ if [[ -n "$GHOSTTY_SSH_INTEGRATION" ]]; then # Level: full - All features _ghostty_ssh_full() { # Full integration: Two-step terminfo installation - if command -v infocmp >/dev/null 2>&1; then + if builtin 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 + if infocmp -x xterm-ghostty 2>/dev/null | builtin 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 From b6bb9abfbcbc02212353283c56cf68491eba9266 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Tue, 17 Jun 2025 16:37:37 -0700 Subject: [PATCH 18/52] fix: address comprehensive shell integration code review issues - Fix elvish function name mismatch and use conj for list operations - Simplify terminfo installation command per ghostty docs (tic -x -) - Fix conditional structure to ensure error messages always print - Remove redundant checks and optimize array initialization - Use consistent patterns across bash, fish, elvish, and zsh implementations --- src/shell-integration/bash/ghostty.bash | 13 ++-- .../elvish/lib/ghostty-integration.elv | 70 ++++++++----------- .../ghostty-shell-integration.fish | 35 ++++------ src/shell-integration/zsh/ghostty-integration | 67 +++++++++--------- 4 files changed, 82 insertions(+), 103 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index b40bf3816..2ce1f9503 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -157,16 +157,12 @@ if [[ -n "$GHOSTTY_SSH_INTEGRATION" ]]; then if builtin 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 | builtin command ssh "$@" 'mkdir -p ~/.terminfo/x 2>/dev/null && tic -x -o ~/.terminfo /dev/stdin 2>/dev/null'; then + # Step 1: Install terminfo + if infocmp -x xterm-ghostty 2>/dev/null | builtin command ssh "$@" 'tic -x - 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") + local env_vars=("TERM=xterm-ghostty") # Propagate Ghostty shell integration environment variables [[ -n "$GHOSTTY_SHELL_FEATURES" ]] && env_vars+=("GHOSTTY_SHELL_FEATURES=$GHOSTTY_SHELL_FEATURES") @@ -174,9 +170,8 @@ if [[ -n "$GHOSTTY_SSH_INTEGRATION" ]]; then # Normal SSH connection with Ghostty terminfo available env "${env_vars[@]}" ssh "$@" builtin return 0 - else - echo "Terminfo installation failed. Using basic integration." >&2 fi + echo "Terminfo installation failed. Using basic integration." >&2 fi # Fallback to basic integration diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 7611d6745..2f938df97 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -130,14 +130,12 @@ # Fix TERM compatibility if (eq "xterm-ghostty" $E:TERM) { - set env-vars = [$@env-vars TERM=xterm-256color] + set env-vars = (conj $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 (not-eq "" $E:GHOSTTY_SHELL_FEATURES) { + set env-vars = (conj $env-vars GHOSTTY_SHELL_FEATURES=$E:GHOSTTY_SHELL_FEATURES) } # Execute with environment variables if any were set @@ -148,47 +146,39 @@ } } - 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_FEATURES) { - if (not-eq "" $E:GHOSTTY_SHELL_FEATURES) { - set env-vars = [$@env-vars GHOSTTY_SHELL_FEATURES=$E:GHOSTTY_SHELL_FEATURES] - } - } - - # Normal SSH connection with Ghostty terminfo available - (external env) $@env-vars ssh $@args - return - } catch e { - echo "Terminfo installation failed. Using basic integration." >&2 - } - } + fn ssh-full {|@args| + # Full integration: Two-step terminfo installation + if (has-external infocmp) { + echo "Installing Ghostty terminfo on remote host..." >&2 - # Fallback to basic integration - ghostty-ssh-basic $@args + try { + infocmp -x xterm-ghostty 2>/dev/null | (external ssh) $@args 'tic -x - 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 = [TERM=xterm-ghostty] + + # Propagate Ghostty shell integration environment variables + if (not-eq "" $E:GHOSTTY_SHELL_FEATURES) { + set env-vars = (conj $env-vars GHOSTTY_SHELL_FEATURES=$E:GHOSTTY_SHELL_FEATURES) + } + + # Normal SSH connection with Ghostty terminfo available + (external env) $@env-vars ssh $@args + return + } catch e { + echo "Terminfo installation failed. Using basic integration." >&2 + } + } + + # Fallback to basic integration + ssh-basic $@args } - # Register SSH integration if enabled +# Register SSH integration if enabled if (and (has-env 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 4ceb5324a..0fe7155a1 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 @@ -104,10 +104,10 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" # 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 + if test "$TERM" = "xterm-ghostty" + TERM=xterm-256color builtin command ssh $argv else - command ssh $argv + builtin command ssh $argv end end @@ -117,7 +117,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" set --local env_vars # Fix TERM compatibility - if test "$TERM" = xterm-ghostty + if test "$TERM" = "xterm-ghostty" set --append env_vars TERM=xterm-256color end @@ -127,10 +127,10 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" end # Execute with environment variables if any were set - if test (count $env_vars) -gt 0 + if test "$(count $env_vars)" -gt 0 env $env_vars ssh $argv else - command ssh $argv + builtin command ssh $argv end end @@ -140,35 +140,28 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" if type -q 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 - 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' + # Step 1: Install terminfo + if infocmp -x xterm-ghostty 2>/dev/null | ssh $argv 'tic -x - 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 - + set --local env_vars TERM=xterm-ghostty + # Propagate Ghostty shell integration environment variables if test -n "$GHOSTTY_SHELL_FEATURES" set --append env_vars GHOSTTY_SHELL_FEATURES="$GHOSTTY_SHELL_FEATURES" end - - # Normal SSH connection with Ghostty terminfo available + env $env_vars ssh $argv - return 0 - else - echo "Terminfo installation failed. Using basic integration." >&2 + builtin return 0 end + echo "Terminfo installation failed. Using basic integration." >&2 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 0ffb283b8..109c9fd60 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -276,49 +276,50 @@ _ghostty_deferred_init() { # Level: basic - TERM fix + environment variable propagation _ghostty_ssh_basic() { - # Fix TERM compatibility and propagate key environment variables + local env_vars=() + + # Fix TERM compatibility if [[ "$TERM" == "xterm-ghostty" ]]; then - TERM=xterm-256color \ - GHOSTTY_SHELL_FEATURES="${GHOSTTY_SHELL_FEATURES}" \ - builtin command ssh "$@" + env_vars+=("TERM=xterm-256color") + fi + + # Propagate Ghostty shell integration environment variables + [[ -n "$GHOSTTY_SHELL_FEATURES" ]] && env_vars+=("GHOSTTY_SHELL_FEATURES=$GHOSTTY_SHELL_FEATURES") + + # Execute with environment variables if any were set + if [[ ${#env_vars[@]} -gt 0 ]]; then + env "${env_vars[@]}" ssh "$@" else - GHOSTTY_SHELL_FEATURES="${GHOSTTY_SHELL_FEATURES}" \ 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_FEATURES" ]] && env_vars+=("GHOSTTY_SHELL_FEATURES=$GHOSTTY_SHELL_FEATURES") - - # Normal SSH connection with Ghostty terminfo available - env "${env_vars[@]}" ssh "$@" - return 0 - else - echo "Terminfo installation failed. Using basic integration." >&2 - fi - fi + # Full integration: Two-step terminfo installation + if builtin command -v infocmp >/dev/null 2>&1; then + echo "Installing Ghostty terminfo on remote host..." >&2 - # Fallback to basic integration - _ghostty_ssh_basic "$@" + # Step 1: Install terminfo + if infocmp -x xterm-ghostty 2>/dev/null | builtin command ssh "$@" 'tic -x - 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=("TERM=xterm-ghostty") + + # Propagate Ghostty shell integration environment variables + [[ -n "$GHOSTTY_SHELL_FEATURES" ]] && env_vars+=("GHOSTTY_SHELL_FEATURES=$GHOSTTY_SHELL_FEATURES") + + # Normal SSH connection with Ghostty terminfo available + env "${env_vars[@]}" ssh "$@" + builtin return 0 + fi + echo "Terminfo installation failed. Using basic integration." >&2 + fi + + # Fallback to basic integration + _ghostty_ssh_basic "$@" } - fi # Some zsh users manually run `source ~/.zshrc` in order to apply rc file From 4cebee5c8e34a26861fea40034cd78abf679d98f Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Tue, 17 Jun 2025 17:43:21 -0700 Subject: [PATCH 19/52] fix: add client-side caching to eliminate redundant terminfo installations - Cache known hosts with terminfo in $GHOSTTY_RESOURCES_DIR/terminfo_hosts - Skip installation step for cached hosts (single connection instead of two) - Use secure file permissions (600) and atomic writes - Extract SSH target safely from command arguments - Maintains full functionality while improving user experience on repeated connections --- src/shell-integration/bash/ghostty.bash | 94 ++++++++++++-- .../elvish/lib/ghostty-integration.elv | 120 +++++++++++++++--- .../ghostty-shell-integration.fish | 101 +++++++++++++-- src/shell-integration/zsh/ghostty-integration | 102 +++++++++++++-- 4 files changed, 366 insertions(+), 51 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 2ce1f9503..51ab6c3e5 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -99,6 +99,66 @@ fi # SSH if [[ -n "$GHOSTTY_SSH_INTEGRATION" ]]; then + # Cache file for tracking hosts with terminfo installed + _ghostty_cache_file="${GHOSTTY_RESOURCES_DIR:-$HOME/.config/ghostty}/terminfo_hosts" + + # Extract target host from SSH arguments + _ghostty_get_ssh_target() { + local target="" + local skip_next=false + + for arg in "$@"; do + if [[ "$skip_next" == "true" ]]; then + skip_next=false + continue + fi + + # Skip flags that take arguments + if [[ "$arg" =~ ^-[bcDEeFIiJLlmOopQRSWw]$ ]]; then + skip_next=true + continue + fi + + # Skip other flags + if [[ "$arg" =~ ^- ]]; then + continue + fi + + # This should be the target + target="$arg" + break + done + + echo "$target" + } + + # Check if host has terminfo installed + _ghostty_host_has_terminfo() { + local target="$1" + [[ -f "$_ghostty_cache_file" ]] && grep -qFx "$target" "$_ghostty_cache_file" 2>/dev/null + } + + # Add host to terminfo cache + _ghostty_cache_host() { + local target="$1" + local cache_dir + cache_dir="$(dirname "$_ghostty_cache_file")" + + # Create cache directory if needed + [[ ! -d "$cache_dir" ]] && mkdir -p "$cache_dir" + + # Atomic write to cache file + { + if [[ -f "$_ghostty_cache_file" ]]; then + cat "$_ghostty_cache_file" + fi + echo "$target" + } | sort -u > "$_ghostty_cache_file.tmp" && mv "$_ghostty_cache_file.tmp" "$_ghostty_cache_file" + + # Secure permissions + chmod 600 "$_ghostty_cache_file" 2>/dev/null + } + # Wrap `ssh` command to provide Ghostty SSH integration. # # This approach supports wrapping an `ssh` alias, but the alias definition @@ -153,21 +213,35 @@ if [[ -n "$GHOSTTY_SSH_INTEGRATION" ]]; then # Level: full - All features _ghostty_ssh_full() { - # Full integration: Two-step terminfo installation + local target + target="$(_ghostty_get_ssh_target "$@")" + + # Check if we already know this host has terminfo + if [[ -n "$target" ]] && _ghostty_host_has_terminfo "$target"; then + # Direct connection with xterm-ghostty + local env_vars=("TERM=xterm-ghostty") + [[ -n "$GHOSTTY_SHELL_FEATURES" ]] && env_vars+=("GHOSTTY_SHELL_FEATURES=$GHOSTTY_SHELL_FEATURES") + env "${env_vars[@]}" ssh "$@" + return 0 + fi + + # Full integration: Install terminfo if needed if builtin command -v infocmp >/dev/null 2>&1; then - echo "Installing Ghostty terminfo on remote host..." >&2 + # Install terminfo only if needed + if infocmp -x xterm-ghostty 2>/dev/null | builtin command ssh "$@" ' + if ! infocmp xterm-ghostty >/dev/null 2>&1; then + echo "Installing Ghostty terminfo..." >&2 + tic -x - 2>/dev/null + fi + '; then + echo "Connecting with full Ghostty support..." >&2 - # Step 1: Install terminfo - if infocmp -x xterm-ghostty 2>/dev/null | builtin command ssh "$@" 'tic -x - 2>/dev/null'; then - echo "Terminfo installed successfully. Connecting with full Ghostty support..." >&2 + # Cache this host for future connections + [[ -n "$target" ]] && _ghostty_cache_host "$target" - # Step 2: Connect with xterm-ghostty since we know terminfo is now available + # Connect with xterm-ghostty since terminfo is available local env_vars=("TERM=xterm-ghostty") - - # Propagate Ghostty shell integration environment variables [[ -n "$GHOSTTY_SHELL_FEATURES" ]] && env_vars+=("GHOSTTY_SHELL_FEATURES=$GHOSTTY_SHELL_FEATURES") - - # Normal SSH connection with Ghostty terminfo available env "${env_vars[@]}" ssh "$@" builtin return 0 fi diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 2f938df97..deb258ae7 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -98,6 +98,70 @@ (external sudo) $@args } + # SSH Integration + # Cache file for tracking hosts with terminfo installed + var ghostty-cache-file = (if (has-env GHOSTTY_RESOURCES_DIR) { put $E:GHOSTTY_RESOURCES_DIR"/terminfo_hosts" } else { put $E:HOME"/.config/ghostty/terminfo_hosts" }) + + # Extract target host from SSH arguments + fn ghostty-get-ssh-target {|@args| + var target = "" + var skip-next = $false + + for arg $args { + if (eq $skip-next $true) { + set skip-next = $false + continue + } + + # Skip flags that take arguments + if (re:match '^-[bcDEeFIiJLlmOopQRSWw]$' $arg) { + set skip-next = $true + continue + } + + # Skip other flags + if (re:match '^-' $arg) { + continue + } + + # This should be the target + set target = $arg + break + } + + put $target + } + + # Check if host has terminfo installed + fn ghostty-host-has-terminfo {|target| + and (path:is-regular $ghostty-cache-file) ?(grep -qFx $target $ghostty-cache-file 2>/dev/null) + } + + # Add host to terminfo cache + fn ghostty-cache-host {|target| + var cache-dir = (path:dir $ghostty-cache-file) + + # Create cache directory if needed + if (not (path:is-dir $cache-dir)) { + mkdir -p $cache-dir + } + + # Atomic write to cache file + var temp-file = $ghostty-cache-file".tmp" + + { + if (path:is-regular $ghostty-cache-file) { + cat $ghostty-cache-file + } + echo $target + } | sort -u > $temp-file + + mv $temp-file $ghostty-cache-file + + # Secure permissions + ?chmod 600 $ghostty-cache-file 2>/dev/null + } + fn ssh-with-ghostty-integration {|@args| if (has-env GHOSTTY_SSH_INTEGRATION) { if (eq "term-only" $E:GHOSTTY_SSH_INTEGRATION) { @@ -127,17 +191,17 @@ 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 = (conj $env-vars TERM=xterm-256color) } - + # Propagate Ghostty shell integration environment variables if (not-eq "" $E:GHOSTTY_SHELL_FEATURES) { set env-vars = (conj $env-vars GHOSTTY_SHELL_FEATURES=$E:GHOSTTY_SHELL_FEATURES) } - + # Execute with environment variables if any were set if (> (count $env-vars) 0) { (external env) $@env-vars ssh $@args @@ -147,22 +211,47 @@ } fn ssh-full {|@args| - # Full integration: Two-step terminfo installation + var target = (ghostty-get-ssh-target $@args) + + # Check if we already know this host has terminfo + if (and (not-eq "" $target) (ghostty-host-has-terminfo $target)) { + # Direct connection with xterm-ghostty + var env-vars = [TERM=xterm-ghostty] + + # Propagate Ghostty shell integration environment variables + if (not-eq "" $E:GHOSTTY_SHELL_FEATURES) { + set env-vars = (conj $env-vars GHOSTTY_SHELL_FEATURES=$E:GHOSTTY_SHELL_FEATURES) + } + + (external env) $@env-vars ssh $@args + return + } + + # Full integration: Install terminfo if needed if (has-external infocmp) { - echo "Installing Ghostty terminfo on remote host..." >&2 - try { - infocmp -x xterm-ghostty 2>/dev/null | (external ssh) $@args 'tic -x - 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 + # Install terminfo only if needed + infocmp -x xterm-ghostty 2>/dev/null | (external ssh) $@args ' + if ! infocmp xterm-ghostty >/dev/null 2>&1; then + echo "Installing Ghostty terminfo..." >&2 + tic -x - 2>/dev/null + fi + ' + echo "Connecting with full Ghostty support..." >&2 + + # Cache this host for future connections + if (not-eq "" $target) { + ghostty-cache-host $target + } + + # Connect with xterm-ghostty since terminfo is available var env-vars = [TERM=xterm-ghostty] - + # Propagate Ghostty shell integration environment variables if (not-eq "" $E:GHOSTTY_SHELL_FEATURES) { set env-vars = (conj $env-vars GHOSTTY_SHELL_FEATURES=$E:GHOSTTY_SHELL_FEATURES) } - + # Normal SSH connection with Ghostty terminfo available (external env) $@env-vars ssh $@args return @@ -170,15 +259,16 @@ echo "Terminfo installation failed. Using basic integration." >&2 } } - + # Fallback to basic integration ssh-basic $@args } -# Register SSH integration if enabled + # Register SSH integration if enabled if (and (has-env 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 0fe7155a1..bcf97cb82 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 @@ -86,8 +86,68 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" end end - # SSH integration wrapper + # SSH integration if test -n "$GHOSTTY_SSH_INTEGRATION" + # Cache file for tracking hosts with terminfo installed + set -l _ghostty_cache_file (string join / (test -n "$GHOSTTY_RESOURCES_DIR"; and echo "$GHOSTTY_RESOURCES_DIR"; or echo "$HOME/.config/ghostty") "terminfo_hosts") + + # Extract target host from SSH arguments + function _ghostty_get_ssh_target + set -l target "" + set -l skip_next false + + for arg in $argv + if test "$skip_next" = "true" + set skip_next false + continue + end + + # Skip flags that take arguments + if string match -qr '^-[bcDEeFIiJLlmOopQRSWw]$' -- "$arg" + set skip_next true + continue + end + + # Skip other flags + if string match -q -- '-*' "$arg" + continue + end + + # This should be the target + set target "$arg" + break + end + + echo "$target" + end + + # Check if host has terminfo installed + function _ghostty_host_has_terminfo + set -l target $argv[1] + test -f "$_ghostty_cache_file"; and grep -qFx "$target" "$_ghostty_cache_file" 2>/dev/null + end + + # Add host to terminfo cache + function _ghostty_cache_host + set -l target $argv[1] + set -l cache_dir (dirname "$_ghostty_cache_file") + + # Create cache directory if needed + test -d "$cache_dir"; or mkdir -p "$cache_dir" + + # Atomic write to cache file + begin + if test -f "$_ghostty_cache_file" + cat "$_ghostty_cache_file" + end + echo "$target" + end | sort -u > "$_ghostty_cache_file.tmp"; and mv "$_ghostty_cache_file.tmp" "$_ghostty_cache_file" + + # Secure permissions + chmod 600 "$_ghostty_cache_file" 2>/dev/null + end + + # Wrap `ssh` command to provide Ghostty SSH integration. function ssh -d "Wrap ssh to provide Ghostty SSH integration" switch "$GHOSTTY_SSH_INTEGRATION" case term-only @@ -136,22 +196,38 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" # Level: full - All features function _ghostty_ssh_full - # Full integration: Two-step terminfo installation - if type -q infocmp - echo "Installing Ghostty terminfo on remote host..." >&2 + set -l target (_ghostty_get_ssh_target $argv) - # Step 1: Install terminfo - if infocmp -x xterm-ghostty 2>/dev/null | ssh $argv 'tic -x - 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 + # Check if we already know this host has terminfo + if test -n "$target"; and _ghostty_host_has_terminfo "$target" + # Direct connection with xterm-ghostty + set --local env_vars TERM=xterm-ghostty + if test -n "$GHOSTTY_SHELL_FEATURES" + set --append env_vars GHOSTTY_SHELL_FEATURES="$GHOSTTY_SHELL_FEATURES" + end + env $env_vars ssh $argv + return 0 + end + + # Full integration: Install terminfo if needed + if type -q infocmp + # Install terminfo only if needed + if infocmp -x xterm-ghostty 2>/dev/null | ssh $argv ' + if ! infocmp xterm-ghostty >/dev/null 2>&1 + echo "Installing Ghostty terminfo..." >&2 + tic -x - 2>/dev/null + end + ' + echo "Connecting with full Ghostty support..." >&2 + + # Cache this host for future connections + test -n "$target"; and _ghostty_cache_host "$target" + + # Connect with xterm-ghostty since terminfo is available set --local env_vars TERM=xterm-ghostty - - # Propagate Ghostty shell integration environment variables if test -n "$GHOSTTY_SHELL_FEATURES" set --append env_vars GHOSTTY_SHELL_FEATURES="$GHOSTTY_SHELL_FEATURES" end - env $env_vars ssh $argv builtin return 0 end @@ -162,6 +238,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty 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 109c9fd60..c172fc02d 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -243,9 +243,69 @@ _ghostty_deferred_init() { fi } fi - + # SSH if [[ -n "$GHOSTTY_SSH_INTEGRATION" ]]; then + # Cache file for tracking hosts with terminfo installed + _ghostty_cache_file="${GHOSTTY_RESOURCES_DIR:-$HOME/.config/ghostty}/terminfo_hosts" + + # Extract target host from SSH arguments + _ghostty_get_ssh_target() { + local target="" + local skip_next=false + + for arg in "$@"; do + if [[ "$skip_next" == "true" ]]; then + skip_next=false + continue + fi + + # Skip flags that take arguments + if [[ "$arg" =~ ^-[bcDEeFIiJLlmOopQRSWw]$ ]]; then + skip_next=true + continue + fi + + # Skip other flags + if [[ "$arg" =~ ^- ]]; then + continue + fi + + # This should be the target + target="$arg" + break + done + + echo "$target" + } + + # Check if host has terminfo installed + _ghostty_host_has_terminfo() { + local target="$1" + [[ -f "$_ghostty_cache_file" ]] && grep -qFx "$target" "$_ghostty_cache_file" 2>/dev/null + } + + # Add host to terminfo cache + _ghostty_cache_host() { + local target="$1" + local cache_dir + cache_dir="$(dirname "$_ghostty_cache_file")" + + # Create cache directory if needed + [[ ! -d "$cache_dir" ]] && mkdir -p "$cache_dir" + + # Atomic write to cache file + { + if [[ -f "$_ghostty_cache_file" ]]; then + cat "$_ghostty_cache_file" + fi + echo "$target" + } | sort -u > "$_ghostty_cache_file.tmp" && mv "$_ghostty_cache_file.tmp" "$_ghostty_cache_file" + + # Secure permissions + chmod 600 "$_ghostty_cache_file" 2>/dev/null + } + # Wrap `ssh` command to provide Ghostty SSH integration ssh() { case "$GHOSTTY_SSH_INTEGRATION" in @@ -296,27 +356,41 @@ _ghostty_deferred_init() { # Level: full - All features _ghostty_ssh_full() { - # Full integration: Two-step terminfo installation + local target + target="$(_ghostty_get_ssh_target "$@")" + + # Check if we already know this host has terminfo + if [[ -n "$target" ]] && _ghostty_host_has_terminfo "$target"; then + # Direct connection with xterm-ghostty + local env_vars=("TERM=xterm-ghostty") + [[ -n "$GHOSTTY_SHELL_FEATURES" ]] && env_vars+=("GHOSTTY_SHELL_FEATURES=$GHOSTTY_SHELL_FEATURES") + env "${env_vars[@]}" ssh "$@" + return 0 + fi + + # Full integration: Install terminfo if needed if builtin command -v infocmp >/dev/null 2>&1; then - echo "Installing Ghostty terminfo on remote host..." >&2 - - # Step 1: Install terminfo - if infocmp -x xterm-ghostty 2>/dev/null | builtin command ssh "$@" 'tic -x - 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 + # Install terminfo only if needed + if infocmp -x xterm-ghostty 2>/dev/null | builtin command ssh "$@" ' + if ! infocmp xterm-ghostty >/dev/null 2>&1; then + echo "Installing Ghostty terminfo..." >&2 + tic -x - 2>/dev/null + fi + '; then + echo "Connecting with full Ghostty support..." >&2 + + # Cache this host for future connections + [[ -n "$target" ]] && _ghostty_cache_host "$target" + + # Connect with xterm-ghostty since terminfo is available local env_vars=("TERM=xterm-ghostty") - - # Propagate Ghostty shell integration environment variables [[ -n "$GHOSTTY_SHELL_FEATURES" ]] && env_vars+=("GHOSTTY_SHELL_FEATURES=$GHOSTTY_SHELL_FEATURES") - - # Normal SSH connection with Ghostty terminfo available env "${env_vars[@]}" ssh "$@" builtin return 0 fi echo "Terminfo installation failed. Using basic integration." >&2 fi - + # Fallback to basic integration _ghostty_ssh_basic "$@" } From 69f9976394d903b5b5e94ee86e2249331deee104 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Tue, 17 Jun 2025 17:56:46 -0700 Subject: [PATCH 20/52] fix: manual formatting pass to ensure consistency with existing patterns --- .../ghostty-shell-integration.fish | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) 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 bcf97cb82..e01eabac3 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 @@ -89,22 +89,22 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" # SSH integration if test -n "$GHOSTTY_SSH_INTEGRATION" # Cache file for tracking hosts with terminfo installed - set -l _ghostty_cache_file (string join / (test -n "$GHOSTTY_RESOURCES_DIR"; and echo "$GHOSTTY_RESOURCES_DIR"; or echo "$HOME/.config/ghostty") "terminfo_hosts") + set --local _ghostty_cache_file (string join / (test -n "$GHOSTTY_RESOURCES_DIR"; and echo "$GHOSTTY_RESOURCES_DIR"; or echo "$HOME/.config/ghostty") "terminfo_hosts") # Extract target host from SSH arguments function _ghostty_get_ssh_target - set -l target "" - set -l skip_next false + set --local target "" + set --local skip_next "false" for arg in $argv if test "$skip_next" = "true" - set skip_next false + set skip_next "false" continue end # Skip flags that take arguments - if string match -qr '^-[bcDEeFIiJLlmOopQRSWw]$' -- "$arg" - set skip_next true + if string match -qr -- '^-[bcDEeFIiJLlmOopQRSWw]$' "$arg" + set skip_next "true" continue end @@ -123,14 +123,14 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" # Check if host has terminfo installed function _ghostty_host_has_terminfo - set -l target $argv[1] + set --local target "$argv[1]" test -f "$_ghostty_cache_file"; and grep -qFx "$target" "$_ghostty_cache_file" 2>/dev/null end # Add host to terminfo cache function _ghostty_cache_host - set -l target $argv[1] - set -l cache_dir (dirname "$_ghostty_cache_file") + set --local target "$argv[1]" + set --local cache_dir (dirname "$_ghostty_cache_file") # Create cache directory if needed test -d "$cache_dir"; or mkdir -p "$cache_dir" @@ -147,14 +147,14 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" chmod 600 "$_ghostty_cache_file" 2>/dev/null end - # Wrap `ssh` command to provide Ghostty SSH integration. + # Wrap `ssh` command to provide Ghostty SSH integration function ssh -d "Wrap ssh to provide Ghostty SSH integration" switch "$GHOSTTY_SSH_INTEGRATION" - case term-only + case "term-only" _ghostty_ssh_term-only $argv - case basic + case "basic" _ghostty_ssh_basic $argv - case full + case "full" _ghostty_ssh_full $argv case "*" # Unknown level, fall back to basic @@ -187,7 +187,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" end # Execute with environment variables if any were set - if test "$(count $env_vars)" -gt 0 + if test (count $env_vars) -gt 0 env $env_vars ssh $argv else builtin command ssh $argv @@ -196,7 +196,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" # Level: full - All features function _ghostty_ssh_full - set -l target (_ghostty_get_ssh_target $argv) + set --local target (_ghostty_get_ssh_target $argv) # Check if we already know this host has terminfo if test -n "$target"; and _ghostty_host_has_terminfo "$target" From f206e7684193a11cccd935b25ccea1dbf538a03b Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Tue, 17 Jun 2025 21:33:29 -0700 Subject: [PATCH 21/52] ssh-integration: improve host caching, new method for "full" integration Need a sanity check on this new approach for "full" to help determine if it's worth additional iteration/refinement. It solves the double auth issue, successfully propagates env vars, and avoids output noise for connections that happen after terminfo is installed. The only issue I don't have time to fix tonight is the fact that it drops the MOTD for cached (re)connections. --- src/shell-integration/bash/ghostty.bash | 282 ++++++++++++++++-------- 1 file changed, 187 insertions(+), 95 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 51ab6c3e5..74f48cf32 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -99,91 +99,90 @@ fi # SSH if [[ -n "$GHOSTTY_SSH_INTEGRATION" ]]; then - # Cache file for tracking hosts with terminfo installed - _ghostty_cache_file="${GHOSTTY_RESOURCES_DIR:-$HOME/.config/ghostty}/terminfo_hosts" + # Cache configuration + _ghostty_cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/ghostty" + _ghostty_cache_file="$_ghostty_cache_dir/terminfo_hosts" - # Extract target host from SSH arguments + # Create cache directory with proper permissions + [[ ! -d "$_ghostty_cache_dir" ]] && mkdir -p "$_ghostty_cache_dir" && chmod 700 "$_ghostty_cache_dir" + + # Extract SSH target from arguments _ghostty_get_ssh_target() { local target="" local skip_next=false + local args=("$@") - for arg in "$@"; do - if [[ "$skip_next" == "true" ]]; then - skip_next=false - continue - fi + for ((i=0; i<${#args[@]}; i++)); do + local arg="${args[i]}" - # Skip flags that take arguments + # Skip if we're processing a flag's argument + [[ "$skip_next" == "true" ]] && { skip_next=false; continue; } + + # Handle flags that take arguments if [[ "$arg" =~ ^-[bcDEeFIiJLlmOopQRSWw]$ ]]; then skip_next=true continue fi - # Skip other flags - if [[ "$arg" =~ ^- ]]; then - continue - fi + # Handle combined short flags with values (e.g., -p22) + [[ "$arg" =~ ^-[bcDEeFIiJLlmOopQRSWw].+ ]] && continue - # This should be the target + # Skip other flags + [[ "$arg" =~ ^- ]] && continue + + # This should be our target target="$arg" break done - echo "$target" + # Handle user@host format + echo "${target##*@}" } - # Check if host has terminfo installed + # Check if host has terminfo cached _ghostty_host_has_terminfo() { - local target="$1" - [[ -f "$_ghostty_cache_file" ]] && grep -qFx "$target" "$_ghostty_cache_file" 2>/dev/null + local host="$1" + [[ -f "$_ghostty_cache_file" ]] && grep -qFx "$host" "$_ghostty_cache_file" 2>/dev/null } - # Add host to terminfo cache + # Add host to cache atomically _ghostty_cache_host() { - local target="$1" - local cache_dir - cache_dir="$(dirname "$_ghostty_cache_file")" + local host="$1" + local temp_file + temp_file="$_ghostty_cache_file.$$" - # Create cache directory if needed - [[ ! -d "$cache_dir" ]] && mkdir -p "$cache_dir" - - # Atomic write to cache file + # Merge existing cache with new host { - if [[ -f "$_ghostty_cache_file" ]]; then - cat "$_ghostty_cache_file" - fi - echo "$target" - } | sort -u > "$_ghostty_cache_file.tmp" && mv "$_ghostty_cache_file.tmp" "$_ghostty_cache_file" + [[ -f "$_ghostty_cache_file" ]] && cat "$_ghostty_cache_file" + echo "$host" + } | sort -u > "$temp_file" - # Secure permissions - chmod 600 "$_ghostty_cache_file" 2>/dev/null + # Atomic replace with proper permissions + mv -f "$temp_file" "$_ghostty_cache_file" && chmod 600 "$_ghostty_cache_file" } - # 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 { + # Remove host from cache (for maintenance) + _ghostty_uncache_host() { + local host="$1" + [[ -f "$_ghostty_cache_file" ]] || return 0 + + local temp_file="$_ghostty_cache_file.$$" + grep -vFx "$host" "$_ghostty_cache_file" > "$temp_file" 2>/dev/null || true + mv -f "$temp_file" "$_ghostty_cache_file" + } + + # Main SSH wrapper + 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 "$@" - ;; + term-only) _ghostty_ssh_term_only "$@" ;; + basic) _ghostty_ssh_basic "$@" ;; + full) _ghostty_ssh_full "$@" ;; + *) _ghostty_ssh_basic "$@" ;; # Default to basic esac } # Level: term-only - Just fix TERM compatibility - _ghostty_ssh_term-only() { + _ghostty_ssh_term_only() { if [[ "$TERM" == "xterm-ghostty" ]]; then TERM=xterm-256color builtin command ssh "$@" else @@ -191,65 +190,158 @@ if [[ -n "$GHOSTTY_SSH_INTEGRATION" ]]; then fi } - # Level: basic - TERM fix + environment variable propagation + # Level: basic - TERM fix + environment propagation _ghostty_ssh_basic() { - local env_vars=() + local term_value + term_value=$([[ "$TERM" == "xterm-ghostty" ]] && echo "xterm-256color" || echo "$TERM") - # Fix TERM compatibility - if [[ "$TERM" == "xterm-ghostty" ]]; then - env_vars+=("TERM=xterm-256color") - fi + builtin command ssh "$@" " + # Set environment for this session + export GHOSTTY_SHELL_FEATURES='$GHOSTTY_SHELL_FEATURES' + export TERM='$term_value' - # Propagate Ghostty shell integration environment variables - [[ -n "$GHOSTTY_SHELL_FEATURES" ]] && env_vars+=("GHOSTTY_SHELL_FEATURES=$GHOSTTY_SHELL_FEATURES") - - # Execute with environment variables if any were set - if [[ ${#env_vars[@]} -gt 0 ]]; then - env "${env_vars[@]}" ssh "$@" - else - builtin command ssh "$@" - fi + # Start interactive shell + exec \$SHELL -l + " } - # Level: full - All features + # Level: full - Complete integration with terminfo _ghostty_ssh_full() { local target target="$(_ghostty_get_ssh_target "$@")" - # Check if we already know this host has terminfo + # Quick path for cached hosts if [[ -n "$target" ]] && _ghostty_host_has_terminfo "$target"; then - # Direct connection with xterm-ghostty - local env_vars=("TERM=xterm-ghostty") - [[ -n "$GHOSTTY_SHELL_FEATURES" ]] && env_vars+=("GHOSTTY_SHELL_FEATURES=$GHOSTTY_SHELL_FEATURES") - env "${env_vars[@]}" ssh "$@" - return 0 + # Direct connection with full ghostty support + builtin command ssh -t "$@" " + export GHOSTTY_SHELL_FEATURES='$GHOSTTY_SHELL_FEATURES' + export TERM='xterm-ghostty' + exec \$SHELL -l + " + return $? fi - # Full integration: Install terminfo if needed - if builtin command -v infocmp >/dev/null 2>&1; then - # Install terminfo only if needed - if infocmp -x xterm-ghostty 2>/dev/null | builtin command ssh "$@" ' - if ! infocmp xterm-ghostty >/dev/null 2>&1; then - echo "Installing Ghostty terminfo..." >&2 - tic -x - 2>/dev/null - fi - '; then - echo "Connecting with full Ghostty support..." >&2 + # Check if we can export terminfo + if ! builtin command -v infocmp >/dev/null 2>&1; then + echo "Warning: infocmp not found locally. Using basic integration." >&2 + _ghostty_ssh_basic "$@" + return $? + fi - # Cache this host for future connections - [[ -n "$target" ]] && _ghostty_cache_host "$target" + # Generate terminfo data + local terminfo_data + terminfo_data="$(infocmp -x xterm-ghostty 2>/dev/null)" || { + echo "Warning: xterm-ghostty terminfo not found locally. Using basic integration." >&2 + _ghostty_ssh_basic "$@" + return $? + } - # Connect with xterm-ghostty since terminfo is available - local env_vars=("TERM=xterm-ghostty") - [[ -n "$GHOSTTY_SHELL_FEATURES" ]] && env_vars+=("GHOSTTY_SHELL_FEATURES=$GHOSTTY_SHELL_FEATURES") - env "${env_vars[@]}" ssh "$@" - builtin return 0 + echo "Setting up Ghostty terminal support on remote host..." >&2 + + # Create control socket path + local control_path="/tmp/ghostty-ssh-${USER}-$" + trap "rm -f '$control_path'" EXIT + + # Start control master and check/install terminfo + local setup_script=' + if ! infocmp xterm-ghostty >/dev/null 2>&1; then + if command -v tic >/dev/null 2>&1; then + mkdir -p "$HOME/.terminfo" 2>/dev/null + echo "NEEDS_INSTALL" + else + echo "NO_TIC" + fi + else + echo "ALREADY_INSTALLED" fi - echo "Terminfo installation failed. Using basic integration." >&2 + ' + + # First connection: Start control master and check status + local install_status + install_status=$(builtin command ssh -o ControlMaster=yes \ + -o ControlPath="$control_path" \ + -o ControlPersist=30s \ + "$@" "$setup_script") + + case "$install_status" in + "NEEDS_INSTALL") + echo "Installing xterm-ghostty terminfo..." >&2 + # Send terminfo through existing control connection + if echo "$terminfo_data" | builtin command ssh -o ControlPath="$control_path" "$@" \ + 'tic -x - 2>/dev/null && echo "SUCCESS"' | grep -q "SUCCESS"; then + echo "Terminfo installed successfully." >&2 + [[ -n "$target" ]] && _ghostty_cache_host "$target" + else + echo "Warning: Failed to install terminfo. Using basic integration." >&2 + ssh -O exit -o ControlPath="$control_path" "$@" 2>/dev/null || true + _ghostty_ssh_basic "$@" + return $? + fi + ;; + "ALREADY_INSTALLED") + [[ -n "$target" ]] && _ghostty_cache_host "$target" + ;; + "NO_TIC") + echo "Warning: tic not found on remote host. Using basic integration." >&2 + ssh -O exit -o ControlPath="$control_path" "$@" 2>/dev/null || true + _ghostty_ssh_basic "$@" + return $? + ;; + esac + + # Now use the existing control connection for interactive session + echo "Connecting with full Ghostty support..." >&2 + + # Pass environment through and start login shell to show MOTD + builtin command ssh -t -o ControlPath="$control_path" "$@" " + # Set up Ghostty environment + export GHOSTTY_SHELL_FEATURES='$GHOSTTY_SHELL_FEATURES' + export TERM='xterm-ghostty' + + # Display MOTD if this is a fresh connection + if [[ '$install_status' == 'NEEDS_INSTALL' ]]; then + # Try to display MOTD manually + if [[ -f /etc/motd ]]; then + cat /etc/motd 2>/dev/null || true + fi + # Run update-motd if available (Ubuntu/Debian) + if [[ -d /etc/update-motd.d ]]; then + run-parts /etc/update-motd.d 2>/dev/null || true + fi + fi + + # Force a login shell + exec \$SHELL -l + " + + local exit_code=$? + + # Clean up control socket + ssh -O exit -o ControlPath="$control_path" "$@" 2>/dev/null || true + + return $exit_code + } + + # Utility function to clear cache for a specific host + ghostty_ssh_reset() { + local host="${1:-}" + if [[ -z "$host" ]]; then + echo "Usage: ghostty_ssh_reset " >&2 + return 1 fi - # Fallback to basic integration - _ghostty_ssh_basic "$@" + _ghostty_uncache_host "$host" + echo "Cleared Ghostty terminfo cache for: $host" + } + + # Utility function to list cached hosts + ghostty_ssh_list_cached() { + if [[ -f "$_ghostty_cache_file" ]]; then + echo "Hosts with cached Ghostty terminfo:" + cat "$_ghostty_cache_file" + else + echo "No hosts cached yet." + fi } fi From 30683979bcb836777ea0166baa75e166b647f850 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Tue, 17 Jun 2025 22:07:06 -0700 Subject: [PATCH 22/52] fix: catch up to current state --- src/shell-integration/bash/ghostty.bash | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 74f48cf32..550e8a6c3 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -15,10 +15,8 @@ # 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 and we need to have the Ghostty -# resources dir set which also tells us we're running in Ghostty. +# We need to be in interactive mode to proceed. 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. @@ -346,7 +344,7 @@ if [[ -n "$GHOSTTY_SSH_INTEGRATION" ]]; then fi # Import bash-preexec, safe to do multiple times -builtin source "$GHOSTTY_RESOURCES_DIR/shell-integration/bash/bash-preexec.sh" +builtin source "$(dirname -- "${BASH_SOURCE[0]}")/bash-preexec.sh" # This is set to 1 when we're executing a command so that we don't # send prompt marks multiple times. From ddd3da487e46e467cb09829b0f41139e3651a29b Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Wed, 18 Jun 2025 14:59:57 -0700 Subject: [PATCH 23/52] fix: update cache file location --- src/shell-integration/bash/ghostty.bash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 550e8a6c3..46734525d 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -98,7 +98,7 @@ fi # SSH if [[ -n "$GHOSTTY_SSH_INTEGRATION" ]]; then # Cache configuration - _ghostty_cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/ghostty" + _ghostty_cache_dir="${XDG_STATE_HOME:-$HOME/.local/state}/ghostty" _ghostty_cache_file="$_ghostty_cache_dir/terminfo_hosts" # Create cache directory with proper permissions From e73313ed40bf7da202688709e634c7a0b934c553 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Mon, 23 Jun 2025 22:34:32 -0700 Subject: [PATCH 24/52] change: migrate SSH integration from standalone option to shell-integration-features flags - Add ssh_env and ssh_terminfo flags to ShellIntegrationFeatures - Remove SSHIntegration enum and ssh-integration config option - Update setupFeatures to handle new flags via reflection - Remove setupSSHIntegration function and all references Integrates SSH functionality into existing shell-integration-features system for better consistency and user control. --- src/Surface.zig | 1 - src/config.zig | 1 - src/config/Config.zig | 48 +++++++------------------------- src/termio/Exec.zig | 2 -- src/termio/shell_integration.zig | 14 +--------- 5 files changed, 11 insertions(+), 55 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 78363e87c..6005635d9 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -545,7 +545,6 @@ 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 e34819fa1..7f390fb08 100644 --- a/src/config.zig +++ b/src/config.zig @@ -33,7 +33,6 @@ 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 0743dc4cf..41171b3be 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1972,39 +1972,17 @@ keybind: Keybinds = .{}, /// /// * `title` - Set the window title via shell integration. /// +/// * `ssh-env` - Enable SSH environment variable compatibility. Automatically +/// converts TERM from `xterm-ghostty` to `xterm-256color` when connecting to +/// remote hosts and propagates COLORTERM, TERM_PROGRAM, and TERM_PROGRAM_VERSION. +/// +/// * `ssh-terminfo` - Enable automatic terminfo installation on remote hosts. +/// Attempts to install Ghostty's terminfo entry using `infocmp` and `tic` when +/// connecting to hosts that lack it. +/// /// Example: `cursor`, `no-cursor`, `sudo`, `no-sudo`, `title`, `no-title` @"shell-integration-features": ShellIntegrationFeatures = .{}, -/// SSH integration configuration for shell integration. -/// -/// When enabled (any value other than `off`), Ghostty replaces the `ssh` command -/// with a shell function to provide enhanced terminal compatibility and feature -/// propagation when connecting to remote hosts. Users can verify this by running -/// `type ssh` which will show "ssh is a shell function" instead of the binary path. -/// -/// Allowable values are: -/// -/// * `off` - No SSH integration, use standard ssh binary. -/// -/// * `term-only` - Automatically converts TERM from `xterm-ghostty` to `xterm-256color` -/// when connecting to remote hosts. This prevents "unknown terminal type" errors -/// on systems that lack Ghostty's terminfo entry, but sacrifices Ghostty-specific -/// terminal features like enhanced cursor reporting and shell integration markers. -/// See: https://ghostty.org/docs/help/terminfo -/// -/// * `basic` - TERM compatibility fix plus environment variable propagation. -/// Forwards `GHOSTTY_SHELL_FEATURES` to enable shell integration features on -/// remote systems that have Ghostty installed and configured. -/// -/// * `full` - All basic features plus automatic terminfo installation. Attempts -/// to install Ghostty's terminfo entry on the remote host using `infocmp` and `tic`, -/// then connects with full `xterm-ghostty` support. Requires two SSH authentications -/// (one for installation, one for the session) but enables complete Ghostty -/// terminal functionality on the remote system. -/// -/// 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 /// OSC 4 (256 color palette) queries, and by default the reported values @@ -6126,14 +6104,8 @@ pub const ShellIntegrationFeatures = packed struct { cursor: bool = true, sudo: bool = false, title: bool = true, -}; - -/// See ssh-integration -pub const SSHIntegration = enum { - off, - @"term-only", - basic, - full, + @"ssh-env": bool = false, + @"ssh-terminfo": bool = false, }; /// OSC 4, 10, 11, and 12 default color reporting format. diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 9b4707db5..aed7cefb6 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -733,7 +733,6 @@ 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, @@ -938,7 +937,6 @@ 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 fc3fbb63c..fb62327d3 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -45,7 +45,6 @@ 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", @@ -71,9 +70,8 @@ pub fn setup( exe, ); - // Setup our feature env vars and SSH integration + // Setup our feature env vars try setupFeatures(env, features); - try setupSSHIntegration(env, ssh_integration); return result; } @@ -161,7 +159,6 @@ test "force shell" { &env, shell, .{}, - .off, ); try testing.expectEqual(shell, result.?.shell); } @@ -227,15 +224,6 @@ 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 From 81641e56b1c471f9e110ebe7ec8372b75f366ed7 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Tue, 24 Jun 2025 08:58:00 -0700 Subject: [PATCH 25/52] ssh-integration: replace levels with flags, optimize implementation Rewrote shell functions to support the two new flags for shell-integration-features: - ssh-env: TERM compatibility + best effort environment variable propagation (anything beyond TERM will depend on what the remote host allows) - ssh-terminfo: automatic terminfo installation with control socket orchestration - Flags work independently or combined Implementation optimizations: - ~65% code reduction through unified execution path - Eliminated GHOSTTY_SSH_INTEGRATION environment variable system - Replaced complex function dispatch with direct flag detection - Consolidated 4 cache helper functions into single _ghst_cache() utility - Simplified control socket management (removed multi-step orchestration) - Subsequent connections to cached hosts are now directly executed and more reliable New additions: - If ssh-terminfo is enabled, ghostty will be wrapped to provide users with convenient commands to invoke either of the two utility functions: `ghostty ssh-cache-list` and `ghostty ssh-cache-clear` --- src/shell-integration/bash/ghostty.bash | 315 +++++------------- .../elvish/lib/ghostty-integration.elv | 311 +++++++++-------- .../ghostty-shell-integration.fish | 240 ++++++------- src/shell-integration/zsh/ghostty-integration | 222 +++++------- 4 files changed, 420 insertions(+), 668 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 46734525d..6e09ab193 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -95,252 +95,103 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then } fi -# SSH -if [[ -n "$GHOSTTY_SSH_INTEGRATION" ]]; then - # Cache configuration - _ghostty_cache_dir="${XDG_STATE_HOME:-$HOME/.local/state}/ghostty" - _ghostty_cache_file="$_ghostty_cache_dir/terminfo_hosts" +# SSH Integration +if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then + # Only define cache functions and variable if ssh-terminfo is enabled + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then + _cache="${XDG_STATE_HOME:-$HOME/.local/state}/ghostty/terminfo_hosts" - # Create cache directory with proper permissions - [[ ! -d "$_ghostty_cache_dir" ]] && mkdir -p "$_ghostty_cache_dir" && chmod 700 "$_ghostty_cache_dir" - - # Extract SSH target from arguments - _ghostty_get_ssh_target() { - local target="" - local skip_next=false - local args=("$@") - - for ((i=0; i<${#args[@]}; i++)); do - local arg="${args[i]}" - - # Skip if we're processing a flag's argument - [[ "$skip_next" == "true" ]] && { skip_next=false; continue; } - - # Handle flags that take arguments - if [[ "$arg" =~ ^-[bcDEeFIiJLlmOopQRSWw]$ ]]; then - skip_next=true - continue - fi - - # Handle combined short flags with values (e.g., -p22) - [[ "$arg" =~ ^-[bcDEeFIiJLlmOopQRSWw].+ ]] && continue - - # Skip other flags - [[ "$arg" =~ ^- ]] && continue - - # This should be our target - target="$arg" - break - done - - # Handle user@host format - echo "${target##*@}" - } - - # Check if host has terminfo cached - _ghostty_host_has_terminfo() { - local host="$1" - [[ -f "$_ghostty_cache_file" ]] && grep -qFx "$host" "$_ghostty_cache_file" 2>/dev/null - } - - # Add host to cache atomically - _ghostty_cache_host() { - local host="$1" - local temp_file - temp_file="$_ghostty_cache_file.$$" - - # Merge existing cache with new host - { - [[ -f "$_ghostty_cache_file" ]] && cat "$_ghostty_cache_file" - echo "$host" - } | sort -u > "$temp_file" - - # Atomic replace with proper permissions - mv -f "$temp_file" "$_ghostty_cache_file" && chmod 600 "$_ghostty_cache_file" - } - - # Remove host from cache (for maintenance) - _ghostty_uncache_host() { - local host="$1" - [[ -f "$_ghostty_cache_file" ]] || return 0 - - local temp_file="$_ghostty_cache_file.$$" - grep -vFx "$host" "$_ghostty_cache_file" > "$temp_file" 2>/dev/null || true - mv -f "$temp_file" "$_ghostty_cache_file" - } - - # Main SSH wrapper - ssh() { - case "$GHOSTTY_SSH_INTEGRATION" in - term-only) _ghostty_ssh_term_only "$@" ;; - basic) _ghostty_ssh_basic "$@" ;; - full) _ghostty_ssh_full "$@" ;; - *) _ghostty_ssh_basic "$@" ;; # Default to 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 propagation - _ghostty_ssh_basic() { - local term_value - term_value=$([[ "$TERM" == "xterm-ghostty" ]] && echo "xterm-256color" || echo "$TERM") - - builtin command ssh "$@" " - # Set environment for this session - export GHOSTTY_SHELL_FEATURES='$GHOSTTY_SHELL_FEATURES' - export TERM='$term_value' - - # Start interactive shell - exec \$SHELL -l - " - } - - # Level: full - Complete integration with terminfo - _ghostty_ssh_full() { - local target - target="$(_ghostty_get_ssh_target "$@")" - - # Quick path for cached hosts - if [[ -n "$target" ]] && _ghostty_host_has_terminfo "$target"; then - # Direct connection with full ghostty support - builtin command ssh -t "$@" " - export GHOSTTY_SHELL_FEATURES='$GHOSTTY_SHELL_FEATURES' - export TERM='xterm-ghostty' - exec \$SHELL -l - " - return $? - fi - - # Check if we can export terminfo - if ! builtin command -v infocmp >/dev/null 2>&1; then - echo "Warning: infocmp not found locally. Using basic integration." >&2 - _ghostty_ssh_basic "$@" - return $? - fi - - # Generate terminfo data - local terminfo_data - terminfo_data="$(infocmp -x xterm-ghostty 2>/dev/null)" || { - echo "Warning: xterm-ghostty terminfo not found locally. Using basic integration." >&2 - _ghostty_ssh_basic "$@" - return $? + # Cache operations and utilities + _ghst_cache() { + case $2 in + chk) [[ -f $_cache ]] && grep -qFx "$1" "$_cache" 2>/dev/null ;; + add) + mkdir -p "${_cache%/*}" + { + [[ -f $_cache ]] && cat "$_cache" + builtin echo "$1" + } | sort -u >"$_cache.tmp" && mv "$_cache.tmp" "$_cache" && chmod 600 "$_cache" + ;; + esac } - echo "Setting up Ghostty terminal support on remote host..." >&2 + function ghostty_ssh_cache_clear() { + rm -f "$_cache" 2>/dev/null && builtin echo "Ghostty SSH terminfo cache cleared." || builtin echo "No Ghostty SSH terminfo cache found." + } - # Create control socket path - local control_path="/tmp/ghostty-ssh-${USER}-$" - trap "rm -f '$control_path'" EXIT + function ghostty_ssh_cache_list() { + [[ -s $_cache ]] && builtin echo "Hosts with Ghostty terminfo installed:" && cat "$_cache" || builtin echo "No cached hosts found." + } + fi - # Start control master and check/install terminfo - local setup_script=' - if ! infocmp xterm-ghostty >/dev/null 2>&1; then - if command -v tic >/dev/null 2>&1; then - mkdir -p "$HOME/.terminfo" 2>/dev/null - echo "NEEDS_INSTALL" - else - echo "NO_TIC" + # SSH wrapper + ssh() { + local e=() o=() c=() t + + # Get target + t=$(builtin command ssh -G "$@" 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') + + # Set up env vars first so terminfo installation inherits them + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then + builtin export COLORTERM=${COLORTERM:-truecolor} TERM_PROGRAM=${TERM_PROGRAM:-ghostty} ${GHOSTTY_VERSION:+TERM_PROGRAM_VERSION=$GHOSTTY_VERSION} + for v in COLORTERM=truecolor TERM_PROGRAM=ghostty ${GHOSTTY_VERSION:+TERM_PROGRAM_VERSION=$GHOSTTY_VERSION}; do + o+=(-o "SendEnv ${v%=*}" -o "SetEnv $v") + done + fi + + # Install terminfo if needed, reuse control connection for main session + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then + if [[ -n $t ]] && _ghst_cache "$t" chk; then + e+=(TERM=xterm-ghostty) + elif builtin command -v infocmp >/dev/null 2>&1; then + builtin local ti + ti=$(infocmp -x xterm-ghostty 2>/dev/null) || builtin echo "Warning: xterm-ghostty terminfo not found locally." >&2 + if [[ -n $ti ]]; then + builtin echo "Setting up Ghostty terminfo on remote host..." >&2 + builtin local cp + cp="/tmp/ghostty-ssh-$USER-$RANDOM-$(date +%s)" + case $(builtin echo "$ti" | builtin command ssh "${o[@]}" -o ControlMaster=yes -o ControlPath="$cp" -o ControlPersist=60s "$@" ' + infocmp xterm-ghostty >/dev/null 2>&1 && echo OK && exit + command -v tic >/dev/null 2>&1 || { echo NO_TIC; exit 1; } + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && echo OK || echo FAIL + ') in + OK) + builtin echo "Terminfo setup complete." >&2 + [[ -n $t ]] && _ghst_cache "$t" add + e+=(TERM=xterm-ghostty) + c+=(-o "ControlPath=$cp") + ;; + *) builtin echo "Warning: Failed to install terminfo." >&2 ;; + esac fi else - echo "ALREADY_INSTALLED" + builtin echo "Warning: infocmp not found locally. Terminfo installation unavailable." >&2 fi - ' - - # First connection: Start control master and check status - local install_status - install_status=$(builtin command ssh -o ControlMaster=yes \ - -o ControlPath="$control_path" \ - -o ControlPersist=30s \ - "$@" "$setup_script") - - case "$install_status" in - "NEEDS_INSTALL") - echo "Installing xterm-ghostty terminfo..." >&2 - # Send terminfo through existing control connection - if echo "$terminfo_data" | builtin command ssh -o ControlPath="$control_path" "$@" \ - 'tic -x - 2>/dev/null && echo "SUCCESS"' | grep -q "SUCCESS"; then - echo "Terminfo installed successfully." >&2 - [[ -n "$target" ]] && _ghostty_cache_host "$target" - else - echo "Warning: Failed to install terminfo. Using basic integration." >&2 - ssh -O exit -o ControlPath="$control_path" "$@" 2>/dev/null || true - _ghostty_ssh_basic "$@" - return $? - fi - ;; - "ALREADY_INSTALLED") - [[ -n "$target" ]] && _ghostty_cache_host "$target" - ;; - "NO_TIC") - echo "Warning: tic not found on remote host. Using basic integration." >&2 - ssh -O exit -o ControlPath="$control_path" "$@" 2>/dev/null || true - _ghostty_ssh_basic "$@" - return $? - ;; - esac - - # Now use the existing control connection for interactive session - echo "Connecting with full Ghostty support..." >&2 - - # Pass environment through and start login shell to show MOTD - builtin command ssh -t -o ControlPath="$control_path" "$@" " - # Set up Ghostty environment - export GHOSTTY_SHELL_FEATURES='$GHOSTTY_SHELL_FEATURES' - export TERM='xterm-ghostty' - - # Display MOTD if this is a fresh connection - if [[ '$install_status' == 'NEEDS_INSTALL' ]]; then - # Try to display MOTD manually - if [[ -f /etc/motd ]]; then - cat /etc/motd 2>/dev/null || true - fi - # Run update-motd if available (Ubuntu/Debian) - if [[ -d /etc/update-motd.d ]]; then - run-parts /etc/update-motd.d 2>/dev/null || true - fi - fi - - # Force a login shell - exec \$SHELL -l - " - - local exit_code=$? - - # Clean up control socket - ssh -O exit -o ControlPath="$control_path" "$@" 2>/dev/null || true - - return $exit_code - } - - # Utility function to clear cache for a specific host - ghostty_ssh_reset() { - local host="${1:-}" - if [[ -z "$host" ]]; then - echo "Usage: ghostty_ssh_reset " >&2 - return 1 fi - _ghostty_uncache_host "$host" - echo "Cleared Ghostty terminfo cache for: $host" - } + # Fallback TERM only if terminfo didn't set it + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then + [[ $TERM == xterm-ghostty && ! " ${e[*]} " =~ " TERM=" ]] && e+=(TERM=xterm-256color) + fi - # Utility function to list cached hosts - ghostty_ssh_list_cached() { - if [[ -f "$_ghostty_cache_file" ]]; then - echo "Hosts with cached Ghostty terminfo:" - cat "$_ghostty_cache_file" + # Execute + if [[ ${#e[@]} -gt 0 ]]; then + env "${e[@]}" ssh "${o[@]}" "${c[@]}" "$@" else - echo "No hosts cached yet." + builtin command ssh "${o[@]}" "${c[@]}" "$@" fi } + + # If 'ssh-terminfo' flag is enabled, wrap ghostty to provide 'ghostty ssh-cache-list' and `ghostty ssh-cache-clear` utility commands + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then + ghostty() { + case "$1" in + ssh-cache-list) ghostty_ssh_cache_list ;; + ssh-cache-clear) ghostty_ssh_cache_clear ;; + *) builtin command ghostty "$@" ;; + esac + } + fi fi # Import bash-preexec, safe to do multiple times diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index deb258ae7..1e0c08732 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -99,176 +99,167 @@ } # SSH Integration - # Cache file for tracking hosts with terminfo installed - var ghostty-cache-file = (if (has-env GHOSTTY_RESOURCES_DIR) { put $E:GHOSTTY_RESOURCES_DIR"/terminfo_hosts" } else { put $E:HOME"/.config/ghostty/terminfo_hosts" }) + use str + use path + use re - # Extract target host from SSH arguments - fn ghostty-get-ssh-target {|@args| - var target = "" - var skip-next = $false + if (re:match 'ssh-(env|terminfo)' $E:GHOSTTY_SHELL_FEATURES) { + # Only define cache functions and variable if ssh-terminfo is enabled + if (re:match 'ssh-terminfo' $E:GHOSTTY_SHELL_FEATURES) { + var _cache = (path:join (or $E:XDG_STATE_HOME $E:HOME/.local/state) ghostty terminfo_hosts) - for arg $args { - if (eq $skip-next $true) { - set skip-next = $false - continue + # Cache operations and utilities + fn _ghst_cache {|target action| + if (eq $action chk) { + if (path:is-regular $_cache) { + try { + grep -qFx $target $_cache 2>/dev/null + } catch e { + fail + } + } else { + fail + } + } elif (eq $action add) { + mkdir -p (path:dir $_cache) + var tmpfile = $_cache.tmp + { + if (path:is-regular $_cache) { + cat $_cache + } + echo $target + } | sort -u > $tmpfile + mv $tmpfile $_cache + chmod 600 $_cache + } + } + + fn ghostty_ssh_cache_clear { + try { + rm -f $_cache 2>/dev/null + echo "Ghostty SSH terminfo cache cleared." + } catch e { + echo "No Ghostty SSH terminfo cache found." + } + } + + fn ghostty_ssh_cache_list { + if (and (path:is-regular $_cache) (> (wc -c < $_cache | str:trim-space) 0)) { + echo "Hosts with Ghostty terminfo installed:" + cat $_cache + } else { + echo "No cached hosts found." + } + } } - # Skip flags that take arguments - if (re:match '^-[bcDEeFIiJLlmOopQRSWw]$' $arg) { - set skip-next = $true - continue + # SSH wrapper + fn ssh {|@args| + var e = [] + var o = [] + var c = [] + var t = "" + + # Get target (only if ssh-terminfo enabled for caching) + if (re:match 'ssh-terminfo' $E:GHOSTTY_SHELL_FEATURES) { + try { + set t = (e:ssh -G $@args 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@' | str:trim-space) + } catch e { + # Ignore errors + } + } + + # Set up env vars first so terminfo installation inherits them + if (re:match 'ssh-env' $E:GHOSTTY_SHELL_FEATURES) { + set-env COLORTERM (or $E:COLORTERM truecolor) + set-env TERM_PROGRAM (or $E:TERM_PROGRAM ghostty) + if (has-env GHOSTTY_VERSION) { + set-env TERM_PROGRAM_VERSION $E:GHOSTTY_VERSION + } + + var vars = [COLORTERM=truecolor TERM_PROGRAM=ghostty] + if (has-env GHOSTTY_VERSION) { + set vars = [$@vars TERM_PROGRAM_VERSION=$E:GHOSTTY_VERSION] + } + for v $vars { + var varname = (str:split &max=2 '=' $v | take 1) + set o = [$@o -o "SendEnv "$varname -o "SetEnv "$v] + } + } + + # Install terminfo if needed, reuse control connection for main session + if (re:match 'ssh-terminfo' $E:GHOSTTY_SHELL_FEATURES) { + if (and (not-eq $t "") (try { _ghst_cache $t chk } catch e { put $false })) { + set e = [$@e TERM=xterm-ghostty] + } elif (has-external infocmp) { + var ti = "" + try { + set ti = (infocmp -x xterm-ghostty 2>/dev/null | slurp) + } catch e { + echo "Warning: xterm-ghostty terminfo not found locally." >&2 + } + if (not-eq $ti "") { + echo "Setting up Ghostty terminfo on remote host..." >&2 + var cp = "/tmp/ghostty-ssh-"$E:USER"-"(randint 10000)"-"(date +%s | str:trim-space) + var result = (echo $ti | e:ssh $@o -o ControlMaster=yes -o ControlPath=$cp -o ControlPersist=60s $@args ' + infocmp xterm-ghostty >/dev/null 2>&1 && echo OK && exit + command -v tic >/dev/null 2>&1 || { echo NO_TIC; exit 1; } + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && echo OK || echo FAIL + ' | str:trim-space) + if (eq $result OK) { + echo "Terminfo setup complete." >&2 + if (not-eq $t "") { + _ghst_cache $t add + } + set e = [$@e TERM=xterm-ghostty] + set c = [$@c -o ControlPath=$cp] + } else { + echo "Warning: Failed to install terminfo." >&2 + } + } + } else { + echo "Warning: infocmp not found locally. Terminfo installation unavailable." >&2 + } + } + + # Fallback TERM only if terminfo didn't set it + if (re:match 'ssh-env' $E:GHOSTTY_SHELL_FEATURES) { + if (and (eq $E:TERM xterm-ghostty) (not (re:match 'TERM=' (str:join ' ' $e)))) { + set e = [$@e TERM=xterm-256color] + } + } + + # Execute + if (> (count $e) 0) { + e:env $@e e:ssh $@o $@c $@args + } else { + e:ssh $@o $@c $@args + } } - # Skip other flags - if (re:match '^-' $arg) { - continue + # Wrap ghostty command only if ssh-terminfo is enabled + if (re:match 'ssh-terminfo' $E:GHOSTTY_SHELL_FEATURES) { + fn ghostty {|@args| + if (eq $args[0] ssh-cache-list) { + ghostty_ssh_cache_list + } elif (eq $args[0] ssh-cache-clear) { + ghostty_ssh_cache_clear + } else { + (external ghostty) $@args + } + } + + edit:add-var ghostty~ $ghostty~ + + # Export cache functions for global use + set edit:add-var[ghostty_ssh_cache_clear] = $ghostty_ssh_cache_clear~ + set edit:add-var[ghostty_ssh_cache_list] = $ghostty_ssh_cache_list~ } - # This should be the target - set target = $arg - break - } - - put $target + # Export ssh function for global use + set edit:add-var[ssh] = $ssh~ } - # Check if host has terminfo installed - fn ghostty-host-has-terminfo {|target| - and (path:is-regular $ghostty-cache-file) ?(grep -qFx $target $ghostty-cache-file 2>/dev/null) - } - - # Add host to terminfo cache - fn ghostty-cache-host {|target| - var cache-dir = (path:dir $ghostty-cache-file) - - # Create cache directory if needed - if (not (path:is-dir $cache-dir)) { - mkdir -p $cache-dir - } - - # Atomic write to cache file - var temp-file = $ghostty-cache-file".tmp" - - { - if (path:is-regular $ghostty-cache-file) { - cat $ghostty-cache-file - } - echo $target - } | sort -u > $temp-file - - mv $temp-file $ghostty-cache-file - - # Secure permissions - ?chmod 600 $ghostty-cache-file 2>/dev/null - } - - fn ssh-with-ghostty-integration {|@args| - if (has-env 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) { - (external env) TERM=xterm-256color 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 = (conj $env-vars TERM=xterm-256color) - } - - # Propagate Ghostty shell integration environment variables - if (not-eq "" $E:GHOSTTY_SHELL_FEATURES) { - set env-vars = (conj $env-vars GHOSTTY_SHELL_FEATURES=$E:GHOSTTY_SHELL_FEATURES) - } - - # Execute with environment variables if any were set - if (> (count $env-vars) 0) { - (external env) $@env-vars ssh $@args - } else { - (external ssh) $@args - } - } - - fn ssh-full {|@args| - var target = (ghostty-get-ssh-target $@args) - - # Check if we already know this host has terminfo - if (and (not-eq "" $target) (ghostty-host-has-terminfo $target)) { - # Direct connection with xterm-ghostty - var env-vars = [TERM=xterm-ghostty] - - # Propagate Ghostty shell integration environment variables - if (not-eq "" $E:GHOSTTY_SHELL_FEATURES) { - set env-vars = (conj $env-vars GHOSTTY_SHELL_FEATURES=$E:GHOSTTY_SHELL_FEATURES) - } - - (external env) $@env-vars ssh $@args - return - } - - # Full integration: Install terminfo if needed - if (has-external infocmp) { - try { - # Install terminfo only if needed - infocmp -x xterm-ghostty 2>/dev/null | (external ssh) $@args ' - if ! infocmp xterm-ghostty >/dev/null 2>&1; then - echo "Installing Ghostty terminfo..." >&2 - tic -x - 2>/dev/null - fi - ' - echo "Connecting with full Ghostty support..." >&2 - - # Cache this host for future connections - if (not-eq "" $target) { - ghostty-cache-host $target - } - - # Connect with xterm-ghostty since terminfo is available - var env-vars = [TERM=xterm-ghostty] - - # Propagate Ghostty shell integration environment variables - if (not-eq "" $E:GHOSTTY_SHELL_FEATURES) { - set env-vars = (conj $env-vars GHOSTTY_SHELL_FEATURES=$E:GHOSTTY_SHELL_FEATURES) - } - - # Normal SSH connection with Ghostty terminfo available - (external env) $@env-vars ssh $@args - return - } catch e { - echo "Terminfo installation failed. Using basic integration." >&2 - } - } - - # Fallback to basic integration - ssh-basic $@args - } - - # Register SSH integration if enabled - if (and (has-env 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 e01eabac3..73ccf9874 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 @@ -86,156 +86,116 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" end end - # SSH integration - if test -n "$GHOSTTY_SSH_INTEGRATION" - # Cache file for tracking hosts with terminfo installed - set --local _ghostty_cache_file (string join / (test -n "$GHOSTTY_RESOURCES_DIR"; and echo "$GHOSTTY_RESOURCES_DIR"; or echo "$HOME/.config/ghostty") "terminfo_hosts") + # SSH Integration + if string match -qr 'ssh-(env|terminfo)' "$GHOSTTY_SHELL_FEATURES" + # Only define cache functions and variable if ssh-terminfo is enabled + if string match -qr 'ssh-terminfo' "$GHOSTTY_SHELL_FEATURES" + set -g _cache (test -n "$XDG_STATE_HOME" && echo "$XDG_STATE_HOME" || echo "$HOME/.local/state")/ghostty/terminfo_hosts - # Extract target host from SSH arguments - function _ghostty_get_ssh_target - set --local target "" - set --local skip_next "false" - - for arg in $argv - if test "$skip_next" = "true" - set skip_next "false" - continue + # Cache operations and utilities + function _ghst_cache + switch $argv[2] + case chk + test -f $_cache && grep -qFx "$argv[1]" "$_cache" 2>/dev/null + case add + mkdir -p (dirname "$_cache") + begin + test -f $_cache && cat "$_cache" + builtin echo "$argv[1]" + end | sort -u >"$_cache.tmp" && mv "$_cache.tmp" "$_cache" && chmod 600 "$_cache" end + end - # Skip flags that take arguments - if string match -qr -- '^-[bcDEeFIiJLlmOopQRSWw]$' "$arg" - set skip_next "true" - continue + function ghostty_ssh_cache_clear -d "Clear Ghostty SSH terminfo cache" + rm -f "$_cache" 2>/dev/null && builtin echo "Ghostty SSH terminfo cache cleared." || builtin echo "No Ghostty SSH terminfo cache found." + end + + function ghostty_ssh_cache_list -d "List hosts with Ghostty terminfo installed" + test -s $_cache && builtin echo "Hosts with Ghostty terminfo installed:" && cat "$_cache" || builtin echo "No cached hosts found." + end + end + + # SSH wrapper + function ssh + set -l e + set -l o + set -l c + set -l t + + # Get target (only if ssh-terminfo enabled for caching) + if string match -qr 'ssh-terminfo' "$GHOSTTY_SHELL_FEATURES" + set t (builtin command ssh -G $argv 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') + end + + # Set up env vars first so terminfo installation inherits them + if string match -qr 'ssh-env' "$GHOSTTY_SHELL_FEATURES" + set -gx COLORTERM (test -n "$COLORTERM" && echo "$COLORTERM" || echo "truecolor") + set -gx TERM_PROGRAM (test -n "$TERM_PROGRAM" && echo "$TERM_PROGRAM" || echo "ghostty") + test -n "$GHOSTTY_VERSION" && set -gx TERM_PROGRAM_VERSION "$GHOSTTY_VERSION" + + for v in COLORTERM=truecolor TERM_PROGRAM=ghostty (test -n "$GHOSTTY_VERSION" && echo "TERM_PROGRAM_VERSION=$GHOSTTY_VERSION") + set -l varname (string split -m1 '=' "$v")[1] + set o $o -o "SendEnv $varname" -o "SetEnv $v" end - - # Skip other flags - if string match -q -- '-*' "$arg" - continue - end - - # This should be the target - set target "$arg" - break end - echo "$target" - end - - # Check if host has terminfo installed - function _ghostty_host_has_terminfo - set --local target "$argv[1]" - test -f "$_ghostty_cache_file"; and grep -qFx "$target" "$_ghostty_cache_file" 2>/dev/null - end - - # Add host to terminfo cache - function _ghostty_cache_host - set --local target "$argv[1]" - set --local cache_dir (dirname "$_ghostty_cache_file") - - # Create cache directory if needed - test -d "$cache_dir"; or mkdir -p "$cache_dir" - - # Atomic write to cache file - begin - if test -f "$_ghostty_cache_file" - cat "$_ghostty_cache_file" - end - echo "$target" - end | sort -u > "$_ghostty_cache_file.tmp"; and mv "$_ghostty_cache_file.tmp" "$_ghostty_cache_file" - - # Secure permissions - chmod 600 "$_ghostty_cache_file" 2>/dev/null - end - - # Wrap `ssh` command to provide Ghostty SSH integration - 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 builtin command ssh $argv - else - builtin 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 --append env_vars TERM=xterm-256color - end - - # Propagate Ghostty shell integration environment variables - if test -n "$GHOSTTY_SHELL_FEATURES" - set --append env_vars GHOSTTY_SHELL_FEATURES="$GHOSTTY_SHELL_FEATURES" - end - - # Execute with environment variables if any were set - if test (count $env_vars) -gt 0 - env $env_vars ssh $argv - else - builtin command ssh $argv - end - end - - # Level: full - All features - function _ghostty_ssh_full - set --local target (_ghostty_get_ssh_target $argv) - - # Check if we already know this host has terminfo - if test -n "$target"; and _ghostty_host_has_terminfo "$target" - # Direct connection with xterm-ghostty - set --local env_vars TERM=xterm-ghostty - if test -n "$GHOSTTY_SHELL_FEATURES" - set --append env_vars GHOSTTY_SHELL_FEATURES="$GHOSTTY_SHELL_FEATURES" - end - env $env_vars ssh $argv - return 0 - end - - # Full integration: Install terminfo if needed - if type -q infocmp - # Install terminfo only if needed - if infocmp -x xterm-ghostty 2>/dev/null | ssh $argv ' - if ! infocmp xterm-ghostty >/dev/null 2>&1 - echo "Installing Ghostty terminfo..." >&2 - tic -x - 2>/dev/null + # Install terminfo if needed, reuse control connection for main session + if string match -qr 'ssh-terminfo' "$GHOSTTY_SHELL_FEATURES" + if test -n "$t" && _ghst_cache "$t" chk + set e $e TERM=xterm-ghostty + else if command -v infocmp >/dev/null 2>&1 + set -l ti + set ti (infocmp -x xterm-ghostty 2>/dev/null) || builtin echo "Warning: xterm-ghostty terminfo not found locally." >&2 + if test -n "$ti" + builtin echo "Setting up Ghostty terminfo on remote host..." >&2 + set -l cp "/tmp/ghostty-ssh-$USER-"(random)"-"(date +%s) + set -l result (builtin echo "$ti" | builtin command ssh $o -o ControlMaster=yes -o ControlPath="$cp" -o ControlPersist=60s $argv ' + infocmp xterm-ghostty >/dev/null 2>&1 && echo OK && exit + command -v tic >/dev/null 2>&1 || { echo NO_TIC; exit 1; } + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && echo OK || echo FAIL + ') + switch $result + case OK + builtin echo "Terminfo setup complete." >&2 + test -n "$t" && _ghst_cache "$t" add + set e $e TERM=xterm-ghostty + set c $c -o "ControlPath=$cp" + case '*' + builtin echo "Warning: Failed to install terminfo." >&2 + end end - ' - echo "Connecting with full Ghostty support..." >&2 - - # Cache this host for future connections - test -n "$target"; and _ghostty_cache_host "$target" - - # Connect with xterm-ghostty since terminfo is available - set --local env_vars TERM=xterm-ghostty - if test -n "$GHOSTTY_SHELL_FEATURES" - set --append env_vars GHOSTTY_SHELL_FEATURES="$GHOSTTY_SHELL_FEATURES" - end - env $env_vars ssh $argv - builtin return 0 + else + builtin echo "Warning: infocmp not found locally. Terminfo installation unavailable." >&2 end - echo "Terminfo installation failed. Using basic integration." >&2 end - # Fallback to basic integration - _ghostty_ssh_basic $argv + # Fallback TERM only if terminfo didn't set it + if string match -qr 'ssh-env' "$GHOSTTY_SHELL_FEATURES" + if test "$TERM" = xterm-ghostty && not string match -q '*TERM=*' "$e" + set e $e TERM=xterm-256color + end + end + + # Execute + if test (count $e) -gt 0 + env $e ssh $o $c $argv + else + builtin command ssh $o $c $argv + end + end + + # Wrap ghostty command only if ssh-terminfo is enabled + if string match -qr 'ssh-terminfo' "$GHOSTTY_SHELL_FEATURES" + function ghostty -d "Wrap ghostty to provide cache management commands" + switch "$argv[1]" + case ssh-cache-list + ghostty_ssh_cache_list + case ssh-cache-clear + ghostty_ssh_cache_clear + case "*" + command ghostty $argv + end + end end end diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index c172fc02d..b3c604c83 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -244,156 +244,106 @@ _ghostty_deferred_init() { } fi - # SSH - if [[ -n "$GHOSTTY_SSH_INTEGRATION" ]]; then - # Cache file for tracking hosts with terminfo installed - _ghostty_cache_file="${GHOSTTY_RESOURCES_DIR:-$HOME/.config/ghostty}/terminfo_hosts" + # SSH Integration + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then + # Only define cache functions and variable if ssh-terminfo is enabled + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then + _cache="${XDG_STATE_HOME:-$HOME/.local/state}/ghostty/terminfo_hosts" - # Extract target host from SSH arguments - _ghostty_get_ssh_target() { - local target="" - local skip_next=false + # Cache operations and utilities + _ghst_cache() { + case $2 in + chk) [[ -f $_cache ]] && grep -qFx "$1" "$_cache" 2>/dev/null ;; + add) + mkdir -p "${_cache:h}" + { + [[ -f $_cache ]] && cat "$_cache" + builtin echo "$1" + } | sort -u >"$_cache.tmp" && mv "$_cache.tmp" "$_cache" && chmod 600 "$_cache" + ;; + esac + } - for arg in "$@"; do - if [[ "$skip_next" == "true" ]]; then - skip_next=false - continue - fi + ghostty_ssh_cache_clear() { + rm -f "$_cache" 2>/dev/null && builtin echo "Ghostty SSH terminfo cache cleared." || builtin echo "No Ghostty SSH terminfo cache found." + } - # Skip flags that take arguments - if [[ "$arg" =~ ^-[bcDEeFIiJLlmOopQRSWw]$ ]]; then - skip_next=true - continue - fi + ghostty_ssh_cache_list() { + [[ -s $_cache ]] && builtin echo "Hosts with Ghostty terminfo installed:" && cat "$_cache" || builtin echo "No cached hosts found." + } + fi - # Skip other flags - if [[ "$arg" =~ ^- ]]; then - continue - fi - - # This should be the target - target="$arg" - break - done - - echo "$target" - } - - # Check if host has terminfo installed - _ghostty_host_has_terminfo() { - local target="$1" - [[ -f "$_ghostty_cache_file" ]] && grep -qFx "$target" "$_ghostty_cache_file" 2>/dev/null - } - - # Add host to terminfo cache - _ghostty_cache_host() { - local target="$1" - local cache_dir - cache_dir="$(dirname "$_ghostty_cache_file")" - - # Create cache directory if needed - [[ ! -d "$cache_dir" ]] && mkdir -p "$cache_dir" - - # Atomic write to cache file - { - if [[ -f "$_ghostty_cache_file" ]]; then - cat "$_ghostty_cache_file" - fi - echo "$target" - } | sort -u > "$_ghostty_cache_file.tmp" && mv "$_ghostty_cache_file.tmp" "$_ghostty_cache_file" - - # Secure permissions - chmod 600 "$_ghostty_cache_file" 2>/dev/null - } - - # Wrap `ssh` command to provide Ghostty SSH integration + # SSH wrapper 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 - } + local -a e o c + local t - # 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() { - local env_vars=() - - # Fix TERM compatibility - if [[ "$TERM" == "xterm-ghostty" ]]; then - env_vars+=("TERM=xterm-256color") + # Get target (only if ssh-terminfo enabled for caching) + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then + t=$(builtin command ssh -G "$@" 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') fi - # Propagate Ghostty shell integration environment variables - [[ -n "$GHOSTTY_SHELL_FEATURES" ]] && env_vars+=("GHOSTTY_SHELL_FEATURES=$GHOSTTY_SHELL_FEATURES") - - # 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() { - local target - target="$(_ghostty_get_ssh_target "$@")" - - # Check if we already know this host has terminfo - if [[ -n "$target" ]] && _ghostty_host_has_terminfo "$target"; then - # Direct connection with xterm-ghostty - local env_vars=("TERM=xterm-ghostty") - [[ -n "$GHOSTTY_SHELL_FEATURES" ]] && env_vars+=("GHOSTTY_SHELL_FEATURES=$GHOSTTY_SHELL_FEATURES") - env "${env_vars[@]}" ssh "$@" - return 0 + # Set up env vars first so terminfo installation inherits them + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then + builtin export COLORTERM=${COLORTERM:-truecolor} TERM_PROGRAM=${TERM_PROGRAM:-ghostty} ${GHOSTTY_VERSION:+TERM_PROGRAM_VERSION=$GHOSTTY_VERSION} + for v in COLORTERM=truecolor TERM_PROGRAM=ghostty ${GHOSTTY_VERSION:+TERM_PROGRAM_VERSION=$GHOSTTY_VERSION}; do + o+=(-o "SendEnv ${v%=*}" -o "SetEnv $v") + done fi - # Full integration: Install terminfo if needed - if builtin command -v infocmp >/dev/null 2>&1; then - # Install terminfo only if needed - if infocmp -x xterm-ghostty 2>/dev/null | builtin command ssh "$@" ' - if ! infocmp xterm-ghostty >/dev/null 2>&1; then - echo "Installing Ghostty terminfo..." >&2 - tic -x - 2>/dev/null - fi - '; then - echo "Connecting with full Ghostty support..." >&2 - - # Cache this host for future connections - [[ -n "$target" ]] && _ghostty_cache_host "$target" - - # Connect with xterm-ghostty since terminfo is available - local env_vars=("TERM=xterm-ghostty") - [[ -n "$GHOSTTY_SHELL_FEATURES" ]] && env_vars+=("GHOSTTY_SHELL_FEATURES=$GHOSTTY_SHELL_FEATURES") - env "${env_vars[@]}" ssh "$@" - builtin return 0 + # Install terminfo if needed, reuse control connection for main session + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then + if [[ -n $t ]] && _ghst_cache "$t" chk; then + e+=(TERM=xterm-ghostty) + elif builtin command -v infocmp >/dev/null 2>&1; then + local ti + ti=$(infocmp -x xterm-ghostty 2>/dev/null) || builtin echo "Warning: xterm-ghostty terminfo not found locally." >&2 + if [[ -n $ti ]]; then + builtin echo "Setting up Ghostty terminfo on remote host..." >&2 + local cp + cp="/tmp/ghostty-ssh-$USER-$RANDOM-$(date +%s)" + case $(builtin echo "$ti" | builtin command ssh "${o[@]}" -o ControlMaster=yes -o ControlPath="$cp" -o ControlPersist=60s "$@" ' + infocmp xterm-ghostty >/dev/null 2>&1 && echo OK && exit + command -v tic >/dev/null 2>&1 || { echo NO_TIC; exit 1; } + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && echo OK || echo FAIL + ') in + OK) + builtin echo "Terminfo setup complete." >&2 + [[ -n $t ]] && _ghst_cache "$t" add + e+=(TERM=xterm-ghostty) + c+=(-o "ControlPath=$cp") + ;; + *) builtin echo "Warning: Failed to install terminfo." >&2 ;; + esac + fi + else + builtin echo "Warning: infocmp not found locally. Terminfo installation unavailable." >&2 fi - echo "Terminfo installation failed. Using basic integration." >&2 fi - # Fallback to basic integration - _ghostty_ssh_basic "$@" + # Fallback TERM only if terminfo didn't set it + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then + [[ $TERM == xterm-ghostty && ! " ${(j: :)e} " =~ " TERM=" ]] && e+=(TERM=xterm-256color) + fi + + # Execute + if (( ${#e} > 0 )); then + env "${e[@]}" ssh "${o[@]}" "${c[@]}" "$@" + else + builtin command ssh "${o[@]}" "${c[@]}" "$@" + fi } + + # Wrap ghostty command only if ssh-terminfo is enabled + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then + ghostty() { + case "$1" in + ssh-cache-list) ghostty_ssh_cache_list ;; + ssh-cache-clear) ghostty_ssh_cache_clear ;; + *) builtin command ghostty "$@" ;; + esac + } + fi fi # Some zsh users manually run `source ~/.zshrc` in order to apply rc file From c8d5e603906f5d442e0707c38e2303cc6d744b67 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Tue, 24 Jun 2025 11:41:35 -0700 Subject: [PATCH 26/52] docs: expand flag descriptions, usage overview --- src/config/Config.zig | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 41171b3be..b03773295 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1975,10 +1975,19 @@ keybind: Keybinds = .{}, /// * `ssh-env` - Enable SSH environment variable compatibility. Automatically /// converts TERM from `xterm-ghostty` to `xterm-256color` when connecting to /// remote hosts and propagates COLORTERM, TERM_PROGRAM, and TERM_PROGRAM_VERSION. +/// Whether or not these variables will be accepted by the remote host(s) will +/// depend on whether or not the variables are allowed in their sshd_config. /// /// * `ssh-terminfo` - Enable automatic terminfo installation on remote hosts. /// Attempts to install Ghostty's terminfo entry using `infocmp` and `tic` when -/// connecting to hosts that lack it. +/// connecting to hosts that lack it. Requires `infocmp` and `tic` to be available +/// locally. Provides `ghostty ssh-cache-list` and `ghostty ssh-cache-clear` +/// utilities for managing the installation cache. +/// +/// SSH features work independently and can be combined for optimal experience: +/// when both `ssh-env` and `ssh-terminfo` are enabled, Ghostty will install its +/// terminfo on remote hosts and use `xterm-ghostty` as TERM, falling back to +/// `xterm-256color` with environment variables if terminfo installation fails. /// /// Example: `cursor`, `no-cursor`, `sudo`, `no-sudo`, `title`, `no-title` @"shell-integration-features": ShellIntegrationFeatures = .{}, From bbb02a83923c25be263b51c35779b5e1dbff4a44 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Tue, 24 Jun 2025 12:00:22 -0700 Subject: [PATCH 27/52] test: update shell integration tests for SSH flags Add ssh-env and ssh-terminfo fields to existing setupFeatures tests. --- src/termio/shell_integration.zig | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index fb62327d3..36ae116d5 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -201,8 +201,8 @@ test "setup features" { var env = EnvMap.init(alloc); defer env.deinit(); - try setupFeatures(&env, .{ .cursor = true, .sudo = true, .title = true }); - try testing.expectEqualStrings("cursor,sudo,title", env.get("GHOSTTY_SHELL_FEATURES").?); + try setupFeatures(&env, .{ .cursor = true, .sudo = true, .title = true, .@"ssh-env" = true, .@"ssh-terminfo" = true }); + try testing.expectEqualStrings("cursor,ssh-env,ssh-terminfo,sudo,title", env.get("GHOSTTY_SHELL_FEATURES").?); } // Test: all features disabled @@ -210,7 +210,7 @@ test "setup features" { var env = EnvMap.init(alloc); defer env.deinit(); - try setupFeatures(&env, .{ .cursor = false, .sudo = false, .title = false }); + try setupFeatures(&env, .{ .cursor = false, .sudo = false, .title = false, .@"ssh-env" = false, .@"ssh-terminfo" = false }); try testing.expect(env.get("GHOSTTY_SHELL_FEATURES") == null); } @@ -219,8 +219,8 @@ test "setup features" { var env = EnvMap.init(alloc); defer env.deinit(); - try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false }); - try testing.expectEqualStrings("sudo", env.get("GHOSTTY_SHELL_FEATURES").?); + try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false, .@"ssh-env" = true, .@"ssh-terminfo" = false }); + try testing.expectEqualStrings("sudo,ssh-env", env.get("GHOSTTY_SHELL_FEATURES").?); } } From 8a2fa6485e54483332b6224f04be47c9047f1947 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Tue, 24 Jun 2025 17:15:35 -0700 Subject: [PATCH 28/52] refactor: extract SSH cache functionality to shared script Addresses feedback about separation of concerns in shell integration scripts. Extracts host caching logic to `src/shell-integration/shared/ghostty-ssh-cache` and updates all four shell integrations to use the shared script. The `shared/` subdirectory preserves the existing organizational pattern where all shell-specific code lives in subdirectories. This cleanly separates SSH transport logic from cache management while reducing code duplication by ~25%. All existing SSH integration behavior remains identical. --- src/shell-integration/bash/ghostty.bash | 65 ++--- .../elvish/lib/ghostty-integration.elv | 233 +++++++----------- .../ghostty-shell-integration.fish | 81 +++--- .../shared/ghostty-ssh-cache | 11 + src/shell-integration/zsh/ghostty-integration | 66 ++--- 5 files changed, 174 insertions(+), 282 deletions(-) create mode 100755 src/shell-integration/shared/ghostty-ssh-cache diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 6e09ab193..9ba11c845 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -97,56 +97,48 @@ fi # SSH Integration if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then - # Only define cache functions and variable if ssh-terminfo is enabled + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then - _cache="${XDG_STATE_HOME:-$HOME/.local/state}/ghostty/terminfo_hosts" - - # Cache operations and utilities - _ghst_cache() { - case $2 in - chk) [[ -f $_cache ]] && grep -qFx "$1" "$_cache" 2>/dev/null ;; - add) - mkdir -p "${_cache%/*}" - { - [[ -f $_cache ]] && cat "$_cache" - builtin echo "$1" - } | sort -u >"$_cache.tmp" && mv "$_cache.tmp" "$_cache" && chmod 600 "$_cache" - ;; + readonly _CACHE="${GHOSTTY_RESOURCES_DIR}/shell-integration/shared/ghostty-ssh-cache" + # If 'ssh-terminfo' flag is enabled, wrap ghostty to provide cache management commands + ghostty() { + case "$1" in + ssh-cache-list) "$_CACHE" list ;; + ssh-cache-clear) "$_CACHE" clear ;; + *) builtin command ghostty "$@" ;; esac } - - function ghostty_ssh_cache_clear() { - rm -f "$_cache" 2>/dev/null && builtin echo "Ghostty SSH terminfo cache cleared." || builtin echo "No Ghostty SSH terminfo cache found." - } - - function ghostty_ssh_cache_list() { - [[ -s $_cache ]] && builtin echo "Hosts with Ghostty terminfo installed:" && cat "$_cache" || builtin echo "No cached hosts found." - } fi # SSH wrapper ssh() { - local e=() o=() c=() t - - # Get target - t=$(builtin command ssh -G "$@" 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') + local e=() o=() c=() # Removed 't' from here # Set up env vars first so terminfo installation inherits them if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then - builtin export COLORTERM=${COLORTERM:-truecolor} TERM_PROGRAM=${TERM_PROGRAM:-ghostty} ${GHOSTTY_VERSION:+TERM_PROGRAM_VERSION=$GHOSTTY_VERSION} - for v in COLORTERM=truecolor TERM_PROGRAM=ghostty ${GHOSTTY_VERSION:+TERM_PROGRAM_VERSION=$GHOSTTY_VERSION}; do + local vars=( + COLORTERM=truecolor + TERM_PROGRAM=ghostty + ${GHOSTTY_VERSION:+TERM_PROGRAM_VERSION=$GHOSTTY_VERSION} + ) + for v in "${vars[@]}"; do + builtin export "${v?}" o+=(-o "SendEnv ${v%=*}" -o "SetEnv $v") done fi # Install terminfo if needed, reuse control connection for main session if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then - if [[ -n $t ]] && _ghst_cache "$t" chk; then + # Get target (only when needed for terminfo) + builtin local t + t=$(builtin command ssh -G "$@" 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') + + if [[ -n "$t" ]] && "$_CACHE" chk "$t"; then e+=(TERM=xterm-ghostty) elif builtin command -v infocmp >/dev/null 2>&1; then builtin local ti ti=$(infocmp -x xterm-ghostty 2>/dev/null) || builtin echo "Warning: xterm-ghostty terminfo not found locally." >&2 - if [[ -n $ti ]]; then + if [[ -n "$ti" ]]; then builtin echo "Setting up Ghostty terminfo on remote host..." >&2 builtin local cp cp="/tmp/ghostty-ssh-$USER-$RANDOM-$(date +%s)" @@ -157,7 +149,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then ') in OK) builtin echo "Terminfo setup complete." >&2 - [[ -n $t ]] && _ghst_cache "$t" add + [[ -n "$t" ]] && "$_CACHE" add "$t" e+=(TERM=xterm-ghostty) c+=(-o "ControlPath=$cp") ;; @@ -181,17 +173,6 @@ if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then builtin command ssh "${o[@]}" "${c[@]}" "$@" fi } - - # If 'ssh-terminfo' flag is enabled, wrap ghostty to provide 'ghostty ssh-cache-list' and `ghostty ssh-cache-clear` utility commands - if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then - ghostty() { - case "$1" in - ssh-cache-list) ghostty_ssh_cache_list ;; - ssh-cache-clear) ghostty_ssh_cache_clear ;; - *) builtin command ghostty "$@" ;; - esac - } - fi fi # Import bash-preexec, safe to do multiple times diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 1e0c08732..09aa09f31 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -104,160 +104,107 @@ use re if (re:match 'ssh-(env|terminfo)' $E:GHOSTTY_SHELL_FEATURES) { - # Only define cache functions and variable if ssh-terminfo is enabled + if (re:match 'ssh-terminfo' $E:GHOSTTY_SHELL_FEATURES) { + var _cache_script = (path:join $E:GHOSTTY_RESOURCES_DIR shell-integration shared ghostty-ssh-cache) + + # Wrap ghostty command to provide cache management commands + fn ghostty {|@args| + if (eq $args[0] ssh-cache-list) { + (external $_cache_script) list + } elif (eq $args[0] ssh-cache-clear) { + (external $_cache_script) clear + } else { + (external ghostty) $@args + } + } + + edit:add-var ghostty~ $ghostty~ + } + + # SSH wrapper + fn ssh {|@args| + var e = [] + var o = [] + var c = [] + + # Set up env vars first so terminfo installation inherits them + if (re:match 'ssh-env' $E:GHOSTTY_SHELL_FEATURES) { + set-env COLORTERM (or $E:COLORTERM truecolor) + set-env TERM_PROGRAM (or $E:TERM_PROGRAM ghostty) + if (has-env GHOSTTY_VERSION) { + set-env TERM_PROGRAM_VERSION $E:GHOSTTY_VERSION + } + + var vars = [COLORTERM=truecolor TERM_PROGRAM=ghostty] + if (has-env GHOSTTY_VERSION) { + set vars = [$@vars TERM_PROGRAM_VERSION=$E:GHOSTTY_VERSION] + } + for v $vars { + var varname = (str:split &max=2 '=' $v | take 1) + set o = [$@o -o "SendEnv "$varname -o "SetEnv "$v] + } + } + + # Install terminfo if needed, reuse control connection for main session if (re:match 'ssh-terminfo' $E:GHOSTTY_SHELL_FEATURES) { - var _cache = (path:join (or $E:XDG_STATE_HOME $E:HOME/.local/state) ghostty terminfo_hosts) + # Get target (only when needed for terminfo) + var t = "" + try { + set t = (e:ssh -G $@args 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@' | str:trim-space) + } catch e { + # Ignore errors + } - # Cache operations and utilities - fn _ghst_cache {|target action| - if (eq $action chk) { - if (path:is-regular $_cache) { - try { - grep -qFx $target $_cache 2>/dev/null - } catch e { - fail - } - } else { - fail - } - } elif (eq $action add) { - mkdir -p (path:dir $_cache) - var tmpfile = $_cache.tmp - { - if (path:is-regular $_cache) { - cat $_cache - } - echo $target - } | sort -u > $tmpfile - mv $tmpfile $_cache - chmod 600 $_cache - } + if (and (not-eq $t "") (try { (external $_cache_script) chk $t } catch e { put $false })) { + set e = [$@e TERM=xterm-ghostty] + } elif (has-external infocmp) { + var ti = "" + try { + set ti = (infocmp -x xterm-ghostty 2>/dev/null | slurp) + } catch e { + echo "Warning: xterm-ghostty terminfo not found locally." >&2 } - - fn ghostty_ssh_cache_clear { - try { - rm -f $_cache 2>/dev/null - echo "Ghostty SSH terminfo cache cleared." - } catch e { - echo "No Ghostty SSH terminfo cache found." - } - } - - fn ghostty_ssh_cache_list { - if (and (path:is-regular $_cache) (> (wc -c < $_cache | str:trim-space) 0)) { - echo "Hosts with Ghostty terminfo installed:" - cat $_cache - } else { - echo "No cached hosts found." + if (not-eq $ti "") { + echo "Setting up Ghostty terminfo on remote host..." >&2 + var cp = "/tmp/ghostty-ssh-"$E:USER"-"(randint 10000)"-"(date +%s | str:trim-space) + var result = (echo $ti | e:ssh $@o -o ControlMaster=yes -o ControlPath=$cp -o ControlPersist=60s $@args ' + infocmp xterm-ghostty >/dev/null 2>&1 && echo OK && exit + command -v tic >/dev/null 2>&1 || { echo NO_TIC; exit 1; } + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && echo OK || echo FAIL + ' | str:trim-space) + if (eq $result OK) { + echo "Terminfo setup complete." >&2 + if (not-eq $t "") { + (external $_cache_script) add $t } + set e = [$@e TERM=xterm-ghostty] + set c = [$@c -o ControlPath=$cp] + } else { + echo "Warning: Failed to install terminfo." >&2 + } } + } else { + echo "Warning: infocmp not found locally. Terminfo installation unavailable." >&2 + } } - # SSH wrapper - fn ssh {|@args| - var e = [] - var o = [] - var c = [] - var t = "" - - # Get target (only if ssh-terminfo enabled for caching) - if (re:match 'ssh-terminfo' $E:GHOSTTY_SHELL_FEATURES) { - try { - set t = (e:ssh -G $@args 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@' | str:trim-space) - } catch e { - # Ignore errors - } - } - - # Set up env vars first so terminfo installation inherits them - if (re:match 'ssh-env' $E:GHOSTTY_SHELL_FEATURES) { - set-env COLORTERM (or $E:COLORTERM truecolor) - set-env TERM_PROGRAM (or $E:TERM_PROGRAM ghostty) - if (has-env GHOSTTY_VERSION) { - set-env TERM_PROGRAM_VERSION $E:GHOSTTY_VERSION - } - - var vars = [COLORTERM=truecolor TERM_PROGRAM=ghostty] - if (has-env GHOSTTY_VERSION) { - set vars = [$@vars TERM_PROGRAM_VERSION=$E:GHOSTTY_VERSION] - } - for v $vars { - var varname = (str:split &max=2 '=' $v | take 1) - set o = [$@o -o "SendEnv "$varname -o "SetEnv "$v] - } - } - - # Install terminfo if needed, reuse control connection for main session - if (re:match 'ssh-terminfo' $E:GHOSTTY_SHELL_FEATURES) { - if (and (not-eq $t "") (try { _ghst_cache $t chk } catch e { put $false })) { - set e = [$@e TERM=xterm-ghostty] - } elif (has-external infocmp) { - var ti = "" - try { - set ti = (infocmp -x xterm-ghostty 2>/dev/null | slurp) - } catch e { - echo "Warning: xterm-ghostty terminfo not found locally." >&2 - } - if (not-eq $ti "") { - echo "Setting up Ghostty terminfo on remote host..." >&2 - var cp = "/tmp/ghostty-ssh-"$E:USER"-"(randint 10000)"-"(date +%s | str:trim-space) - var result = (echo $ti | e:ssh $@o -o ControlMaster=yes -o ControlPath=$cp -o ControlPersist=60s $@args ' - infocmp xterm-ghostty >/dev/null 2>&1 && echo OK && exit - command -v tic >/dev/null 2>&1 || { echo NO_TIC; exit 1; } - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && echo OK || echo FAIL - ' | str:trim-space) - if (eq $result OK) { - echo "Terminfo setup complete." >&2 - if (not-eq $t "") { - _ghst_cache $t add - } - set e = [$@e TERM=xterm-ghostty] - set c = [$@c -o ControlPath=$cp] - } else { - echo "Warning: Failed to install terminfo." >&2 - } - } - } else { - echo "Warning: infocmp not found locally. Terminfo installation unavailable." >&2 - } - } - - # Fallback TERM only if terminfo didn't set it - if (re:match 'ssh-env' $E:GHOSTTY_SHELL_FEATURES) { - if (and (eq $E:TERM xterm-ghostty) (not (re:match 'TERM=' (str:join ' ' $e)))) { - set e = [$@e TERM=xterm-256color] - } - } - - # Execute - if (> (count $e) 0) { - e:env $@e e:ssh $@o $@c $@args - } else { - e:ssh $@o $@c $@args - } + # Fallback TERM only if terminfo didn't set it + if (re:match 'ssh-env' $E:GHOSTTY_SHELL_FEATURES) { + if (and (eq $E:TERM xterm-ghostty) (not (re:match 'TERM=' (str:join ' ' $e)))) { + set e = [$@e TERM=xterm-256color] + } } - # Wrap ghostty command only if ssh-terminfo is enabled - if (re:match 'ssh-terminfo' $E:GHOSTTY_SHELL_FEATURES) { - fn ghostty {|@args| - if (eq $args[0] ssh-cache-list) { - ghostty_ssh_cache_list - } elif (eq $args[0] ssh-cache-clear) { - ghostty_ssh_cache_clear - } else { - (external ghostty) $@args - } - } - - edit:add-var ghostty~ $ghostty~ - - # Export cache functions for global use - set edit:add-var[ghostty_ssh_cache_clear] = $ghostty_ssh_cache_clear~ - set edit:add-var[ghostty_ssh_cache_list] = $ghostty_ssh_cache_list~ + # Execute + if (> (count $e) 0) { + e:env $@e e:ssh $@o $@c $@args + } else { + e:ssh $@o $@c $@args } + } - # Export ssh function for global use - set edit:add-var[ssh] = $ssh~ + # Export ssh function for global use + set edit:add-var[ssh] = $ssh~ } defer { 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 73ccf9874..55ce34985 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 @@ -88,31 +88,20 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" # SSH Integration if string match -qr 'ssh-(env|terminfo)' "$GHOSTTY_SHELL_FEATURES" - # Only define cache functions and variable if ssh-terminfo is enabled - if string match -qr 'ssh-terminfo' "$GHOSTTY_SHELL_FEATURES" - set -g _cache (test -n "$XDG_STATE_HOME" && echo "$XDG_STATE_HOME" || echo "$HOME/.local/state")/ghostty/terminfo_hosts + if string match -qr ssh-terminfo "$GHOSTTY_SHELL_FEATURES" + set -g _cache_script "$GHOSTTY_RESOURCES_DIR/shell-integration/shared/ghostty-ssh-cache" - # Cache operations and utilities - function _ghst_cache - switch $argv[2] - case chk - test -f $_cache && grep -qFx "$argv[1]" "$_cache" 2>/dev/null - case add - mkdir -p (dirname "$_cache") - begin - test -f $_cache && cat "$_cache" - builtin echo "$argv[1]" - end | sort -u >"$_cache.tmp" && mv "$_cache.tmp" "$_cache" && chmod 600 "$_cache" + # Wrap ghostty command to provide cache management commands + function ghostty -d "Wrap ghostty to provide cache management commands" + switch "$argv[1]" + case ssh-cache-list + command "$_cache_script" list + case ssh-cache-clear + command "$_cache_script" clear + case "*" + command ghostty $argv end end - - function ghostty_ssh_cache_clear -d "Clear Ghostty SSH terminfo cache" - rm -f "$_cache" 2>/dev/null && builtin echo "Ghostty SSH terminfo cache cleared." || builtin echo "No Ghostty SSH terminfo cache found." - end - - function ghostty_ssh_cache_list -d "List hosts with Ghostty terminfo installed" - test -s $_cache && builtin echo "Hosts with Ghostty terminfo installed:" && cat "$_cache" || builtin echo "No cached hosts found." - end end # SSH wrapper @@ -120,28 +109,28 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" set -l e set -l o set -l c - set -l t - - # Get target (only if ssh-terminfo enabled for caching) - if string match -qr 'ssh-terminfo' "$GHOSTTY_SHELL_FEATURES" - set t (builtin command ssh -G $argv 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') - end # Set up env vars first so terminfo installation inherits them - if string match -qr 'ssh-env' "$GHOSTTY_SHELL_FEATURES" + if string match -qr ssh-env "$GHOSTTY_SHELL_FEATURES" set -gx COLORTERM (test -n "$COLORTERM" && echo "$COLORTERM" || echo "truecolor") set -gx TERM_PROGRAM (test -n "$TERM_PROGRAM" && echo "$TERM_PROGRAM" || echo "ghostty") test -n "$GHOSTTY_VERSION" && set -gx TERM_PROGRAM_VERSION "$GHOSTTY_VERSION" - for v in COLORTERM=truecolor TERM_PROGRAM=ghostty (test -n "$GHOSTTY_VERSION" && echo "TERM_PROGRAM_VERSION=$GHOSTTY_VERSION") + set -l vars COLORTERM=truecolor TERM_PROGRAM=ghostty + test -n "$GHOSTTY_VERSION" && set vars $vars "TERM_PROGRAM_VERSION=$GHOSTTY_VERSION" + + for v in $vars set -l varname (string split -m1 '=' "$v")[1] set o $o -o "SendEnv $varname" -o "SetEnv $v" end end # Install terminfo if needed, reuse control connection for main session - if string match -qr 'ssh-terminfo' "$GHOSTTY_SHELL_FEATURES" - if test -n "$t" && _ghst_cache "$t" chk + if string match -qr ssh-terminfo "$GHOSTTY_SHELL_FEATURES" + # Get target (only when needed for terminfo) + set -l t (builtin command ssh -G $argv 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') + + if test -n "$t" && command "$_cache_script" chk "$t" set e $e TERM=xterm-ghostty else if command -v infocmp >/dev/null 2>&1 set -l ti @@ -157,7 +146,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" switch $result case OK builtin echo "Terminfo setup complete." >&2 - test -n "$t" && _ghst_cache "$t" add + test -n "$t" && command "$_cache_script" add "$t" set e $e TERM=xterm-ghostty set c $c -o "ControlPath=$cp" case '*' @@ -170,7 +159,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" end # Fallback TERM only if terminfo didn't set it - if string match -qr 'ssh-env' "$GHOSTTY_SHELL_FEATURES" + if string match -qr ssh-env "$GHOSTTY_SHELL_FEATURES" if test "$TERM" = xterm-ghostty && not string match -q '*TERM=*' "$e" set e $e TERM=xterm-256color end @@ -183,20 +172,6 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" builtin command ssh $o $c $argv end end - - # Wrap ghostty command only if ssh-terminfo is enabled - if string match -qr 'ssh-terminfo' "$GHOSTTY_SHELL_FEATURES" - function ghostty -d "Wrap ghostty to provide cache management commands" - switch "$argv[1]" - case ssh-cache-list - ghostty_ssh_cache_list - case ssh-cache-clear - ghostty_ssh_cache_clear - case "*" - command ghostty $argv - end - end - end end # Setup prompt marking diff --git a/src/shell-integration/shared/ghostty-ssh-cache b/src/shell-integration/shared/ghostty-ssh-cache new file mode 100755 index 000000000..e0a6d8452 --- /dev/null +++ b/src/shell-integration/shared/ghostty-ssh-cache @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Minimal Ghostty SSH terminfo host cache + +readonly CACHE_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/ghostty/terminfo_hosts" + +case "${1:-}" in + chk) [[ -f "$CACHE_FILE" ]] && grep -qFx "$2" "$CACHE_FILE" 2>/dev/null ;; + add) mkdir -p "${CACHE_FILE%/*}"; { [[ -f "$CACHE_FILE" ]] && cat "$CACHE_FILE"; echo "$2"; } | sort -u > "$CACHE_FILE.tmp" && mv "$CACHE_FILE.tmp" "$CACHE_FILE" && chmod 600 "$CACHE_FILE" ;; + list) [[ -s "$CACHE_FILE" ]] && echo "Hosts with Ghostty terminfo installed:" && cat "$CACHE_FILE" || echo "No cached hosts found." ;; + clear) rm -f "$CACHE_FILE" 2>/dev/null && echo "Ghostty SSH terminfo cache cleared." || echo "No Ghostty SSH terminfo cache found." ;; +esac diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index b3c604c83..4305ab61f 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -246,54 +246,43 @@ _ghostty_deferred_init() { # SSH Integration if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then - # Only define cache functions and variable if ssh-terminfo is enabled if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then - _cache="${XDG_STATE_HOME:-$HOME/.local/state}/ghostty/terminfo_hosts" - - # Cache operations and utilities - _ghst_cache() { - case $2 in - chk) [[ -f $_cache ]] && grep -qFx "$1" "$_cache" 2>/dev/null ;; - add) - mkdir -p "${_cache:h}" - { - [[ -f $_cache ]] && cat "$_cache" - builtin echo "$1" - } | sort -u >"$_cache.tmp" && mv "$_cache.tmp" "$_cache" && chmod 600 "$_cache" - ;; + readonly _cache_script="${GHOSTTY_RESOURCES_DIR}/shell-integration/shared/ghostty-ssh-cache" + + # Wrap ghostty command to provide cache management commands + ghostty() { + case "$1" in + ssh-cache-list) "$_cache_script" list ;; + ssh-cache-clear) "$_cache_script" clear ;; + *) builtin command ghostty "$@" ;; esac } - - ghostty_ssh_cache_clear() { - rm -f "$_cache" 2>/dev/null && builtin echo "Ghostty SSH terminfo cache cleared." || builtin echo "No Ghostty SSH terminfo cache found." - } - - ghostty_ssh_cache_list() { - [[ -s $_cache ]] && builtin echo "Hosts with Ghostty terminfo installed:" && cat "$_cache" || builtin echo "No cached hosts found." - } fi # SSH wrapper ssh() { local -a e o c - local t - - # Get target (only if ssh-terminfo enabled for caching) - if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then - t=$(builtin command ssh -G "$@" 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') - fi - + # Set up env vars first so terminfo installation inherits them if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then - builtin export COLORTERM=${COLORTERM:-truecolor} TERM_PROGRAM=${TERM_PROGRAM:-ghostty} ${GHOSTTY_VERSION:+TERM_PROGRAM_VERSION=$GHOSTTY_VERSION} - for v in COLORTERM=truecolor TERM_PROGRAM=ghostty ${GHOSTTY_VERSION:+TERM_PROGRAM_VERSION=$GHOSTTY_VERSION}; do + local vars=( + COLORTERM=truecolor + TERM_PROGRAM=ghostty + ${GHOSTTY_VERSION:+TERM_PROGRAM_VERSION=$GHOSTTY_VERSION} + ) + for v in "${vars[@]}"; do + builtin export "${v?}" o+=(-o "SendEnv ${v%=*}" -o "SetEnv $v") done fi # Install terminfo if needed, reuse control connection for main session if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then - if [[ -n $t ]] && _ghst_cache "$t" chk; then + # Get target (only when needed for terminfo) + builtin local t + t=$(builtin command ssh -G "$@" 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') + + if [[ -n $t ]] && "$_cache_script" chk "$t"; then e+=(TERM=xterm-ghostty) elif builtin command -v infocmp >/dev/null 2>&1; then local ti @@ -309,7 +298,7 @@ _ghostty_deferred_init() { ') in OK) builtin echo "Terminfo setup complete." >&2 - [[ -n $t ]] && _ghst_cache "$t" add + [[ -n $t ]] && "$_cache_script" add "$t" e+=(TERM=xterm-ghostty) c+=(-o "ControlPath=$cp") ;; @@ -333,17 +322,6 @@ _ghostty_deferred_init() { builtin command ssh "${o[@]}" "${c[@]}" "$@" fi } - - # Wrap ghostty command only if ssh-terminfo is enabled - if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then - ghostty() { - case "$1" in - ssh-cache-list) ghostty_ssh_cache_list ;; - ssh-cache-clear) ghostty_ssh_cache_clear ;; - *) builtin command ghostty "$@" ;; - esac - } - fi fi # Some zsh users manually run `source ~/.zshrc` in order to apply rc file From 6789b7fb6e459b8ca143b946fd0f4f7e6d8fda34 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Wed, 25 Jun 2025 12:41:55 -0700 Subject: [PATCH 29/52] docs: add shared directory section to shell-integration README --- src/shell-integration/README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 1fd11091d..c09ea34db 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -88,3 +88,17 @@ if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then source "$GHOSTTY_RESOURCES_DIR"/shell-integration/zsh/ghostty-integration fi ``` + +## Shared Resources + +The `shared/` directory contains utilities available to all shell integrations: + +- `ghostty-ssh-cache`: A standalone script that manages the SSH terminfo host + cache for the `ssh-terminfo` shell integration feature. This script handles + cache file operations (list, clear, check, add) and is called by all shell + integrations when `ssh-terminfo` is enabled. It is also called by the + `+list-ssh-cache` and `+clear-ssh-cache` CLI actions, providing users with + direct cache management capabilities. + +The shared approach maintains separation of concerns by keeping shell-specific +integration files independent of secondary logic. From 0565ed39546a982b7f47d0dd0ba341bc41cc092f Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Wed, 25 Jun 2025 12:47:38 -0700 Subject: [PATCH 30/52] refactor: replace ghostty wrapper with proper CLI actions for terminfo cache management - Add +list-ssh-cache and +clear-ssh-cache CLI actions - Remove ghostty() wrapper functions from all shell integrations - Improve variable naming in shell scripts for readability Addresses @00-kat's feedback about CLI discoverability and naming consistency. The new CLI actions follow established Ghostty patterns and are discoverable via `ghostty --help`, while maintaining clean separation of concerns between shell logic and cache management. --- src/cli/action.zig | 12 +++ src/cli/clear_ssh_cache.zig | 40 +++++++ src/cli/list_ssh_cache.zig | 38 +++++++ src/cli/ssh_cache.zig | 71 ++++++++++++ src/shell-integration/bash/ghostty.bash | 46 ++++---- .../elvish/lib/ghostty-integration.elv | 102 +++++++----------- .../ghostty-shell-integration.fish | 93 ++++++++-------- src/shell-integration/zsh/ghostty-integration | 70 ++++++------ 8 files changed, 299 insertions(+), 173 deletions(-) create mode 100644 src/cli/clear_ssh_cache.zig create mode 100644 src/cli/list_ssh_cache.zig create mode 100644 src/cli/ssh_cache.zig diff --git a/src/cli/action.zig b/src/cli/action.zig index 009afb4c9..1d1c3bfa0 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -9,6 +9,8 @@ const list_keybinds = @import("list_keybinds.zig"); const list_themes = @import("list_themes.zig"); const list_colors = @import("list_colors.zig"); const list_actions = @import("list_actions.zig"); +const list_ssh_cache = @import("list_ssh_cache.zig"); +const clear_ssh_cache = @import("clear_ssh_cache.zig"); const edit_config = @import("edit_config.zig"); const show_config = @import("show_config.zig"); const validate_config = @import("validate_config.zig"); @@ -41,6 +43,12 @@ pub const Action = enum { /// List keybind actions @"list-actions", + /// List hosts with Ghostty SSH terminfo installed + @"list-ssh-cache", + + /// Clear Ghostty SSH terminfo cache + @"clear-ssh-cache", + /// Edit the config file in the configured terminal editor. @"edit-config", @@ -155,6 +163,8 @@ pub const Action = enum { .@"list-themes" => try list_themes.run(alloc), .@"list-colors" => try list_colors.run(alloc), .@"list-actions" => try list_actions.run(alloc), + .@"list-ssh-cache" => @import("list_ssh_cache.zig").run(alloc), + .@"clear-ssh-cache" => @import("clear_ssh_cache.zig").run(alloc), .@"edit-config" => try edit_config.run(alloc), .@"show-config" => try show_config.run(alloc), .@"validate-config" => try validate_config.run(alloc), @@ -192,6 +202,8 @@ pub const Action = enum { .@"list-themes" => list_themes.Options, .@"list-colors" => list_colors.Options, .@"list-actions" => list_actions.Options, + .@"list-ssh-cache" => list_ssh_cache.Options, + .@"clear-ssh-cache" => clear_ssh_cache.Options, .@"edit-config" => edit_config.Options, .@"show-config" => show_config.Options, .@"validate-config" => validate_config.Options, diff --git a/src/cli/clear_ssh_cache.zig b/src/cli/clear_ssh_cache.zig new file mode 100644 index 000000000..062af4221 --- /dev/null +++ b/src/cli/clear_ssh_cache.zig @@ -0,0 +1,40 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const args = @import("args.zig"); +const Action = @import("action.zig").Action; +const ssh_cache = @import("ssh_cache.zig"); + +pub const Options = struct { + pub fn deinit(self: Options) void { + _ = self; + } + + /// Enables `-h` and `--help` to work. + pub fn help(self: Options) !void { + _ = self; + return Action.help_error; + } +}; + +/// Clear the Ghostty SSH terminfo cache. +/// +/// This command removes the cache of hosts where Ghostty's terminfo has been installed +/// via the ssh-terminfo shell integration feature. After clearing, terminfo will be +/// reinstalled on the next SSH connection to previously cached hosts. +/// +/// Use this if you need to force reinstallation of terminfo or clean up old entries. +pub fn run(alloc: Allocator) !u8 { + var opts: Options = .{}; + defer opts.deinit(); + + { + var iter = try args.argsIterator(alloc); + defer iter.deinit(); + try args.parse(Options, alloc, &opts, &iter); + } + + const stdout = std.io.getStdOut().writer(); + try ssh_cache.clearCache(alloc, stdout); + + return 0; +} diff --git a/src/cli/list_ssh_cache.zig b/src/cli/list_ssh_cache.zig new file mode 100644 index 000000000..a799d95bd --- /dev/null +++ b/src/cli/list_ssh_cache.zig @@ -0,0 +1,38 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const args = @import("args.zig"); +const Action = @import("action.zig").Action; +const ssh_cache = @import("ssh_cache.zig"); + +pub const Options = struct { + pub fn deinit(self: Options) void { + _ = self; + } + + /// Enables `-h` and `--help` to work. + pub fn help(self: Options) !void { + _ = self; + return Action.help_error; + } +}; + +/// List hosts with Ghostty SSH terminfo installed via the ssh-terminfo shell integration feature. +/// +/// This command shows all remote hosts where Ghostty's terminfo has been successfully +/// installed through the SSH integration. The cache is automatically maintained when +/// connecting to remote hosts with `shell-integration-features = ssh-terminfo` enabled. +pub fn run(alloc: Allocator) !u8 { + var opts: Options = .{}; + defer opts.deinit(); + + { + var iter = try args.argsIterator(alloc); + defer iter.deinit(); + try args.parse(Options, alloc, &opts, &iter); + } + + const stdout = std.io.getStdOut().writer(); + try ssh_cache.listCachedHosts(alloc, stdout); + + return 0; +} diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig new file mode 100644 index 000000000..c3484735a --- /dev/null +++ b/src/cli/ssh_cache.zig @@ -0,0 +1,71 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Child = std.process.Child; + +/// Get the path to the shared cache script +fn getCacheScriptPath(alloc: Allocator) ![]u8 { + // Use GHOSTTY_RESOURCES_DIR if available, otherwise assume relative path + const resources_dir = std.process.getEnvVarOwned(alloc, "GHOSTTY_RESOURCES_DIR") catch { + // Fallback: assume we're running from build directory + return try alloc.dupe(u8, "src/shell-integration/shared/ghostty-ssh-cache"); + }; + defer alloc.free(resources_dir); + + return try std.fs.path.join(alloc, &[_][]const u8{ resources_dir, "shell-integration", "shared", "ghostty-ssh-cache" }); +} + +/// List cached hosts by calling the external script +pub fn listCachedHosts(alloc: Allocator, writer: anytype) !void { + const script_path = try getCacheScriptPath(alloc); + defer alloc.free(script_path); + + var child = Child.init(&[_][]const u8{ script_path, "list" }, alloc); + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Pipe; + + try child.spawn(); + + const stdout = try child.stdout.?.readToEndAlloc(alloc, std.math.maxInt(usize)); + defer alloc.free(stdout); + + const stderr = try child.stderr.?.readToEndAlloc(alloc, std.math.maxInt(usize)); + defer alloc.free(stderr); + + _ = try child.wait(); + + // Output the results regardless of exit code + try writer.writeAll(stdout); + if (stderr.len > 0) { + try writer.writeAll(stderr); + } + + // Script handles its own success/error messaging, so we don't need to check exit code +} + +/// Clear cache by calling the external script +pub fn clearCache(alloc: Allocator, writer: anytype) !void { + const script_path = try getCacheScriptPath(alloc); + defer alloc.free(script_path); + + var child = Child.init(&[_][]const u8{ script_path, "clear" }, alloc); + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Pipe; + + try child.spawn(); + + const stdout = try child.stdout.?.readToEndAlloc(alloc, std.math.maxInt(usize)); + defer alloc.free(stdout); + + const stderr = try child.stderr.?.readToEndAlloc(alloc, std.math.maxInt(usize)); + defer alloc.free(stderr); + + _ = try child.wait(); + + // Output the results regardless of exit code + try writer.writeAll(stdout); + if (stderr.len > 0) { + try writer.writeAll(stderr); + } + + // Script handles its own success/error messaging, so we don't need to check exit code +} diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 9ba11c845..f07df125e 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -100,19 +100,11 @@ if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then readonly _CACHE="${GHOSTTY_RESOURCES_DIR}/shell-integration/shared/ghostty-ssh-cache" - # If 'ssh-terminfo' flag is enabled, wrap ghostty to provide cache management commands - ghostty() { - case "$1" in - ssh-cache-list) "$_CACHE" list ;; - ssh-cache-clear) "$_CACHE" clear ;; - *) builtin command ghostty "$@" ;; - esac - } fi # SSH wrapper ssh() { - local e=() o=() c=() # Removed 't' from here + local env=() opts=() ctrl=() # Set up env vars first so terminfo installation inherits them if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then @@ -123,35 +115,35 @@ if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then ) for v in "${vars[@]}"; do builtin export "${v?}" - o+=(-o "SendEnv ${v%=*}" -o "SetEnv $v") + opts+=(-o "SendEnv ${v%=*}" -o "SetEnv $v") done fi # Install terminfo if needed, reuse control connection for main session if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then # Get target (only when needed for terminfo) - builtin local t - t=$(builtin command ssh -G "$@" 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') + builtin local target + target=$(builtin command ssh -G "$@" 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') - if [[ -n "$t" ]] && "$_CACHE" chk "$t"; then - e+=(TERM=xterm-ghostty) + if [[ -n "$target" ]] && "$_CACHE" chk "$target"; then + env+=(TERM=xterm-ghostty) elif builtin command -v infocmp >/dev/null 2>&1; then - builtin local ti - ti=$(infocmp -x xterm-ghostty 2>/dev/null) || builtin echo "Warning: xterm-ghostty terminfo not found locally." >&2 - if [[ -n "$ti" ]]; then + builtin local tinfo + tinfo=$(infocmp -x xterm-ghostty 2>/dev/null) || builtin echo "Warning: xterm-ghostty terminfo not found locally." >&2 + if [[ -n "$tinfo" ]]; then builtin echo "Setting up Ghostty terminfo on remote host..." >&2 - builtin local cp - cp="/tmp/ghostty-ssh-$USER-$RANDOM-$(date +%s)" - case $(builtin echo "$ti" | builtin command ssh "${o[@]}" -o ControlMaster=yes -o ControlPath="$cp" -o ControlPersist=60s "$@" ' + builtin local cpath + cpath="/tmp/ghostty-ssh-$USER-$RANDOM-$(date +%s)" + case $(builtin echo "$tinfo" | builtin command ssh "${opts[@]}" -o ControlMaster=yes -o ControlPath="$cpath" -o ControlPersist=60s "$@" ' infocmp xterm-ghostty >/dev/null 2>&1 && echo OK && exit command -v tic >/dev/null 2>&1 || { echo NO_TIC; exit 1; } mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && echo OK || echo FAIL ') in OK) builtin echo "Terminfo setup complete." >&2 - [[ -n "$t" ]] && "$_CACHE" add "$t" - e+=(TERM=xterm-ghostty) - c+=(-o "ControlPath=$cp") + [[ -n "$target" ]] && "$_CACHE" add "$target" + env+=(TERM=xterm-ghostty) + ctrl+=(-o "ControlPath=$cpath") ;; *) builtin echo "Warning: Failed to install terminfo." >&2 ;; esac @@ -163,14 +155,14 @@ if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then # Fallback TERM only if terminfo didn't set it if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then - [[ $TERM == xterm-ghostty && ! " ${e[*]} " =~ " TERM=" ]] && e+=(TERM=xterm-256color) + [[ $TERM == xterm-ghostty && ! " ${env[*]} " =~ " TERM=" ]] && env+=(TERM=xterm-256color) fi # Execute - if [[ ${#e[@]} -gt 0 ]]; then - env "${e[@]}" ssh "${o[@]}" "${c[@]}" "$@" + if [[ ${#env[@]} -gt 0 ]]; then + env "${env[@]}" ssh "${opts[@]}" "${ctrl[@]}" "$@" else - builtin command ssh "${o[@]}" "${c[@]}" "$@" + builtin command ssh "${opts[@]}" "${ctrl[@]}" "$@" fi } fi diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 09aa09f31..7048d56b3 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -100,85 +100,68 @@ # SSH Integration use str - use path - use re - if (re:match 'ssh-(env|terminfo)' $E:GHOSTTY_SHELL_FEATURES) { - if (re:match 'ssh-terminfo' $E:GHOSTTY_SHELL_FEATURES) { - var _cache_script = (path:join $E:GHOSTTY_RESOURCES_DIR shell-integration shared ghostty-ssh-cache) - - # Wrap ghostty command to provide cache management commands - fn ghostty {|@args| - if (eq $args[0] ssh-cache-list) { - (external $_cache_script) list - } elif (eq $args[0] ssh-cache-clear) { - (external $_cache_script) clear - } else { - (external ghostty) $@args - } - } + if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) or (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-terminfo) { - edit:add-var ghostty~ $ghostty~ + if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-terminfo) { + var _CACHE = $E:GHOSTTY_RESOURCES_DIR/shell-integration/shared/ghostty-ssh-cache } # SSH wrapper fn ssh {|@args| - var e = [] - var o = [] - var c = [] + var env = [] + var opts = [] + var ctrl = [] # Set up env vars first so terminfo installation inherits them - if (re:match 'ssh-env' $E:GHOSTTY_SHELL_FEATURES) { - set-env COLORTERM (or $E:COLORTERM truecolor) - set-env TERM_PROGRAM (or $E:TERM_PROGRAM ghostty) - if (has-env GHOSTTY_VERSION) { - set-env TERM_PROGRAM_VERSION $E:GHOSTTY_VERSION - } - - var vars = [COLORTERM=truecolor TERM_PROGRAM=ghostty] - if (has-env GHOSTTY_VERSION) { + if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { + var vars = [ + COLORTERM=truecolor + TERM_PROGRAM=ghostty + ] + if (not-eq $E:GHOSTTY_VERSION '') { set vars = [$@vars TERM_PROGRAM_VERSION=$E:GHOSTTY_VERSION] } + for v $vars { - var varname = (str:split &max=2 '=' $v | take 1) - set o = [$@o -o "SendEnv "$varname -o "SetEnv "$v] + set-env (str:split = $v | take 1) (str:split = $v | drop 1 | str:join =) + var varname = (str:split = $v | take 1) + set opts = [$@opts -o 'SendEnv '$varname -o 'SetEnv '$v] } } # Install terminfo if needed, reuse control connection for main session - if (re:match 'ssh-terminfo' $E:GHOSTTY_SHELL_FEATURES) { - # Get target (only when needed for terminfo) - var t = "" + if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-terminfo) { + # Get target + var target = '' try { - set t = (e:ssh -G $@args 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@' | str:trim-space) - } catch e { - # Ignore errors - } + set target = (e:ssh -G $@args 2>/dev/null | e:awk '/^(user|hostname) /{print $2}' | e:paste -sd'@') + } catch { } - if (and (not-eq $t "") (try { (external $_cache_script) chk $t } catch e { put $false })) { - set e = [$@e TERM=xterm-ghostty] + if (and (not-eq $target '') ($_CACHE chk $target)) { + set env = [$@env TERM=xterm-ghostty] } elif (has-external infocmp) { - var ti = "" + var tinfo = '' try { - set ti = (infocmp -x xterm-ghostty 2>/dev/null | slurp) - } catch e { + set tinfo = (e:infocmp -x xterm-ghostty 2>/dev/null) + } catch { echo "Warning: xterm-ghostty terminfo not found locally." >&2 } - if (not-eq $ti "") { + + if (not-eq $tinfo '') { echo "Setting up Ghostty terminfo on remote host..." >&2 - var cp = "/tmp/ghostty-ssh-"$E:USER"-"(randint 10000)"-"(date +%s | str:trim-space) - var result = (echo $ti | e:ssh $@o -o ControlMaster=yes -o ControlPath=$cp -o ControlPersist=60s $@args ' + var cpath = '/tmp/ghostty-ssh-'$E:USER'-'(randint 0 32767)'-'(date +%s) + var result = (echo $tinfo | e:ssh $@opts -o ControlMaster=yes -o ControlPath=$cpath -o ControlPersist=60s $@args ' infocmp xterm-ghostty >/dev/null 2>&1 && echo OK && exit command -v tic >/dev/null 2>&1 || { echo NO_TIC; exit 1; } mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && echo OK || echo FAIL - ' | str:trim-space) + ') + if (eq $result OK) { echo "Terminfo setup complete." >&2 - if (not-eq $t "") { - (external $_cache_script) add $t - } - set e = [$@e TERM=xterm-ghostty] - set c = [$@c -o ControlPath=$cp] + if (not-eq $target '') { $_CACHE add $target } + set env = [$@env TERM=xterm-ghostty] + set ctrl = [$@ctrl -o ControlPath=$cpath] } else { echo "Warning: Failed to install terminfo." >&2 } @@ -189,22 +172,19 @@ } # Fallback TERM only if terminfo didn't set it - if (re:match 'ssh-env' $E:GHOSTTY_SHELL_FEATURES) { - if (and (eq $E:TERM xterm-ghostty) (not (re:match 'TERM=' (str:join ' ' $e)))) { - set e = [$@e TERM=xterm-256color] + if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { + if (and (eq $E:TERM xterm-ghostty) (not (str:contains (str:join ' ' $env) 'TERM='))) { + set env = [$@env TERM=xterm-256color] } } # Execute - if (> (count $e) 0) { - e:env $@e e:ssh $@o $@c $@args + if (> (count $env) 0) { + e:env $@env e:ssh $@opts $@ctrl $@args } else { - e:ssh $@o $@c $@args + e:ssh $@opts $@ctrl $@args } } - - # Export ssh function for global use - set edit:add-var[ssh] = $ssh~ } defer { 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 55ce34985..9b82f5fa2 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 @@ -87,89 +87,86 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" end # SSH Integration - if string match -qr 'ssh-(env|terminfo)' "$GHOSTTY_SHELL_FEATURES" - if string match -qr ssh-terminfo "$GHOSTTY_SHELL_FEATURES" - set -g _cache_script "$GHOSTTY_RESOURCES_DIR/shell-integration/shared/ghostty-ssh-cache" + if string match -qr 'ssh-(env|terminfo)' $GHOSTTY_SHELL_FEATURES - # Wrap ghostty command to provide cache management commands - function ghostty -d "Wrap ghostty to provide cache management commands" - switch "$argv[1]" - case ssh-cache-list - command "$_cache_script" list - case ssh-cache-clear - command "$_cache_script" clear - case "*" - command ghostty $argv - end - end + if string match -q '*ssh-terminfo*' $GHOSTTY_SHELL_FEATURES + set -g _CACHE "$GHOSTTY_RESOURCES_DIR/shell-integration/shared/ghostty-ssh-cache" end # SSH wrapper function ssh - set -l e - set -l o - set -l c + set -l env + set -l opts + set -l ctrl # Set up env vars first so terminfo installation inherits them - if string match -qr ssh-env "$GHOSTTY_SHELL_FEATURES" - set -gx COLORTERM (test -n "$COLORTERM" && echo "$COLORTERM" || echo "truecolor") - set -gx TERM_PROGRAM (test -n "$TERM_PROGRAM" && echo "$TERM_PROGRAM" || echo "ghostty") - test -n "$GHOSTTY_VERSION" && set -gx TERM_PROGRAM_VERSION "$GHOSTTY_VERSION" + if string match -q '*ssh-env*' $GHOSTTY_SHELL_FEATURES + set -l vars \ + COLORTERM=truecolor \ + TERM_PROGRAM=ghostty - set -l vars COLORTERM=truecolor TERM_PROGRAM=ghostty - test -n "$GHOSTTY_VERSION" && set vars $vars "TERM_PROGRAM_VERSION=$GHOSTTY_VERSION" + if test -n "$GHOSTTY_VERSION" + set -a vars "TERM_PROGRAM_VERSION=$GHOSTTY_VERSION" + end for v in $vars - set -l varname (string split -m1 '=' "$v")[1] - set o $o -o "SendEnv $varname" -o "SetEnv $v" + set -l parts (string split = $v) + set -gx $parts[1] $parts[2] + set -a opts -o "SendEnv $parts[1]" -o "SetEnv $v" end end # Install terminfo if needed, reuse control connection for main session - if string match -qr ssh-terminfo "$GHOSTTY_SHELL_FEATURES" - # Get target (only when needed for terminfo) - set -l t (builtin command ssh -G $argv 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') + if string match -q '*ssh-terminfo*' $GHOSTTY_SHELL_FEATURES + # Get target + set -l target (command ssh -G $argv 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') - if test -n "$t" && command "$_cache_script" chk "$t" - set e $e TERM=xterm-ghostty + if test -n "$target" -a ("$_CACHE" chk "$target") + set -a env TERM=xterm-ghostty else if command -v infocmp >/dev/null 2>&1 - set -l ti - set ti (infocmp -x xterm-ghostty 2>/dev/null) || builtin echo "Warning: xterm-ghostty terminfo not found locally." >&2 - if test -n "$ti" - builtin echo "Setting up Ghostty terminfo on remote host..." >&2 - set -l cp "/tmp/ghostty-ssh-$USER-"(random)"-"(date +%s) - set -l result (builtin echo "$ti" | builtin command ssh $o -o ControlMaster=yes -o ControlPath="$cp" -o ControlPersist=60s $argv ' + set -l tinfo (infocmp -x xterm-ghostty 2>/dev/null) + set -l status_code $status + + if test $status_code -ne 0 + echo "Warning: xterm-ghostty terminfo not found locally." >&2 + end + + if test -n "$tinfo" + echo "Setting up Ghostty terminfo on remote host..." >&2 + set -l cpath "/tmp/ghostty-ssh-$USER-"(random)"-"(date +%s) + set -l result (echo "$tinfo" | command ssh $opts -o ControlMaster=yes -o ControlPath="$cpath" -o ControlPersist=60s $argv ' infocmp xterm-ghostty >/dev/null 2>&1 && echo OK && exit command -v tic >/dev/null 2>&1 || { echo NO_TIC; exit 1; } mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && echo OK || echo FAIL ') + switch $result case OK - builtin echo "Terminfo setup complete." >&2 - test -n "$t" && command "$_cache_script" add "$t" - set e $e TERM=xterm-ghostty - set c $c -o "ControlPath=$cp" + echo "Terminfo setup complete." >&2 + test -n "$target" && "$_CACHE" add "$target" + set -a env TERM=xterm-ghostty + set -a ctrl -o "ControlPath=$cpath" case '*' - builtin echo "Warning: Failed to install terminfo." >&2 + echo "Warning: Failed to install terminfo." >&2 end end else - builtin echo "Warning: infocmp not found locally. Terminfo installation unavailable." >&2 + echo "Warning: infocmp not found locally. Terminfo installation unavailable." >&2 end end # Fallback TERM only if terminfo didn't set it - if string match -qr ssh-env "$GHOSTTY_SHELL_FEATURES" - if test "$TERM" = xterm-ghostty && not string match -q '*TERM=*' "$e" - set e $e TERM=xterm-256color + if string match -q '*ssh-env*' $GHOSTTY_SHELL_FEATURES + if test "$TERM" = xterm-ghostty -a ! (string join ' ' $env | string match -q '*TERM=*') + set -a env TERM=xterm-256color end end # Execute - if test (count $e) -gt 0 - env $e ssh $o $c $argv + if test (count $env) -gt 0 + env $env command ssh $opts $ctrl $argv else - builtin command ssh $o $c $argv + command ssh $opts $ctrl $argv end end end diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 4305ab61f..4d4461775 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -246,80 +246,76 @@ _ghostty_deferred_init() { # SSH Integration if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then - readonly _cache_script="${GHOSTTY_RESOURCES_DIR}/shell-integration/shared/ghostty-ssh-cache" - - # Wrap ghostty command to provide cache management commands - ghostty() { - case "$1" in - ssh-cache-list) "$_cache_script" list ;; - ssh-cache-clear) "$_cache_script" clear ;; - *) builtin command ghostty "$@" ;; - esac - } + readonly _CACHE="${GHOSTTY_RESOURCES_DIR}/shell-integration/shared/ghostty-ssh-cache" fi # SSH wrapper ssh() { - local -a e o c - + local -a env opts ctrl + env=() + opts=() + ctrl=() + # Set up env vars first so terminfo installation inherits them if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then - local vars=( + local -a vars + vars=( COLORTERM=truecolor TERM_PROGRAM=ghostty ${GHOSTTY_VERSION:+TERM_PROGRAM_VERSION=$GHOSTTY_VERSION} ) for v in "${vars[@]}"; do - builtin export "${v?}" - o+=(-o "SendEnv ${v%=*}" -o "SetEnv $v") + export "${v?}" + opts+=(-o "SendEnv ${v%=*}" -o "SetEnv $v") done fi # Install terminfo if needed, reuse control connection for main session if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then # Get target (only when needed for terminfo) - builtin local t - t=$(builtin command ssh -G "$@" 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') + local target + target=$(command ssh -G "$@" 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') - if [[ -n $t ]] && "$_cache_script" chk "$t"; then - e+=(TERM=xterm-ghostty) - elif builtin command -v infocmp >/dev/null 2>&1; then - local ti - ti=$(infocmp -x xterm-ghostty 2>/dev/null) || builtin echo "Warning: xterm-ghostty terminfo not found locally." >&2 - if [[ -n $ti ]]; then - builtin echo "Setting up Ghostty terminfo on remote host..." >&2 - local cp - cp="/tmp/ghostty-ssh-$USER-$RANDOM-$(date +%s)" - case $(builtin echo "$ti" | builtin command ssh "${o[@]}" -o ControlMaster=yes -o ControlPath="$cp" -o ControlPersist=60s "$@" ' + if [[ -n "$target" ]] && "$_CACHE" chk "$target"; then + env+=(TERM=xterm-ghostty) + elif command -v infocmp >/dev/null 2>&1; then + local tinfo + tinfo=$(infocmp -x xterm-ghostty 2>/dev/null) || echo "Warning: xterm-ghostty terminfo not found locally." >&2 + if [[ -n "$tinfo" ]]; then + echo "Setting up Ghostty terminfo on remote host..." >&2 + local cpath + cpath="/tmp/ghostty-ssh-$USER-$RANDOM-$(date +%s)" + case $(echo "$tinfo" | command ssh "${opts[@]}" -o ControlMaster=yes -o ControlPath="$cpath" -o ControlPersist=60s "$@" ' infocmp xterm-ghostty >/dev/null 2>&1 && echo OK && exit command -v tic >/dev/null 2>&1 || { echo NO_TIC; exit 1; } mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && echo OK || echo FAIL ') in OK) - builtin echo "Terminfo setup complete." >&2 - [[ -n $t ]] && "$_cache_script" add "$t" - e+=(TERM=xterm-ghostty) - c+=(-o "ControlPath=$cp") + echo "Terminfo setup complete." >&2 + [[ -n "$target" ]] && "$_CACHE" add "$target" + env+=(TERM=xterm-ghostty) + ctrl+=(-o "ControlPath=$cpath") ;; - *) builtin echo "Warning: Failed to install terminfo." >&2 ;; + *) echo "Warning: Failed to install terminfo." >&2 ;; esac fi else - builtin echo "Warning: infocmp not found locally. Terminfo installation unavailable." >&2 + echo "Warning: infocmp not found locally. Terminfo installation unavailable." >&2 fi fi # Fallback TERM only if terminfo didn't set it if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then - [[ $TERM == xterm-ghostty && ! " ${(j: :)e} " =~ " TERM=" ]] && e+=(TERM=xterm-256color) + [[ $TERM == xterm-ghostty && ! " ${env[*]} " =~ " TERM=" ]] && env+=(TERM=xterm-256color) fi # Execute - if (( ${#e} > 0 )); then - env "${e[@]}" ssh "${o[@]}" "${c[@]}" "$@" + if [[ ${#env[@]} -gt 0 ]]; then + env "${env[@]}" command ssh "${opts[@]}" "${ctrl[@]}" "$@" else - builtin command ssh "${o[@]}" "${c[@]}" "$@" + command ssh "${opts[@]}" "${ctrl[@]}" "$@" fi } fi From f617c9b3b7125ecad6d4be2341658a180b7a9d65 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Wed, 25 Jun 2025 13:09:31 -0700 Subject: [PATCH 31/52] docs: update ssh-terminfo description to reference new CLI actions Updates Config.zig documentation to reflect that SSH cache management is now handled by proper CLI actions (+list-ssh-cache and +clear-ssh-cache) rather than shell wrapper commands. Fixes documentation missed in e8c8a51. --- src/config/Config.zig | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index b03773295..a9df83e0a 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1981,8 +1981,9 @@ keybind: Keybinds = .{}, /// * `ssh-terminfo` - Enable automatic terminfo installation on remote hosts. /// Attempts to install Ghostty's terminfo entry using `infocmp` and `tic` when /// connecting to hosts that lack it. Requires `infocmp` and `tic` to be available -/// locally. Provides `ghostty ssh-cache-list` and `ghostty ssh-cache-clear` -/// utilities for managing the installation cache. +/// locally. Provides `+list-ssh-cache` and `+clear-ssh-cache` CLI actions for +/// managing the installation cache (caching is otherwise automatic and requires +/// no user intervention). /// /// SSH features work independently and can be combined for optimal experience: /// when both `ssh-env` and `ssh-terminfo` are enabled, Ghostty will install its From 0ccb7cf3538e189ea25a518c48c721eee243243a Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Wed, 25 Jun 2025 14:46:59 -0700 Subject: [PATCH 32/52] docs: improve SSH cache CLI action descriptions - Clarify that +list-ssh-cache shows shell integration cached hosts - Add note about +clear-ssh-cache command and when to use it Addresses mitchellh's feedback on action descriptions. --- src/cli/action.zig | 2 +- src/cli/list_ssh_cache.zig | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli/action.zig b/src/cli/action.zig index 1d1c3bfa0..443fafb8a 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -43,7 +43,7 @@ pub const Action = enum { /// List keybind actions @"list-actions", - /// List hosts with Ghostty SSH terminfo installed + /// List hosts cached by SSH shell integration for terminfo installation @"list-ssh-cache", /// Clear Ghostty SSH terminfo cache diff --git a/src/cli/list_ssh_cache.zig b/src/cli/list_ssh_cache.zig index a799d95bd..b4328201d 100644 --- a/src/cli/list_ssh_cache.zig +++ b/src/cli/list_ssh_cache.zig @@ -21,6 +21,9 @@ pub const Options = struct { /// This command shows all remote hosts where Ghostty's terminfo has been successfully /// installed through the SSH integration. The cache is automatically maintained when /// connecting to remote hosts with `shell-integration-features = ssh-terminfo` enabled. +/// +/// Use `+clear-ssh-cache` to remove cached entries if you need to force terminfo +/// reinstallation or clean up stale host entries. pub fn run(alloc: Allocator) !u8 { var opts: Options = .{}; defer opts.deinit(); From 21d95c42c6dc7c94347de91aacae5dece67fb202 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Wed, 25 Jun 2025 15:58:13 -0700 Subject: [PATCH 33/52] docs: improve clear-ssh-cache description (missed in previous commit) Clarifies this clears hosts cached by SSH shell integration, completing mitchellh's feedback on both action descriptions. --- src/cli/action.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/action.zig b/src/cli/action.zig index 443fafb8a..db9b54cd3 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -46,7 +46,7 @@ pub const Action = enum { /// List hosts cached by SSH shell integration for terminfo installation @"list-ssh-cache", - /// Clear Ghostty SSH terminfo cache + /// Clear hosts cached by SSH shell integration for terminfo installation @"clear-ssh-cache", /// Edit the config file in the configured terminal editor. From 931efcd1e384df62766957ca9a7609337bd0092e Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Wed, 25 Jun 2025 16:15:41 -0700 Subject: [PATCH 34/52] fix: restore background-image config accidentally removed during rebase --- src/config/Config.zig | 339 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 321 insertions(+), 18 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index a9df83e0a..44e7ab3b6 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -466,6 +466,93 @@ 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). @@ -1964,6 +2051,8 @@ keybind: Keybinds = .{}, /// its default value is used, so you must explicitly disable features you don't /// want. You can also use `true` or `false` to turn all features on or off. /// +/// Example: `cursor`, `no-cursor`, `sudo`, `no-sudo`, `title`, `no-title` +/// /// Available features: /// /// * `cursor` - Set the cursor to a blinking bar at the prompt. @@ -1989,10 +2078,30 @@ keybind: Keybinds = .{}, /// when both `ssh-env` and `ssh-terminfo` are enabled, Ghostty will install its /// terminfo on remote hosts and use `xterm-ghostty` as TERM, falling back to /// `xterm-256color` with environment variables if terminfo installation fails. -/// -/// Example: `cursor`, `no-cursor`, `sudo`, `no-sudo`, `title`, `no-title` @"shell-integration-features": ShellIntegrationFeatures = .{}, +/// Custom entries into the command palette. +/// +/// 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. +/// +/// ```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 = .{}, + /// Sets the reporting format for OSC sequences that request color information. /// Ghostty currently supports OSC 10 (foreground), OSC 11 (background), and /// OSC 4 (256 color palette) queries, and by default the reported values @@ -2802,6 +2911,9 @@ 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, @@ -3316,6 +3428,15 @@ 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 => {}, } } @@ -4980,25 +5101,29 @@ pub const Keybinds = struct { .{ .key = .{ .unicode = 'w' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .close_tab = {} }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_left }, .mods = .{ .ctrl = true, .shift = true } }, .{ .previous_tab = {} }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_right }, .mods = .{ .ctrl = true, .shift = true } }, .{ .next_tab = {} }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .page_up }, .mods = .{ .ctrl = true } }, .{ .previous_tab = {} }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .page_down }, .mods = .{ .ctrl = true } }, .{ .next_tab = {} }, + .{ .performable = true }, ); try self.set.put( alloc, @@ -5010,57 +5135,67 @@ pub const Keybinds = struct { .{ .key = .{ .unicode = 'e' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_split = .down }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .bracket_left }, .mods = .{ .ctrl = true, .super = true } }, .{ .goto_split = .previous }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .bracket_right }, .mods = .{ .ctrl = true, .super = true } }, .{ .goto_split = .next }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .up }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_down }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .down }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_left }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .left }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_right }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .right }, + .{ .performable = true }, ); // Resizing splits - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_up }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .up, 10 } }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_down }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .down, 10 } }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_left }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .left, 10 } }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_right }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .right, 10 } }, + .{ .performable = true }, ); // Viewport scrolling @@ -5131,22 +5266,24 @@ pub const Keybinds = struct { const end: u21 = '8'; var i: u21 = start; while (i <= end) : (i += 1) { - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .unicode = i }, .mods = mods, }, .{ .goto_tab = (i - start) + 1 }, + .{ .performable = true }, ); } - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .unicode = '9' }, .mods = mods, }, .{ .last_tab = {} }, + .{ .performable = true }, ); } @@ -6118,6 +6255,150 @@ pub const ShellIntegrationFeatures = packed struct { @"ssh-terminfo": bool = false, }; +pub const RepeatableCommand = struct { + value: std.ArrayListUnmanaged(inputpkg.Command) = .empty, + + 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, + ) !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); + } +}; + /// OSC 4, 10, 11, and 12 default color reporting format. pub const OSCColorReportFormat = enum { none, @@ -6573,6 +6854,28 @@ 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 From 59229d76816d11d2e651b55401504fad7d26e4f5 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Wed, 25 Jun 2025 16:36:08 -0700 Subject: [PATCH 35/52] style: revert fish_indent quote removal forgot to disable autoformat for this buffer (again) --- .../fish/vendor_conf.d/ghostty-shell-integration.fish | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 9b82f5fa2..083087bd5 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 From e5e2a56c983d676291a37c65dc9d82484d9b1b1b Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Wed, 25 Jun 2025 16:47:17 -0700 Subject: [PATCH 36/52] fix: use imported modules consistently in action dispatch --- src/cli/action.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/action.zig b/src/cli/action.zig index db9b54cd3..9d1fad027 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -163,8 +163,8 @@ pub const Action = enum { .@"list-themes" => try list_themes.run(alloc), .@"list-colors" => try list_colors.run(alloc), .@"list-actions" => try list_actions.run(alloc), - .@"list-ssh-cache" => @import("list_ssh_cache.zig").run(alloc), - .@"clear-ssh-cache" => @import("clear_ssh_cache.zig").run(alloc), + .@"list-ssh-cache" => try list_ssh_cache.run(alloc), + .@"clear-ssh-cache" => try clear_ssh_cache.run(alloc), .@"edit-config" => try edit_config.run(alloc), .@"show-config" => try show_config.run(alloc), .@"validate-config" => try validate_config.run(alloc), From 1873add697ac9a4b9b5b73d1d6225689eebfa071 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Wed, 25 Jun 2025 17:00:17 -0700 Subject: [PATCH 37/52] docs: call out bash dependency --- src/shell-integration/README.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index c09ea34db..36a6f9de1 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -93,12 +93,18 @@ fi The `shared/` directory contains utilities available to all shell integrations: -- `ghostty-ssh-cache`: A standalone script that manages the SSH terminfo host - cache for the `ssh-terminfo` shell integration feature. This script handles - cache file operations (list, clear, check, add) and is called by all shell - integrations when `ssh-terminfo` is enabled. It is also called by the - `+list-ssh-cache` and `+clear-ssh-cache` CLI actions, providing users with - direct cache management capabilities. +### ghostty-ssh-cache + +> [!NOTE] +> +> This script requires `bash` to be available in the system PATH. + +This is a standalone script that manages the SSH terminfo host cache for the +`ssh-terminfo` shell integration feature. This script handles cache file +operations (list, clear, check, add) and is called by all shell integrations +when `ssh-terminfo` is enabled. It is also called by the `+list-ssh-cache` +and `+clear-ssh-cache` CLI actions, providing users with direct cache +management capabilities. The shared approach maintains separation of concerns by keeping shell-specific integration files independent of secondary logic. From eed2006b4d7110b935c14f3fcb7ba5679aa010e5 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Wed, 25 Jun 2025 17:47:01 -0700 Subject: [PATCH 38/52] fix: correct resources directory fallback path and eliminate code duplication in ssh_cache - Fix fallback path from full path to "src" since full path is built later - Extract duplicate code from listCachedHosts and clearCache into runCacheCommand helper - Addresses feedback from @00-kat --- src/cli/ssh_cache.zig | 38 +++++++++----------------------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig index c3484735a..02462816c 100644 --- a/src/cli/ssh_cache.zig +++ b/src/cli/ssh_cache.zig @@ -7,19 +7,19 @@ fn getCacheScriptPath(alloc: Allocator) ![]u8 { // Use GHOSTTY_RESOURCES_DIR if available, otherwise assume relative path const resources_dir = std.process.getEnvVarOwned(alloc, "GHOSTTY_RESOURCES_DIR") catch { // Fallback: assume we're running from build directory - return try alloc.dupe(u8, "src/shell-integration/shared/ghostty-ssh-cache"); + return try alloc.dupe(u8, "src"); }; defer alloc.free(resources_dir); return try std.fs.path.join(alloc, &[_][]const u8{ resources_dir, "shell-integration", "shared", "ghostty-ssh-cache" }); } -/// List cached hosts by calling the external script -pub fn listCachedHosts(alloc: Allocator, writer: anytype) !void { +/// Generic function to run cache script commands +fn runCacheCommand(alloc: Allocator, writer: anytype, command: []const u8) !void { const script_path = try getCacheScriptPath(alloc); defer alloc.free(script_path); - var child = Child.init(&[_][]const u8{ script_path, "list" }, alloc); + var child = Child.init(&[_][]const u8{ script_path, command }, alloc); child.stdout_behavior = .Pipe; child.stderr_behavior = .Pipe; @@ -38,34 +38,14 @@ pub fn listCachedHosts(alloc: Allocator, writer: anytype) !void { if (stderr.len > 0) { try writer.writeAll(stderr); } +} - // Script handles its own success/error messaging, so we don't need to check exit code +/// List cached hosts by calling the external script +pub fn listCachedHosts(alloc: Allocator, writer: anytype) !void { + try runCacheCommand(alloc, writer, "list"); } /// Clear cache by calling the external script pub fn clearCache(alloc: Allocator, writer: anytype) !void { - const script_path = try getCacheScriptPath(alloc); - defer alloc.free(script_path); - - var child = Child.init(&[_][]const u8{ script_path, "clear" }, alloc); - child.stdout_behavior = .Pipe; - child.stderr_behavior = .Pipe; - - try child.spawn(); - - const stdout = try child.stdout.?.readToEndAlloc(alloc, std.math.maxInt(usize)); - defer alloc.free(stdout); - - const stderr = try child.stderr.?.readToEndAlloc(alloc, std.math.maxInt(usize)); - defer alloc.free(stderr); - - _ = try child.wait(); - - // Output the results regardless of exit code - try writer.writeAll(stdout); - if (stderr.len > 0) { - try writer.writeAll(stderr); - } - - // Script handles its own success/error messaging, so we don't need to check exit code + try runCacheCommand(alloc, writer, "clear"); } From b5372468e46d0aee5ec42c61c567d67c1a0c5ab6 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Wed, 25 Jun 2025 17:47:26 -0700 Subject: [PATCH 39/52] docs: clarify infocmp/tic requirements for ssh-terminfo feature infocmp is required locally to extract terminfo, tic is required on remote hosts to install it --- src/config/Config.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 44e7ab3b6..0f9efd34d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2069,10 +2069,10 @@ keybind: Keybinds = .{}, /// /// * `ssh-terminfo` - Enable automatic terminfo installation on remote hosts. /// Attempts to install Ghostty's terminfo entry using `infocmp` and `tic` when -/// connecting to hosts that lack it. Requires `infocmp` and `tic` to be available -/// locally. Provides `+list-ssh-cache` and `+clear-ssh-cache` CLI actions for -/// managing the installation cache (caching is otherwise automatic and requires -/// no user intervention). +/// connecting to hosts that lack it. Requires `infocmp` to be available locally +/// and `tic` to be available on remote hosts. Provides `+list-ssh-cache` and +/// `+clear-ssh-cache` CLI actions for managing the installation cache (caching +/// is otherwise automatic and requires no user intervention). /// /// SSH features work independently and can be combined for optimal experience: /// when both `ssh-env` and `ssh-terminfo` are enabled, Ghostty will install its From 076f742dd4da1b1a90c98d237977c356a2c7f079 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Wed, 25 Jun 2025 17:50:15 -0700 Subject: [PATCH 40/52] fix: replace non-existent GHOSTTY_VERSION with TERM_PROGRAM_VERSION in shell integration GHOSTTY_VERSION was mistakenly referenced but is never set. Use TERM_PROGRAM_VERSION which is actually provided by Exec.zig from build_config.version_string. --- src/shell-integration/bash/ghostty.bash | 2 +- .../elvish/lib/ghostty-integration.elv | 10 +++++----- .../fish/vendor_conf.d/ghostty-shell-integration.fish | 4 ++-- src/shell-integration/zsh/ghostty-integration | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index f07df125e..b51dae9c7 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -111,7 +111,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then local vars=( COLORTERM=truecolor TERM_PROGRAM=ghostty - ${GHOSTTY_VERSION:+TERM_PROGRAM_VERSION=$GHOSTTY_VERSION} + ${TERM_PROGRAM_VERSION:+TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION} ) for v in "${vars[@]}"; do builtin export "${v?}" diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 7048d56b3..084861434 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -119,10 +119,10 @@ COLORTERM=truecolor TERM_PROGRAM=ghostty ] - if (not-eq $E:GHOSTTY_VERSION '') { - set vars = [$@vars TERM_PROGRAM_VERSION=$E:GHOSTTY_VERSION] + if (not-eq $E:TERM_PROGRAM_VERSION '') { + set vars = [$@vars TERM_PROGRAM_VERSION=$E:TERM_PROGRAM_VERSION] } - + for v $vars { set-env (str:split = $v | take 1) (str:split = $v | drop 1 | str:join =) var varname = (str:split = $v | take 1) @@ -147,7 +147,7 @@ } catch { echo "Warning: xterm-ghostty terminfo not found locally." >&2 } - + if (not-eq $tinfo '') { echo "Setting up Ghostty terminfo on remote host..." >&2 var cpath = '/tmp/ghostty-ssh-'$E:USER'-'(randint 0 32767)'-'(date +%s) @@ -156,7 +156,7 @@ command -v tic >/dev/null 2>&1 || { echo NO_TIC; exit 1; } mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && echo OK || echo FAIL ') - + if (eq $result OK) { echo "Terminfo setup complete." >&2 if (not-eq $target '') { $_CACHE add $target } 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 083087bd5..4c780b5a7 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 @@ -105,8 +105,8 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" COLORTERM=truecolor \ TERM_PROGRAM=ghostty - if test -n "$GHOSTTY_VERSION" - set -a vars "TERM_PROGRAM_VERSION=$GHOSTTY_VERSION" + if test -n "$TERM_PROGRAM_VERSION" + set -a vars "TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION" end for v in $vars diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 4d4461775..48f8cc934 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -264,7 +264,7 @@ _ghostty_deferred_init() { vars=( COLORTERM=truecolor TERM_PROGRAM=ghostty - ${GHOSTTY_VERSION:+TERM_PROGRAM_VERSION=$GHOSTTY_VERSION} + ${TERM_PROGRAM_VERSION:+TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION} ) for v in "${vars[@]}"; do export "${v?}" @@ -277,7 +277,7 @@ _ghostty_deferred_init() { # Get target (only when needed for terminfo) local target target=$(command ssh -G "$@" 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') - + if [[ -n "$target" ]] && "$_CACHE" chk "$target"; then env+=(TERM=xterm-ghostty) elif command -v infocmp >/dev/null 2>&1; then From e25aa9f424097938362a9d6b02fa32c1714ab97f Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Tue, 1 Jul 2025 17:52:36 -0700 Subject: [PATCH 41/52] docs: update to reflect changes after porting terminfo host caching to Zig - Minor tweak to Config.zig to show the new action. - Rolled back README.md to remove reference to the now non-existent 'shared' subdir and bash-based cache script. --- src/config/Config.zig | 7 ++++--- src/shell-integration/README.md | 20 -------------------- 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 0f9efd34d..dee2fe10a 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2070,9 +2070,10 @@ keybind: Keybinds = .{}, /// * `ssh-terminfo` - Enable automatic terminfo installation on remote hosts. /// Attempts to install Ghostty's terminfo entry using `infocmp` and `tic` when /// connecting to hosts that lack it. Requires `infocmp` to be available locally -/// and `tic` to be available on remote hosts. Provides `+list-ssh-cache` and -/// `+clear-ssh-cache` CLI actions for managing the installation cache (caching -/// is otherwise automatic and requires no user intervention). +/// and `tic` to be available on remote hosts. Once terminfo is installed on a +/// remote host, it will be automatically "cached" to avoid repeat installations. +/// If desired, the `+ssh-cache` CLI action can be used to manage the installation +/// cache manually using various arguments. /// /// SSH features work independently and can be combined for optimal experience: /// when both `ssh-env` and `ssh-terminfo` are enabled, Ghostty will install its diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 36a6f9de1..1fd11091d 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -88,23 +88,3 @@ if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then source "$GHOSTTY_RESOURCES_DIR"/shell-integration/zsh/ghostty-integration fi ``` - -## Shared Resources - -The `shared/` directory contains utilities available to all shell integrations: - -### ghostty-ssh-cache - -> [!NOTE] -> -> This script requires `bash` to be available in the system PATH. - -This is a standalone script that manages the SSH terminfo host cache for the -`ssh-terminfo` shell integration feature. This script handles cache file -operations (list, clear, check, add) and is called by all shell integrations -when `ssh-terminfo` is enabled. It is also called by the `+list-ssh-cache` -and `+clear-ssh-cache` CLI actions, providing users with direct cache -management capabilities. - -The shared approach maintains separation of concerns by keeping shell-specific -integration files independent of secondary logic. From 75c703071a6ab176f2d7982a17cde1e593e14737 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Thu, 3 Jul 2025 20:11:45 -0700 Subject: [PATCH 42/52] feat(ssh): rewrite SSH cache system in native Zig - Eliminates standalone bash dependency - Consolidates `+list-ssh-cache` and `+clear-ssh-cache` actions into single `+ssh-cache` action with args - Structured cache format with timestamps and expiration support - Memory-safe entry handling with proper file locking - Comprehensive hostname validation (IPv4/IPv6/domains) - Atomic updates via temp file + rename - Updated shell integrations for improved cross-platform support and reliability - Cache operations are now unit-testable --- src/cli/action.zig | 16 +- src/cli/clear_ssh_cache.zig | 40 - src/cli/list_ssh_cache.zig | 41 - src/cli/ssh_cache.zig | 769 +++++++++++++++++- src/shell-integration/bash/ghostty.bash | 197 +++-- .../elvish/lib/ghostty-integration.elv | 297 +++++-- .../ghostty-shell-integration.fish | 213 +++-- .../shared/ghostty-ssh-cache | 11 - src/shell-integration/zsh/ghostty-integration | 194 +++-- 9 files changed, 1401 insertions(+), 377 deletions(-) delete mode 100644 src/cli/clear_ssh_cache.zig delete mode 100644 src/cli/list_ssh_cache.zig delete mode 100755 src/shell-integration/shared/ghostty-ssh-cache diff --git a/src/cli/action.zig b/src/cli/action.zig index 9d1fad027..728f36efe 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -9,8 +9,7 @@ const list_keybinds = @import("list_keybinds.zig"); const list_themes = @import("list_themes.zig"); const list_colors = @import("list_colors.zig"); const list_actions = @import("list_actions.zig"); -const list_ssh_cache = @import("list_ssh_cache.zig"); -const clear_ssh_cache = @import("clear_ssh_cache.zig"); +const ssh_cache = @import("ssh_cache.zig"); const edit_config = @import("edit_config.zig"); const show_config = @import("show_config.zig"); const validate_config = @import("validate_config.zig"); @@ -43,11 +42,8 @@ pub const Action = enum { /// List keybind actions @"list-actions", - /// List hosts cached by SSH shell integration for terminfo installation - @"list-ssh-cache", - - /// Clear hosts cached by SSH shell integration for terminfo installation - @"clear-ssh-cache", + /// Manage SSH terminfo cache for automatic remote host setup + @"ssh-cache", /// Edit the config file in the configured terminal editor. @"edit-config", @@ -163,8 +159,7 @@ pub const Action = enum { .@"list-themes" => try list_themes.run(alloc), .@"list-colors" => try list_colors.run(alloc), .@"list-actions" => try list_actions.run(alloc), - .@"list-ssh-cache" => try list_ssh_cache.run(alloc), - .@"clear-ssh-cache" => try clear_ssh_cache.run(alloc), + .@"ssh-cache" => try ssh_cache.run(alloc), .@"edit-config" => try edit_config.run(alloc), .@"show-config" => try show_config.run(alloc), .@"validate-config" => try validate_config.run(alloc), @@ -202,8 +197,7 @@ pub const Action = enum { .@"list-themes" => list_themes.Options, .@"list-colors" => list_colors.Options, .@"list-actions" => list_actions.Options, - .@"list-ssh-cache" => list_ssh_cache.Options, - .@"clear-ssh-cache" => clear_ssh_cache.Options, + .@"ssh-cache" => ssh_cache.Options, .@"edit-config" => edit_config.Options, .@"show-config" => show_config.Options, .@"validate-config" => validate_config.Options, diff --git a/src/cli/clear_ssh_cache.zig b/src/cli/clear_ssh_cache.zig deleted file mode 100644 index 062af4221..000000000 --- a/src/cli/clear_ssh_cache.zig +++ /dev/null @@ -1,40 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const args = @import("args.zig"); -const Action = @import("action.zig").Action; -const ssh_cache = @import("ssh_cache.zig"); - -pub const Options = struct { - pub fn deinit(self: Options) void { - _ = self; - } - - /// Enables `-h` and `--help` to work. - pub fn help(self: Options) !void { - _ = self; - return Action.help_error; - } -}; - -/// Clear the Ghostty SSH terminfo cache. -/// -/// This command removes the cache of hosts where Ghostty's terminfo has been installed -/// via the ssh-terminfo shell integration feature. After clearing, terminfo will be -/// reinstalled on the next SSH connection to previously cached hosts. -/// -/// Use this if you need to force reinstallation of terminfo or clean up old entries. -pub fn run(alloc: Allocator) !u8 { - var opts: Options = .{}; - defer opts.deinit(); - - { - var iter = try args.argsIterator(alloc); - defer iter.deinit(); - try args.parse(Options, alloc, &opts, &iter); - } - - const stdout = std.io.getStdOut().writer(); - try ssh_cache.clearCache(alloc, stdout); - - return 0; -} diff --git a/src/cli/list_ssh_cache.zig b/src/cli/list_ssh_cache.zig deleted file mode 100644 index b4328201d..000000000 --- a/src/cli/list_ssh_cache.zig +++ /dev/null @@ -1,41 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const args = @import("args.zig"); -const Action = @import("action.zig").Action; -const ssh_cache = @import("ssh_cache.zig"); - -pub const Options = struct { - pub fn deinit(self: Options) void { - _ = self; - } - - /// Enables `-h` and `--help` to work. - pub fn help(self: Options) !void { - _ = self; - return Action.help_error; - } -}; - -/// List hosts with Ghostty SSH terminfo installed via the ssh-terminfo shell integration feature. -/// -/// This command shows all remote hosts where Ghostty's terminfo has been successfully -/// installed through the SSH integration. The cache is automatically maintained when -/// connecting to remote hosts with `shell-integration-features = ssh-terminfo` enabled. -/// -/// Use `+clear-ssh-cache` to remove cached entries if you need to force terminfo -/// reinstallation or clean up stale host entries. -pub fn run(alloc: Allocator) !u8 { - var opts: Options = .{}; - defer opts.deinit(); - - { - var iter = try args.argsIterator(alloc); - defer iter.deinit(); - try args.parse(Options, alloc, &opts, &iter); - } - - const stdout = std.io.getStdOut().writer(); - try ssh_cache.listCachedHosts(alloc, stdout); - - return 0; -} diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig index 02462816c..71c47a7a7 100644 --- a/src/cli/ssh_cache.zig +++ b/src/cli/ssh_cache.zig @@ -1,51 +1,754 @@ const std = @import("std"); +const fs = std.fs; const Allocator = std.mem.Allocator; -const Child = std.process.Child; +const xdg = @import("../os/xdg.zig"); +const args = @import("args.zig"); +const Action = @import("action.zig").Action; -/// Get the path to the shared cache script -fn getCacheScriptPath(alloc: Allocator) ![]u8 { - // Use GHOSTTY_RESOURCES_DIR if available, otherwise assume relative path - const resources_dir = std.process.getEnvVarOwned(alloc, "GHOSTTY_RESOURCES_DIR") catch { - // Fallback: assume we're running from build directory - return try alloc.dupe(u8, "src"); - }; - defer alloc.free(resources_dir); +pub const CacheError = error{ + InvalidCacheKey, + CacheLocked, +} || fs.File.OpenError || fs.File.WriteError || Allocator.Error; - return try std.fs.path.join(alloc, &[_][]const u8{ resources_dir, "shell-integration", "shared", "ghostty-ssh-cache" }); +const MAX_CACHE_SIZE = 512 * 1024; // 512KB - sufficient for approximately 10k entries +const NEVER_EXPIRE = 0; +const SECONDS_PER_DAY = 86400; + +pub const Options = struct { + clear: bool = false, + add: ?[]const u8 = null, + remove: ?[]const u8 = null, + host: ?[]const u8 = null, + @"expire-days": u32 = NEVER_EXPIRE, + + pub fn deinit(self: *Options) void { + _ = self; + } + + pub fn help(self: Options) !void { + _ = self; + return Action.help_error; + } +}; + +const CacheEntry = struct { + hostname: []const u8, + timestamp: i64, + terminfo_version: []const u8, + + fn parse(line: []const u8) ?CacheEntry { + const trimmed = std.mem.trim(u8, line, " \t\r\n"); + if (trimmed.len == 0) return null; + + // Parse format: hostname|timestamp|terminfo_version + var iter = std.mem.tokenizeScalar(u8, trimmed, '|'); + const hostname = iter.next() orelse return null; + const timestamp_str = iter.next() orelse return null; + const terminfo_version = iter.next() orelse "xterm-ghostty"; + + const timestamp = std.fmt.parseInt(i64, timestamp_str, 10) catch |err| { + std.log.warn("Invalid timestamp in cache entry: {s} err={}", .{ timestamp_str, err }); + return null; + }; + + return CacheEntry{ + .hostname = hostname, + .timestamp = timestamp, + .terminfo_version = terminfo_version, + }; + } + + fn format(self: CacheEntry, writer: anytype) !void { + try writer.print("{s}|{d}|{s}\n", .{ self.hostname, self.timestamp, self.terminfo_version }); + } + + fn isExpired(self: CacheEntry, expire_days: u32) bool { + if (expire_days == NEVER_EXPIRE) return false; + const now = std.time.timestamp(); + const age_days = @divTrunc(now - self.timestamp, SECONDS_PER_DAY); + return age_days > expire_days; + } +}; + +const AddResult = enum { + added, + updated, +}; + +fn getCachePath(allocator: Allocator) ![]const u8 { + const state_dir = try xdg.state(allocator, .{ .subdir = "ghostty" }); + defer allocator.free(state_dir); + return try std.fs.path.join(allocator, &.{ state_dir, "ssh_cache" }); } -/// Generic function to run cache script commands -fn runCacheCommand(alloc: Allocator, writer: anytype, command: []const u8) !void { - const script_path = try getCacheScriptPath(alloc); - defer alloc.free(script_path); +// Supports both standalone hostnames and user@hostname format +fn isValidCacheKey(key: []const u8) bool { + if (key.len == 0 or key.len > 320) return false; // 253 + 1 + 64 for user@hostname - var child = Child.init(&[_][]const u8{ script_path, command }, alloc); - child.stdout_behavior = .Pipe; - child.stderr_behavior = .Pipe; + // Check for user@hostname format + if (std.mem.indexOf(u8, key, "@")) |at_pos| { + const user = key[0..at_pos]; + const hostname = key[at_pos + 1 ..]; + return isValidUser(user) and isValidHostname(hostname); + } - try child.spawn(); + return isValidHostname(key); +} - const stdout = try child.stdout.?.readToEndAlloc(alloc, std.math.maxInt(usize)); - defer alloc.free(stdout); +// Basic hostname validation - accepts domains and IPs (including IPv6 in brackets) +fn isValidHostname(host: []const u8) bool { + if (host.len == 0 or host.len > 253) return false; - const stderr = try child.stderr.?.readToEndAlloc(alloc, std.math.maxInt(usize)); - defer alloc.free(stderr); + // Handle IPv6 addresses in brackets + if (host.len >= 4 and host[0] == '[' and host[host.len - 1] == ']') { + const ipv6_part = host[1 .. host.len - 1]; + if (ipv6_part.len == 0) return false; + var has_colon = false; + for (ipv6_part) |c| { + switch (c) { + 'a'...'f', 'A'...'F', '0'...'9', ':' => { + if (c == ':') has_colon = true; + }, + else => return false, + } + } + return has_colon; + } - _ = try child.wait(); + // Standard hostname/domain validation + for (host) |c| { + switch (c) { + 'a'...'z', 'A'...'Z', '0'...'9', '.', '-' => {}, + else => return false, + } + } - // Output the results regardless of exit code - try writer.writeAll(stdout); - if (stderr.len > 0) { - try writer.writeAll(stderr); + // No leading/trailing dots or hyphens, no consecutive dots + if (host[0] == '.' or host[0] == '-' or + host[host.len - 1] == '.' or host[host.len - 1] == '-') + { + return false; + } + + return std.mem.indexOf(u8, host, "..") == null; +} + +fn isValidUser(user: []const u8) bool { + if (user.len == 0 or user.len > 64) return false; + for (user) |c| { + switch (c) { + 'a'...'z', 'A'...'Z', '0'...'9', '_', '-', '.' => {}, + else => return false, + } + } + return true; +} + +fn acquireFileLock(file: fs.File) CacheError!void { + _ = file.tryLock(.exclusive) catch { + return CacheError.CacheLocked; + }; +} + +fn readCacheFile( + alloc: Allocator, + path: []const u8, + entries: *std.StringHashMap(CacheEntry), +) !void { + const file = fs.openFileAbsolute(path, .{}) catch |err| switch (err) { + error.FileNotFound => return, + else => return err, + }; + defer file.close(); + + const content = try file.readToEndAlloc(alloc, MAX_CACHE_SIZE); + defer alloc.free(content); + + var lines = std.mem.tokenizeScalar(u8, content, '\n'); + + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r"); + + if (CacheEntry.parse(trimmed)) |entry| { + // Always allocate hostname first to avoid key pointer confusion + const hostname_copy = try alloc.dupe(u8, entry.hostname); + errdefer alloc.free(hostname_copy); + + const gop = try entries.getOrPut(hostname_copy); + if (!gop.found_existing) { + const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); + gop.value_ptr.* = CacheEntry{ + .hostname = hostname_copy, + .timestamp = entry.timestamp, + .terminfo_version = terminfo_copy, + }; + } else { + // Don't need the copy since entry already exists + alloc.free(hostname_copy); + + // Handle duplicate entries - keep newer timestamp + if (entry.timestamp > gop.value_ptr.timestamp) { + gop.value_ptr.timestamp = entry.timestamp; + if (!std.mem.eql(u8, gop.value_ptr.terminfo_version, entry.terminfo_version)) { + alloc.free(gop.value_ptr.terminfo_version); + const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); + gop.value_ptr.terminfo_version = terminfo_copy; + } + } + } + } } } -/// List cached hosts by calling the external script -pub fn listCachedHosts(alloc: Allocator, writer: anytype) !void { - try runCacheCommand(alloc, writer, "list"); +// Atomic write via temp file + rename, filters out expired entries +fn writeCacheFile( + alloc: Allocator, + path: []const u8, + entries: *const std.StringHashMap(CacheEntry), + expire_days: u32, +) !void { + // Ensure parent directory exists + const dir = std.fs.path.dirname(path).?; + fs.makeDirAbsolute(dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + + // Write to temp file first + const tmp_path = try std.fmt.allocPrint(alloc, "{s}.tmp", .{path}); + defer alloc.free(tmp_path); + + const tmp_file = try fs.createFileAbsolute(tmp_path, .{ .mode = 0o600 }); + defer tmp_file.close(); + errdefer fs.deleteFileAbsolute(tmp_path) catch {}; + + const writer = tmp_file.writer(); + + // Only write non-expired entries + var iter = entries.iterator(); + while (iter.next()) |kv| { + if (!kv.value_ptr.isExpired(expire_days)) { + try kv.value_ptr.format(writer); + } + } + + // Atomic replace + try fs.renameAbsolute(tmp_path, path); } -/// Clear cache by calling the external script -pub fn clearCache(alloc: Allocator, writer: anytype) !void { - try runCacheCommand(alloc, writer, "clear"); +fn checkHost(alloc: Allocator, host: []const u8) !bool { + if (!isValidCacheKey(host)) return CacheError.InvalidCacheKey; + + const path = try getCachePath(alloc); + + var entries = std.StringHashMap(CacheEntry).init(alloc); + + try readCacheFile(alloc, path, &entries); + return entries.contains(host); } + +fn addHost(alloc: Allocator, host: []const u8) !AddResult { + if (!isValidCacheKey(host)) return CacheError.InvalidCacheKey; + + const path = try getCachePath(alloc); + + // Create cache directory if needed + const dir = std.fs.path.dirname(path).?; + fs.makeDirAbsolute(dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + + // Open or create cache file with secure permissions + const file = fs.createFileAbsolute(path, .{ + .read = true, + .truncate = false, + .mode = 0o600, + }) catch |err| switch (err) { + error.PathAlreadyExists => blk: { + const existing_file = fs.openFileAbsolute(path, .{ .mode = .read_write }) catch |open_err| { + return open_err; + }; + + // Verify and fix permissions on existing file + const stat = existing_file.stat() catch |stat_err| { + existing_file.close(); + return stat_err; + }; + + // Ensure file has correct permissions (readable/writable by owner only) + if (stat.mode & 0o777 != 0o600) { + existing_file.chmod(0o600) catch |chmod_err| { + existing_file.close(); + return chmod_err; + }; + } + + break :blk existing_file; + }, + else => return err, + }; + defer file.close(); + + try acquireFileLock(file); + defer file.unlock(); + + var entries = std.StringHashMap(CacheEntry).init(alloc); + + try readCacheFile(alloc, path, &entries); + + // Add or update entry + const gop = try entries.getOrPut(host); + const result = if (!gop.found_existing) blk: { + gop.key_ptr.* = try alloc.dupe(u8, host); + gop.value_ptr.* = .{ + .hostname = gop.key_ptr.*, + .timestamp = std.time.timestamp(), + .terminfo_version = "xterm-ghostty", + }; + break :blk AddResult.added; + } else blk: { + // Update timestamp for existing entry + gop.value_ptr.timestamp = std.time.timestamp(); + break :blk AddResult.updated; + }; + + try writeCacheFile(alloc, path, &entries, NEVER_EXPIRE); + return result; +} + +fn removeHost(alloc: Allocator, host: []const u8) !void { + if (!isValidCacheKey(host)) return CacheError.InvalidCacheKey; + + const path = try getCachePath(alloc); + + const file = fs.openFileAbsolute(path, .{ .mode = .read_write }) catch |err| switch (err) { + error.FileNotFound => return, + else => return err, + }; + defer file.close(); + + try acquireFileLock(file); + defer file.unlock(); + + var entries = std.StringHashMap(CacheEntry).init(alloc); + + try readCacheFile(alloc, path, &entries); + + _ = entries.fetchRemove(host); + + try writeCacheFile(alloc, path, &entries, NEVER_EXPIRE); +} + +fn listHosts(alloc: Allocator, writer: anytype) !void { + const path = try getCachePath(alloc); + + var entries = std.StringHashMap(CacheEntry).init(alloc); + + readCacheFile(alloc, path, &entries) catch |err| switch (err) { + error.FileNotFound => { + try writer.print("No hosts in cache.\n", .{}); + return; + }, + else => return err, + }; + + if (entries.count() == 0) { + try writer.print("No hosts in cache.\n", .{}); + return; + } + + // Sort entries by hostname for consistent output + var items = std.ArrayList(CacheEntry).init(alloc); + defer items.deinit(); + + var iter = entries.iterator(); + while (iter.next()) |kv| { + try items.append(kv.value_ptr.*); + } + + std.mem.sort(CacheEntry, items.items, {}, struct { + fn lessThan(_: void, a: CacheEntry, b: CacheEntry) bool { + return std.mem.lessThan(u8, a.hostname, b.hostname); + } + }.lessThan); + + try writer.print("Cached hosts ({d}):\n", .{items.items.len}); + const now = std.time.timestamp(); + + for (items.items) |entry| { + const age_days = @divTrunc(now - entry.timestamp, SECONDS_PER_DAY); + if (age_days == 0) { + try writer.print(" {s} (today)\n", .{entry.hostname}); + } else if (age_days == 1) { + try writer.print(" {s} (yesterday)\n", .{entry.hostname}); + } else { + try writer.print(" {s} ({d} days ago)\n", .{ entry.hostname, age_days }); + } + } +} + +fn clearCache(alloc: Allocator) !void { + const path = try getCachePath(alloc); + + fs.deleteFileAbsolute(path) catch |err| switch (err) { + error.FileNotFound => {}, + else => return err, + }; +} + +/// Manage the SSH terminfo cache for automatic remote host setup. +/// +/// When SSH integration is enabled with `shell-integration-features = ssh-terminfo`, +/// Ghostty automatically installs its terminfo on remote hosts. This command +/// manages the cache of successful installations to avoid redundant uploads. +/// +/// The cache stores hostnames (or user@hostname combinations) along with timestamps. +/// Entries older than the expiration period are automatically removed during cache +/// operations. By default, entries never expire. +/// +/// Examples: +/// ghostty +ssh-cache # List all cached hosts +/// ghostty +ssh-cache --host=example.com # Check if host is cached +/// ghostty +ssh-cache --add=example.com # Manually add host to cache +/// ghostty +ssh-cache --add=user@example.com # Add user@host combination +/// ghostty +ssh-cache --remove=example.com # Remove host from cache +/// ghostty +ssh-cache --clear # Clear entire cache +/// ghostty +ssh-cache --expire-days=30 # Set custom expiration period +pub fn run(alloc_gpa: Allocator) !u8 { + var arena = std.heap.ArenaAllocator.init(alloc_gpa); + defer arena.deinit(); + const alloc = arena.allocator(); + + var opts: Options = .{}; + defer opts.deinit(); + + { + var iter = try args.argsIterator(alloc_gpa); + defer iter.deinit(); + try args.parse(Options, alloc_gpa, &opts, &iter); + } + + const stdout = std.io.getStdOut().writer(); + const stderr = std.io.getStdErr().writer(); + + if (opts.clear) { + try clearCache(alloc); + try stdout.print("Cache cleared.\n", .{}); + return 0; + } + + if (opts.add) |host| { + const result = addHost(alloc, host) catch |err| switch (err) { + CacheError.InvalidCacheKey => { + try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); + try stderr.print("Expected format: hostname or user@hostname\n", .{}); + return 1; + }, + CacheError.CacheLocked => { + try stderr.print("Error: Cache is busy, try again\n", .{}); + return 1; + }, + error.AccessDenied, error.PermissionDenied => { + try stderr.print("Error: Permission denied\n", .{}); + return 1; + }, + else => { + try stderr.print("Error: Unable to add '{s}' to cache\n", .{host}); + return 1; + }, + }; + + switch (result) { + .added => try stdout.print("Added '{s}' to cache.\n", .{host}), + .updated => try stdout.print("Updated '{s}' cache entry.\n", .{host}), + } + return 0; + } + + if (opts.remove) |host| { + removeHost(alloc, host) catch |err| switch (err) { + CacheError.InvalidCacheKey => { + try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); + try stderr.print("Expected format: hostname or user@hostname\n", .{}); + return 1; + }, + CacheError.CacheLocked => { + try stderr.print("Error: Cache is busy, try again\n", .{}); + return 1; + }, + error.AccessDenied, error.PermissionDenied => { + try stderr.print("Error: Permission denied\n", .{}); + return 1; + }, + else => { + try stderr.print("Error: Unable to remove '{s}' from cache\n", .{host}); + return 1; + }, + }; + try stdout.print("Removed '{s}' from cache.\n", .{host}); + return 0; + } + + if (opts.host) |host| { + const cached = checkHost(alloc, host) catch |err| switch (err) { + CacheError.InvalidCacheKey => { + try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); + try stderr.print("Expected format: hostname or user@hostname\n", .{}); + return 1; + }, + error.AccessDenied, error.PermissionDenied => { + try stderr.print("Error: Permission denied\n", .{}); + return 1; + }, + else => { + try stderr.print("Error: Unable to check host '{s}' in cache\n", .{host}); + return 1; + }, + }; + + if (cached) { + try stdout.print("'{s}' has Ghostty terminfo installed.\n", .{host}); + return 0; + } else { + try stdout.print("'{s}' does not have Ghostty terminfo installed.\n", .{host}); + return 1; + } + } + + // Default action: list all hosts + try listHosts(alloc, stdout); + return 0; +} + +// Tests +test "hostname validation - valid cases" { + const testing = std.testing; + try testing.expect(isValidHostname("example.com")); + try testing.expect(isValidHostname("sub.example.com")); + try testing.expect(isValidHostname("host-name.domain.org")); + try testing.expect(isValidHostname("192.168.1.1")); + try testing.expect(isValidHostname("a")); + try testing.expect(isValidHostname("1")); +} + +test "hostname validation - IPv6 addresses" { + const testing = std.testing; + try testing.expect(isValidHostname("[::1]")); + try testing.expect(isValidHostname("[2001:db8::1]")); + try testing.expect(isValidHostname("[fe80::1%eth0]") == false); // Interface notation not supported + try testing.expect(isValidHostname("[]") == false); // Empty IPv6 + try testing.expect(isValidHostname("[invalid]") == false); // No colons +} + +test "hostname validation - invalid cases" { + const testing = std.testing; + try testing.expect(!isValidHostname("")); + try testing.expect(!isValidHostname("host\nname")); + try testing.expect(!isValidHostname(".example.com")); + try testing.expect(!isValidHostname("example.com.")); + try testing.expect(!isValidHostname("host..domain")); + try testing.expect(!isValidHostname("-hostname")); + try testing.expect(!isValidHostname("hostname-")); + try testing.expect(!isValidHostname("host name")); + try testing.expect(!isValidHostname("host_name")); + try testing.expect(!isValidHostname("host@domain")); + try testing.expect(!isValidHostname("host:port")); + + // Too long + const long_host = "a" ** 254; + try testing.expect(!isValidHostname(long_host)); +} + +test "user validation - valid cases" { + const testing = std.testing; + try testing.expect(isValidUser("user")); + try testing.expect(isValidUser("deploy")); + try testing.expect(isValidUser("test-user")); + try testing.expect(isValidUser("user_name")); + try testing.expect(isValidUser("user.name")); + try testing.expect(isValidUser("user123")); + try testing.expect(isValidUser("a")); +} + +test "user validation - complex realistic cases" { + const testing = std.testing; + try testing.expect(isValidUser("git")); + try testing.expect(isValidUser("ubuntu")); + try testing.expect(isValidUser("root")); + try testing.expect(isValidUser("service.account")); + try testing.expect(isValidUser("user-with-dashes")); +} + +test "user validation - invalid cases" { + const testing = std.testing; + try testing.expect(!isValidUser("")); + try testing.expect(!isValidUser("user name")); + try testing.expect(!isValidUser("user@domain")); + try testing.expect(!isValidUser("user:group")); + try testing.expect(!isValidUser("user\nname")); + + // Too long + const long_user = "a" ** 65; + try testing.expect(!isValidUser(long_user)); +} + +test "cache key validation - hostname format" { + const testing = std.testing; + try testing.expect(isValidCacheKey("example.com")); + try testing.expect(isValidCacheKey("sub.example.com")); + try testing.expect(isValidCacheKey("192.168.1.1")); + try testing.expect(isValidCacheKey("[::1]")); + try testing.expect(!isValidCacheKey("")); + try testing.expect(!isValidCacheKey(".invalid.com")); +} + +test "cache key validation - user@hostname format" { + const testing = std.testing; + try testing.expect(isValidCacheKey("user@example.com")); + try testing.expect(isValidCacheKey("deploy@prod.server.com")); + try testing.expect(isValidCacheKey("test-user@192.168.1.1")); + try testing.expect(isValidCacheKey("user_name@host.domain.org")); + try testing.expect(isValidCacheKey("git@github.com")); + try testing.expect(isValidCacheKey("ubuntu@[::1]")); + try testing.expect(!isValidCacheKey("@example.com")); + try testing.expect(!isValidCacheKey("user@")); + try testing.expect(!isValidCacheKey("user@@host")); + try testing.expect(!isValidCacheKey("user@.invalid.com")); +} + +test "cache entry expiration" { + const testing = std.testing; + const now = std.time.timestamp(); + + const fresh_entry = CacheEntry{ + .hostname = "test.com", + .timestamp = now - SECONDS_PER_DAY, // 1 day old + .terminfo_version = "xterm-ghostty", + }; + try testing.expect(!fresh_entry.isExpired(90)); + + const old_entry = CacheEntry{ + .hostname = "old.com", + .timestamp = now - (SECONDS_PER_DAY * 100), // 100 days old + .terminfo_version = "xterm-ghostty", + }; + try testing.expect(old_entry.isExpired(90)); + + // Test never-expire case + try testing.expect(!old_entry.isExpired(NEVER_EXPIRE)); +} + +test "cache entry expiration - boundary cases" { + const testing = std.testing; + const now = std.time.timestamp(); + + // Exactly at expiration boundary + const boundary_entry = CacheEntry{ + .hostname = "boundary.com", + .timestamp = now - (SECONDS_PER_DAY * 30), // Exactly 30 days old + .terminfo_version = "xterm-ghostty", + }; + try testing.expect(!boundary_entry.isExpired(30)); // Should not be expired + try testing.expect(boundary_entry.isExpired(29)); // Should be expired +} + +test "cache entry parsing - valid formats" { + const testing = std.testing; + + const entry = CacheEntry.parse("example.com|1640995200|xterm-ghostty").?; + try testing.expectEqualStrings("example.com", entry.hostname); + try testing.expectEqual(@as(i64, 1640995200), entry.timestamp); + try testing.expectEqualStrings("xterm-ghostty", entry.terminfo_version); + + // Test default terminfo version + const entry_no_version = CacheEntry.parse("test.com|1640995200").?; + try testing.expectEqualStrings("xterm-ghostty", entry_no_version.terminfo_version); + + // Test complex hostnames + const complex_entry = CacheEntry.parse("user@server.example.com|1640995200|xterm-ghostty").?; + try testing.expectEqualStrings("user@server.example.com", complex_entry.hostname); +} + +test "cache entry parsing - invalid formats" { + const testing = std.testing; + + try testing.expect(CacheEntry.parse("") == null); + try testing.expect(CacheEntry.parse("v1") == null); // Invalid format (no pipe) + try testing.expect(CacheEntry.parse("example.com") == null); // Missing timestamp + try testing.expect(CacheEntry.parse("example.com|invalid") == null); // Invalid timestamp + try testing.expect(CacheEntry.parse("example.com|1640995200|") != null); // Empty terminfo should default +} + +test "cache entry parsing - malformed data resilience" { + const testing = std.testing; + + // Extra pipes should not break parsing + try testing.expect(CacheEntry.parse("host|123|term|extra") != null); + + // Whitespace handling + try testing.expect(CacheEntry.parse(" host|123|term ") != null); + try testing.expect(CacheEntry.parse("\n") == null); + try testing.expect(CacheEntry.parse(" \t \n") == null); +} + +test "duplicate cache entries - memory management" { + const testing = std.testing; + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var entries = std.StringHashMap(CacheEntry).init(alloc); + defer entries.deinit(); + + // Simulate reading a cache file with duplicate hostnames + const cache_content = "example.com|1640995200|xterm-ghostty\nexample.com|1640995300|xterm-ghostty-v2\n"; + + var lines = std.mem.tokenizeScalar(u8, cache_content, '\n'); + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r"); + if (CacheEntry.parse(trimmed)) |entry| { + const gop = try entries.getOrPut(entry.hostname); + if (!gop.found_existing) { + const hostname_copy = try alloc.dupe(u8, entry.hostname); + const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); + gop.key_ptr.* = hostname_copy; + gop.value_ptr.* = CacheEntry{ + .hostname = hostname_copy, + .timestamp = entry.timestamp, + .terminfo_version = terminfo_copy, + }; + } else { + // Test the duplicate handling logic + if (entry.timestamp > gop.value_ptr.timestamp) { + gop.value_ptr.timestamp = entry.timestamp; + if (!std.mem.eql(u8, gop.value_ptr.terminfo_version, entry.terminfo_version)) { + alloc.free(gop.value_ptr.terminfo_version); + const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); + gop.value_ptr.terminfo_version = terminfo_copy; + } + } + } + } + } + + // Verify only one entry exists with the newer timestamp + try testing.expect(entries.count() == 1); + const entry = entries.get("example.com").?; + try testing.expectEqual(@as(i64, 1640995300), entry.timestamp); + try testing.expectEqualStrings("xterm-ghostty-v2", entry.terminfo_version); +} + +test "concurrent access simulation - file locking" { + const testing = std.testing; + + // This test simulates the file locking mechanism + // In practice, this would require actual file operations + // but we can test the error handling logic + + const TestError = error{CacheLocked}; + + const result = TestError.CacheLocked; + try testing.expectError(TestError.CacheLocked, result); +} + diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index b51dae9c7..8c4cd9e12 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -97,73 +97,172 @@ fi # SSH Integration if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then + : "${GHOSTTY_SSH_CACHE_TIMEOUT:=5}" + : "${GHOSTTY_SSH_CHECK_TIMEOUT:=3}" - if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then - readonly _CACHE="${GHOSTTY_RESOURCES_DIR}/shell-integration/shared/ghostty-ssh-cache" - fi - - # SSH wrapper + # SSH wrapper that preserves Ghostty features across remote connections ssh() { - local env=() opts=() ctrl=() + local ssh_env=() ssh_opts=() - # Set up env vars first so terminfo installation inherits them + # Configure environment variables for remote session if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then - local vars=( - COLORTERM=truecolor - TERM_PROGRAM=ghostty - ${TERM_PROGRAM_VERSION:+TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION} + local -a ssh_env_vars=( + "COLORTERM=truecolor" + "TERM_PROGRAM=ghostty" ) - for v in "${vars[@]}"; do - builtin export "${v?}" - opts+=(-o "SendEnv ${v%=*}" -o "SetEnv $v") + if [[ -n "$TERM_PROGRAM_VERSION" ]]; then + ssh_env_vars+=("TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION") + fi + + # Temporarily export variables for SSH transmission + local -a ssh_exported_vars=() + for ssh_v in "${ssh_env_vars[@]}"; do + local ssh_var_name="${ssh_v%%=*}" + + if [[ -n "${!ssh_var_name+x}" ]]; then + ssh_exported_vars+=("$ssh_var_name=${!ssh_var_name}") + else + ssh_exported_vars+=("$ssh_var_name") + fi + + builtin export "${ssh_v?}" + + # Use both SendEnv and SetEnv for maximum compatibility + ssh_opts+=(-o "SendEnv $ssh_var_name") + ssh_opts+=(-o "SetEnv $ssh_v") done + + ssh_env+=("${ssh_env_vars[@]}") fi - # Install terminfo if needed, reuse control connection for main session + # Install terminfo on remote host if needed if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then - # Get target (only when needed for terminfo) - builtin local target - target=$(builtin command ssh -G "$@" 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') - - if [[ -n "$target" ]] && "$_CACHE" chk "$target"; then - env+=(TERM=xterm-ghostty) - elif builtin command -v infocmp >/dev/null 2>&1; then - builtin local tinfo - tinfo=$(infocmp -x xterm-ghostty 2>/dev/null) || builtin echo "Warning: xterm-ghostty terminfo not found locally." >&2 - if [[ -n "$tinfo" ]]; then - builtin echo "Setting up Ghostty terminfo on remote host..." >&2 - builtin local cpath - cpath="/tmp/ghostty-ssh-$USER-$RANDOM-$(date +%s)" - case $(builtin echo "$tinfo" | builtin command ssh "${opts[@]}" -o ControlMaster=yes -o ControlPath="$cpath" -o ControlPersist=60s "$@" ' - infocmp xterm-ghostty >/dev/null 2>&1 && echo OK && exit - command -v tic >/dev/null 2>&1 || { echo NO_TIC; exit 1; } - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && echo OK || echo FAIL - ') in - OK) - builtin echo "Terminfo setup complete." >&2 - [[ -n "$target" ]] && "$_CACHE" add "$target" - env+=(TERM=xterm-ghostty) - ctrl+=(-o "ControlPath=$cpath") - ;; - *) builtin echo "Warning: Failed to install terminfo." >&2 ;; - esac + builtin local ssh_config ssh_user ssh_hostname + ssh_config=$(builtin command ssh -G "$@" 2>/dev/null) + ssh_user=$(echo "$ssh_config" | while IFS=' ' read -r ssh_key ssh_value; do + [[ "$ssh_key" == "ssh_user" ]] && echo "$ssh_value" && break + done) + ssh_hostname=$(echo "$ssh_config" | while IFS=' ' read -r ssh_key ssh_value; do + [[ "$ssh_key" == "hostname" ]] && echo "$ssh_value" && break + done) + ssh_target="${ssh_user}@${ssh_hostname}" + + if [[ -n "$ssh_hostname" ]]; then + # Detect timeout command (BSD compatibility) + local ssh_timeout_cmd="" + if command -v timeout >/dev/null 2>&1; then + ssh_timeout_cmd="timeout" + elif command -v gtimeout >/dev/null 2>&1; then + ssh_timeout_cmd="gtimeout" + fi + + # Check if terminfo is already cached + local ssh_cache_check_success=false + if command -v ghostty >/dev/null 2>&1; then + if [[ -n "$ssh_timeout_cmd" ]]; then + $ssh_timeout_cmd "${GHOSTTY_SSH_CHECK_TIMEOUT}s" ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 && ssh_cache_check_success=true + else + ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 && ssh_cache_check_success=true + fi + fi + + if [[ "$ssh_cache_check_success" == "true" ]]; then + ssh_env+=(TERM=xterm-ghostty) + elif builtin command -v infocmp >/dev/null 2>&1; then + builtin local ssh_terminfo + + # Generate terminfo data (BSD base64 compatibility) + if base64 --help 2>&1 | grep -q GNU; then + ssh_terminfo=$(infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 -w0 2>/dev/null) + else + ssh_terminfo=$(infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 2>/dev/null | tr -d '\n') + fi + + if [[ -n "$ssh_terminfo" ]]; then + builtin echo "Setting up Ghostty terminfo on remote host..." >&2 + builtin local ssh_cpath_dir ssh_cpath + + ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$" + ssh_cpath="$ssh_cpath_dir/socket" + + local ssh_base64_decode_cmd + if base64 --help 2>&1 | grep -q GNU; then + ssh_base64_decode_cmd="base64 -d" + else + ssh_base64_decode_cmd="base64 -D" + fi + + if builtin echo "$ssh_terminfo" | $ssh_base64_decode_cmd | builtin command ssh "${ssh_opts[@]}" -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + ' 2>/dev/null; then + builtin echo "Terminfo setup complete." >&2 + ssh_env+=(TERM=xterm-ghostty) + ssh_opts+=(-o "ControlPath=$ssh_cpath") + + # Cache successful installation + if [[ -n "$ssh_target" ]] && command -v ghostty >/dev/null 2>&1; then + ( + set +m + { + if [[ -n "$ssh_timeout_cmd" ]]; then + $ssh_timeout_cmd "${GHOSTTY_SSH_CACHE_TIMEOUT}s" ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true + else + ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true + fi + } & + ) + fi + else + builtin echo "Warning: Failed to install terminfo." >&2 + ssh_env+=(TERM=xterm-256color) + fi + else + builtin echo "Warning: Could not generate terminfo data." >&2 + ssh_env+=(TERM=xterm-256color) + fi + else + builtin echo "Warning: ghostty command not available for cache management." >&2 + ssh_env+=(TERM=xterm-256color) fi else - builtin echo "Warning: infocmp not found locally. Terminfo installation unavailable." >&2 + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then + ssh_env+=(TERM=xterm-256color) + fi fi fi - # Fallback TERM only if terminfo didn't set it + # Ensure TERM is set when using ssh-env feature if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then - [[ $TERM == xterm-ghostty && ! " ${env[*]} " =~ " TERM=" ]] && env+=(TERM=xterm-256color) + local ssh_term_set=false + for ssh_v in "${ssh_env[@]}"; do + if [[ "$ssh_v" =~ ^TERM= ]]; then + ssh_term_set=true + break + fi + done + if [[ "$ssh_term_set" == "false" && "$TERM" == "xterm-ghostty" ]]; then + ssh_env+=(TERM=xterm-256color) + fi fi - # Execute - if [[ ${#env[@]} -gt 0 ]]; then - env "${env[@]}" ssh "${opts[@]}" "${ctrl[@]}" "$@" - else - builtin command ssh "${opts[@]}" "${ctrl[@]}" "$@" + builtin command ssh "${ssh_opts[@]}" "$@" + local ssh_ret=$? + + # Restore original environment variables + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then + for ssh_v in "${ssh_exported_vars[@]}"; do + if [[ "$ssh_v" == *=* ]]; then + builtin export "${ssh_v?}" + else + builtin unset "${ssh_v}" + fi + done fi + + return $ssh_ret } fi diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 084861434..76fa7bafa 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -100,91 +100,238 @@ # SSH Integration use str + use re - if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) or (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-terminfo) { + if (or (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-terminfo)) { + var GHOSTTY_SSH_CACHE_TIMEOUT = (if (has-env GHOSTTY_SSH_CACHE_TIMEOUT) { echo $E:GHOSTTY_SSH_CACHE_TIMEOUT } else { echo 5 }) + var GHOSTTY_SSH_CHECK_TIMEOUT = (if (has-env GHOSTTY_SSH_CHECK_TIMEOUT) { echo $E:GHOSTTY_SSH_CHECK_TIMEOUT } else { echo 3 }) - if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-terminfo) { - var _CACHE = $E:GHOSTTY_RESOURCES_DIR/shell-integration/shared/ghostty-ssh-cache - } + # SSH wrapper that preserves Ghostty features across remote connections + fn ssh {|@args| + var ssh-env = [] + var ssh-opts = [] - # SSH wrapper - fn ssh {|@args| - var env = [] - var opts = [] - var ctrl = [] + # Configure environment variables for remote session + if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { + var ssh-env-vars = [ + COLORTERM=truecolor + TERM_PROGRAM=ghostty + ] - # Set up env vars first so terminfo installation inherits them - if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { - var vars = [ - COLORTERM=truecolor - TERM_PROGRAM=ghostty - ] - if (not-eq $E:TERM_PROGRAM_VERSION '') { - set vars = [$@vars TERM_PROGRAM_VERSION=$E:TERM_PROGRAM_VERSION] - } + if (has-env TERM_PROGRAM_VERSION) { + set ssh-env-vars = [$@ssh-env-vars TERM_PROGRAM_VERSION=$E:TERM_PROGRAM_VERSION] + } - for v $vars { - set-env (str:split = $v | take 1) (str:split = $v | drop 1 | str:join =) - var varname = (str:split = $v | take 1) - set opts = [$@opts -o 'SendEnv '$varname -o 'SetEnv '$v] - } - } - - # Install terminfo if needed, reuse control connection for main session - if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-terminfo) { - # Get target - var target = '' - try { - set target = (e:ssh -G $@args 2>/dev/null | e:awk '/^(user|hostname) /{print $2}' | e:paste -sd'@') - } catch { } - - if (and (not-eq $target '') ($_CACHE chk $target)) { - set env = [$@env TERM=xterm-ghostty] - } elif (has-external infocmp) { - var tinfo = '' - try { - set tinfo = (e:infocmp -x xterm-ghostty 2>/dev/null) - } catch { - echo "Warning: xterm-ghostty terminfo not found locally." >&2 - } - - if (not-eq $tinfo '') { - echo "Setting up Ghostty terminfo on remote host..." >&2 - var cpath = '/tmp/ghostty-ssh-'$E:USER'-'(randint 0 32767)'-'(date +%s) - var result = (echo $tinfo | e:ssh $@opts -o ControlMaster=yes -o ControlPath=$cpath -o ControlPersist=60s $@args ' - infocmp xterm-ghostty >/dev/null 2>&1 && echo OK && exit - command -v tic >/dev/null 2>&1 || { echo NO_TIC; exit 1; } - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && echo OK || echo FAIL - ') + # Store original values for restoration + var ssh-exported-vars = [] + for ssh-v $ssh-env-vars { + var ssh-var-name = (str:split &max=2 = $ssh-v)[0] - if (eq $result OK) { - echo "Terminfo setup complete." >&2 - if (not-eq $target '') { $_CACHE add $target } - set env = [$@env TERM=xterm-ghostty] - set ctrl = [$@ctrl -o ControlPath=$cpath] - } else { - echo "Warning: Failed to install terminfo." >&2 - } + if (has-env $ssh-var-name) { + var original-value = (get-env $ssh-var-name) + set ssh-exported-vars = [$@ssh-exported-vars $ssh-var-name=$original-value] + } else { + set ssh-exported-vars = [$@ssh-exported-vars $ssh-var-name] + } + + # Export the variable + var ssh-var-parts = (str:split &max=2 = $ssh-v) + set-env $ssh-var-parts[0] $ssh-var-parts[1] + + # Use both SendEnv and SetEnv for maximum compatibility + set ssh-opts = [$@ssh-opts -o "SendEnv "$ssh-var-name] + set ssh-opts = [$@ssh-opts -o "SetEnv "$ssh-v] + } + + set ssh-env = [$@ssh-env $@ssh-env-vars] } - } else { - echo "Warning: infocmp not found locally. Terminfo installation unavailable." >&2 - } - } - # Fallback TERM only if terminfo didn't set it - if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { - if (and (eq $E:TERM xterm-ghostty) (not (str:contains (str:join ' ' $env) 'TERM='))) { - set env = [$@env TERM=xterm-256color] - } - } + # Install terminfo on remote host if needed + if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-terminfo) { + var ssh-config = "" + try { + set ssh-config = (external ssh -G $@args 2>/dev/null | slurp) + } catch { + set ssh-config = "" + } - # Execute - if (> (count $env) 0) { - e:env $@env e:ssh $@opts $@ctrl $@args - } else { - e:ssh $@opts $@ctrl $@args + var ssh-user = "" + var ssh-hostname = "" + + for line (str:split "\n" $ssh-config) { + var parts = (str:split " " $line) + if (and (> (count $parts) 1) (eq $parts[0] user)) { + set ssh-user = $parts[1] + } + if (and (> (count $parts) 1) (eq $parts[0] hostname)) { + set ssh-hostname = $parts[1] + } + } + + var ssh-target = $ssh-user"@"$ssh-hostname + + if (not-eq $ssh-hostname "") { + # Detect timeout command (BSD compatibility) + var ssh-timeout-cmd = "" + try { + external timeout --help >/dev/null 2>&1 + set ssh-timeout-cmd = timeout + } catch { + try { + external gtimeout --help >/dev/null 2>&1 + set ssh-timeout-cmd = gtimeout + } catch { + # no timeout command available + } + } + + # Check if terminfo is already cached + var ssh-cache-check-success = $false + try { + external ghostty --help >/dev/null 2>&1 + if (not-eq $ssh-timeout-cmd "") { + try { + external $ssh-timeout-cmd $GHOSTTY_SSH_CHECK_TIMEOUT"s" ghostty +ssh-cache --host=$ssh-target >/dev/null 2>&1 + set ssh-cache-check-success = $true + } catch { + # cache check failed + } + } else { + try { + external ghostty +ssh-cache --host=$ssh-target >/dev/null 2>&1 + set ssh-cache-check-success = $true + } catch { + # cache check failed + } + } + } catch { + # ghostty not available + } + + if $ssh-cache-check-success { + set ssh-env = [$@ssh-env TERM=xterm-ghostty] + } else { + try { + external infocmp --help >/dev/null 2>&1 + + # Generate terminfo data (BSD base64 compatibility) + var ssh-terminfo = "" + try { + var base64-help = (external base64 --help 2>&1 | slurp) + if (str:contains $base64-help GNU) { + set ssh-terminfo = (external infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | external base64 -w0 2>/dev/null | slurp) + } else { + set ssh-terminfo = (external infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | external base64 2>/dev/null | external tr -d '\n' | slurp) + } + } catch { + set ssh-terminfo = "" + } + + if (not-eq $ssh-terminfo "") { + echo "Setting up Ghostty terminfo on remote host..." >&2 + var ssh-cpath-dir = "" + try { + set ssh-cpath-dir = (external mktemp -d "/tmp/ghostty-ssh-"$ssh-user".XXXXXX" 2>/dev/null | slurp) + } catch { + set ssh-cpath-dir = "/tmp/ghostty-ssh-"$ssh-user"."(randint 10000 99999) + } + var ssh-cpath = $ssh-cpath-dir"/socket" + + var ssh-base64-decode-cmd = "" + try { + var base64-help = (external base64 --help 2>&1 | slurp) + if (str:contains $base64-help GNU) { + set ssh-base64-decode-cmd = "base64 -d" + } else { + set ssh-base64-decode-cmd = "base64 -D" + } + } catch { + set ssh-base64-decode-cmd = "base64 -d" + } + + var terminfo-install-success = $false + try { + echo $ssh-terminfo | external sh -c $ssh-base64-decode-cmd | external ssh $@ssh-opts -o ControlMaster=yes -o ControlPath=$ssh-cpath -o ControlPersist=60s $@args ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + ' >/dev/null 2>&1 + set terminfo-install-success = $true + } catch { + set terminfo-install-success = $false + } + + if $terminfo-install-success { + echo "Terminfo setup complete." >&2 + set ssh-env = [$@ssh-env TERM=xterm-ghostty] + set ssh-opts = [$@ssh-opts -o ControlPath=$ssh-cpath] + + # Cache successful installation + if (and (not-eq $ssh-target "") (has-external ghostty)) { + if (not-eq $ssh-timeout-cmd "") { + external $ssh-timeout-cmd $GHOSTTY_SSH_CACHE_TIMEOUT"s" ghostty +ssh-cache --add=$ssh-target >/dev/null 2>&1 & + } else { + external ghostty +ssh-cache --add=$ssh-target >/dev/null 2>&1 & + } + } + } else { + echo "Warning: Failed to install terminfo." >&2 + set ssh-env = [$@ssh-env TERM=xterm-256color] + } + } else { + echo "Warning: Could not generate terminfo data." >&2 + set ssh-env = [$@ssh-env TERM=xterm-256color] + } + } catch { + echo "Warning: ghostty command not available for cache management." >&2 + set ssh-env = [$@ssh-env TERM=xterm-256color] + } + } + } else { + if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { + set ssh-env = [$@ssh-env TERM=xterm-256color] + } + } + } + + # Ensure TERM is set when using ssh-env feature + if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { + var ssh-term-set = $false + for ssh-v $ssh-env { + if (str:has-prefix $ssh-v TERM=) { + set ssh-term-set = $true + break + } + } + if (and (not $ssh-term-set) (eq $E:TERM xterm-ghostty)) { + set ssh-env = [$@ssh-env TERM=xterm-256color] + } + } + + var ssh-ret = 0 + try { + external ssh $@ssh-opts $@args + } catch e { + set ssh-ret = $e[reason][exit-status] + } + + # Restore original environment variables + if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { + for ssh-v $ssh-exported-vars { + if (str:contains $ssh-v =) { + var ssh-var-parts = (str:split &max=2 = $ssh-v) + set-env $ssh-var-parts[0] $ssh-var-parts[1] + } else { + unset-env $ssh-v + } + } + } + + if (not-eq $ssh-ret 0) { + fail ssh-failed + } } - } } defer { 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 4c780b5a7..7dc121919 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 @@ -86,88 +86,173 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" end end - # SSH Integration - if string match -qr 'ssh-(env|terminfo)' $GHOSTTY_SHELL_FEATURES + # SSH Integration for Fish Shell + if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES"; or string match -q '*ssh-terminfo*' -- "$GHOSTTY_SHELL_FEATURES" + set -g GHOSTTY_SSH_CACHE_TIMEOUT (test -n "$GHOSTTY_SSH_CACHE_TIMEOUT"; and echo $GHOSTTY_SSH_CACHE_TIMEOUT; or echo 5) + set -g GHOSTTY_SSH_CHECK_TIMEOUT (test -n "$GHOSTTY_SSH_CHECK_TIMEOUT"; and echo $GHOSTTY_SSH_CHECK_TIMEOUT; or echo 3) - if string match -q '*ssh-terminfo*' $GHOSTTY_SHELL_FEATURES - set -g _CACHE "$GHOSTTY_RESOURCES_DIR/shell-integration/shared/ghostty-ssh-cache" - end - - # SSH wrapper - function ssh - set -l env - set -l opts - set -l ctrl - - # Set up env vars first so terminfo installation inherits them - if string match -q '*ssh-env*' $GHOSTTY_SHELL_FEATURES - set -l vars \ - COLORTERM=truecolor \ - TERM_PROGRAM=ghostty + # SSH wrapper that preserves Ghostty features across remote connections + function ssh --wraps=ssh --description "SSH wrapper with Ghostty integration" + set -l ssh_env + set -l ssh_opts + # Configure environment variables for remote session + if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES" + set -l ssh_env_vars \ + "COLORTERM=truecolor" \ + "TERM_PROGRAM=ghostty" + if test -n "$TERM_PROGRAM_VERSION" - set -a vars "TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION" + set -a ssh_env_vars "TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION" end - for v in $vars - set -l parts (string split = $v) - set -gx $parts[1] $parts[2] - set -a opts -o "SendEnv $parts[1]" -o "SetEnv $v" - end - end - - # Install terminfo if needed, reuse control connection for main session - if string match -q '*ssh-terminfo*' $GHOSTTY_SHELL_FEATURES - # Get target - set -l target (command ssh -G $argv 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') - - if test -n "$target" -a ("$_CACHE" chk "$target") - set -a env TERM=xterm-ghostty - else if command -v infocmp >/dev/null 2>&1 - set -l tinfo (infocmp -x xterm-ghostty 2>/dev/null) - set -l status_code $status - - if test $status_code -ne 0 - echo "Warning: xterm-ghostty terminfo not found locally." >&2 + # Store original values for restoration + set -l ssh_exported_vars + for ssh_v in $ssh_env_vars + set -l ssh_var_name (string split -m1 '=' -- $ssh_v)[1] + + if set -q $ssh_var_name + set -a ssh_exported_vars "$ssh_var_name="(eval echo \$$ssh_var_name) + else + set -a ssh_exported_vars $ssh_var_name end - if test -n "$tinfo" - echo "Setting up Ghostty terminfo on remote host..." >&2 - set -l cpath "/tmp/ghostty-ssh-$USER-"(random)"-"(date +%s) - set -l result (echo "$tinfo" | command ssh $opts -o ControlMaster=yes -o ControlPath="$cpath" -o ControlPersist=60s $argv ' - infocmp xterm-ghostty >/dev/null 2>&1 && echo OK && exit - command -v tic >/dev/null 2>&1 || { echo NO_TIC; exit 1; } - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && echo OK || echo FAIL - ') + # Export the variable + set -gx (string split -m1 '=' -- $ssh_v) - switch $result - case OK - echo "Terminfo setup complete." >&2 - test -n "$target" && "$_CACHE" add "$target" - set -a env TERM=xterm-ghostty - set -a ctrl -o "ControlPath=$cpath" - case '*' - echo "Warning: Failed to install terminfo." >&2 + # Use both SendEnv and SetEnv for maximum compatibility + set -a ssh_opts -o "SendEnv $ssh_var_name" + set -a ssh_opts -o "SetEnv $ssh_v" + end + + set -a ssh_env $ssh_env_vars + end + + # Install terminfo on remote host if needed + if string match -q '*ssh-terminfo*' -- "$GHOSTTY_SHELL_FEATURES" + set -l ssh_config (command ssh -G $argv 2>/dev/null) + set -l ssh_user (echo $ssh_config | while read -l ssh_key ssh_value + test "$ssh_key" = "user"; and echo $ssh_value; and break + end) + set -l ssh_hostname (echo $ssh_config | while read -l ssh_key ssh_value + test "$ssh_key" = "hostname"; and echo $ssh_value; and break + end) + set -l ssh_target "$ssh_user@$ssh_hostname" + + if test -n "$ssh_hostname" + # Detect timeout command (BSD compatibility) + set -l ssh_timeout_cmd + if command -v timeout >/dev/null 2>&1 + set ssh_timeout_cmd timeout + else if command -v gtimeout >/dev/null 2>&1 + set ssh_timeout_cmd gtimeout + end + + # Check if terminfo is already cached + set -l ssh_cache_check_success false + if command -v ghostty >/dev/null 2>&1 + if test -n "$ssh_timeout_cmd" + if $ssh_timeout_cmd "$GHOSTTY_SSH_CHECK_TIMEOUT"s ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 + set ssh_cache_check_success true + end + else + if ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 + set ssh_cache_check_success true + end end end + + if test "$ssh_cache_check_success" = "true" + set -a ssh_env TERM=xterm-ghostty + else if command -v infocmp >/dev/null 2>&1 + # Generate terminfo data (BSD base64 compatibility) + set -l ssh_terminfo + if base64 --help 2>&1 | grep -q GNU + set ssh_terminfo (infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 -w0 2>/dev/null) + else + set ssh_terminfo (infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 2>/dev/null | tr -d '\n') + end + + if test -n "$ssh_terminfo" + echo "Setting up Ghostty terminfo on remote host..." >&2 + set -l ssh_cpath_dir (mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null; or echo "/tmp/ghostty-ssh-$ssh_user."(random)) + set -l ssh_cpath "$ssh_cpath_dir/socket" + + set -l ssh_base64_decode_cmd + if base64 --help 2>&1 | grep -q GNU + set ssh_base64_decode_cmd "base64 -d" + else + set ssh_base64_decode_cmd "base64 -D" + end + + if echo "$ssh_terminfo" | eval $ssh_base64_decode_cmd | command ssh $ssh_opts -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s $argv ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + ' 2>/dev/null + echo "Terminfo setup complete." >&2 + set -a ssh_env TERM=xterm-ghostty + set -a ssh_opts -o "ControlPath=$ssh_cpath" + + # Cache successful installation + if test -n "$ssh_target"; and command -v ghostty >/dev/null 2>&1 + fish -c " + if test -n '$ssh_timeout_cmd' + $ssh_timeout_cmd '$GHOSTTY_SSH_CACHE_TIMEOUT's ghostty +ssh-cache --add='$ssh_target' >/dev/null 2>&1; or true + else + ghostty +ssh-cache --add='$ssh_target' >/dev/null 2>&1; or true + end + " & + end + else + echo "Warning: Failed to install terminfo." >&2 + set -a ssh_env TERM=xterm-256color + end + else + echo "Warning: Could not generate terminfo data." >&2 + set -a ssh_env TERM=xterm-256color + end + else + echo "Warning: ghostty command not available for cache management." >&2 + set -a ssh_env TERM=xterm-256color + end else - echo "Warning: infocmp not found locally. Terminfo installation unavailable." >&2 + if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES" + set -a ssh_env TERM=xterm-256color + end end end - # Fallback TERM only if terminfo didn't set it - if string match -q '*ssh-env*' $GHOSTTY_SHELL_FEATURES - if test "$TERM" = xterm-ghostty -a ! (string join ' ' $env | string match -q '*TERM=*') - set -a env TERM=xterm-256color + # Ensure TERM is set when using ssh-env feature + if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES" + set -l ssh_term_set false + for ssh_v in $ssh_env + if string match -q 'TERM=*' -- $ssh_v + set ssh_term_set true + break + end + end + if test "$ssh_term_set" = "false"; and test "$TERM" = "xterm-ghostty" + set -a ssh_env TERM=xterm-256color end end - # Execute - if test (count $env) -gt 0 - env $env command ssh $opts $ctrl $argv - else - command ssh $opts $ctrl $argv + command ssh $ssh_opts $argv + set -l ssh_ret $status + + # Restore original environment variables + if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES" + for ssh_v in $ssh_exported_vars + if string match -q '*=*' -- $ssh_v + set -gx (string split -m1 '=' -- $ssh_v) + else + set -e $ssh_v + end + end end + + return $ssh_ret end end diff --git a/src/shell-integration/shared/ghostty-ssh-cache b/src/shell-integration/shared/ghostty-ssh-cache deleted file mode 100755 index e0a6d8452..000000000 --- a/src/shell-integration/shared/ghostty-ssh-cache +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -# Minimal Ghostty SSH terminfo host cache - -readonly CACHE_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/ghostty/terminfo_hosts" - -case "${1:-}" in - chk) [[ -f "$CACHE_FILE" ]] && grep -qFx "$2" "$CACHE_FILE" 2>/dev/null ;; - add) mkdir -p "${CACHE_FILE%/*}"; { [[ -f "$CACHE_FILE" ]] && cat "$CACHE_FILE"; echo "$2"; } | sort -u > "$CACHE_FILE.tmp" && mv "$CACHE_FILE.tmp" "$CACHE_FILE" && chmod 600 "$CACHE_FILE" ;; - list) [[ -s "$CACHE_FILE" ]] && echo "Hosts with Ghostty terminfo installed:" && cat "$CACHE_FILE" || echo "No cached hosts found." ;; - clear) rm -f "$CACHE_FILE" 2>/dev/null && echo "Ghostty SSH terminfo cache cleared." || echo "No Ghostty SSH terminfo cache found." ;; -esac diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 48f8cc934..9f78e9a89 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -245,78 +245,166 @@ _ghostty_deferred_init() { fi # SSH Integration - if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then + if [[ "$GHOSTTY_SHELL_FEATURES" =~ (ssh-env|ssh-terminfo) ]]; then + : "${GHOSTTY_SSH_CACHE_TIMEOUT:=5}" + : "${GHOSTTY_SSH_CHECK_TIMEOUT:=3}" - if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then - readonly _CACHE="${GHOSTTY_RESOURCES_DIR}/shell-integration/shared/ghostty-ssh-cache" - fi - - # SSH wrapper + # SSH wrapper that preserves Ghostty features across remote connections ssh() { - local -a env opts ctrl - env=() - opts=() - ctrl=() + emulate -L zsh + setopt local_options no_glob_subst + + local -a ssh_env ssh_opts - # Set up env vars first so terminfo installation inherits them + # Configure environment variables for remote session if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then - local -a vars - vars=( - COLORTERM=truecolor - TERM_PROGRAM=ghostty - ${TERM_PROGRAM_VERSION:+TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION} + local -a ssh_env_vars=( + "COLORTERM=truecolor" + "TERM_PROGRAM=ghostty" ) - for v in "${vars[@]}"; do - export "${v?}" - opts+=(-o "SendEnv ${v%=*}" -o "SetEnv $v") + [[ -n "$TERM_PROGRAM_VERSION" ]] && ssh_env_vars+=("TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION") + + # Temporarily export variables for SSH transmission + local -a ssh_exported_vars=() + local ssh_v ssh_var_name + for ssh_v in "${ssh_env_vars[@]}"; do + ssh_var_name="${ssh_v%%=*}" + + if [[ -n "${(P)ssh_var_name+x}" ]]; then + ssh_exported_vars+=("$ssh_var_name=${(P)ssh_var_name}") + else + ssh_exported_vars+=("$ssh_var_name") + fi + + export "${ssh_v}" + + # Use both SendEnv and SetEnv for maximum compatibility + ssh_opts+=(-o "SendEnv $ssh_var_name") + ssh_opts+=(-o "SetEnv $ssh_v") done + + ssh_env+=("${ssh_env_vars[@]}") fi - # Install terminfo if needed, reuse control connection for main session + # Install terminfo on remote host if needed if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then - # Get target (only when needed for terminfo) - local target - target=$(command ssh -G "$@" 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') + local ssh_config ssh_user ssh_hostname ssh_target + ssh_config=$(command ssh -G "$@" 2>/dev/null) + ssh_user=$(printf '%s\n' "${(@f)ssh_config}" | while IFS=' ' read -r ssh_key ssh_value; do + [[ "$ssh_key" == "user" ]] && printf '%s\n' "$ssh_value" && break + done) + ssh_hostname=$(printf '%s\n' "${(@f)ssh_config}" | while IFS=' ' read -r ssh_key ssh_value; do + [[ "$ssh_key" == "hostname" ]] && printf '%s\n' "$ssh_value" && break + done) + ssh_target="${ssh_user}@${ssh_hostname}" - if [[ -n "$target" ]] && "$_CACHE" chk "$target"; then - env+=(TERM=xterm-ghostty) - elif command -v infocmp >/dev/null 2>&1; then - local tinfo - tinfo=$(infocmp -x xterm-ghostty 2>/dev/null) || echo "Warning: xterm-ghostty terminfo not found locally." >&2 - if [[ -n "$tinfo" ]]; then - echo "Setting up Ghostty terminfo on remote host..." >&2 - local cpath - cpath="/tmp/ghostty-ssh-$USER-$RANDOM-$(date +%s)" - case $(echo "$tinfo" | command ssh "${opts[@]}" -o ControlMaster=yes -o ControlPath="$cpath" -o ControlPersist=60s "$@" ' - infocmp xterm-ghostty >/dev/null 2>&1 && echo OK && exit - command -v tic >/dev/null 2>&1 || { echo NO_TIC; exit 1; } - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && echo OK || echo FAIL - ') in - OK) - echo "Terminfo setup complete." >&2 - [[ -n "$target" ]] && "$_CACHE" add "$target" - env+=(TERM=xterm-ghostty) - ctrl+=(-o "ControlPath=$cpath") - ;; - *) echo "Warning: Failed to install terminfo." >&2 ;; - esac + if [[ -n "$ssh_hostname" ]]; then + # Detect timeout command (BSD compatibility) + local ssh_timeout_cmd="" + if (( $+commands[timeout] )); then + ssh_timeout_cmd="timeout" + elif (( $+commands[gtimeout] )); then + ssh_timeout_cmd="gtimeout" + fi + + # Check if terminfo is already cached + local ssh_cache_check_success=false + if (( $+commands[ghostty] )); then + if [[ -n "$ssh_timeout_cmd" ]]; then + $ssh_timeout_cmd "${GHOSTTY_SSH_CHECK_TIMEOUT}s" ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 && ssh_cache_check_success=true + else + ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 && ssh_cache_check_success=true + fi + fi + + if [[ "$ssh_cache_check_success" == "true" ]]; then + ssh_env+=(TERM=xterm-ghostty) + elif (( $+commands[infocmp] )); then + local ssh_terminfo + + # Generate terminfo data (BSD base64 compatibility) + if base64 --help 2>&1 | grep -q GNU; then + ssh_terminfo=$(infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 -w0 2>/dev/null) + else + ssh_terminfo=$(infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 2>/dev/null | tr -d '\n') + fi + + if [[ -n "$ssh_terminfo" ]]; then + print "Setting up Ghostty terminfo on remote host..." >&2 + local ssh_cpath_dir ssh_cpath + + ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$" + ssh_cpath="$ssh_cpath_dir/socket" + + local ssh_base64_decode_cmd + if base64 --help 2>&1 | grep -q GNU; then + ssh_base64_decode_cmd="base64 -d" + else + ssh_base64_decode_cmd="base64 -D" + fi + + if print "$ssh_terminfo" | $ssh_base64_decode_cmd | command ssh "${ssh_opts[@]}" -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + ' 2>/dev/null; then + print "Terminfo setup complete." >&2 + ssh_env+=(TERM=xterm-ghostty) + ssh_opts+=(-o "ControlPath=$ssh_cpath") + + # Cache successful installation + if [[ -n "$ssh_target" ]] && (( $+commands[ghostty] )); then + { + if [[ -n "$ssh_timeout_cmd" ]]; then + $ssh_timeout_cmd "${GHOSTTY_SSH_CACHE_TIMEOUT}s" ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true + else + ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true + fi + } &! + fi + else + print "Warning: Failed to install terminfo." >&2 + ssh_env+=(TERM=xterm-256color) + fi + else + print "Warning: Could not generate terminfo data." >&2 + ssh_env+=(TERM=xterm-256color) + fi + else + print "Warning: ghostty command not available for cache management." >&2 + ssh_env+=(TERM=xterm-256color) fi else - echo "Warning: infocmp not found locally. Terminfo installation unavailable." >&2 + [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]] && ssh_env+=(TERM=xterm-256color) fi fi - # Fallback TERM only if terminfo didn't set it + # Ensure TERM is set when using ssh-env feature if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then - [[ $TERM == xterm-ghostty && ! " ${env[*]} " =~ " TERM=" ]] && env+=(TERM=xterm-256color) + local ssh_term_set=false ssh_v + for ssh_v in "${ssh_env[@]}"; do + [[ "$ssh_v" =~ ^TERM= ]] && ssh_term_set=true && break + done + [[ "$ssh_term_set" == "false" && "$TERM" == "xterm-ghostty" ]] && ssh_env+=(TERM=xterm-256color) fi - # Execute - if [[ ${#env[@]} -gt 0 ]]; then - env "${env[@]}" command ssh "${opts[@]}" "${ctrl[@]}" "$@" - else - command ssh "${opts[@]}" "${ctrl[@]}" "$@" + command ssh "${ssh_opts[@]}" "$@" + local ssh_ret=$? + + # Restore original environment variables + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then + local ssh_v + for ssh_v in "${ssh_exported_vars[@]}"; do + if [[ "$ssh_v" == *=* ]]; then + export "${ssh_v}" + else + unset "${ssh_v}" + fi + done fi + + return $ssh_ret } fi From 5ec18f426c409273599d72a4b6f98055aff8e53b Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Thu, 3 Jul 2025 23:17:46 -0700 Subject: [PATCH 43/52] tests: use ! operator instead of == false for consistency Co-authored-by: Kat <65649991+00-kat@users.noreply.github.com> --- src/cli/ssh_cache.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig index 71c47a7a7..e5acdfb7a 100644 --- a/src/cli/ssh_cache.zig +++ b/src/cli/ssh_cache.zig @@ -536,9 +536,9 @@ test "hostname validation - IPv6 addresses" { const testing = std.testing; try testing.expect(isValidHostname("[::1]")); try testing.expect(isValidHostname("[2001:db8::1]")); - try testing.expect(isValidHostname("[fe80::1%eth0]") == false); // Interface notation not supported - try testing.expect(isValidHostname("[]") == false); // Empty IPv6 - try testing.expect(isValidHostname("[invalid]") == false); // No colons + try testing.expect(!isValidHostname("[fe80::1%eth0]")); // Interface notation not supported + try testing.expect(!isValidHostname("[]")); // Empty IPv6 + try testing.expect(!isValidHostname("[invalid]")); // No colons } test "hostname validation - invalid cases" { From a22074a85ca21ccae413e161766d6bf7f063c596 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Sat, 5 Jul 2025 13:02:35 -0700 Subject: [PATCH 44/52] fix: optimize SSH integration and improve error handling - Replace dual-loop SSH config parsing with efficient single-pass case statement - Remove overly cautious timeout logic from cache checks for simplicity - Add base64 availability check with xterm-256color fallback when missing - Include hostname in terminfo setup messages for better UX - Maintain SendEnv/SetEnv dual approach for maximum OpenSSH compatibility (relying on SetEnv alone seems to drop some vars during my tests, despite them being explicitly included in AcceptEnv on the remote host) --- src/shell-integration/bash/ghostty.bash | 193 +++++++-------- .../elvish/lib/ghostty-integration.elv | 230 ++++++++---------- .../ghostty-shell-integration.fish | 190 +++++++-------- src/shell-integration/zsh/ghostty-integration | 190 +++++++-------- 4 files changed, 381 insertions(+), 422 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 8c4cd9e12..6016e9096 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -97,131 +97,116 @@ fi # SSH Integration if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then - : "${GHOSTTY_SSH_CACHE_TIMEOUT:=5}" - : "${GHOSTTY_SSH_CHECK_TIMEOUT:=3}" - - # SSH wrapper that preserves Ghostty features across remote connections ssh() { - local ssh_env=() ssh_opts=() + builtin local ssh_env ssh_opts ssh_exported_vars + ssh_env=() + ssh_opts=() + ssh_exported_vars=() # Configure environment variables for remote session if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then - local -a ssh_env_vars=( + ssh_opts+=(-o "SetEnv COLORTERM=truecolor") + + if [[ -n "${TERM_PROGRAM+x}" ]]; then + ssh_exported_vars+=("TERM_PROGRAM=${TERM_PROGRAM}") + else + ssh_exported_vars+=("TERM_PROGRAM") + fi + builtin export "TERM_PROGRAM=ghostty" + ssh_opts+=(-o "SendEnv TERM_PROGRAM") + + if [[ -n "$TERM_PROGRAM_VERSION" ]]; then + if [[ -n "${TERM_PROGRAM_VERSION+x}" ]]; then + ssh_exported_vars+=("TERM_PROGRAM_VERSION=${TERM_PROGRAM_VERSION}") + else + ssh_exported_vars+=("TERM_PROGRAM_VERSION") + fi + ssh_opts+=(-o "SendEnv TERM_PROGRAM_VERSION") + fi + + ssh_env+=( "COLORTERM=truecolor" "TERM_PROGRAM=ghostty" ) if [[ -n "$TERM_PROGRAM_VERSION" ]]; then - ssh_env_vars+=("TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION") + ssh_env+=("TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION") fi - - # Temporarily export variables for SSH transmission - local -a ssh_exported_vars=() - for ssh_v in "${ssh_env_vars[@]}"; do - local ssh_var_name="${ssh_v%%=*}" - - if [[ -n "${!ssh_var_name+x}" ]]; then - ssh_exported_vars+=("$ssh_var_name=${!ssh_var_name}") - else - ssh_exported_vars+=("$ssh_var_name") - fi - - builtin export "${ssh_v?}" - - # Use both SendEnv and SetEnv for maximum compatibility - ssh_opts+=(-o "SendEnv $ssh_var_name") - ssh_opts+=(-o "SetEnv $ssh_v") - done - - ssh_env+=("${ssh_env_vars[@]}") fi # Install terminfo on remote host if needed if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then builtin local ssh_config ssh_user ssh_hostname ssh_config=$(builtin command ssh -G "$@" 2>/dev/null) - ssh_user=$(echo "$ssh_config" | while IFS=' ' read -r ssh_key ssh_value; do - [[ "$ssh_key" == "ssh_user" ]] && echo "$ssh_value" && break - done) - ssh_hostname=$(echo "$ssh_config" | while IFS=' ' read -r ssh_key ssh_value; do - [[ "$ssh_key" == "hostname" ]] && echo "$ssh_value" && break - done) + + while IFS=' ' read -r ssh_key ssh_value; do + case "$ssh_key" in + user) ssh_user="$ssh_value" ;; + hostname) ssh_hostname="$ssh_value" ;; + esac + [[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break + done <<< "$ssh_config" + ssh_target="${ssh_user}@${ssh_hostname}" if [[ -n "$ssh_hostname" ]]; then - # Detect timeout command (BSD compatibility) - local ssh_timeout_cmd="" - if command -v timeout >/dev/null 2>&1; then - ssh_timeout_cmd="timeout" - elif command -v gtimeout >/dev/null 2>&1; then - ssh_timeout_cmd="gtimeout" - fi - # Check if terminfo is already cached - local ssh_cache_check_success=false - if command -v ghostty >/dev/null 2>&1; then - if [[ -n "$ssh_timeout_cmd" ]]; then - $ssh_timeout_cmd "${GHOSTTY_SSH_CHECK_TIMEOUT}s" ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 && ssh_cache_check_success=true - else - ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 && ssh_cache_check_success=true - fi + builtin local ssh_cache_check_success=false + if builtin command -v ghostty >/dev/null 2>&1; then + ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 && ssh_cache_check_success=true fi if [[ "$ssh_cache_check_success" == "true" ]]; then ssh_env+=(TERM=xterm-ghostty) elif builtin command -v infocmp >/dev/null 2>&1; then - builtin local ssh_terminfo - - # Generate terminfo data (BSD base64 compatibility) - if base64 --help 2>&1 | grep -q GNU; then - ssh_terminfo=$(infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 -w0 2>/dev/null) + if ! builtin command -v base64 >/dev/null 2>&1; then + builtin echo "Warning: base64 command not available for terminfo installation." >&2 + ssh_env+=(TERM=xterm-256color) else - ssh_terminfo=$(infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 2>/dev/null | tr -d '\n') - fi + builtin local ssh_terminfo ssh_base64_decode_cmd - if [[ -n "$ssh_terminfo" ]]; then - builtin echo "Setting up Ghostty terminfo on remote host..." >&2 - builtin local ssh_cpath_dir ssh_cpath - - ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$" - ssh_cpath="$ssh_cpath_dir/socket" - - local ssh_base64_decode_cmd + # BSD vs GNU base64 compatibility if base64 --help 2>&1 | grep -q GNU; then ssh_base64_decode_cmd="base64 -d" + ssh_terminfo=$(infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 -w0 2>/dev/null) else ssh_base64_decode_cmd="base64 -D" + ssh_terminfo=$(infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 2>/dev/null | tr -d '\n') fi - if builtin echo "$ssh_terminfo" | $ssh_base64_decode_cmd | builtin command ssh "${ssh_opts[@]}" -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" ' - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1 - ' 2>/dev/null; then - builtin echo "Terminfo setup complete." >&2 - ssh_env+=(TERM=xterm-ghostty) - ssh_opts+=(-o "ControlPath=$ssh_cpath") + if [[ -n "$ssh_terminfo" ]]; then + builtin echo "Setting up Ghostty terminfo on $ssh_hostname..." >&2 + builtin local ssh_cpath_dir ssh_cpath - # Cache successful installation - if [[ -n "$ssh_target" ]] && command -v ghostty >/dev/null 2>&1; then - ( - set +m - { - if [[ -n "$ssh_timeout_cmd" ]]; then - $ssh_timeout_cmd "${GHOSTTY_SSH_CACHE_TIMEOUT}s" ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true - else + ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$" + ssh_cpath="$ssh_cpath_dir/socket" + + if builtin echo "$ssh_terminfo" | $ssh_base64_decode_cmd | builtin command ssh "${ssh_opts[@]}" -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + ' 2>/dev/null; then + builtin echo "Terminfo setup complete on $ssh_hostname." >&2 + ssh_env+=(TERM=xterm-ghostty) + ssh_opts+=(-o "ControlPath=$ssh_cpath") + + # Cache successful installation + if [[ -n "$ssh_target" ]] && builtin command -v ghostty >/dev/null 2>&1; then + ( + set +m + { ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true - fi - } & - ) + } & + ) + fi + else + builtin echo "Warning: Failed to install terminfo." >&2 + ssh_env+=(TERM=xterm-256color) fi else - builtin echo "Warning: Failed to install terminfo." >&2 + builtin echo "Warning: Could not generate terminfo data." >&2 ssh_env+=(TERM=xterm-256color) fi - else - builtin echo "Warning: Could not generate terminfo data." >&2 - ssh_env+=(TERM=xterm-256color) fi else builtin echo "Warning: ghostty command not available for cache management." >&2 @@ -234,22 +219,30 @@ if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then fi fi - # Ensure TERM is set when using ssh-env feature - if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then - local ssh_term_set=false - for ssh_v in "${ssh_env[@]}"; do - if [[ "$ssh_v" =~ ^TERM= ]]; then - ssh_term_set=true - break - fi - done - if [[ "$ssh_term_set" == "false" && "$TERM" == "xterm-ghostty" ]]; then - ssh_env+=(TERM=xterm-256color) + # Execute SSH with environment handling + builtin local ssh_term_override="" + for ssh_v in "${ssh_env[@]}"; do + if [[ "$ssh_v" =~ ^TERM=(.*)$ ]]; then + ssh_term_override="${BASH_REMATCH[1]}" + break fi + done + + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env && -z "$ssh_term_override" ]]; then + ssh_env+=(TERM=xterm-256color) + ssh_term_override="xterm-256color" fi - builtin command ssh "${ssh_opts[@]}" "$@" - local ssh_ret=$? + if [[ -n "$ssh_term_override" ]]; then + builtin local ssh_original_term="$TERM" + builtin export TERM="$ssh_term_override" + builtin command ssh "${ssh_opts[@]}" "$@" + local ssh_ret=$? + builtin export TERM="$ssh_original_term" + else + builtin command ssh "${ssh_opts[@]}" "$@" + local ssh_ret=$? + fi # Restore original environment variables if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 76fa7bafa..52d01e4fb 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -100,50 +100,41 @@ # SSH Integration use str - use re if (or (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-terminfo)) { - var GHOSTTY_SSH_CACHE_TIMEOUT = (if (has-env GHOSTTY_SSH_CACHE_TIMEOUT) { echo $E:GHOSTTY_SSH_CACHE_TIMEOUT } else { echo 5 }) - var GHOSTTY_SSH_CHECK_TIMEOUT = (if (has-env GHOSTTY_SSH_CHECK_TIMEOUT) { echo $E:GHOSTTY_SSH_CHECK_TIMEOUT } else { echo 3 }) - - # SSH wrapper that preserves Ghostty features across remote connections fn ssh {|@args| var ssh-env = [] var ssh-opts = [] + var ssh-exported-vars = [] # Configure environment variables for remote session if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { - var ssh-env-vars = [ - COLORTERM=truecolor - TERM_PROGRAM=ghostty - ] + set ssh-opts = [$@ssh-opts -o "SetEnv COLORTERM=truecolor"] + + if (has-env TERM_PROGRAM) { + set ssh-exported-vars = [$@ssh-exported-vars "TERM_PROGRAM="$E:TERM_PROGRAM] + } else { + set ssh-exported-vars = [$@ssh-exported-vars "TERM_PROGRAM"] + } + set-env TERM_PROGRAM ghostty + set ssh-opts = [$@ssh-opts -o "SendEnv TERM_PROGRAM"] if (has-env TERM_PROGRAM_VERSION) { - set ssh-env-vars = [$@ssh-env-vars TERM_PROGRAM_VERSION=$E:TERM_PROGRAM_VERSION] - } - - # Store original values for restoration - var ssh-exported-vars = [] - for ssh-v $ssh-env-vars { - var ssh-var-name = (str:split &max=2 = $ssh-v)[0] - - if (has-env $ssh-var-name) { - var original-value = (get-env $ssh-var-name) - set ssh-exported-vars = [$@ssh-exported-vars $ssh-var-name=$original-value] + if (has-env TERM_PROGRAM_VERSION) { + set ssh-exported-vars = [$@ssh-exported-vars "TERM_PROGRAM_VERSION="$E:TERM_PROGRAM_VERSION] } else { - set ssh-exported-vars = [$@ssh-exported-vars $ssh-var-name] + set ssh-exported-vars = [$@ssh-exported-vars "TERM_PROGRAM_VERSION"] } - - # Export the variable - var ssh-var-parts = (str:split &max=2 = $ssh-v) - set-env $ssh-var-parts[0] $ssh-var-parts[1] - - # Use both SendEnv and SetEnv for maximum compatibility - set ssh-opts = [$@ssh-opts -o "SendEnv "$ssh-var-name] - set ssh-opts = [$@ssh-opts -o "SetEnv "$ssh-v] + set ssh-opts = [$@ssh-opts -o "SendEnv TERM_PROGRAM_VERSION"] } - set ssh-env = [$@ssh-env $@ssh-env-vars] + set ssh-env = [ + "COLORTERM=truecolor" + "TERM_PROGRAM=ghostty" + ] + if (has-env TERM_PROGRAM_VERSION) { + set ssh-env = [$@ssh-env "TERM_PROGRAM_VERSION="$E:TERM_PROGRAM_VERSION] + } } # Install terminfo on remote host if needed @@ -160,52 +151,28 @@ for line (str:split "\n" $ssh-config) { var parts = (str:split " " $line) - if (and (> (count $parts) 1) (eq $parts[0] user)) { - set ssh-user = $parts[1] - } - if (and (> (count $parts) 1) (eq $parts[0] hostname)) { - set ssh-hostname = $parts[1] + if (> (count $parts) 1) { + if (eq $parts[0] user) { + set ssh-user = $parts[1] + } elif (eq $parts[0] hostname) { + set ssh-hostname = $parts[1] + } + if (and (not-eq $ssh-user "") (not-eq $ssh-hostname "")) { + break + } } } - + var ssh-target = $ssh-user"@"$ssh-hostname if (not-eq $ssh-hostname "") { - # Detect timeout command (BSD compatibility) - var ssh-timeout-cmd = "" - try { - external timeout --help >/dev/null 2>&1 - set ssh-timeout-cmd = timeout - } catch { - try { - external gtimeout --help >/dev/null 2>&1 - set ssh-timeout-cmd = gtimeout - } catch { - # no timeout command available - } - } - # Check if terminfo is already cached var ssh-cache-check-success = $false try { - external ghostty --help >/dev/null 2>&1 - if (not-eq $ssh-timeout-cmd "") { - try { - external $ssh-timeout-cmd $GHOSTTY_SSH_CHECK_TIMEOUT"s" ghostty +ssh-cache --host=$ssh-target >/dev/null 2>&1 - set ssh-cache-check-success = $true - } catch { - # cache check failed - } - } else { - try { - external ghostty +ssh-cache --host=$ssh-target >/dev/null 2>&1 - set ssh-cache-check-success = $true - } catch { - # cache check failed - } - } + external ghostty +ssh-cache --host=$ssh-target >/dev/null 2>&1 + set ssh-cache-check-success = $true } catch { - # ghostty not available + # cache check failed } if $ssh-cache-check-success { @@ -213,74 +180,68 @@ } else { try { external infocmp --help >/dev/null 2>&1 - - # Generate terminfo data (BSD base64 compatibility) - var ssh-terminfo = "" + try { - var base64-help = (external base64 --help 2>&1 | slurp) - if (str:contains $base64-help GNU) { - set ssh-terminfo = (external infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | external base64 -w0 2>/dev/null | slurp) - } else { - set ssh-terminfo = (external infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | external base64 2>/dev/null | external tr -d '\n' | slurp) - } - } catch { - set ssh-terminfo = "" - } - - if (not-eq $ssh-terminfo "") { - echo "Setting up Ghostty terminfo on remote host..." >&2 - var ssh-cpath-dir = "" - try { - set ssh-cpath-dir = (external mktemp -d "/tmp/ghostty-ssh-"$ssh-user".XXXXXX" 2>/dev/null | slurp) - } catch { - set ssh-cpath-dir = "/tmp/ghostty-ssh-"$ssh-user"."(randint 10000 99999) - } - var ssh-cpath = $ssh-cpath-dir"/socket" + external base64 --help >/dev/null 2>&1 + # Generate terminfo data (BSD base64 compatibility) + var ssh-terminfo = "" var ssh-base64-decode-cmd = "" try { var base64-help = (external base64 --help 2>&1 | slurp) if (str:contains $base64-help GNU) { set ssh-base64-decode-cmd = "base64 -d" + set ssh-terminfo = (external infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | external base64 -w0 2>/dev/null | slurp) } else { set ssh-base64-decode-cmd = "base64 -D" + set ssh-terminfo = (external infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | external base64 2>/dev/null | external tr -d '\n' | slurp) } } catch { - set ssh-base64-decode-cmd = "base64 -d" + set ssh-terminfo = "" } - var terminfo-install-success = $false - try { - echo $ssh-terminfo | external sh -c $ssh-base64-decode-cmd | external ssh $@ssh-opts -o ControlMaster=yes -o ControlPath=$ssh-cpath -o ControlPersist=60s $@args ' - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1 - ' >/dev/null 2>&1 - set terminfo-install-success = $true - } catch { - set terminfo-install-success = $false - } + if (not-eq $ssh-terminfo "") { + echo "Setting up Ghostty terminfo on "$ssh-hostname"..." >&2 + var ssh-cpath-dir = "" + try { + set ssh-cpath-dir = (external mktemp -d "/tmp/ghostty-ssh-"$ssh-user".XXXXXX" 2>/dev/null | slurp) + } catch { + set ssh-cpath-dir = "/tmp/ghostty-ssh-"$ssh-user"."(randint 10000 99999) + } + var ssh-cpath = $ssh-cpath-dir"/socket" - if $terminfo-install-success { - echo "Terminfo setup complete." >&2 - set ssh-env = [$@ssh-env TERM=xterm-ghostty] - set ssh-opts = [$@ssh-opts -o ControlPath=$ssh-cpath] + var terminfo-install-success = $false + try { + echo $ssh-terminfo | external sh -c $ssh-base64-decode-cmd | external ssh $@ssh-opts -o ControlMaster=yes -o ControlPath=$ssh-cpath -o ControlPersist=60s $@args ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + ' >/dev/null 2>&1 + set terminfo-install-success = $true + } catch { + set terminfo-install-success = $false + } - # Cache successful installation - if (and (not-eq $ssh-target "") (has-external ghostty)) { - if (not-eq $ssh-timeout-cmd "") { - external $ssh-timeout-cmd $GHOSTTY_SSH_CACHE_TIMEOUT"s" ghostty +ssh-cache --add=$ssh-target >/dev/null 2>&1 & - } else { + if $terminfo-install-success { + echo "Terminfo setup complete on "$ssh-hostname"." >&2 + set ssh-env = [$@ssh-env TERM=xterm-ghostty] + set ssh-opts = [$@ssh-opts -o ControlPath=$ssh-cpath] + + # Cache successful installation + if (and (not-eq $ssh-target "") (has-external ghostty)) { external ghostty +ssh-cache --add=$ssh-target >/dev/null 2>&1 & } + } else { + echo "Warning: Failed to install terminfo." >&2 + set ssh-env = [$@ssh-env TERM=xterm-256color] } } else { - echo "Warning: Failed to install terminfo." >&2 + echo "Warning: Could not generate terminfo data." >&2 set ssh-env = [$@ssh-env TERM=xterm-256color] } - } else { - echo "Warning: Could not generate terminfo data." >&2 + } catch { + echo "Warning: base64 command not available for terminfo installation." >&2 set ssh-env = [$@ssh-env TERM=xterm-256color] } } catch { @@ -295,25 +256,36 @@ } } - # Ensure TERM is set when using ssh-env feature - if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { - var ssh-term-set = $false - for ssh-v $ssh-env { - if (str:has-prefix $ssh-v TERM=) { - set ssh-term-set = $true - break - } - } - if (and (not $ssh-term-set) (eq $E:TERM xterm-ghostty)) { - set ssh-env = [$@ssh-env TERM=xterm-256color] + # Execute SSH with environment handling + var ssh-term-override = "" + for ssh-v $ssh-env { + if (str:has-prefix $ssh-v TERM=) { + set ssh-term-override = (str:trim-prefix $ssh-v TERM=) + break } } + if (and (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) (eq $ssh-term-override "")) { + set ssh-env = [$@ssh-env TERM=xterm-256color] + set ssh-term-override = xterm-256color + } + var ssh-ret = 0 - try { - external ssh $@ssh-opts $@args - } catch e { - set ssh-ret = $e[reason][exit-status] + if (not-eq $ssh-term-override "") { + var ssh-original-term = $E:TERM + set-env TERM $ssh-term-override + try { + external ssh $@ssh-opts $@args + } catch e { + set ssh-ret = $e[reason][exit-status] + } + set-env TERM $ssh-original-term + } else { + try { + external ssh $@ssh-opts $@args + } catch e { + set ssh-ret = $e[reason][exit-status] + } } # Restore original environment variables 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 7dc121919..332675264 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 @@ -86,132 +86,119 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" end end - # SSH Integration for Fish Shell + # SSH Integration if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES"; or string match -q '*ssh-terminfo*' -- "$GHOSTTY_SHELL_FEATURES" - set -g GHOSTTY_SSH_CACHE_TIMEOUT (test -n "$GHOSTTY_SSH_CACHE_TIMEOUT"; and echo $GHOSTTY_SSH_CACHE_TIMEOUT; or echo 5) - set -g GHOSTTY_SSH_CHECK_TIMEOUT (test -n "$GHOSTTY_SSH_CHECK_TIMEOUT"; and echo $GHOSTTY_SSH_CHECK_TIMEOUT; or echo 3) - - # SSH wrapper that preserves Ghostty features across remote connections function ssh --wraps=ssh --description "SSH wrapper with Ghostty integration" set -l ssh_env set -l ssh_opts + set -l ssh_exported_vars # Configure environment variables for remote session if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES" - set -l ssh_env_vars \ - "COLORTERM=truecolor" \ - "TERM_PROGRAM=ghostty" - + set -a ssh_opts -o "SetEnv COLORTERM=truecolor" + + if set -q TERM_PROGRAM + set -a ssh_exported_vars "TERM_PROGRAM=$TERM_PROGRAM" + else + set -a ssh_exported_vars "TERM_PROGRAM" + end + set -gx TERM_PROGRAM ghostty + set -a ssh_opts -o "SendEnv TERM_PROGRAM" + if test -n "$TERM_PROGRAM_VERSION" - set -a ssh_env_vars "TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION" - end - - # Store original values for restoration - set -l ssh_exported_vars - for ssh_v in $ssh_env_vars - set -l ssh_var_name (string split -m1 '=' -- $ssh_v)[1] - - if set -q $ssh_var_name - set -a ssh_exported_vars "$ssh_var_name="(eval echo \$$ssh_var_name) + if set -q TERM_PROGRAM_VERSION + set -a ssh_exported_vars "TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION" else - set -a ssh_exported_vars $ssh_var_name + set -a ssh_exported_vars "TERM_PROGRAM_VERSION" end - - # Export the variable - set -gx (string split -m1 '=' -- $ssh_v) - - # Use both SendEnv and SetEnv for maximum compatibility - set -a ssh_opts -o "SendEnv $ssh_var_name" - set -a ssh_opts -o "SetEnv $ssh_v" + set -a ssh_opts -o "SendEnv TERM_PROGRAM_VERSION" end - set -a ssh_env $ssh_env_vars + set -a ssh_env "COLORTERM=truecolor" + set -a ssh_env "TERM_PROGRAM=ghostty" + if test -n "$TERM_PROGRAM_VERSION" + set -a ssh_env "TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION" + end end # Install terminfo on remote host if needed if string match -q '*ssh-terminfo*' -- "$GHOSTTY_SHELL_FEATURES" set -l ssh_config (command ssh -G $argv 2>/dev/null) - set -l ssh_user (echo $ssh_config | while read -l ssh_key ssh_value - test "$ssh_key" = "user"; and echo $ssh_value; and break - end) - set -l ssh_hostname (echo $ssh_config | while read -l ssh_key ssh_value - test "$ssh_key" = "hostname"; and echo $ssh_value; and break - end) + set -l ssh_user + set -l ssh_hostname + + for line in $ssh_config + set -l parts (string split ' ' -- $line) + if test (count $parts) -ge 2 + switch $parts[1] + case user + set ssh_user $parts[2] + case hostname + set ssh_hostname $parts[2] + end + if test -n "$ssh_user"; and test -n "$ssh_hostname" + break + end + end + end + set -l ssh_target "$ssh_user@$ssh_hostname" if test -n "$ssh_hostname" - # Detect timeout command (BSD compatibility) - set -l ssh_timeout_cmd - if command -v timeout >/dev/null 2>&1 - set ssh_timeout_cmd timeout - else if command -v gtimeout >/dev/null 2>&1 - set ssh_timeout_cmd gtimeout - end - # Check if terminfo is already cached set -l ssh_cache_check_success false if command -v ghostty >/dev/null 2>&1 - if test -n "$ssh_timeout_cmd" - if $ssh_timeout_cmd "$GHOSTTY_SSH_CHECK_TIMEOUT"s ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 - set ssh_cache_check_success true - end - else - if ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 - set ssh_cache_check_success true - end + if ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 + set ssh_cache_check_success true end end if test "$ssh_cache_check_success" = "true" set -a ssh_env TERM=xterm-ghostty else if command -v infocmp >/dev/null 2>&1 - # Generate terminfo data (BSD base64 compatibility) - set -l ssh_terminfo - if base64 --help 2>&1 | grep -q GNU - set ssh_terminfo (infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 -w0 2>/dev/null) + if not command -v base64 >/dev/null 2>&1 + echo "Warning: base64 command not available for terminfo installation." >&2 + set -a ssh_env TERM=xterm-256color else - set ssh_terminfo (infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 2>/dev/null | tr -d '\n') - end - - if test -n "$ssh_terminfo" - echo "Setting up Ghostty terminfo on remote host..." >&2 - set -l ssh_cpath_dir (mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null; or echo "/tmp/ghostty-ssh-$ssh_user."(random)) - set -l ssh_cpath "$ssh_cpath_dir/socket" - + set -l ssh_terminfo set -l ssh_base64_decode_cmd + + # BSD vs GNU base64 compatibility if base64 --help 2>&1 | grep -q GNU set ssh_base64_decode_cmd "base64 -d" + set ssh_terminfo (infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 -w0 2>/dev/null) else set ssh_base64_decode_cmd "base64 -D" + set ssh_terminfo (infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 2>/dev/null | tr -d '\n') end - if echo "$ssh_terminfo" | eval $ssh_base64_decode_cmd | command ssh $ssh_opts -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s $argv ' - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1 - ' 2>/dev/null - echo "Terminfo setup complete." >&2 - set -a ssh_env TERM=xterm-ghostty - set -a ssh_opts -o "ControlPath=$ssh_cpath" + if test -n "$ssh_terminfo" + echo "Setting up Ghostty terminfo on $ssh_hostname..." >&2 + set -l ssh_cpath_dir (mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null; or echo "/tmp/ghostty-ssh-$ssh_user."(random)) + set -l ssh_cpath "$ssh_cpath_dir/socket" - # Cache successful installation - if test -n "$ssh_target"; and command -v ghostty >/dev/null 2>&1 - fish -c " - if test -n '$ssh_timeout_cmd' - $ssh_timeout_cmd '$GHOSTTY_SSH_CACHE_TIMEOUT's ghostty +ssh-cache --add='$ssh_target' >/dev/null 2>&1; or true - else - ghostty +ssh-cache --add='$ssh_target' >/dev/null 2>&1; or true - end - " & + if echo "$ssh_terminfo" | eval $ssh_base64_decode_cmd | command ssh $ssh_opts -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s $argv ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + ' 2>/dev/null + echo "Terminfo setup complete on $ssh_hostname." >&2 + set -a ssh_env TERM=xterm-ghostty + set -a ssh_opts -o "ControlPath=$ssh_cpath" + + # Cache successful installation + if test -n "$ssh_target"; and command -v ghostty >/dev/null 2>&1 + fish -c "ghostty +ssh-cache --add='$ssh_target' >/dev/null 2>&1; or true" & + end + else + echo "Warning: Failed to install terminfo." >&2 + set -a ssh_env TERM=xterm-256color end else - echo "Warning: Failed to install terminfo." >&2 + echo "Warning: Could not generate terminfo data." >&2 set -a ssh_env TERM=xterm-256color end - else - echo "Warning: Could not generate terminfo data." >&2 - set -a ssh_env TERM=xterm-256color end else echo "Warning: ghostty command not available for cache management." >&2 @@ -224,22 +211,31 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" end end - # Ensure TERM is set when using ssh-env feature - if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES" - set -l ssh_term_set false - for ssh_v in $ssh_env - if string match -q 'TERM=*' -- $ssh_v - set ssh_term_set true - break - end - end - if test "$ssh_term_set" = "false"; and test "$TERM" = "xterm-ghostty" - set -a ssh_env TERM=xterm-256color + # Execute SSH with environment handling + set -l ssh_term_override + for ssh_v in $ssh_env + if string match -q 'TERM=*' -- $ssh_v + set ssh_term_override (string replace 'TERM=' '' -- $ssh_v) + break end end - command ssh $ssh_opts $argv - set -l ssh_ret $status + if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES"; and test -z "$ssh_term_override" + set -a ssh_env TERM=xterm-256color + set ssh_term_override xterm-256color + end + + set -l ssh_ret + if test -n "$ssh_term_override" + set -l ssh_original_term "$TERM" + set -gx TERM "$ssh_term_override" + command ssh $ssh_opts $argv + set ssh_ret $status + set -gx TERM "$ssh_original_term" + else + command ssh $ssh_opts $argv + set ssh_ret $status + end # Restore original environment variables if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES" diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 9f78e9a89..40ee58b49 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -244,132 +244,116 @@ _ghostty_deferred_init() { } fi - # SSH Integration +# SSH Integration if [[ "$GHOSTTY_SHELL_FEATURES" =~ (ssh-env|ssh-terminfo) ]]; then - : "${GHOSTTY_SSH_CACHE_TIMEOUT:=5}" - : "${GHOSTTY_SSH_CHECK_TIMEOUT:=3}" - - # SSH wrapper that preserves Ghostty features across remote connections ssh() { emulate -L zsh setopt local_options no_glob_subst - - local -a ssh_env ssh_opts + + local ssh_env ssh_opts ssh_exported_vars + ssh_env=() + ssh_opts=() + ssh_exported_vars=() # Configure environment variables for remote session if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then - local -a ssh_env_vars=( + ssh_opts+=(-o "SetEnv COLORTERM=truecolor") + + if [[ -n "${TERM_PROGRAM+x}" ]]; then + ssh_exported_vars+=("TERM_PROGRAM=${TERM_PROGRAM}") + else + ssh_exported_vars+=("TERM_PROGRAM") + fi + export "TERM_PROGRAM=ghostty" + ssh_opts+=(-o "SendEnv TERM_PROGRAM") + + if [[ -n "$TERM_PROGRAM_VERSION" ]]; then + if [[ -n "${TERM_PROGRAM_VERSION+x}" ]]; then + ssh_exported_vars+=("TERM_PROGRAM_VERSION=${TERM_PROGRAM_VERSION}") + else + ssh_exported_vars+=("TERM_PROGRAM_VERSION") + fi + ssh_opts+=(-o "SendEnv TERM_PROGRAM_VERSION") + fi + + ssh_env+=( "COLORTERM=truecolor" "TERM_PROGRAM=ghostty" ) - [[ -n "$TERM_PROGRAM_VERSION" ]] && ssh_env_vars+=("TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION") - - # Temporarily export variables for SSH transmission - local -a ssh_exported_vars=() - local ssh_v ssh_var_name - for ssh_v in "${ssh_env_vars[@]}"; do - ssh_var_name="${ssh_v%%=*}" - - if [[ -n "${(P)ssh_var_name+x}" ]]; then - ssh_exported_vars+=("$ssh_var_name=${(P)ssh_var_name}") - else - ssh_exported_vars+=("$ssh_var_name") - fi - - export "${ssh_v}" - - # Use both SendEnv and SetEnv for maximum compatibility - ssh_opts+=(-o "SendEnv $ssh_var_name") - ssh_opts+=(-o "SetEnv $ssh_v") - done - - ssh_env+=("${ssh_env_vars[@]}") + [[ -n "$TERM_PROGRAM_VERSION" ]] && ssh_env+=("TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION") fi # Install terminfo on remote host if needed if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then - local ssh_config ssh_user ssh_hostname ssh_target + local ssh_config ssh_user ssh_hostname ssh_config=$(command ssh -G "$@" 2>/dev/null) - ssh_user=$(printf '%s\n' "${(@f)ssh_config}" | while IFS=' ' read -r ssh_key ssh_value; do - [[ "$ssh_key" == "user" ]] && printf '%s\n' "$ssh_value" && break - done) - ssh_hostname=$(printf '%s\n' "${(@f)ssh_config}" | while IFS=' ' read -r ssh_key ssh_value; do - [[ "$ssh_key" == "hostname" ]] && printf '%s\n' "$ssh_value" && break - done) + + while IFS=' ' read -r ssh_key ssh_value; do + case "$ssh_key" in + user) ssh_user="$ssh_value" ;; + hostname) ssh_hostname="$ssh_value" ;; + esac + [[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break + done <<< "$ssh_config" + ssh_target="${ssh_user}@${ssh_hostname}" if [[ -n "$ssh_hostname" ]]; then - # Detect timeout command (BSD compatibility) - local ssh_timeout_cmd="" - if (( $+commands[timeout] )); then - ssh_timeout_cmd="timeout" - elif (( $+commands[gtimeout] )); then - ssh_timeout_cmd="gtimeout" - fi - # Check if terminfo is already cached local ssh_cache_check_success=false if (( $+commands[ghostty] )); then - if [[ -n "$ssh_timeout_cmd" ]]; then - $ssh_timeout_cmd "${GHOSTTY_SSH_CHECK_TIMEOUT}s" ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 && ssh_cache_check_success=true - else - ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 && ssh_cache_check_success=true - fi + ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 && ssh_cache_check_success=true fi if [[ "$ssh_cache_check_success" == "true" ]]; then ssh_env+=(TERM=xterm-ghostty) elif (( $+commands[infocmp] )); then - local ssh_terminfo - - # Generate terminfo data (BSD base64 compatibility) - if base64 --help 2>&1 | grep -q GNU; then - ssh_terminfo=$(infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 -w0 2>/dev/null) + if ! (( $+commands[base64] )); then + print "Warning: base64 command not available for terminfo installation." >&2 + ssh_env+=(TERM=xterm-256color) else - ssh_terminfo=$(infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 2>/dev/null | tr -d '\n') - fi + local ssh_terminfo ssh_base64_decode_cmd - if [[ -n "$ssh_terminfo" ]]; then - print "Setting up Ghostty terminfo on remote host..." >&2 - local ssh_cpath_dir ssh_cpath - - ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$" - ssh_cpath="$ssh_cpath_dir/socket" - - local ssh_base64_decode_cmd + # BSD vs GNU base64 compatibility if base64 --help 2>&1 | grep -q GNU; then ssh_base64_decode_cmd="base64 -d" + ssh_terminfo=$(infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 -w0 2>/dev/null) else ssh_base64_decode_cmd="base64 -D" + ssh_terminfo=$(infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 2>/dev/null | tr -d '\n') fi - if print "$ssh_terminfo" | $ssh_base64_decode_cmd | command ssh "${ssh_opts[@]}" -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" ' - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1 - ' 2>/dev/null; then - print "Terminfo setup complete." >&2 - ssh_env+=(TERM=xterm-ghostty) - ssh_opts+=(-o "ControlPath=$ssh_cpath") + if [[ -n "$ssh_terminfo" ]]; then + print "Setting up Ghostty terminfo on $ssh_hostname..." >&2 + local ssh_cpath_dir ssh_cpath - # Cache successful installation - if [[ -n "$ssh_target" ]] && (( $+commands[ghostty] )); then - { - if [[ -n "$ssh_timeout_cmd" ]]; then - $ssh_timeout_cmd "${GHOSTTY_SSH_CACHE_TIMEOUT}s" ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true - else + ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$" + ssh_cpath="$ssh_cpath_dir/socket" + + if print "$ssh_terminfo" | $ssh_base64_decode_cmd | command ssh "${ssh_opts[@]}" -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + ' 2>/dev/null; then + print "Terminfo setup complete on $ssh_hostname." >&2 + ssh_env+=(TERM=xterm-ghostty) + ssh_opts+=(-o "ControlPath=$ssh_cpath") + + # Cache successful installation + if [[ -n "$ssh_target" ]] && (( $+commands[ghostty] )); then + { ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true - fi - } &! + } &! + fi + else + print "Warning: Failed to install terminfo." >&2 + ssh_env+=(TERM=xterm-256color) fi else - print "Warning: Failed to install terminfo." >&2 + print "Warning: Could not generate terminfo data." >&2 ssh_env+=(TERM=xterm-256color) fi - else - print "Warning: Could not generate terminfo data." >&2 - ssh_env+=(TERM=xterm-256color) fi else print "Warning: ghostty command not available for cache management." >&2 @@ -380,21 +364,35 @@ _ghostty_deferred_init() { fi fi - # Ensure TERM is set when using ssh-env feature - if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then - local ssh_term_set=false ssh_v - for ssh_v in "${ssh_env[@]}"; do - [[ "$ssh_v" =~ ^TERM= ]] && ssh_term_set=true && break - done - [[ "$ssh_term_set" == "false" && "$TERM" == "xterm-ghostty" ]] && ssh_env+=(TERM=xterm-256color) + # Execute SSH with environment handling + local ssh_term_override="" + local ssh_v + for ssh_v in "${ssh_env[@]}"; do + if [[ "$ssh_v" =~ ^TERM=(.*)$ ]]; then + ssh_term_override="${match[1]}" + break + fi + done + + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env && -z "$ssh_term_override" ]]; then + ssh_env+=(TERM=xterm-256color) + ssh_term_override="xterm-256color" fi - command ssh "${ssh_opts[@]}" "$@" - local ssh_ret=$? + local ssh_ret + if [[ -n "$ssh_term_override" ]]; then + local ssh_original_term="$TERM" + export TERM="$ssh_term_override" + command ssh "${ssh_opts[@]}" "$@" + ssh_ret=$? + export TERM="$ssh_original_term" + else + command ssh "${ssh_opts[@]}" "$@" + ssh_ret=$? + fi # Restore original environment variables if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then - local ssh_v for ssh_v in "${ssh_exported_vars[@]}"; do if [[ "$ssh_v" == *=* ]]; then export "${ssh_v}" From a727b59b2beda24ef7d79ce282a87ac5bcf84013 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Sat, 5 Jul 2025 13:15:59 -0700 Subject: [PATCH 45/52] fix: replace custom const with std lib, remove dead-weight test - Replaced custom const `SECONDS_PER_DAY` with `std.time.s_per_day` - Removed concurrent access test - would need real file ops to be meaningful --- src/cli/ssh_cache.zig | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig index e5acdfb7a..112f3a5c5 100644 --- a/src/cli/ssh_cache.zig +++ b/src/cli/ssh_cache.zig @@ -12,7 +12,6 @@ pub const CacheError = error{ const MAX_CACHE_SIZE = 512 * 1024; // 512KB - sufficient for approximately 10k entries const NEVER_EXPIRE = 0; -const SECONDS_PER_DAY = 86400; pub const Options = struct { clear: bool = false, @@ -65,7 +64,7 @@ const CacheEntry = struct { fn isExpired(self: CacheEntry, expire_days: u32) bool { if (expire_days == NEVER_EXPIRE) return false; const now = std.time.timestamp(); - const age_days = @divTrunc(now - self.timestamp, SECONDS_PER_DAY); + const age_days = @divTrunc(now - self.timestamp, std.time.s_per_day); return age_days > expire_days; } }; @@ -377,7 +376,7 @@ fn listHosts(alloc: Allocator, writer: anytype) !void { const now = std.time.timestamp(); for (items.items) |entry| { - const age_days = @divTrunc(now - entry.timestamp, SECONDS_PER_DAY); + const age_days = @divTrunc(now - entry.timestamp, std.time.s_per_day); if (age_days == 0) { try writer.print(" {s} (today)\n", .{entry.hostname}); } else if (age_days == 1) { @@ -623,14 +622,14 @@ test "cache entry expiration" { const fresh_entry = CacheEntry{ .hostname = "test.com", - .timestamp = now - SECONDS_PER_DAY, // 1 day old + .timestamp = now - std.time.s_per_day, // 1 day old .terminfo_version = "xterm-ghostty", }; try testing.expect(!fresh_entry.isExpired(90)); const old_entry = CacheEntry{ .hostname = "old.com", - .timestamp = now - (SECONDS_PER_DAY * 100), // 100 days old + .timestamp = now - (std.time.s_per_day * 100), // 100 days old .terminfo_version = "xterm-ghostty", }; try testing.expect(old_entry.isExpired(90)); @@ -646,7 +645,7 @@ test "cache entry expiration - boundary cases" { // Exactly at expiration boundary const boundary_entry = CacheEntry{ .hostname = "boundary.com", - .timestamp = now - (SECONDS_PER_DAY * 30), // Exactly 30 days old + .timestamp = now - (std.time.s_per_day * 30), // Exactly 30 days old .terminfo_version = "xterm-ghostty", }; try testing.expect(!boundary_entry.isExpired(30)); // Should not be expired @@ -738,17 +737,3 @@ test "duplicate cache entries - memory management" { try testing.expectEqual(@as(i64, 1640995300), entry.timestamp); try testing.expectEqualStrings("xterm-ghostty-v2", entry.terminfo_version); } - -test "concurrent access simulation - file locking" { - const testing = std.testing; - - // This test simulates the file locking mechanism - // In practice, this would require actual file operations - // but we can test the error handling logic - - const TestError = error{CacheLocked}; - - const result = TestError.CacheLocked; - try testing.expectError(TestError.CacheLocked, result); -} - From 08db61e27e875b1a211867523d72ca414224656b Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Mon, 7 Jul 2025 08:58:44 -0700 Subject: [PATCH 46/52] refactor: simplify SSH environment variable handling - Remove complex ssh_exported_vars tracking and local environment modification in favor of trusting Ghostty's local environment - Replace regex patterns with glob-based feature detection for better performance - Fix local variable declaration consistency throughout - Streamline logic while maintaining all functionality --- src/shell-integration/bash/ghostty.bash | 48 ++++--------------- .../elvish/lib/ghostty-integration.elv | 31 +----------- .../ghostty-shell-integration.fish | 30 +----------- src/shell-integration/zsh/ghostty-integration | 46 ++++-------------- 4 files changed, 21 insertions(+), 134 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index f13a7d378..205494c94 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -96,33 +96,16 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then fi # SSH Integration -if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then +if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]] || [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-terminfo* ]]; then ssh() { - builtin local ssh_env ssh_opts ssh_exported_vars + builtin local ssh_env ssh_opts ssh_env=() ssh_opts=() - ssh_exported_vars=() # Configure environment variables for remote session - if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then + if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]]; then ssh_opts+=(-o "SetEnv COLORTERM=truecolor") - - if [[ -n "${TERM_PROGRAM+x}" ]]; then - ssh_exported_vars+=("TERM_PROGRAM=${TERM_PROGRAM}") - else - ssh_exported_vars+=("TERM_PROGRAM") - fi - builtin export "TERM_PROGRAM=ghostty" - ssh_opts+=(-o "SendEnv TERM_PROGRAM") - - if [[ -n "$TERM_PROGRAM_VERSION" ]]; then - if [[ -n "${TERM_PROGRAM_VERSION+x}" ]]; then - ssh_exported_vars+=("TERM_PROGRAM_VERSION=${TERM_PROGRAM_VERSION}") - else - ssh_exported_vars+=("TERM_PROGRAM_VERSION") - fi - ssh_opts+=(-o "SendEnv TERM_PROGRAM_VERSION") - fi + ssh_opts+=(-o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION") ssh_env+=( "COLORTERM=truecolor" @@ -134,7 +117,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then fi # Install terminfo on remote host if needed - if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then + if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-terminfo* ]]; then builtin local ssh_config ssh_user ssh_hostname ssh_config=$(builtin command ssh -G "$@" 2>/dev/null) @@ -146,7 +129,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then [[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break done <<< "$ssh_config" - ssh_target="${ssh_user}@${ssh_hostname}" + builtin local ssh_target="${ssh_user}@${ssh_hostname}" if [[ -n "$ssh_hostname" ]]; then # Check if terminfo is already cached @@ -213,7 +196,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then ssh_env+=(TERM=xterm-256color) fi else - if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then + if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]]; then ssh_env+=(TERM=xterm-256color) fi fi @@ -228,7 +211,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then fi done - if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env && -z "$ssh_term_override" ]]; then + if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* && -z "$ssh_term_override" ]]; then ssh_env+=(TERM=xterm-256color) ssh_term_override="xterm-256color" fi @@ -244,17 +227,6 @@ if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then local ssh_ret=$? fi - # Restore original environment variables - if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then - for ssh_v in "${ssh_exported_vars[@]}"; do - if [[ "$ssh_v" == *=* ]]; then - builtin export "${ssh_v?}" - else - builtin unset "${ssh_v}" - fi - done - fi - return $ssh_ret } fi @@ -286,8 +258,8 @@ function __ghostty_precmd() { # Cursor if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then - PS1=$PS1'\[\e[5 q\]' # blinking bar for input - builtin printf "\e[0 q" # reset to default cursor + [[ "$PS1" != *'\[\e[5 q\]'* ]] && PS1=$PS1'\[\e[5 q\]' # input + [[ "$PS0" != *'\[\e[0 q\]'* ]] && PS0=$PS0'\[\e[0 q\]' # reset fi # Title (working directory) diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 52d01e4fb..c2d187961 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -105,28 +105,11 @@ fn ssh {|@args| var ssh-env = [] var ssh-opts = [] - var ssh-exported-vars = [] # Configure environment variables for remote session if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { set ssh-opts = [$@ssh-opts -o "SetEnv COLORTERM=truecolor"] - - if (has-env TERM_PROGRAM) { - set ssh-exported-vars = [$@ssh-exported-vars "TERM_PROGRAM="$E:TERM_PROGRAM] - } else { - set ssh-exported-vars = [$@ssh-exported-vars "TERM_PROGRAM"] - } - set-env TERM_PROGRAM ghostty - set ssh-opts = [$@ssh-opts -o "SendEnv TERM_PROGRAM"] - - if (has-env TERM_PROGRAM_VERSION) { - if (has-env TERM_PROGRAM_VERSION) { - set ssh-exported-vars = [$@ssh-exported-vars "TERM_PROGRAM_VERSION="$E:TERM_PROGRAM_VERSION] - } else { - set ssh-exported-vars = [$@ssh-exported-vars "TERM_PROGRAM_VERSION"] - } - set ssh-opts = [$@ssh-opts -o "SendEnv TERM_PROGRAM_VERSION"] - } + set ssh-opts = [$@ssh-opts -o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"] set ssh-env = [ "COLORTERM=truecolor" @@ -288,18 +271,6 @@ } } - # Restore original environment variables - if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { - for ssh-v $ssh-exported-vars { - if (str:contains $ssh-v =) { - var ssh-var-parts = (str:split &max=2 = $ssh-v) - set-env $ssh-var-parts[0] $ssh-var-parts[1] - } else { - unset-env $ssh-v - } - } - } - if (not-eq $ssh-ret 0) { fail ssh-failed } 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 332675264..c45c20f92 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 @@ -91,28 +91,11 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" function ssh --wraps=ssh --description "SSH wrapper with Ghostty integration" set -l ssh_env set -l ssh_opts - set -l ssh_exported_vars # Configure environment variables for remote session if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES" set -a ssh_opts -o "SetEnv COLORTERM=truecolor" - - if set -q TERM_PROGRAM - set -a ssh_exported_vars "TERM_PROGRAM=$TERM_PROGRAM" - else - set -a ssh_exported_vars "TERM_PROGRAM" - end - set -gx TERM_PROGRAM ghostty - set -a ssh_opts -o "SendEnv TERM_PROGRAM" - - if test -n "$TERM_PROGRAM_VERSION" - if set -q TERM_PROGRAM_VERSION - set -a ssh_exported_vars "TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION" - else - set -a ssh_exported_vars "TERM_PROGRAM_VERSION" - end - set -a ssh_opts -o "SendEnv TERM_PROGRAM_VERSION" - end + set -a ssh_opts -o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION" set -a ssh_env "COLORTERM=truecolor" set -a ssh_env "TERM_PROGRAM=ghostty" @@ -237,17 +220,6 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" set ssh_ret $status end - # Restore original environment variables - if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES" - for ssh_v in $ssh_exported_vars - if string match -q '*=*' -- $ssh_v - set -gx (string split -m1 '=' -- $ssh_v) - else - set -e $ssh_v - end - end - end - return $ssh_ret end end diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 40ee58b49..37880e5f8 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -244,37 +244,20 @@ _ghostty_deferred_init() { } fi -# SSH Integration - if [[ "$GHOSTTY_SHELL_FEATURES" =~ (ssh-env|ssh-terminfo) ]]; then + # SSH Integration + if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]] || [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-terminfo* ]]; then ssh() { emulate -L zsh setopt local_options no_glob_subst - local ssh_env ssh_opts ssh_exported_vars + local ssh_env ssh_opts ssh_env=() ssh_opts=() - ssh_exported_vars=() # Configure environment variables for remote session - if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then + if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]]; then ssh_opts+=(-o "SetEnv COLORTERM=truecolor") - - if [[ -n "${TERM_PROGRAM+x}" ]]; then - ssh_exported_vars+=("TERM_PROGRAM=${TERM_PROGRAM}") - else - ssh_exported_vars+=("TERM_PROGRAM") - fi - export "TERM_PROGRAM=ghostty" - ssh_opts+=(-o "SendEnv TERM_PROGRAM") - - if [[ -n "$TERM_PROGRAM_VERSION" ]]; then - if [[ -n "${TERM_PROGRAM_VERSION+x}" ]]; then - ssh_exported_vars+=("TERM_PROGRAM_VERSION=${TERM_PROGRAM_VERSION}") - else - ssh_exported_vars+=("TERM_PROGRAM_VERSION") - fi - ssh_opts+=(-o "SendEnv TERM_PROGRAM_VERSION") - fi + ssh_opts+=(-o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION") ssh_env+=( "COLORTERM=truecolor" @@ -284,7 +267,7 @@ _ghostty_deferred_init() { fi # Install terminfo on remote host if needed - if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then + if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-terminfo* ]]; then local ssh_config ssh_user ssh_hostname ssh_config=$(command ssh -G "$@" 2>/dev/null) @@ -296,7 +279,7 @@ _ghostty_deferred_init() { [[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break done <<< "$ssh_config" - ssh_target="${ssh_user}@${ssh_hostname}" + local ssh_target="${ssh_user}@${ssh_hostname}" if [[ -n "$ssh_hostname" ]]; then # Check if terminfo is already cached @@ -360,7 +343,7 @@ _ghostty_deferred_init() { ssh_env+=(TERM=xterm-256color) fi else - [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]] && ssh_env+=(TERM=xterm-256color) + [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]] && ssh_env+=(TERM=xterm-256color) fi fi @@ -374,7 +357,7 @@ _ghostty_deferred_init() { fi done - if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env && -z "$ssh_term_override" ]]; then + if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* && -z "$ssh_term_override" ]]; then ssh_env+=(TERM=xterm-256color) ssh_term_override="xterm-256color" fi @@ -391,17 +374,6 @@ _ghostty_deferred_init() { ssh_ret=$? fi - # Restore original environment variables - if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then - for ssh_v in "${ssh_exported_vars[@]}"; do - if [[ "$ssh_v" == *=* ]]; then - export "${ssh_v}" - else - unset "${ssh_v}" - fi - done - fi - return $ssh_ret } fi From c3b14dff71073989e330218d98aeabed292ecd59 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Mon, 7 Jul 2025 10:00:56 -0700 Subject: [PATCH 47/52] refactor: simplify SSH terminfo and environment handling - Simplify feature detection to use single wildcard check - Replace ssh_env array with simple ssh_term string variable - Use TERM environment prefix instead of save/restore pattern - Remove unnecessary backgrounded subshell for cache operations --- src/shell-integration/bash/ghostty.bash | 59 ++++------------ .../elvish/lib/ghostty-integration.elv | 68 +++++++------------ .../ghostty-shell-integration.fish | 51 ++++---------- src/shell-integration/zsh/ghostty-integration | 56 ++++----------- 4 files changed, 68 insertions(+), 166 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 205494c94..287fbb584 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -96,24 +96,16 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then fi # SSH Integration -if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]] || [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-terminfo* ]]; then +if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then ssh() { - builtin local ssh_env ssh_opts - ssh_env=() + builtin local ssh_term ssh_opts + ssh_term="" ssh_opts=() # Configure environment variables for remote session if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]]; then ssh_opts+=(-o "SetEnv COLORTERM=truecolor") ssh_opts+=(-o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION") - - ssh_env+=( - "COLORTERM=truecolor" - "TERM_PROGRAM=ghostty" - ) - if [[ -n "$TERM_PROGRAM_VERSION" ]]; then - ssh_env+=("TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION") - fi fi # Install terminfo on remote host if needed @@ -139,11 +131,11 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]] || [[ "$GHOSTTY_SHELL_FEATURES" fi if [[ "$ssh_cache_check_success" == "true" ]]; then - ssh_env+=(TERM=xterm-ghostty) + ssh_term="xterm-ghostty" elif builtin command -v infocmp >/dev/null 2>&1; then if ! builtin command -v base64 >/dev/null 2>&1; then builtin echo "Warning: base64 command not available for terminfo installation." >&2 - ssh_env+=(TERM=xterm-256color) + ssh_term="xterm-256color" else builtin local ssh_terminfo ssh_base64_decode_cmd @@ -170,64 +162,43 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]] || [[ "$GHOSTTY_SHELL_FEATURES" exit 1 ' 2>/dev/null; then builtin echo "Terminfo setup complete on $ssh_hostname." >&2 - ssh_env+=(TERM=xterm-ghostty) + ssh_term="xterm-ghostty" ssh_opts+=(-o "ControlPath=$ssh_cpath") # Cache successful installation if [[ -n "$ssh_target" ]] && builtin command -v ghostty >/dev/null 2>&1; then - ( - set +m - { - ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true - } & - ) + ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true fi else builtin echo "Warning: Failed to install terminfo." >&2 - ssh_env+=(TERM=xterm-256color) + ssh_term="xterm-256color" fi else builtin echo "Warning: Could not generate terminfo data." >&2 - ssh_env+=(TERM=xterm-256color) + ssh_term="xterm-256color" fi fi else builtin echo "Warning: ghostty command not available for cache management." >&2 - ssh_env+=(TERM=xterm-256color) + ssh_term="xterm-256color" fi else if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]]; then - ssh_env+=(TERM=xterm-256color) + ssh_term="xterm-256color" fi fi fi # Execute SSH with environment handling - builtin local ssh_term_override="" - for ssh_v in "${ssh_env[@]}"; do - if [[ "$ssh_v" =~ ^TERM=(.*)$ ]]; then - ssh_term_override="${BASH_REMATCH[1]}" - break - fi - done - - if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* && -z "$ssh_term_override" ]]; then - ssh_env+=(TERM=xterm-256color) - ssh_term_override="xterm-256color" + if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* && -z "$ssh_term" ]]; then + ssh_term="xterm-256color" fi - if [[ -n "$ssh_term_override" ]]; then - builtin local ssh_original_term="$TERM" - builtin export TERM="$ssh_term_override" - builtin command ssh "${ssh_opts[@]}" "$@" - local ssh_ret=$? - builtin export TERM="$ssh_original_term" + if [[ -n "$ssh_term" ]]; then + TERM="$ssh_term" builtin command ssh "${ssh_opts[@]}" "$@" else builtin command ssh "${ssh_opts[@]}" "$@" - local ssh_ret=$? fi - - return $ssh_ret } fi diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index c2d187961..d348c7381 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -101,23 +101,16 @@ # SSH Integration use str - if (or (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-terminfo)) { + if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-) { + # SSH wrapper that preserves Ghostty features across remote connections fn ssh {|@args| - var ssh-env = [] + var ssh-term = "" var ssh-opts = [] # Configure environment variables for remote session if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { set ssh-opts = [$@ssh-opts -o "SetEnv COLORTERM=truecolor"] set ssh-opts = [$@ssh-opts -o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"] - - set ssh-env = [ - "COLORTERM=truecolor" - "TERM_PROGRAM=ghostty" - ] - if (has-env TERM_PROGRAM_VERSION) { - set ssh-env = [$@ssh-env "TERM_PROGRAM_VERSION="$E:TERM_PROGRAM_VERSION] - } } # Install terminfo on remote host if needed @@ -159,7 +152,7 @@ } if $ssh-cache-check-success { - set ssh-env = [$@ssh-env TERM=xterm-ghostty] + set ssh-term = "xterm-ghostty" } else { try { external infocmp --help >/dev/null 2>&1 @@ -208,71 +201,58 @@ if $terminfo-install-success { echo "Terminfo setup complete on "$ssh-hostname"." >&2 - set ssh-env = [$@ssh-env TERM=xterm-ghostty] + set ssh-term = "xterm-ghostty" set ssh-opts = [$@ssh-opts -o ControlPath=$ssh-cpath] # Cache successful installation if (and (not-eq $ssh-target "") (has-external ghostty)) { - external ghostty +ssh-cache --add=$ssh-target >/dev/null 2>&1 & + try { + external ghostty +ssh-cache --add=$ssh-target >/dev/null 2>&1 + } catch { + # cache add failed + } } } else { echo "Warning: Failed to install terminfo." >&2 - set ssh-env = [$@ssh-env TERM=xterm-256color] + set ssh-term = "xterm-256color" } } else { echo "Warning: Could not generate terminfo data." >&2 - set ssh-env = [$@ssh-env TERM=xterm-256color] + set ssh-term = "xterm-256color" } } catch { echo "Warning: base64 command not available for terminfo installation." >&2 - set ssh-env = [$@ssh-env TERM=xterm-256color] + set ssh-term = "xterm-256color" } } catch { echo "Warning: ghostty command not available for cache management." >&2 - set ssh-env = [$@ssh-env TERM=xterm-256color] + set ssh-term = "xterm-256color" } } } else { if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { - set ssh-env = [$@ssh-env TERM=xterm-256color] + set ssh-term = "xterm-256color" } } } # Execute SSH with environment handling - var ssh-term-override = "" - for ssh-v $ssh-env { - if (str:has-prefix $ssh-v TERM=) { - set ssh-term-override = (str:trim-prefix $ssh-v TERM=) - break - } + if (and (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) (eq $ssh-term "")) { + set ssh-term = "xterm-256color" } - if (and (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) (eq $ssh-term-override "")) { - set ssh-env = [$@ssh-env TERM=xterm-256color] - set ssh-term-override = xterm-256color - } - - var ssh-ret = 0 - if (not-eq $ssh-term-override "") { - var ssh-original-term = $E:TERM - set-env TERM $ssh-term-override + if (not-eq $ssh-term "") { + var old-term = $E:TERM + set-env TERM $ssh-term try { external ssh $@ssh-opts $@args } catch e { - set ssh-ret = $e[reason][exit-status] + set-env TERM $old-term + fail $e } - set-env TERM $ssh-original-term + set-env TERM $old-term } else { - try { - external ssh $@ssh-opts $@args - } catch e { - set ssh-ret = $e[reason][exit-status] - } - } - - if (not-eq $ssh-ret 0) { - fail ssh-failed + external ssh $@ssh-opts $@args } } } 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 c45c20f92..ba2fb48b8 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 @@ -87,21 +87,15 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" end # SSH Integration - if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES"; or string match -q '*ssh-terminfo*' -- "$GHOSTTY_SHELL_FEATURES" + if string match -q '*ssh-*' -- "$GHOSTTY_SHELL_FEATURES" function ssh --wraps=ssh --description "SSH wrapper with Ghostty integration" - set -l ssh_env + set -l ssh_term "" set -l ssh_opts # Configure environment variables for remote session if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES" set -a ssh_opts -o "SetEnv COLORTERM=truecolor" set -a ssh_opts -o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION" - - set -a ssh_env "COLORTERM=truecolor" - set -a ssh_env "TERM_PROGRAM=ghostty" - if test -n "$TERM_PROGRAM_VERSION" - set -a ssh_env "TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION" - end end # Install terminfo on remote host if needed @@ -137,11 +131,11 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" end if test "$ssh_cache_check_success" = "true" - set -a ssh_env TERM=xterm-ghostty + set ssh_term "xterm-ghostty" else if command -v infocmp >/dev/null 2>&1 if not command -v base64 >/dev/null 2>&1 echo "Warning: base64 command not available for terminfo installation." >&2 - set -a ssh_env TERM=xterm-256color + set ssh_term "xterm-256color" else set -l ssh_terminfo set -l ssh_base64_decode_cmd @@ -167,60 +161,43 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" exit 1 ' 2>/dev/null echo "Terminfo setup complete on $ssh_hostname." >&2 - set -a ssh_env TERM=xterm-ghostty + set ssh_term "xterm-ghostty" set -a ssh_opts -o "ControlPath=$ssh_cpath" # Cache successful installation if test -n "$ssh_target"; and command -v ghostty >/dev/null 2>&1 - fish -c "ghostty +ssh-cache --add='$ssh_target' >/dev/null 2>&1; or true" & + ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1; or true end else echo "Warning: Failed to install terminfo." >&2 - set -a ssh_env TERM=xterm-256color + set ssh_term "xterm-256color" end else echo "Warning: Could not generate terminfo data." >&2 - set -a ssh_env TERM=xterm-256color + set ssh_term "xterm-256color" end end else echo "Warning: ghostty command not available for cache management." >&2 - set -a ssh_env TERM=xterm-256color + set ssh_term "xterm-256color" end else if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES" - set -a ssh_env TERM=xterm-256color + set ssh_term "xterm-256color" end end end # Execute SSH with environment handling - set -l ssh_term_override - for ssh_v in $ssh_env - if string match -q 'TERM=*' -- $ssh_v - set ssh_term_override (string replace 'TERM=' '' -- $ssh_v) - break - end + if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES"; and test -z "$ssh_term" + set ssh_term "xterm-256color" end - if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES"; and test -z "$ssh_term_override" - set -a ssh_env TERM=xterm-256color - set ssh_term_override xterm-256color - end - - set -l ssh_ret - if test -n "$ssh_term_override" - set -l ssh_original_term "$TERM" - set -gx TERM "$ssh_term_override" - command ssh $ssh_opts $argv - set ssh_ret $status - set -gx TERM "$ssh_original_term" + if test -n "$ssh_term" + env TERM="$ssh_term" command ssh $ssh_opts $argv else command ssh $ssh_opts $argv - set ssh_ret $status end - - return $ssh_ret end end diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 37880e5f8..df62cdb6c 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -245,25 +245,19 @@ _ghostty_deferred_init() { fi # SSH Integration - if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]] || [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-terminfo* ]]; then + if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then ssh() { emulate -L zsh setopt local_options no_glob_subst - local ssh_env ssh_opts - ssh_env=() + local ssh_term ssh_opts + ssh_term="" ssh_opts=() # Configure environment variables for remote session if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]]; then ssh_opts+=(-o "SetEnv COLORTERM=truecolor") ssh_opts+=(-o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION") - - ssh_env+=( - "COLORTERM=truecolor" - "TERM_PROGRAM=ghostty" - ) - [[ -n "$TERM_PROGRAM_VERSION" ]] && ssh_env+=("TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION") fi # Install terminfo on remote host if needed @@ -289,11 +283,11 @@ _ghostty_deferred_init() { fi if [[ "$ssh_cache_check_success" == "true" ]]; then - ssh_env+=(TERM=xterm-ghostty) + ssh_term="xterm-ghostty" elif (( $+commands[infocmp] )); then if ! (( $+commands[base64] )); then print "Warning: base64 command not available for terminfo installation." >&2 - ssh_env+=(TERM=xterm-256color) + ssh_term="xterm-256color" else local ssh_terminfo ssh_base64_decode_cmd @@ -320,61 +314,41 @@ _ghostty_deferred_init() { exit 1 ' 2>/dev/null; then print "Terminfo setup complete on $ssh_hostname." >&2 - ssh_env+=(TERM=xterm-ghostty) + ssh_term="xterm-ghostty" ssh_opts+=(-o "ControlPath=$ssh_cpath") # Cache successful installation if [[ -n "$ssh_target" ]] && (( $+commands[ghostty] )); then - { - ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true - } &! + ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true fi else print "Warning: Failed to install terminfo." >&2 - ssh_env+=(TERM=xterm-256color) + ssh_term="xterm-256color" fi else print "Warning: Could not generate terminfo data." >&2 - ssh_env+=(TERM=xterm-256color) + ssh_term="xterm-256color" fi fi else print "Warning: ghostty command not available for cache management." >&2 - ssh_env+=(TERM=xterm-256color) + ssh_term="xterm-256color" fi else - [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]] && ssh_env+=(TERM=xterm-256color) + [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]] && ssh_term="xterm-256color" fi fi # Execute SSH with environment handling - local ssh_term_override="" - local ssh_v - for ssh_v in "${ssh_env[@]}"; do - if [[ "$ssh_v" =~ ^TERM=(.*)$ ]]; then - ssh_term_override="${match[1]}" - break - fi - done - - if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* && -z "$ssh_term_override" ]]; then - ssh_env+=(TERM=xterm-256color) - ssh_term_override="xterm-256color" + if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* && -z "$ssh_term" ]]; then + ssh_term="xterm-256color" fi - local ssh_ret - if [[ -n "$ssh_term_override" ]]; then - local ssh_original_term="$TERM" - export TERM="$ssh_term_override" - command ssh "${ssh_opts[@]}" "$@" - ssh_ret=$? - export TERM="$ssh_original_term" + if [[ -n "$ssh_term" ]]; then + TERM="$ssh_term" command ssh "${ssh_opts[@]}" "$@" else command ssh "${ssh_opts[@]}" "$@" - ssh_ret=$? fi - - return $ssh_ret } fi From f27993737794ea60855bb03c6ae07647be56c230 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Mon, 7 Jul 2025 11:33:26 -0700 Subject: [PATCH 48/52] refactor: simplify terminfo handling and remove base64 dependency - Default ssh_term to xterm-256color to eliminate fallback assignments - Remove base64 and replace infocmp -Q2 with standard -0 -x options for compatibility - Use process substitution instead of intermediate ssh_config variable - Always set TERM explicitly since ssh_term is always defined --- src/shell-integration/bash/ghostty.bash | 81 +++------ .../elvish/lib/ghostty-integration.elv | 161 +++++++----------- .../ghostty-shell-integration.fish | 82 +++------ src/shell-integration/zsh/ghostty-integration | 79 +++------ 4 files changed, 145 insertions(+), 258 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 287fbb584..5b6bb249d 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -99,7 +99,7 @@ fi if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then ssh() { builtin local ssh_term ssh_opts - ssh_term="" + ssh_term="xterm-256color" ssh_opts=() # Configure environment variables for remote session @@ -110,8 +110,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then # Install terminfo on remote host if needed if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-terminfo* ]]; then - builtin local ssh_config ssh_user ssh_hostname - ssh_config=$(builtin command ssh -G "$@" 2>/dev/null) + builtin local ssh_user ssh_hostname while IFS=' ' read -r ssh_key ssh_value; do case "$ssh_key" in @@ -119,7 +118,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then hostname) ssh_hostname="$ssh_value" ;; esac [[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break - done <<< "$ssh_config" + done < <(builtin command ssh -G "$@" 2>/dev/null) builtin local ssh_target="${ssh_user}@${ssh_hostname}" @@ -133,72 +132,44 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then if [[ "$ssh_cache_check_success" == "true" ]]; then ssh_term="xterm-ghostty" elif builtin command -v infocmp >/dev/null 2>&1; then - if ! builtin command -v base64 >/dev/null 2>&1; then - builtin echo "Warning: base64 command not available for terminfo installation." >&2 - ssh_term="xterm-256color" - else - builtin local ssh_terminfo ssh_base64_decode_cmd + builtin local ssh_terminfo ssh_cpath_dir ssh_cpath - # BSD vs GNU base64 compatibility - if base64 --help 2>&1 | grep -q GNU; then - ssh_base64_decode_cmd="base64 -d" - ssh_terminfo=$(infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 -w0 2>/dev/null) - else - ssh_base64_decode_cmd="base64 -D" - ssh_terminfo=$(infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 2>/dev/null | tr -d '\n') - fi + ssh_terminfo=$(infocmp -0 -x xterm-ghostty 2>/dev/null) - if [[ -n "$ssh_terminfo" ]]; then - builtin echo "Setting up Ghostty terminfo on $ssh_hostname..." >&2 - builtin local ssh_cpath_dir ssh_cpath + if [[ -n "$ssh_terminfo" ]]; then + builtin echo "Setting up Ghostty terminfo on $ssh_hostname..." >&2 - ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$" - ssh_cpath="$ssh_cpath_dir/socket" + ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$" + ssh_cpath="$ssh_cpath_dir/socket" - if builtin echo "$ssh_terminfo" | $ssh_base64_decode_cmd | builtin command ssh "${ssh_opts[@]}" -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" ' - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1 - ' 2>/dev/null; then - builtin echo "Terminfo setup complete on $ssh_hostname." >&2 - ssh_term="xterm-ghostty" - ssh_opts+=(-o "ControlPath=$ssh_cpath") + if builtin echo "$ssh_terminfo" | builtin command ssh "${ssh_opts[@]}" -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + ' 2>/dev/null; then + builtin echo "Terminfo setup complete on $ssh_hostname." >&2 + ssh_term="xterm-ghostty" + ssh_opts+=(-o "ControlPath=$ssh_cpath") - # Cache successful installation - if [[ -n "$ssh_target" ]] && builtin command -v ghostty >/dev/null 2>&1; then - ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true - fi - else - builtin echo "Warning: Failed to install terminfo." >&2 - ssh_term="xterm-256color" + # Cache successful installation + if [[ -n "$ssh_target" ]] && builtin command -v ghostty >/dev/null 2>&1; then + ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true fi else - builtin echo "Warning: Could not generate terminfo data." >&2 - ssh_term="xterm-256color" + builtin echo "Warning: Failed to install terminfo." >&2 fi + else + builtin echo "Warning: Could not generate terminfo data." >&2 fi else builtin echo "Warning: ghostty command not available for cache management." >&2 - ssh_term="xterm-256color" - fi - else - if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]]; then - ssh_term="xterm-256color" fi fi fi - # Execute SSH with environment handling - if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* && -z "$ssh_term" ]]; then - ssh_term="xterm-256color" - fi - - if [[ -n "$ssh_term" ]]; then - TERM="$ssh_term" builtin command ssh "${ssh_opts[@]}" "$@" - else - builtin command ssh "${ssh_opts[@]}" "$@" - fi + # Execute SSH with TERM environment variable + TERM="$ssh_term" builtin command ssh "${ssh_opts[@]}" "$@" } fi diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index d348c7381..44cf135dc 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -102,9 +102,8 @@ use str if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-) { - # SSH wrapper that preserves Ghostty features across remote connections fn ssh {|@args| - var ssh-term = "" + var ssh-term = "xterm-256color" var ssh-opts = [] # Configure environment variables for remote session @@ -115,28 +114,26 @@ # Install terminfo on remote host if needed if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-terminfo) { - var ssh-config = "" - try { - set ssh-config = (external ssh -G $@args 2>/dev/null | slurp) - } catch { - set ssh-config = "" - } - var ssh-user = "" var ssh-hostname = "" - for line (str:split "\n" $ssh-config) { - var parts = (str:split " " $line) - if (> (count $parts) 1) { - if (eq $parts[0] user) { - set ssh-user = $parts[1] - } elif (eq $parts[0] hostname) { - set ssh-hostname = $parts[1] - } - if (and (not-eq $ssh-user "") (not-eq $ssh-hostname "")) { - break + try { + var ssh-config = (external ssh -G $@args 2>/dev/null | slurp) + for line (str:split "\n" $ssh-config) { + var parts = (str:split " " $line) + if (> (count $parts) 1) { + if (eq $parts[0] user) { + set ssh-user = $parts[1] + } elif (eq $parts[0] hostname) { + set ssh-hostname = $parts[1] + } + if (and (not-eq $ssh-user "") (not-eq $ssh-hostname "")) { + break + } } } + } catch { + # ssh config failed } var ssh-target = $ssh-user"@"$ssh-hostname @@ -157,103 +154,75 @@ try { external infocmp --help >/dev/null 2>&1 - try { - external base64 --help >/dev/null 2>&1 + var ssh-terminfo = "" + var ssh-cpath-dir = "" + var ssh-cpath = "" + + try { + set ssh-terminfo = (external infocmp -0 -x xterm-ghostty 2>/dev/null | slurp) + } catch { + set ssh-terminfo = "" + } + + if (not-eq $ssh-terminfo "") { + echo "Setting up Ghostty terminfo on "$ssh-hostname"..." >&2 - # Generate terminfo data (BSD base64 compatibility) - var ssh-terminfo = "" - var ssh-base64-decode-cmd = "" try { - var base64-help = (external base64 --help 2>&1 | slurp) - if (str:contains $base64-help GNU) { - set ssh-base64-decode-cmd = "base64 -d" - set ssh-terminfo = (external infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | external base64 -w0 2>/dev/null | slurp) - } else { - set ssh-base64-decode-cmd = "base64 -D" - set ssh-terminfo = (external infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | external base64 2>/dev/null | external tr -d '\n' | slurp) - } + set ssh-cpath-dir = (external mktemp -d "/tmp/ghostty-ssh-"$ssh-user".XXXXXX" 2>/dev/null | slurp) } catch { - set ssh-terminfo = "" + set ssh-cpath-dir = "/tmp/ghostty-ssh-"$ssh-user"."(randint 10000 99999) + } + set ssh-cpath = $ssh-cpath-dir"/socket" + + var terminfo-install-success = $false + try { + echo $ssh-terminfo | external ssh $@ssh-opts -o ControlMaster=yes -o ControlPath=$ssh-cpath -o ControlPersist=60s $@args ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + ' >/dev/null 2>&1 + set terminfo-install-success = $true + } catch { + set terminfo-install-success = $false } - if (not-eq $ssh-terminfo "") { - echo "Setting up Ghostty terminfo on "$ssh-hostname"..." >&2 - var ssh-cpath-dir = "" - try { - set ssh-cpath-dir = (external mktemp -d "/tmp/ghostty-ssh-"$ssh-user".XXXXXX" 2>/dev/null | slurp) - } catch { - set ssh-cpath-dir = "/tmp/ghostty-ssh-"$ssh-user"."(randint 10000 99999) - } - var ssh-cpath = $ssh-cpath-dir"/socket" + if $terminfo-install-success { + echo "Terminfo setup complete on "$ssh-hostname"." >&2 + set ssh-term = "xterm-ghostty" + set ssh-opts = [$@ssh-opts -o ControlPath=$ssh-cpath] - var terminfo-install-success = $false - try { - echo $ssh-terminfo | external sh -c $ssh-base64-decode-cmd | external ssh $@ssh-opts -o ControlMaster=yes -o ControlPath=$ssh-cpath -o ControlPersist=60s $@args ' - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1 - ' >/dev/null 2>&1 - set terminfo-install-success = $true - } catch { - set terminfo-install-success = $false - } - - if $terminfo-install-success { - echo "Terminfo setup complete on "$ssh-hostname"." >&2 - set ssh-term = "xterm-ghostty" - set ssh-opts = [$@ssh-opts -o ControlPath=$ssh-cpath] - - # Cache successful installation - if (and (not-eq $ssh-target "") (has-external ghostty)) { - try { - external ghostty +ssh-cache --add=$ssh-target >/dev/null 2>&1 - } catch { - # cache add failed - } + # Cache successful installation + if (and (not-eq $ssh-target "") (has-external ghostty)) { + try { + external ghostty +ssh-cache --add=$ssh-target >/dev/null 2>&1 + } catch { + # cache add failed } - } else { - echo "Warning: Failed to install terminfo." >&2 - set ssh-term = "xterm-256color" } } else { - echo "Warning: Could not generate terminfo data." >&2 - set ssh-term = "xterm-256color" + echo "Warning: Failed to install terminfo." >&2 } - } catch { - echo "Warning: base64 command not available for terminfo installation." >&2 - set ssh-term = "xterm-256color" + } else { + echo "Warning: Could not generate terminfo data." >&2 } } catch { echo "Warning: ghostty command not available for cache management." >&2 - set ssh-term = "xterm-256color" } } - } else { - if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { - set ssh-term = "xterm-256color" - } } } - # Execute SSH with environment handling - if (and (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) (eq $ssh-term "")) { - set ssh-term = "xterm-256color" - } - - if (not-eq $ssh-term "") { - var old-term = $E:TERM - set-env TERM $ssh-term - try { - external ssh $@ssh-opts $@args - } catch e { - set-env TERM $old-term - fail $e - } - set-env TERM $old-term - } else { + # Execute SSH with TERM environment variable + var old-term = $E:TERM + set-env TERM $ssh-term + try { external ssh $@ssh-opts $@args + } catch e { + set-env TERM $old-term + fail $e } + set-env TERM $old-term } } 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 ba2fb48b8..ab0f23086 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 @@ -89,7 +89,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" # SSH Integration if string match -q '*ssh-*' -- "$GHOSTTY_SHELL_FEATURES" function ssh --wraps=ssh --description "SSH wrapper with Ghostty integration" - set -l ssh_term "" + set -l ssh_term "xterm-256color" set -l ssh_opts # Configure environment variables for remote session @@ -100,11 +100,10 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" # Install terminfo on remote host if needed if string match -q '*ssh-terminfo*' -- "$GHOSTTY_SHELL_FEATURES" - set -l ssh_config (command ssh -G $argv 2>/dev/null) set -l ssh_user set -l ssh_hostname - for line in $ssh_config + for line in (command ssh -G $argv 2>/dev/null) set -l parts (string split ' ' -- $line) if test (count $parts) -ge 2 switch $parts[1] @@ -133,71 +132,46 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" if test "$ssh_cache_check_success" = "true" set ssh_term "xterm-ghostty" else if command -v infocmp >/dev/null 2>&1 - if not command -v base64 >/dev/null 2>&1 - echo "Warning: base64 command not available for terminfo installation." >&2 - set ssh_term "xterm-256color" - else - set -l ssh_terminfo - set -l ssh_base64_decode_cmd + set -l ssh_terminfo + set -l ssh_cpath_dir + set -l ssh_cpath - # BSD vs GNU base64 compatibility - if base64 --help 2>&1 | grep -q GNU - set ssh_base64_decode_cmd "base64 -d" - set ssh_terminfo (infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 -w0 2>/dev/null) - else - set ssh_base64_decode_cmd "base64 -D" - set ssh_terminfo (infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 2>/dev/null | tr -d '\n') - end + set ssh_terminfo (infocmp -0 -x xterm-ghostty 2>/dev/null) - if test -n "$ssh_terminfo" - echo "Setting up Ghostty terminfo on $ssh_hostname..." >&2 - set -l ssh_cpath_dir (mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null; or echo "/tmp/ghostty-ssh-$ssh_user."(random)) - set -l ssh_cpath "$ssh_cpath_dir/socket" + if test -n "$ssh_terminfo" + echo "Setting up Ghostty terminfo on $ssh_hostname..." >&2 - if echo "$ssh_terminfo" | eval $ssh_base64_decode_cmd | command ssh $ssh_opts -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s $argv ' - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1 - ' 2>/dev/null - echo "Terminfo setup complete on $ssh_hostname." >&2 - set ssh_term "xterm-ghostty" - set -a ssh_opts -o "ControlPath=$ssh_cpath" + set ssh_cpath_dir (mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null; or echo "/tmp/ghostty-ssh-$ssh_user."(random)) + set ssh_cpath "$ssh_cpath_dir/socket" - # Cache successful installation - if test -n "$ssh_target"; and command -v ghostty >/dev/null 2>&1 - ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1; or true - end - else - echo "Warning: Failed to install terminfo." >&2 - set ssh_term "xterm-256color" + if echo "$ssh_terminfo" | command ssh $ssh_opts -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s $argv ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + ' 2>/dev/null + echo "Terminfo setup complete on $ssh_hostname." >&2 + set ssh_term "xterm-ghostty" + set -a ssh_opts -o "ControlPath=$ssh_cpath" + + # Cache successful installation + if test -n "$ssh_target"; and command -v ghostty >/dev/null 2>&1 + ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1; or true end else - echo "Warning: Could not generate terminfo data." >&2 - set ssh_term "xterm-256color" + echo "Warning: Failed to install terminfo." >&2 end + else + echo "Warning: Could not generate terminfo data." >&2 end else echo "Warning: ghostty command not available for cache management." >&2 - set ssh_term "xterm-256color" - end - else - if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES" - set ssh_term "xterm-256color" end end end - # Execute SSH with environment handling - if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES"; and test -z "$ssh_term" - set ssh_term "xterm-256color" - end - - if test -n "$ssh_term" - env TERM="$ssh_term" command ssh $ssh_opts $argv - else - command ssh $ssh_opts $argv - end + # Execute SSH with TERM environment variable + env TERM="$ssh_term" command ssh $ssh_opts $argv end end diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index df62cdb6c..7c7ab7972 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -251,7 +251,7 @@ _ghostty_deferred_init() { setopt local_options no_glob_subst local ssh_term ssh_opts - ssh_term="" + ssh_term="xterm-256color" ssh_opts=() # Configure environment variables for remote session @@ -262,8 +262,7 @@ _ghostty_deferred_init() { # Install terminfo on remote host if needed if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-terminfo* ]]; then - local ssh_config ssh_user ssh_hostname - ssh_config=$(command ssh -G "$@" 2>/dev/null) + local ssh_user ssh_hostname while IFS=' ' read -r ssh_key ssh_value; do case "$ssh_key" in @@ -271,7 +270,7 @@ _ghostty_deferred_init() { hostname) ssh_hostname="$ssh_value" ;; esac [[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break - done <<< "$ssh_config" + done < <(command ssh -G "$@" 2>/dev/null) local ssh_target="${ssh_user}@${ssh_hostname}" @@ -285,70 +284,44 @@ _ghostty_deferred_init() { if [[ "$ssh_cache_check_success" == "true" ]]; then ssh_term="xterm-ghostty" elif (( $+commands[infocmp] )); then - if ! (( $+commands[base64] )); then - print "Warning: base64 command not available for terminfo installation." >&2 - ssh_term="xterm-256color" - else - local ssh_terminfo ssh_base64_decode_cmd + local ssh_terminfo ssh_cpath_dir ssh_cpath - # BSD vs GNU base64 compatibility - if base64 --help 2>&1 | grep -q GNU; then - ssh_base64_decode_cmd="base64 -d" - ssh_terminfo=$(infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 -w0 2>/dev/null) - else - ssh_base64_decode_cmd="base64 -D" - ssh_terminfo=$(infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 2>/dev/null | tr -d '\n') - fi + ssh_terminfo=$(infocmp -0 -x xterm-ghostty 2>/dev/null) - if [[ -n "$ssh_terminfo" ]]; then - print "Setting up Ghostty terminfo on $ssh_hostname..." >&2 - local ssh_cpath_dir ssh_cpath + if [[ -n "$ssh_terminfo" ]]; then + print "Setting up Ghostty terminfo on $ssh_hostname..." >&2 - ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$" - ssh_cpath="$ssh_cpath_dir/socket" + ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$" + ssh_cpath="$ssh_cpath_dir/socket" - if print "$ssh_terminfo" | $ssh_base64_decode_cmd | command ssh "${ssh_opts[@]}" -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" ' - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1 - ' 2>/dev/null; then - print "Terminfo setup complete on $ssh_hostname." >&2 - ssh_term="xterm-ghostty" - ssh_opts+=(-o "ControlPath=$ssh_cpath") + if print "$ssh_terminfo" | command ssh "${ssh_opts[@]}" -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + ' 2>/dev/null; then + print "Terminfo setup complete on $ssh_hostname." >&2 + ssh_term="xterm-ghostty" + ssh_opts+=(-o "ControlPath=$ssh_cpath") - # Cache successful installation - if [[ -n "$ssh_target" ]] && (( $+commands[ghostty] )); then - ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true - fi - else - print "Warning: Failed to install terminfo." >&2 - ssh_term="xterm-256color" + # Cache successful installation + if [[ -n "$ssh_target" ]] && (( $+commands[ghostty] )); then + ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true fi else - print "Warning: Could not generate terminfo data." >&2 - ssh_term="xterm-256color" + print "Warning: Failed to install terminfo." >&2 fi + else + print "Warning: Could not generate terminfo data." >&2 fi else print "Warning: ghostty command not available for cache management." >&2 - ssh_term="xterm-256color" fi - else - [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]] && ssh_term="xterm-256color" fi fi - # Execute SSH with environment handling - if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* && -z "$ssh_term" ]]; then - ssh_term="xterm-256color" - fi - - if [[ -n "$ssh_term" ]]; then - TERM="$ssh_term" command ssh "${ssh_opts[@]}" "$@" - else - command ssh "${ssh_opts[@]}" "$@" - fi + # Execute SSH with TERM environment variable + TERM="$ssh_term" command ssh "${ssh_opts[@]}" "$@" } fi From f95476b1815353eb3b20668f32f767cff2bef358 Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Tue, 8 Jul 2025 10:45:42 -0700 Subject: [PATCH 49/52] refactor: apply maintainer feedback to SSH integration scripts across all shells - Update Bash script (baseline): Simplify cache checking logic, clarify "xterm-ghostty terminfo" message, remove unnecessary ssh_opts from terminfo installation, remove extra success message - Align ZSH/Fish/Elvish with updated Bash: Remove extra success messages, adopt simplified cache checking, standardize setup messages - Apply Elvish improvements: Remove unnecessary try/catch blocks, use idiomatic error handling patterns - Apply Fish improvements: Replace string pattern matching with efficient `contains` checks on split features list --- src/shell-integration/bash/ghostty.bash | 12 +- .../elvish/lib/ghostty-integration.elv | 127 ++++++------------ .../ghostty-shell-integration.fish | 20 +-- src/shell-integration/zsh/ghostty-integration | 10 +- 4 files changed, 56 insertions(+), 113 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 5b6bb249d..63255bbc3 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -124,12 +124,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then if [[ -n "$ssh_hostname" ]]; then # Check if terminfo is already cached - builtin local ssh_cache_check_success=false - if builtin command -v ghostty >/dev/null 2>&1; then - ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 && ssh_cache_check_success=true - fi - - if [[ "$ssh_cache_check_success" == "true" ]]; then + if ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1; then ssh_term="xterm-ghostty" elif builtin command -v infocmp >/dev/null 2>&1; then builtin local ssh_terminfo ssh_cpath_dir ssh_cpath @@ -137,18 +132,17 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then ssh_terminfo=$(infocmp -0 -x xterm-ghostty 2>/dev/null) if [[ -n "$ssh_terminfo" ]]; then - builtin echo "Setting up Ghostty terminfo on $ssh_hostname..." >&2 + builtin echo "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2 ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$" ssh_cpath="$ssh_cpath_dir/socket" - if builtin echo "$ssh_terminfo" | builtin command ssh "${ssh_opts[@]}" -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" ' + if builtin echo "$ssh_terminfo" | builtin command ssh -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" ' infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 command -v tic >/dev/null 2>&1 || exit 1 mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 exit 1 ' 2>/dev/null; then - builtin echo "Terminfo setup complete on $ssh_hostname." >&2 ssh_term="xterm-ghostty" ssh_opts+=(-o "ControlPath=$ssh_cpath") diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 44cf135dc..2eadbfd06 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -108,8 +108,10 @@ # Configure environment variables for remote session if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { - set ssh-opts = [$@ssh-opts -o "SetEnv COLORTERM=truecolor"] - set ssh-opts = [$@ssh-opts -o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"] + set ssh-opts = (conj $ssh-opts + -o "SetEnv COLORTERM=truecolor" + -o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION" + ) } # Install terminfo on remote host if needed @@ -117,112 +119,71 @@ var ssh-user = "" var ssh-hostname = "" - try { - var ssh-config = (external ssh -G $@args 2>/dev/null | slurp) - for line (str:split "\n" $ssh-config) { - var parts = (str:split " " $line) - if (> (count $parts) 1) { - if (eq $parts[0] user) { - set ssh-user = $parts[1] - } elif (eq $parts[0] hostname) { - set ssh-hostname = $parts[1] - } - if (and (not-eq $ssh-user "") (not-eq $ssh-hostname "")) { - break - } + # Parse ssh config + var ssh-config = (external ssh -G $@args 2>/dev/null | slurp) + for line (str:split "\n" $ssh-config) { + var parts = (str:split " " $line) + if (> (count $parts) 1) { + var ssh-key = $parts[0] + var ssh-value = $parts[1] + if (eq $ssh-key user) { + set ssh-user = $ssh-value + } elif (eq $ssh-key hostname) { + set ssh-hostname = $ssh-value + } + if (and (not-eq $ssh-user "") (not-eq $ssh-hostname "")) { + break } } - } catch { - # ssh config failed } var ssh-target = $ssh-user"@"$ssh-hostname if (not-eq $ssh-hostname "") { # Check if terminfo is already cached - var ssh-cache-check-success = $false - try { - external ghostty +ssh-cache --host=$ssh-target >/dev/null 2>&1 - set ssh-cache-check-success = $true - } catch { - # cache check failed - } - - if $ssh-cache-check-success { + if (and (has-external ghostty) (bool ?(external ghostty +ssh-cache --host=$ssh-target >/dev/null 2>&1))) { set ssh-term = "xterm-ghostty" - } else { - try { - external infocmp --help >/dev/null 2>&1 + } elif (has-external infocmp) { + var ssh-terminfo = (external infocmp -0 -x xterm-ghostty 2>/dev/null | slurp) + + if (not-eq $ssh-terminfo "") { + echo "Setting up xterm-ghostty terminfo on "$ssh-hostname"..." >&2 - var ssh-terminfo = "" var ssh-cpath-dir = "" - var ssh-cpath = "" - try { - set ssh-terminfo = (external infocmp -0 -x xterm-ghostty 2>/dev/null | slurp) + set ssh-cpath-dir = (external mktemp -d "/tmp/ghostty-ssh-"$ssh-user".XXXXXX" 2>/dev/null | slurp) } catch { - set ssh-terminfo = "" + set ssh-cpath-dir = "/tmp/ghostty-ssh-"$ssh-user"."(randint 10000 99999) } + var ssh-cpath = $ssh-cpath-dir"/socket" - if (not-eq $ssh-terminfo "") { - echo "Setting up Ghostty terminfo on "$ssh-hostname"..." >&2 + if (bool ?(echo $ssh-terminfo | external ssh $@ssh-opts -o ControlMaster=yes -o ControlPath=$ssh-cpath -o ControlPersist=60s $@args ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + ' 2>/dev/null)) { + set ssh-term = "xterm-ghostty" + set ssh-opts = (conj $ssh-opts -o ControlPath=$ssh-cpath) - try { - set ssh-cpath-dir = (external mktemp -d "/tmp/ghostty-ssh-"$ssh-user".XXXXXX" 2>/dev/null | slurp) - } catch { - set ssh-cpath-dir = "/tmp/ghostty-ssh-"$ssh-user"."(randint 10000 99999) - } - set ssh-cpath = $ssh-cpath-dir"/socket" - - var terminfo-install-success = $false - try { - echo $ssh-terminfo | external ssh $@ssh-opts -o ControlMaster=yes -o ControlPath=$ssh-cpath -o ControlPersist=60s $@args ' - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1 - ' >/dev/null 2>&1 - set terminfo-install-success = $true - } catch { - set terminfo-install-success = $false - } - - if $terminfo-install-success { - echo "Terminfo setup complete on "$ssh-hostname"." >&2 - set ssh-term = "xterm-ghostty" - set ssh-opts = [$@ssh-opts -o ControlPath=$ssh-cpath] - - # Cache successful installation - if (and (not-eq $ssh-target "") (has-external ghostty)) { - try { - external ghostty +ssh-cache --add=$ssh-target >/dev/null 2>&1 - } catch { - # cache add failed - } - } - } else { - echo "Warning: Failed to install terminfo." >&2 + # Cache successful installation + if (and (not-eq $ssh-target "") (has-external ghostty)) { + external ghostty +ssh-cache --add=$ssh-target >/dev/null 2>&1 } } else { - echo "Warning: Could not generate terminfo data." >&2 + echo "Warning: Failed to install terminfo." >&2 } - } catch { - echo "Warning: ghostty command not available for cache management." >&2 + } else { + echo "Warning: Could not generate terminfo data." >&2 } + } else { + echo "Warning: ghostty command not available for cache management." >&2 } } } # Execute SSH with TERM environment variable - var old-term = $E:TERM - set-env TERM $ssh-term - try { - external ssh $@ssh-opts $@args - } catch e { - set-env TERM $old-term - fail $e - } - set-env TERM $old-term + external E:TERM=$ssh-term ssh $@ssh-opts $@args } } 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 ab0f23086..0bba43b31 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 @@ -87,19 +87,21 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" end # SSH Integration - if string match -q '*ssh-*' -- "$GHOSTTY_SHELL_FEATURES" + set -l features (string split ',' -- "$GHOSTTY_SHELL_FEATURES") + if contains ssh-env $features; or contains ssh-terminfo $features function ssh --wraps=ssh --description "SSH wrapper with Ghostty integration" + set -l features (string split ',' -- "$GHOSTTY_SHELL_FEATURES") set -l ssh_term "xterm-256color" set -l ssh_opts # Configure environment variables for remote session - if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES" + if contains ssh-env $features set -a ssh_opts -o "SetEnv COLORTERM=truecolor" set -a ssh_opts -o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION" end # Install terminfo on remote host if needed - if string match -q '*ssh-terminfo*' -- "$GHOSTTY_SHELL_FEATURES" + if contains ssh-terminfo $features set -l ssh_user set -l ssh_hostname @@ -122,14 +124,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" if test -n "$ssh_hostname" # Check if terminfo is already cached - set -l ssh_cache_check_success false - if command -v ghostty >/dev/null 2>&1 - if ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 - set ssh_cache_check_success true - end - end - - if test "$ssh_cache_check_success" = "true" + if command -v ghostty >/dev/null 2>&1; and ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 set ssh_term "xterm-ghostty" else if command -v infocmp >/dev/null 2>&1 set -l ssh_terminfo @@ -139,7 +134,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" set ssh_terminfo (infocmp -0 -x xterm-ghostty 2>/dev/null) if test -n "$ssh_terminfo" - echo "Setting up Ghostty terminfo on $ssh_hostname..." >&2 + echo "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2 set ssh_cpath_dir (mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null; or echo "/tmp/ghostty-ssh-$ssh_user."(random)) set ssh_cpath "$ssh_cpath_dir/socket" @@ -150,7 +145,6 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 exit 1 ' 2>/dev/null - echo "Terminfo setup complete on $ssh_hostname." >&2 set ssh_term "xterm-ghostty" set -a ssh_opts -o "ControlPath=$ssh_cpath" diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 7c7ab7972..60101416e 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -276,12 +276,7 @@ _ghostty_deferred_init() { if [[ -n "$ssh_hostname" ]]; then # Check if terminfo is already cached - local ssh_cache_check_success=false - if (( $+commands[ghostty] )); then - ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 && ssh_cache_check_success=true - fi - - if [[ "$ssh_cache_check_success" == "true" ]]; then + if (( $+commands[ghostty] )) && ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1; then ssh_term="xterm-ghostty" elif (( $+commands[infocmp] )); then local ssh_terminfo ssh_cpath_dir ssh_cpath @@ -289,7 +284,7 @@ _ghostty_deferred_init() { ssh_terminfo=$(infocmp -0 -x xterm-ghostty 2>/dev/null) if [[ -n "$ssh_terminfo" ]]; then - print "Setting up Ghostty terminfo on $ssh_hostname..." >&2 + print "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2 ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$" ssh_cpath="$ssh_cpath_dir/socket" @@ -300,7 +295,6 @@ _ghostty_deferred_init() { mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 exit 1 ' 2>/dev/null; then - print "Terminfo setup complete on $ssh_hostname." >&2 ssh_term="xterm-ghostty" ssh_opts+=(-o "ControlPath=$ssh_cpath") From bcb4e624a45a647b9001994a0fe84500b28cc4c5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 9 Jul 2025 06:45:26 -0700 Subject: [PATCH 50/52] cli: fix macOS builds --- src/cli/ssh_cache.zig | 125 ++++++++++++++++++++++++------------------ 1 file changed, 71 insertions(+), 54 deletions(-) diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig index 112f3a5c5..468fc7b3c 100644 --- a/src/cli/ssh_cache.zig +++ b/src/cli/ssh_cache.zig @@ -82,7 +82,8 @@ fn getCachePath(allocator: Allocator) ![]const u8 { // Supports both standalone hostnames and user@hostname format fn isValidCacheKey(key: []const u8) bool { - if (key.len == 0 or key.len > 320) return false; // 253 + 1 + 64 for user@hostname + // 253 + 1 + 64 for user@hostname + if (key.len == 0 or key.len > 320) return false; // Check for user@hostname format if (std.mem.indexOf(u8, key, "@")) |at_pos| { @@ -94,7 +95,8 @@ fn isValidCacheKey(key: []const u8) bool { return isValidHostname(key); } -// Basic hostname validation - accepts domains and IPs (including IPv6 in brackets) +// Basic hostname validation - accepts domains and IPs +// (including IPv6 in brackets) fn isValidHostname(host: []const u8) bool { if (host.len == 0 or host.len > 253) return false; @@ -438,24 +440,27 @@ pub fn run(alloc_gpa: Allocator) !u8 { } if (opts.add) |host| { - const result = addHost(alloc, host) catch |err| switch (err) { - CacheError.InvalidCacheKey => { - try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); - try stderr.print("Expected format: hostname or user@hostname\n", .{}); - return 1; - }, - CacheError.CacheLocked => { - try stderr.print("Error: Cache is busy, try again\n", .{}); - return 1; - }, - error.AccessDenied, error.PermissionDenied => { - try stderr.print("Error: Permission denied\n", .{}); - return 1; - }, - else => { - try stderr.print("Error: Unable to add '{s}' to cache\n", .{host}); - return 1; - }, + const result = addHost(alloc, host) catch |err| { + const Error = error{PermissionDenied} || @TypeOf(err); + switch (@as(Error, err)) { + CacheError.InvalidCacheKey => { + try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); + try stderr.print("Expected format: hostname or user@hostname\n", .{}); + return 1; + }, + CacheError.CacheLocked => { + try stderr.print("Error: Cache is busy, try again\n", .{}); + return 1; + }, + error.AccessDenied, error.PermissionDenied => { + try stderr.print("Error: Permission denied\n", .{}); + return 1; + }, + else => { + try stderr.print("Error: Unable to add '{s}' to cache\n", .{host}); + return 1; + }, + } }; switch (result) { @@ -466,51 +471,63 @@ pub fn run(alloc_gpa: Allocator) !u8 { } if (opts.remove) |host| { - removeHost(alloc, host) catch |err| switch (err) { - CacheError.InvalidCacheKey => { - try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); - try stderr.print("Expected format: hostname or user@hostname\n", .{}); - return 1; - }, - CacheError.CacheLocked => { - try stderr.print("Error: Cache is busy, try again\n", .{}); - return 1; - }, - error.AccessDenied, error.PermissionDenied => { - try stderr.print("Error: Permission denied\n", .{}); - return 1; - }, - else => { - try stderr.print("Error: Unable to remove '{s}' from cache\n", .{host}); - return 1; - }, + removeHost(alloc, host) catch |err| { + const Error = error{PermissionDenied} || @TypeOf(err); + switch (@as(Error, err)) { + CacheError.InvalidCacheKey => { + try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); + try stderr.print("Expected format: hostname or user@hostname\n", .{}); + return 1; + }, + CacheError.CacheLocked => { + try stderr.print("Error: Cache is busy, try again\n", .{}); + return 1; + }, + error.AccessDenied, error.PermissionDenied => { + try stderr.print("Error: Permission denied\n", .{}); + return 1; + }, + else => { + try stderr.print("Error: Unable to remove '{s}' from cache\n", .{host}); + return 1; + }, + } }; try stdout.print("Removed '{s}' from cache.\n", .{host}); return 0; } if (opts.host) |host| { - const cached = checkHost(alloc, host) catch |err| switch (err) { - CacheError.InvalidCacheKey => { - try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); - try stderr.print("Expected format: hostname or user@hostname\n", .{}); - return 1; - }, - error.AccessDenied, error.PermissionDenied => { - try stderr.print("Error: Permission denied\n", .{}); - return 1; - }, - else => { - try stderr.print("Error: Unable to check host '{s}' in cache\n", .{host}); - return 1; - }, + const cached = checkHost(alloc, host) catch |err| { + const Error = error{PermissionDenied} || @TypeOf(err); + switch (@as(Error, err)) { + CacheError.InvalidCacheKey => { + try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); + try stderr.print("Expected format: hostname or user@hostname\n", .{}); + return 1; + }, + error.AccessDenied, error.PermissionDenied => { + try stderr.print("Error: Permission denied\n", .{}); + return 1; + }, + else => { + try stderr.print("Error: Unable to check host '{s}' in cache\n", .{host}); + return 1; + }, + } }; if (cached) { - try stdout.print("'{s}' has Ghostty terminfo installed.\n", .{host}); + try stdout.print( + "'{s}' has Ghostty terminfo installed.\n", + .{host}, + ); return 0; } else { - try stdout.print("'{s}' does not have Ghostty terminfo installed.\n", .{host}); + try stdout.print( + "'{s}' does not have Ghostty terminfo installed.\n", + .{host}, + ); return 1; } } From 7cfb026e84ae1d93d123a8b42930b152b4fb9305 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 9 Jul 2025 06:47:15 -0700 Subject: [PATCH 51/52] cli: ssh-cache stylistic changes --- src/cli/ssh_cache.zig | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig index 468fc7b3c..2912addc1 100644 --- a/src/cli/ssh_cache.zig +++ b/src/cli/ssh_cache.zig @@ -50,7 +50,7 @@ const CacheEntry = struct { return null; }; - return CacheEntry{ + return .{ .hostname = hostname, .timestamp = timestamp, .terminfo_version = terminfo_version, @@ -107,9 +107,8 @@ fn isValidHostname(host: []const u8) bool { var has_colon = false; for (ipv6_part) |c| { switch (c) { - 'a'...'f', 'A'...'F', '0'...'9', ':' => { - if (c == ':') has_colon = true; - }, + 'a'...'f', 'A'...'F', '0'...'9' => {}, + ':' => has_colon = true, else => return false, } } @@ -178,7 +177,7 @@ fn readCacheFile( const gop = try entries.getOrPut(hostname_copy); if (!gop.found_existing) { const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); - gop.value_ptr.* = CacheEntry{ + gop.value_ptr.* = .{ .hostname = hostname_copy, .timestamp = entry.timestamp, .terminfo_version = terminfo_copy, From 8ab3010bb8d2f14a36b6f0f6b3f26fd9e26bbb5b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 9 Jul 2025 07:28:56 -0700 Subject: [PATCH 52/52] cli: rewrite ssh-cache diskcache and test IO --- src/cli/ssh-cache/DiskCache.zig | 549 ++++++++++++++++++++++ src/cli/ssh-cache/Entry.zig | 154 +++++++ src/cli/ssh_cache.zig | 755 +++++-------------------------- src/termio/shell_integration.zig | 26 +- 4 files changed, 829 insertions(+), 655 deletions(-) create mode 100644 src/cli/ssh-cache/DiskCache.zig create mode 100644 src/cli/ssh-cache/Entry.zig diff --git a/src/cli/ssh-cache/DiskCache.zig b/src/cli/ssh-cache/DiskCache.zig new file mode 100644 index 000000000..10f38a27c --- /dev/null +++ b/src/cli/ssh-cache/DiskCache.zig @@ -0,0 +1,549 @@ +/// An SSH terminfo entry cache that stores its cache data on +/// disk. The cache only stores metadata (hostname, terminfo value, +/// etc.) and does not store any sensitive data. +const DiskCache = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const xdg = @import("../../os/main.zig").xdg; +const TempDir = @import("../../os/main.zig").TempDir; +const Entry = @import("Entry.zig"); + +// 512KB - sufficient for approximately 10k entries +const MAX_CACHE_SIZE = 512 * 1024; + +/// Path to a file where the cache is stored. +path: []const u8, + +pub const DefaultPathError = Allocator.Error || error{ + /// The general error that is returned for any filesystem error + /// that may have resulted in the XDG lookup failing. + XdgLookupFailed, +}; + +pub const Error = error{ CacheIsLocked, HostnameIsInvalid }; + +/// Returns the default path for the cache for a given program. +/// +/// On all platforms, this is `${XDG_STATE_HOME}/ghostty/ssh_cache`. +/// +/// The returned value is allocated and must be freed by the caller. +pub fn defaultPath( + alloc: Allocator, + program: []const u8, +) DefaultPathError![]const u8 { + const state_dir: []const u8 = xdg.state( + alloc, + .{ .subdir = program }, + ) catch |err| return switch (err) { + error.OutOfMemory => error.OutOfMemory, + else => error.XdgLookupFailed, + }; + defer alloc.free(state_dir); + return try std.fs.path.join(alloc, &.{ state_dir, "ssh_cache" }); +} + +/// Clear all cache data stored in the disk cache. +/// This removes the cache file from disk, effectively clearing all cached +/// SSH terminfo entries. +pub fn clear(self: DiskCache) !void { + std.fs.cwd().deleteFile(self.path) catch |err| switch (err) { + error.FileNotFound => {}, + else => return err, + }; +} + +pub const AddResult = enum { added, updated }; + +/// Add or update a hostname entry in the cache. +/// Returns AddResult.added for new entries or AddResult.updated for existing ones. +/// The cache file is created if it doesn't exist with secure permissions (0600). +pub fn add( + self: DiskCache, + alloc: Allocator, + hostname: []const u8, +) !AddResult { + if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; + + // Create cache directory if needed + if (std.fs.path.dirname(self.path)) |dir| { + std.fs.makeDirAbsolute(dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + } + + // Open or create cache file with secure permissions + const file = std.fs.createFileAbsolute(self.path, .{ + .read = true, + .truncate = false, + .mode = 0o600, + }) catch |err| switch (err) { + error.PathAlreadyExists => blk: { + const existing_file = try std.fs.openFileAbsolute( + self.path, + .{ .mode = .read_write }, + ); + errdefer existing_file.close(); + try fixupPermissions(existing_file); + break :blk existing_file; + }, + else => return err, + }; + defer file.close(); + + // Lock + _ = file.tryLock(.exclusive) catch return error.CacheIsLocked; + defer file.unlock(); + + var entries = try readEntries(alloc, file); + defer deinitEntries(alloc, &entries); + + // Add or update entry + const gop = try entries.getOrPut(hostname); + const result: AddResult = if (!gop.found_existing) add: { + const hostname_copy = try alloc.dupe(u8, hostname); + errdefer alloc.free(hostname_copy); + const terminfo_copy = try alloc.dupe(u8, "xterm-ghostty"); + errdefer alloc.free(terminfo_copy); + + gop.key_ptr.* = hostname_copy; + gop.value_ptr.* = .{ + .hostname = gop.key_ptr.*, + .timestamp = std.time.timestamp(), + .terminfo_version = terminfo_copy, + }; + break :add .added; + } else update: { + // Update timestamp for existing entry + gop.value_ptr.timestamp = std.time.timestamp(); + break :update .updated; + }; + + try self.writeCacheFile(alloc, entries, null); + return result; +} + +/// Remove a hostname entry from the cache. +/// No error is returned if the hostname doesn't exist or the cache file is missing. +pub fn remove( + self: DiskCache, + alloc: Allocator, + hostname: []const u8, +) !void { + if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; + + // Open our file + const file = std.fs.openFileAbsolute( + self.path, + .{ .mode = .read_write }, + ) catch |err| switch (err) { + error.FileNotFound => return, + else => return err, + }; + defer file.close(); + try fixupPermissions(file); + + // Acquire exclusive lock + _ = file.tryLock(.exclusive) catch return error.CacheIsLocked; + defer file.unlock(); + + // Read existing entries + var entries = try readEntries(alloc, file); + defer deinitEntries(alloc, &entries); + + // Remove the entry if it exists and ensure we free the memory + if (entries.fetchRemove(hostname)) |kv| { + assert(kv.key.ptr == kv.value.hostname.ptr); + alloc.free(kv.value.hostname); + alloc.free(kv.value.terminfo_version); + } + + try self.writeCacheFile(alloc, entries, null); +} + +/// Check if a hostname exists in the cache. +/// Returns false if the cache file doesn't exist. +pub fn contains( + self: DiskCache, + alloc: Allocator, + hostname: []const u8, +) !bool { + if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; + + // Open our file + const file = std.fs.openFileAbsolute( + self.path, + .{ .mode = .read_write }, + ) catch |err| switch (err) { + error.FileNotFound => return false, + else => return err, + }; + defer file.close(); + try fixupPermissions(file); + + // Read existing entries + var entries = try readEntries(alloc, file); + defer deinitEntries(alloc, &entries); + + return entries.contains(hostname); +} + +fn fixupPermissions(file: std.fs.File) !void { + // Ensure file has correct permissions (readable/writable by + // owner only) + const stat = try file.stat(); + if (stat.mode & 0o777 != 0o600) { + try file.chmod(0o600); + } +} + +fn writeCacheFile( + self: DiskCache, + alloc: Allocator, + entries: std.StringHashMap(Entry), + expire_days: ?u32, +) !void { + var td: TempDir = try .init(); + defer td.deinit(); + + const tmp_file = try td.dir.createFile("ssh-cache", .{ .mode = 0o600 }); + defer tmp_file.close(); + const tmp_path = try td.dir.realpathAlloc(alloc, "ssh-cache"); + defer alloc.free(tmp_path); + + const writer = tmp_file.writer(); + var iter = entries.iterator(); + while (iter.next()) |kv| { + // Only write non-expired entries + if (kv.value_ptr.isExpired(expire_days)) continue; + try kv.value_ptr.format(writer); + } + + // Atomic replace + try std.fs.renameAbsolute(tmp_path, self.path); +} + +/// List all entries in the cache. +/// The returned HashMap must be freed using `deinitEntries`. +/// Returns an empty map if the cache file doesn't exist. +pub fn list( + self: DiskCache, + alloc: Allocator, +) !std.StringHashMap(Entry) { + // Open our file + const file = std.fs.openFileAbsolute( + self.path, + .{}, + ) catch |err| switch (err) { + error.FileNotFound => return .init(alloc), + else => return err, + }; + defer file.close(); + return readEntries(alloc, file); +} + +/// Free memory allocated by the `list` function. +/// This must be called to properly deallocate all entry data. +pub fn deinitEntries( + alloc: Allocator, + entries: *std.StringHashMap(Entry), +) void { + // All our entries we dupe the memory owned by the hostname and the + // terminfo, and we always match the hostname key and value. + var it = entries.iterator(); + while (it.next()) |entry| { + assert(entry.key_ptr.*.ptr == entry.value_ptr.hostname.ptr); + alloc.free(entry.value_ptr.hostname); + alloc.free(entry.value_ptr.terminfo_version); + } + entries.deinit(); +} + +fn readEntries( + alloc: Allocator, + file: std.fs.File, +) !std.StringHashMap(Entry) { + const content = try file.readToEndAlloc(alloc, MAX_CACHE_SIZE); + defer alloc.free(content); + + var entries = std.StringHashMap(Entry).init(alloc); + var lines = std.mem.tokenizeScalar(u8, content, '\n'); + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r"); + const entry = Entry.parse(trimmed) orelse continue; + + // Always allocate hostname first to avoid key pointer confusion + const hostname = try alloc.dupe(u8, entry.hostname); + errdefer alloc.free(hostname); + + const gop = try entries.getOrPut(hostname); + if (!gop.found_existing) { + const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); + gop.value_ptr.* = .{ + .hostname = hostname, + .timestamp = entry.timestamp, + .terminfo_version = terminfo_copy, + }; + } else { + // Don't need the copy since entry already exists + alloc.free(hostname); + + // Handle duplicate entries - keep newer timestamp + if (entry.timestamp > gop.value_ptr.timestamp) { + gop.value_ptr.timestamp = entry.timestamp; + if (!std.mem.eql( + u8, + gop.value_ptr.terminfo_version, + entry.terminfo_version, + )) { + alloc.free(gop.value_ptr.terminfo_version); + const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); + gop.value_ptr.terminfo_version = terminfo_copy; + } + } + } + } + + return entries; +} + +// Supports both standalone hostnames and user@hostname format +fn isValidCacheKey(key: []const u8) bool { + // 253 + 1 + 64 for user@hostname + if (key.len == 0 or key.len > 320) return false; + + // Check for user@hostname format + if (std.mem.indexOf(u8, key, "@")) |at_pos| { + const user = key[0..at_pos]; + const hostname = key[at_pos + 1 ..]; + return isValidUser(user) and isValidHostname(hostname); + } + + return isValidHostname(key); +} + +// Basic hostname validation - accepts domains and IPs +// (including IPv6 in brackets) +fn isValidHostname(host: []const u8) bool { + if (host.len == 0 or host.len > 253) return false; + + // Handle IPv6 addresses in brackets + if (host.len >= 4 and host[0] == '[' and host[host.len - 1] == ']') { + const ipv6_part = host[1 .. host.len - 1]; + if (ipv6_part.len == 0) return false; + var has_colon = false; + for (ipv6_part) |c| { + switch (c) { + 'a'...'f', 'A'...'F', '0'...'9' => {}, + ':' => has_colon = true, + else => return false, + } + } + return has_colon; + } + + // Standard hostname/domain validation + for (host) |c| { + switch (c) { + 'a'...'z', 'A'...'Z', '0'...'9', '.', '-' => {}, + else => return false, + } + } + + // No leading/trailing dots or hyphens, no consecutive dots + if (host[0] == '.' or host[0] == '-' or + host[host.len - 1] == '.' or host[host.len - 1] == '-') + { + return false; + } + + return std.mem.indexOf(u8, host, "..") == null; +} + +fn isValidUser(user: []const u8) bool { + if (user.len == 0 or user.len > 64) return false; + for (user) |c| { + switch (c) { + 'a'...'z', 'A'...'Z', '0'...'9', '_', '-', '.' => {}, + else => return false, + } + } + return true; +} + +test "disk cache default path" { + const testing = std.testing; + const alloc = std.testing.allocator; + + const path = try DiskCache.defaultPath(alloc, "ghostty"); + defer alloc.free(path); + try testing.expect(path.len > 0); +} + +test "disk cache clear" { + const testing = std.testing; + const alloc = testing.allocator; + + // Create our path + var td: TempDir = try .init(); + defer td.deinit(); + { + var file = try td.dir.createFile("cache", .{}); + defer file.close(); + try file.writer().writeAll("HELLO!"); + } + const path = try td.dir.realpathAlloc(alloc, "cache"); + defer alloc.free(path); + + // Setup our cache + const cache: DiskCache = .{ .path = path }; + try cache.clear(); + + // Verify the file is gone + try testing.expectError( + error.FileNotFound, + td.dir.openFile("cache", .{}), + ); +} + +test "disk cache operations" { + const testing = std.testing; + const alloc = testing.allocator; + + // Create our path + var td: TempDir = try .init(); + defer td.deinit(); + { + var file = try td.dir.createFile("cache", .{}); + defer file.close(); + try file.writer().writeAll("HELLO!"); + } + const path = try td.dir.realpathAlloc(alloc, "cache"); + defer alloc.free(path); + + // Setup our cache + const cache: DiskCache = .{ .path = path }; + try testing.expectEqual( + AddResult.added, + try cache.add(alloc, "example.com"), + ); + try testing.expectEqual( + AddResult.updated, + try cache.add(alloc, "example.com"), + ); + try testing.expect( + try cache.contains(alloc, "example.com"), + ); + + // List + var entries = try cache.list(alloc); + deinitEntries(alloc, &entries); + + // Remove + try cache.remove(alloc, "example.com"); + try testing.expect( + !(try cache.contains(alloc, "example.com")), + ); + try testing.expectEqual( + AddResult.added, + try cache.add(alloc, "example.com"), + ); +} + +// Tests +test "hostname validation - valid cases" { + const testing = std.testing; + try testing.expect(isValidHostname("example.com")); + try testing.expect(isValidHostname("sub.example.com")); + try testing.expect(isValidHostname("host-name.domain.org")); + try testing.expect(isValidHostname("192.168.1.1")); + try testing.expect(isValidHostname("a")); + try testing.expect(isValidHostname("1")); +} + +test "hostname validation - IPv6 addresses" { + const testing = std.testing; + try testing.expect(isValidHostname("[::1]")); + try testing.expect(isValidHostname("[2001:db8::1]")); + try testing.expect(!isValidHostname("[fe80::1%eth0]")); // Interface notation not supported + try testing.expect(!isValidHostname("[]")); // Empty IPv6 + try testing.expect(!isValidHostname("[invalid]")); // No colons +} + +test "hostname validation - invalid cases" { + const testing = std.testing; + try testing.expect(!isValidHostname("")); + try testing.expect(!isValidHostname("host\nname")); + try testing.expect(!isValidHostname(".example.com")); + try testing.expect(!isValidHostname("example.com.")); + try testing.expect(!isValidHostname("host..domain")); + try testing.expect(!isValidHostname("-hostname")); + try testing.expect(!isValidHostname("hostname-")); + try testing.expect(!isValidHostname("host name")); + try testing.expect(!isValidHostname("host_name")); + try testing.expect(!isValidHostname("host@domain")); + try testing.expect(!isValidHostname("host:port")); + + // Too long + const long_host = "a" ** 254; + try testing.expect(!isValidHostname(long_host)); +} + +test "user validation - valid cases" { + const testing = std.testing; + try testing.expect(isValidUser("user")); + try testing.expect(isValidUser("deploy")); + try testing.expect(isValidUser("test-user")); + try testing.expect(isValidUser("user_name")); + try testing.expect(isValidUser("user.name")); + try testing.expect(isValidUser("user123")); + try testing.expect(isValidUser("a")); +} + +test "user validation - complex realistic cases" { + const testing = std.testing; + try testing.expect(isValidUser("git")); + try testing.expect(isValidUser("ubuntu")); + try testing.expect(isValidUser("root")); + try testing.expect(isValidUser("service.account")); + try testing.expect(isValidUser("user-with-dashes")); +} + +test "user validation - invalid cases" { + const testing = std.testing; + try testing.expect(!isValidUser("")); + try testing.expect(!isValidUser("user name")); + try testing.expect(!isValidUser("user@domain")); + try testing.expect(!isValidUser("user:group")); + try testing.expect(!isValidUser("user\nname")); + + // Too long + const long_user = "a" ** 65; + try testing.expect(!isValidUser(long_user)); +} + +test "cache key validation - hostname format" { + const testing = std.testing; + try testing.expect(isValidCacheKey("example.com")); + try testing.expect(isValidCacheKey("sub.example.com")); + try testing.expect(isValidCacheKey("192.168.1.1")); + try testing.expect(isValidCacheKey("[::1]")); + try testing.expect(!isValidCacheKey("")); + try testing.expect(!isValidCacheKey(".invalid.com")); +} + +test "cache key validation - user@hostname format" { + const testing = std.testing; + try testing.expect(isValidCacheKey("user@example.com")); + try testing.expect(isValidCacheKey("deploy@prod.server.com")); + try testing.expect(isValidCacheKey("test-user@192.168.1.1")); + try testing.expect(isValidCacheKey("user_name@host.domain.org")); + try testing.expect(isValidCacheKey("git@github.com")); + try testing.expect(isValidCacheKey("ubuntu@[::1]")); + try testing.expect(!isValidCacheKey("@example.com")); + try testing.expect(!isValidCacheKey("user@")); + try testing.expect(!isValidCacheKey("user@@host")); + try testing.expect(!isValidCacheKey("user@.invalid.com")); +} diff --git a/src/cli/ssh-cache/Entry.zig b/src/cli/ssh-cache/Entry.zig new file mode 100644 index 000000000..3a691be80 --- /dev/null +++ b/src/cli/ssh-cache/Entry.zig @@ -0,0 +1,154 @@ +/// A single entry within our SSH entry cache. Our SSH entry cache +/// stores which hosts we've sent our terminfo to so that we don't have +/// to send it again. It doesn't store any sensitive information. +const Entry = @This(); + +const std = @import("std"); + +hostname: []const u8, +timestamp: i64, +terminfo_version: []const u8, + +pub fn parse(line: []const u8) ?Entry { + const trimmed = std.mem.trim(u8, line, " \t\r\n"); + if (trimmed.len == 0) return null; + + // Parse format: hostname|timestamp|terminfo_version + var iter = std.mem.tokenizeScalar(u8, trimmed, '|'); + const hostname = iter.next() orelse return null; + const timestamp_str = iter.next() orelse return null; + const terminfo_version = iter.next() orelse "xterm-ghostty"; + const timestamp = std.fmt.parseInt(i64, timestamp_str, 10) catch |err| { + std.log.warn( + "Invalid timestamp in cache entry: {s} err={}", + .{ timestamp_str, err }, + ); + return null; + }; + + return .{ + .hostname = hostname, + .timestamp = timestamp, + .terminfo_version = terminfo_version, + }; +} + +pub fn format(self: Entry, writer: anytype) !void { + try writer.print( + "{s}|{d}|{s}\n", + .{ self.hostname, self.timestamp, self.terminfo_version }, + ); +} + +pub fn isExpired(self: Entry, expire_days_: ?u32) bool { + const expire_days = expire_days_ orelse return false; + const now = std.time.timestamp(); + const age_days = @divTrunc(now -| self.timestamp, std.time.s_per_day); + return age_days > expire_days; +} + +test "cache entry expiration" { + const testing = std.testing; + const now = std.time.timestamp(); + + const fresh_entry: Entry = .{ + .hostname = "test.com", + .timestamp = now - std.time.s_per_day, // 1 day old + .terminfo_version = "xterm-ghostty", + }; + try testing.expect(!fresh_entry.isExpired(90)); + + const old_entry: Entry = .{ + .hostname = "old.com", + .timestamp = now - (std.time.s_per_day * 100), // 100 days old + .terminfo_version = "xterm-ghostty", + }; + try testing.expect(old_entry.isExpired(90)); + + // Test never-expire case + try testing.expect(!old_entry.isExpired(null)); +} + +test "cache entry expiration exact boundary" { + const testing = std.testing; + const now = std.time.timestamp(); + + // Exactly at expiration boundary + const boundary_entry: Entry = .{ + .hostname = "example.com", + .timestamp = now - (std.time.s_per_day * 30), + .terminfo_version = "xterm-ghostty", + }; + try testing.expect(!boundary_entry.isExpired(30)); + try testing.expect(boundary_entry.isExpired(29)); +} + +test "cache entry expiration large timestamp" { + const testing = std.testing; + const now = std.time.timestamp(); + + const boundary_entry: Entry = .{ + .hostname = "example.com", + .timestamp = now + (std.time.s_per_day * 30), + .terminfo_version = "xterm-ghostty", + }; + try testing.expect(!boundary_entry.isExpired(30)); +} + +test "cache entry parsing valid formats" { + const testing = std.testing; + + const entry = Entry.parse("example.com|1640995200|xterm-ghostty").?; + try testing.expectEqualStrings("example.com", entry.hostname); + try testing.expectEqual(@as(i64, 1640995200), entry.timestamp); + try testing.expectEqualStrings("xterm-ghostty", entry.terminfo_version); + + // Test default terminfo version + const entry_no_version = Entry.parse("test.com|1640995200").?; + try testing.expectEqualStrings( + "xterm-ghostty", + entry_no_version.terminfo_version, + ); + + // Test complex hostnames + const complex_entry = Entry.parse("user@server.example.com|1640995200|xterm-ghostty").?; + try testing.expectEqualStrings( + "user@server.example.com", + complex_entry.hostname, + ); +} + +test "cache entry parsing invalid formats" { + const testing = std.testing; + + try testing.expect(Entry.parse("") == null); + + // Invalid format (no pipe) + try testing.expect(Entry.parse("v1") == null); + + // Missing timestamp + try testing.expect(Entry.parse("example.com") == null); + + // Invalid timestamp + try testing.expect(Entry.parse("example.com|invalid") == null); + + // Empty terminfo should default + try testing.expect(Entry.parse("example.com|1640995200|") != null); +} + +test "cache entry parsing malformed data resilience" { + const testing = std.testing; + + // Extra pipes should not break parsing + try testing.expect(Entry.parse("host|123|term|extra") != null); + + // Whitespace handling + try testing.expect(Entry.parse(" host|123|term ") != null); + try testing.expect(Entry.parse("\n") == null); + try testing.expect(Entry.parse(" \t \n") == null); + + // Extremely large timestamp + try testing.expect( + Entry.parse("host|999999999999999999999999999999999999999999999999|xterm-ghostty") == null, + ); +} diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig index 2912addc1..c8e2e1123 100644 --- a/src/cli/ssh_cache.zig +++ b/src/cli/ssh_cache.zig @@ -4,21 +4,15 @@ const Allocator = std.mem.Allocator; const xdg = @import("../os/xdg.zig"); const args = @import("args.zig"); const Action = @import("action.zig").Action; - -pub const CacheError = error{ - InvalidCacheKey, - CacheLocked, -} || fs.File.OpenError || fs.File.WriteError || Allocator.Error; - -const MAX_CACHE_SIZE = 512 * 1024; // 512KB - sufficient for approximately 10k entries -const NEVER_EXPIRE = 0; +pub const Entry = @import("ssh-cache/Entry.zig"); +pub const DiskCache = @import("ssh-cache/DiskCache.zig"); pub const Options = struct { clear: bool = false, add: ?[]const u8 = null, remove: ?[]const u8 = null, host: ?[]const u8 = null, - @"expire-days": u32 = NEVER_EXPIRE, + @"expire-days": ?u32 = null, pub fn deinit(self: *Options) void { _ = self; @@ -30,373 +24,6 @@ pub const Options = struct { } }; -const CacheEntry = struct { - hostname: []const u8, - timestamp: i64, - terminfo_version: []const u8, - - fn parse(line: []const u8) ?CacheEntry { - const trimmed = std.mem.trim(u8, line, " \t\r\n"); - if (trimmed.len == 0) return null; - - // Parse format: hostname|timestamp|terminfo_version - var iter = std.mem.tokenizeScalar(u8, trimmed, '|'); - const hostname = iter.next() orelse return null; - const timestamp_str = iter.next() orelse return null; - const terminfo_version = iter.next() orelse "xterm-ghostty"; - - const timestamp = std.fmt.parseInt(i64, timestamp_str, 10) catch |err| { - std.log.warn("Invalid timestamp in cache entry: {s} err={}", .{ timestamp_str, err }); - return null; - }; - - return .{ - .hostname = hostname, - .timestamp = timestamp, - .terminfo_version = terminfo_version, - }; - } - - fn format(self: CacheEntry, writer: anytype) !void { - try writer.print("{s}|{d}|{s}\n", .{ self.hostname, self.timestamp, self.terminfo_version }); - } - - fn isExpired(self: CacheEntry, expire_days: u32) bool { - if (expire_days == NEVER_EXPIRE) return false; - const now = std.time.timestamp(); - const age_days = @divTrunc(now - self.timestamp, std.time.s_per_day); - return age_days > expire_days; - } -}; - -const AddResult = enum { - added, - updated, -}; - -fn getCachePath(allocator: Allocator) ![]const u8 { - const state_dir = try xdg.state(allocator, .{ .subdir = "ghostty" }); - defer allocator.free(state_dir); - return try std.fs.path.join(allocator, &.{ state_dir, "ssh_cache" }); -} - -// Supports both standalone hostnames and user@hostname format -fn isValidCacheKey(key: []const u8) bool { - // 253 + 1 + 64 for user@hostname - if (key.len == 0 or key.len > 320) return false; - - // Check for user@hostname format - if (std.mem.indexOf(u8, key, "@")) |at_pos| { - const user = key[0..at_pos]; - const hostname = key[at_pos + 1 ..]; - return isValidUser(user) and isValidHostname(hostname); - } - - return isValidHostname(key); -} - -// Basic hostname validation - accepts domains and IPs -// (including IPv6 in brackets) -fn isValidHostname(host: []const u8) bool { - if (host.len == 0 or host.len > 253) return false; - - // Handle IPv6 addresses in brackets - if (host.len >= 4 and host[0] == '[' and host[host.len - 1] == ']') { - const ipv6_part = host[1 .. host.len - 1]; - if (ipv6_part.len == 0) return false; - var has_colon = false; - for (ipv6_part) |c| { - switch (c) { - 'a'...'f', 'A'...'F', '0'...'9' => {}, - ':' => has_colon = true, - else => return false, - } - } - return has_colon; - } - - // Standard hostname/domain validation - for (host) |c| { - switch (c) { - 'a'...'z', 'A'...'Z', '0'...'9', '.', '-' => {}, - else => return false, - } - } - - // No leading/trailing dots or hyphens, no consecutive dots - if (host[0] == '.' or host[0] == '-' or - host[host.len - 1] == '.' or host[host.len - 1] == '-') - { - return false; - } - - return std.mem.indexOf(u8, host, "..") == null; -} - -fn isValidUser(user: []const u8) bool { - if (user.len == 0 or user.len > 64) return false; - for (user) |c| { - switch (c) { - 'a'...'z', 'A'...'Z', '0'...'9', '_', '-', '.' => {}, - else => return false, - } - } - return true; -} - -fn acquireFileLock(file: fs.File) CacheError!void { - _ = file.tryLock(.exclusive) catch { - return CacheError.CacheLocked; - }; -} - -fn readCacheFile( - alloc: Allocator, - path: []const u8, - entries: *std.StringHashMap(CacheEntry), -) !void { - const file = fs.openFileAbsolute(path, .{}) catch |err| switch (err) { - error.FileNotFound => return, - else => return err, - }; - defer file.close(); - - const content = try file.readToEndAlloc(alloc, MAX_CACHE_SIZE); - defer alloc.free(content); - - var lines = std.mem.tokenizeScalar(u8, content, '\n'); - - while (lines.next()) |line| { - const trimmed = std.mem.trim(u8, line, " \t\r"); - - if (CacheEntry.parse(trimmed)) |entry| { - // Always allocate hostname first to avoid key pointer confusion - const hostname_copy = try alloc.dupe(u8, entry.hostname); - errdefer alloc.free(hostname_copy); - - const gop = try entries.getOrPut(hostname_copy); - if (!gop.found_existing) { - const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); - gop.value_ptr.* = .{ - .hostname = hostname_copy, - .timestamp = entry.timestamp, - .terminfo_version = terminfo_copy, - }; - } else { - // Don't need the copy since entry already exists - alloc.free(hostname_copy); - - // Handle duplicate entries - keep newer timestamp - if (entry.timestamp > gop.value_ptr.timestamp) { - gop.value_ptr.timestamp = entry.timestamp; - if (!std.mem.eql(u8, gop.value_ptr.terminfo_version, entry.terminfo_version)) { - alloc.free(gop.value_ptr.terminfo_version); - const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); - gop.value_ptr.terminfo_version = terminfo_copy; - } - } - } - } - } -} - -// Atomic write via temp file + rename, filters out expired entries -fn writeCacheFile( - alloc: Allocator, - path: []const u8, - entries: *const std.StringHashMap(CacheEntry), - expire_days: u32, -) !void { - // Ensure parent directory exists - const dir = std.fs.path.dirname(path).?; - fs.makeDirAbsolute(dir) catch |err| switch (err) { - error.PathAlreadyExists => {}, - else => return err, - }; - - // Write to temp file first - const tmp_path = try std.fmt.allocPrint(alloc, "{s}.tmp", .{path}); - defer alloc.free(tmp_path); - - const tmp_file = try fs.createFileAbsolute(tmp_path, .{ .mode = 0o600 }); - defer tmp_file.close(); - errdefer fs.deleteFileAbsolute(tmp_path) catch {}; - - const writer = tmp_file.writer(); - - // Only write non-expired entries - var iter = entries.iterator(); - while (iter.next()) |kv| { - if (!kv.value_ptr.isExpired(expire_days)) { - try kv.value_ptr.format(writer); - } - } - - // Atomic replace - try fs.renameAbsolute(tmp_path, path); -} - -fn checkHost(alloc: Allocator, host: []const u8) !bool { - if (!isValidCacheKey(host)) return CacheError.InvalidCacheKey; - - const path = try getCachePath(alloc); - - var entries = std.StringHashMap(CacheEntry).init(alloc); - - try readCacheFile(alloc, path, &entries); - return entries.contains(host); -} - -fn addHost(alloc: Allocator, host: []const u8) !AddResult { - if (!isValidCacheKey(host)) return CacheError.InvalidCacheKey; - - const path = try getCachePath(alloc); - - // Create cache directory if needed - const dir = std.fs.path.dirname(path).?; - fs.makeDirAbsolute(dir) catch |err| switch (err) { - error.PathAlreadyExists => {}, - else => return err, - }; - - // Open or create cache file with secure permissions - const file = fs.createFileAbsolute(path, .{ - .read = true, - .truncate = false, - .mode = 0o600, - }) catch |err| switch (err) { - error.PathAlreadyExists => blk: { - const existing_file = fs.openFileAbsolute(path, .{ .mode = .read_write }) catch |open_err| { - return open_err; - }; - - // Verify and fix permissions on existing file - const stat = existing_file.stat() catch |stat_err| { - existing_file.close(); - return stat_err; - }; - - // Ensure file has correct permissions (readable/writable by owner only) - if (stat.mode & 0o777 != 0o600) { - existing_file.chmod(0o600) catch |chmod_err| { - existing_file.close(); - return chmod_err; - }; - } - - break :blk existing_file; - }, - else => return err, - }; - defer file.close(); - - try acquireFileLock(file); - defer file.unlock(); - - var entries = std.StringHashMap(CacheEntry).init(alloc); - - try readCacheFile(alloc, path, &entries); - - // Add or update entry - const gop = try entries.getOrPut(host); - const result = if (!gop.found_existing) blk: { - gop.key_ptr.* = try alloc.dupe(u8, host); - gop.value_ptr.* = .{ - .hostname = gop.key_ptr.*, - .timestamp = std.time.timestamp(), - .terminfo_version = "xterm-ghostty", - }; - break :blk AddResult.added; - } else blk: { - // Update timestamp for existing entry - gop.value_ptr.timestamp = std.time.timestamp(); - break :blk AddResult.updated; - }; - - try writeCacheFile(alloc, path, &entries, NEVER_EXPIRE); - return result; -} - -fn removeHost(alloc: Allocator, host: []const u8) !void { - if (!isValidCacheKey(host)) return CacheError.InvalidCacheKey; - - const path = try getCachePath(alloc); - - const file = fs.openFileAbsolute(path, .{ .mode = .read_write }) catch |err| switch (err) { - error.FileNotFound => return, - else => return err, - }; - defer file.close(); - - try acquireFileLock(file); - defer file.unlock(); - - var entries = std.StringHashMap(CacheEntry).init(alloc); - - try readCacheFile(alloc, path, &entries); - - _ = entries.fetchRemove(host); - - try writeCacheFile(alloc, path, &entries, NEVER_EXPIRE); -} - -fn listHosts(alloc: Allocator, writer: anytype) !void { - const path = try getCachePath(alloc); - - var entries = std.StringHashMap(CacheEntry).init(alloc); - - readCacheFile(alloc, path, &entries) catch |err| switch (err) { - error.FileNotFound => { - try writer.print("No hosts in cache.\n", .{}); - return; - }, - else => return err, - }; - - if (entries.count() == 0) { - try writer.print("No hosts in cache.\n", .{}); - return; - } - - // Sort entries by hostname for consistent output - var items = std.ArrayList(CacheEntry).init(alloc); - defer items.deinit(); - - var iter = entries.iterator(); - while (iter.next()) |kv| { - try items.append(kv.value_ptr.*); - } - - std.mem.sort(CacheEntry, items.items, {}, struct { - fn lessThan(_: void, a: CacheEntry, b: CacheEntry) bool { - return std.mem.lessThan(u8, a.hostname, b.hostname); - } - }.lessThan); - - try writer.print("Cached hosts ({d}):\n", .{items.items.len}); - const now = std.time.timestamp(); - - for (items.items) |entry| { - const age_days = @divTrunc(now - entry.timestamp, std.time.s_per_day); - if (age_days == 0) { - try writer.print(" {s} (today)\n", .{entry.hostname}); - } else if (age_days == 1) { - try writer.print(" {s} (yesterday)\n", .{entry.hostname}); - } else { - try writer.print(" {s} ({d} days ago)\n", .{ entry.hostname, age_days }); - } - } -} - -fn clearCache(alloc: Allocator) !void { - const path = try getCachePath(alloc); - - fs.deleteFileAbsolute(path) catch |err| switch (err) { - error.FileNotFound => {}, - else => return err, - }; -} - /// Manage the SSH terminfo cache for automatic remote host setup. /// /// When SSH integration is enabled with `shell-integration-features = ssh-terminfo`, @@ -407,6 +34,11 @@ fn clearCache(alloc: Allocator) !void { /// Entries older than the expiration period are automatically removed during cache /// operations. By default, entries never expire. /// +/// Only one of `--clear`, `--add`, `--remove`, or `--host` can be specified. +/// If multiple are specified, one of the actions will be executed but +/// it isn't guaranteed which one. This is entirely unsafe so you should split +/// multiple actions into separate commands. +/// /// Examples: /// ghostty +ssh-cache # List all cached hosts /// ghostty +ssh-cache --host=example.com # Check if host is cached @@ -432,34 +64,34 @@ pub fn run(alloc_gpa: Allocator) !u8 { const stdout = std.io.getStdOut().writer(); const stderr = std.io.getStdErr().writer(); + // Setup our disk cache to the standard location + const cache_path = try DiskCache.defaultPath(alloc, "ghostty"); + const cache: DiskCache = .{ .path = cache_path }; + if (opts.clear) { - try clearCache(alloc); + try cache.clear(); try stdout.print("Cache cleared.\n", .{}); return 0; } if (opts.add) |host| { - const result = addHost(alloc, host) catch |err| { - const Error = error{PermissionDenied} || @TypeOf(err); - switch (@as(Error, err)) { - CacheError.InvalidCacheKey => { - try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); - try stderr.print("Expected format: hostname or user@hostname\n", .{}); - return 1; - }, - CacheError.CacheLocked => { - try stderr.print("Error: Cache is busy, try again\n", .{}); - return 1; - }, - error.AccessDenied, error.PermissionDenied => { - try stderr.print("Error: Permission denied\n", .{}); - return 1; - }, - else => { - try stderr.print("Error: Unable to add '{s}' to cache\n", .{host}); - return 1; - }, - } + const result = cache.add(alloc, host) catch |err| switch (err) { + DiskCache.Error.HostnameIsInvalid => { + try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); + try stderr.print("Expected format: hostname or user@hostname\n", .{}); + return 1; + }, + DiskCache.Error.CacheIsLocked => { + try stderr.print("Error: Cache is busy, try again\n", .{}); + return 1; + }, + else => { + try stderr.print( + "Error: Unable to add '{s}' to cache. Error: {}\n", + .{ host, err }, + ); + return 1; + }, }; switch (result) { @@ -470,50 +102,42 @@ pub fn run(alloc_gpa: Allocator) !u8 { } if (opts.remove) |host| { - removeHost(alloc, host) catch |err| { - const Error = error{PermissionDenied} || @TypeOf(err); - switch (@as(Error, err)) { - CacheError.InvalidCacheKey => { - try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); - try stderr.print("Expected format: hostname or user@hostname\n", .{}); - return 1; - }, - CacheError.CacheLocked => { - try stderr.print("Error: Cache is busy, try again\n", .{}); - return 1; - }, - error.AccessDenied, error.PermissionDenied => { - try stderr.print("Error: Permission denied\n", .{}); - return 1; - }, - else => { - try stderr.print("Error: Unable to remove '{s}' from cache\n", .{host}); - return 1; - }, - } + cache.remove(alloc, host) catch |err| switch (err) { + DiskCache.Error.HostnameIsInvalid => { + try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); + try stderr.print("Expected format: hostname or user@hostname\n", .{}); + return 1; + }, + DiskCache.Error.CacheIsLocked => { + try stderr.print("Error: Cache is busy, try again\n", .{}); + return 1; + }, + else => { + try stderr.print( + "Error: Unable to remove '{s}' from cache. Error: {}\n", + .{ host, err }, + ); + return 1; + }, }; try stdout.print("Removed '{s}' from cache.\n", .{host}); return 0; } if (opts.host) |host| { - const cached = checkHost(alloc, host) catch |err| { - const Error = error{PermissionDenied} || @TypeOf(err); - switch (@as(Error, err)) { - CacheError.InvalidCacheKey => { - try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); - try stderr.print("Expected format: hostname or user@hostname\n", .{}); - return 1; - }, - error.AccessDenied, error.PermissionDenied => { - try stderr.print("Error: Permission denied\n", .{}); - return 1; - }, - else => { - try stderr.print("Error: Unable to check host '{s}' in cache\n", .{host}); - return 1; - }, - } + const cached = cache.contains(alloc, host) catch |err| switch (err) { + error.HostnameIsInvalid => { + try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); + try stderr.print("Expected format: hostname or user@hostname\n", .{}); + return 1; + }, + else => { + try stderr.print( + "Error: Unable to check host '{s}' in cache. Error: {}\n", + .{ host, err }, + ); + return 1; + }, }; if (cached) { @@ -532,224 +156,53 @@ pub fn run(alloc_gpa: Allocator) !u8 { } // Default action: list all hosts - try listHosts(alloc, stdout); + var entries = try cache.list(alloc); + defer DiskCache.deinitEntries(alloc, &entries); + try listEntries(alloc, &entries, stdout); return 0; } -// Tests -test "hostname validation - valid cases" { - const testing = std.testing; - try testing.expect(isValidHostname("example.com")); - try testing.expect(isValidHostname("sub.example.com")); - try testing.expect(isValidHostname("host-name.domain.org")); - try testing.expect(isValidHostname("192.168.1.1")); - try testing.expect(isValidHostname("a")); - try testing.expect(isValidHostname("1")); -} - -test "hostname validation - IPv6 addresses" { - const testing = std.testing; - try testing.expect(isValidHostname("[::1]")); - try testing.expect(isValidHostname("[2001:db8::1]")); - try testing.expect(!isValidHostname("[fe80::1%eth0]")); // Interface notation not supported - try testing.expect(!isValidHostname("[]")); // Empty IPv6 - try testing.expect(!isValidHostname("[invalid]")); // No colons -} - -test "hostname validation - invalid cases" { - const testing = std.testing; - try testing.expect(!isValidHostname("")); - try testing.expect(!isValidHostname("host\nname")); - try testing.expect(!isValidHostname(".example.com")); - try testing.expect(!isValidHostname("example.com.")); - try testing.expect(!isValidHostname("host..domain")); - try testing.expect(!isValidHostname("-hostname")); - try testing.expect(!isValidHostname("hostname-")); - try testing.expect(!isValidHostname("host name")); - try testing.expect(!isValidHostname("host_name")); - try testing.expect(!isValidHostname("host@domain")); - try testing.expect(!isValidHostname("host:port")); - - // Too long - const long_host = "a" ** 254; - try testing.expect(!isValidHostname(long_host)); -} - -test "user validation - valid cases" { - const testing = std.testing; - try testing.expect(isValidUser("user")); - try testing.expect(isValidUser("deploy")); - try testing.expect(isValidUser("test-user")); - try testing.expect(isValidUser("user_name")); - try testing.expect(isValidUser("user.name")); - try testing.expect(isValidUser("user123")); - try testing.expect(isValidUser("a")); -} - -test "user validation - complex realistic cases" { - const testing = std.testing; - try testing.expect(isValidUser("git")); - try testing.expect(isValidUser("ubuntu")); - try testing.expect(isValidUser("root")); - try testing.expect(isValidUser("service.account")); - try testing.expect(isValidUser("user-with-dashes")); -} - -test "user validation - invalid cases" { - const testing = std.testing; - try testing.expect(!isValidUser("")); - try testing.expect(!isValidUser("user name")); - try testing.expect(!isValidUser("user@domain")); - try testing.expect(!isValidUser("user:group")); - try testing.expect(!isValidUser("user\nname")); - - // Too long - const long_user = "a" ** 65; - try testing.expect(!isValidUser(long_user)); -} - -test "cache key validation - hostname format" { - const testing = std.testing; - try testing.expect(isValidCacheKey("example.com")); - try testing.expect(isValidCacheKey("sub.example.com")); - try testing.expect(isValidCacheKey("192.168.1.1")); - try testing.expect(isValidCacheKey("[::1]")); - try testing.expect(!isValidCacheKey("")); - try testing.expect(!isValidCacheKey(".invalid.com")); -} - -test "cache key validation - user@hostname format" { - const testing = std.testing; - try testing.expect(isValidCacheKey("user@example.com")); - try testing.expect(isValidCacheKey("deploy@prod.server.com")); - try testing.expect(isValidCacheKey("test-user@192.168.1.1")); - try testing.expect(isValidCacheKey("user_name@host.domain.org")); - try testing.expect(isValidCacheKey("git@github.com")); - try testing.expect(isValidCacheKey("ubuntu@[::1]")); - try testing.expect(!isValidCacheKey("@example.com")); - try testing.expect(!isValidCacheKey("user@")); - try testing.expect(!isValidCacheKey("user@@host")); - try testing.expect(!isValidCacheKey("user@.invalid.com")); -} - -test "cache entry expiration" { - const testing = std.testing; - const now = std.time.timestamp(); - - const fresh_entry = CacheEntry{ - .hostname = "test.com", - .timestamp = now - std.time.s_per_day, // 1 day old - .terminfo_version = "xterm-ghostty", - }; - try testing.expect(!fresh_entry.isExpired(90)); - - const old_entry = CacheEntry{ - .hostname = "old.com", - .timestamp = now - (std.time.s_per_day * 100), // 100 days old - .terminfo_version = "xterm-ghostty", - }; - try testing.expect(old_entry.isExpired(90)); - - // Test never-expire case - try testing.expect(!old_entry.isExpired(NEVER_EXPIRE)); -} - -test "cache entry expiration - boundary cases" { - const testing = std.testing; - const now = std.time.timestamp(); - - // Exactly at expiration boundary - const boundary_entry = CacheEntry{ - .hostname = "boundary.com", - .timestamp = now - (std.time.s_per_day * 30), // Exactly 30 days old - .terminfo_version = "xterm-ghostty", - }; - try testing.expect(!boundary_entry.isExpired(30)); // Should not be expired - try testing.expect(boundary_entry.isExpired(29)); // Should be expired -} - -test "cache entry parsing - valid formats" { - const testing = std.testing; - - const entry = CacheEntry.parse("example.com|1640995200|xterm-ghostty").?; - try testing.expectEqualStrings("example.com", entry.hostname); - try testing.expectEqual(@as(i64, 1640995200), entry.timestamp); - try testing.expectEqualStrings("xterm-ghostty", entry.terminfo_version); - - // Test default terminfo version - const entry_no_version = CacheEntry.parse("test.com|1640995200").?; - try testing.expectEqualStrings("xterm-ghostty", entry_no_version.terminfo_version); - - // Test complex hostnames - const complex_entry = CacheEntry.parse("user@server.example.com|1640995200|xterm-ghostty").?; - try testing.expectEqualStrings("user@server.example.com", complex_entry.hostname); -} - -test "cache entry parsing - invalid formats" { - const testing = std.testing; - - try testing.expect(CacheEntry.parse("") == null); - try testing.expect(CacheEntry.parse("v1") == null); // Invalid format (no pipe) - try testing.expect(CacheEntry.parse("example.com") == null); // Missing timestamp - try testing.expect(CacheEntry.parse("example.com|invalid") == null); // Invalid timestamp - try testing.expect(CacheEntry.parse("example.com|1640995200|") != null); // Empty terminfo should default -} - -test "cache entry parsing - malformed data resilience" { - const testing = std.testing; - - // Extra pipes should not break parsing - try testing.expect(CacheEntry.parse("host|123|term|extra") != null); - - // Whitespace handling - try testing.expect(CacheEntry.parse(" host|123|term ") != null); - try testing.expect(CacheEntry.parse("\n") == null); - try testing.expect(CacheEntry.parse(" \t \n") == null); -} - -test "duplicate cache entries - memory management" { - const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const alloc = arena.allocator(); - - var entries = std.StringHashMap(CacheEntry).init(alloc); - defer entries.deinit(); - - // Simulate reading a cache file with duplicate hostnames - const cache_content = "example.com|1640995200|xterm-ghostty\nexample.com|1640995300|xterm-ghostty-v2\n"; - - var lines = std.mem.tokenizeScalar(u8, cache_content, '\n'); - while (lines.next()) |line| { - const trimmed = std.mem.trim(u8, line, " \t\r"); - if (CacheEntry.parse(trimmed)) |entry| { - const gop = try entries.getOrPut(entry.hostname); - if (!gop.found_existing) { - const hostname_copy = try alloc.dupe(u8, entry.hostname); - const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); - gop.key_ptr.* = hostname_copy; - gop.value_ptr.* = CacheEntry{ - .hostname = hostname_copy, - .timestamp = entry.timestamp, - .terminfo_version = terminfo_copy, - }; - } else { - // Test the duplicate handling logic - if (entry.timestamp > gop.value_ptr.timestamp) { - gop.value_ptr.timestamp = entry.timestamp; - if (!std.mem.eql(u8, gop.value_ptr.terminfo_version, entry.terminfo_version)) { - alloc.free(gop.value_ptr.terminfo_version); - const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); - gop.value_ptr.terminfo_version = terminfo_copy; - } - } - } - } +fn listEntries( + alloc: Allocator, + entries: *const std.StringHashMap(Entry), + writer: anytype, +) !void { + if (entries.count() == 0) { + try writer.print("No hosts in cache.\n", .{}); + return; } - // Verify only one entry exists with the newer timestamp - try testing.expect(entries.count() == 1); - const entry = entries.get("example.com").?; - try testing.expectEqual(@as(i64, 1640995300), entry.timestamp); - try testing.expectEqualStrings("xterm-ghostty-v2", entry.terminfo_version); + // Sort entries by hostname for consistent output + var items = std.ArrayList(Entry).init(alloc); + defer items.deinit(); + + var iter = entries.iterator(); + while (iter.next()) |kv| { + try items.append(kv.value_ptr.*); + } + + std.mem.sort(Entry, items.items, {}, struct { + fn lessThan(_: void, a: Entry, b: Entry) bool { + return std.mem.lessThan(u8, a.hostname, b.hostname); + } + }.lessThan); + + try writer.print("Cached hosts ({d}):\n", .{items.items.len}); + const now = std.time.timestamp(); + + for (items.items) |entry| { + const age_days = @divTrunc(now - entry.timestamp, std.time.s_per_day); + if (age_days == 0) { + try writer.print(" {s} (today)\n", .{entry.hostname}); + } else if (age_days == 1) { + try writer.print(" {s} (yesterday)\n", .{entry.hostname}); + } else { + try writer.print(" {s} ({d} days ago)\n", .{ entry.hostname, age_days }); + } + } +} + +test { + _ = DiskCache; + _ = Entry; } diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 36ae116d5..469ff2859 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -177,10 +177,28 @@ pub fn setupFeatures( }; var buffer = try std.BoundedArray(u8, capacity).init(0); - inline for (fields) |field| { - if (@field(features, field.name)) { + // Sort the fields so that the output is deterministic. This is + // done at comptime so it has no runtime cost + const fields_sorted: [fields.len][]const u8 = comptime fields: { + var fields_sorted: [fields.len][]const u8 = undefined; + for (fields, 0..) |field, i| fields_sorted[i] = field.name; + std.mem.sortUnstable( + []const u8, + &fields_sorted, + {}, + (struct { + fn lessThan(_: void, lhs: []const u8, rhs: []const u8) bool { + return std.ascii.orderIgnoreCase(lhs, rhs) == .lt; + } + }).lessThan, + ); + break :fields fields_sorted; + }; + + inline for (fields_sorted) |name| { + if (@field(features, name)) { if (buffer.len > 0) try buffer.append(','); - try buffer.appendSlice(field.name); + try buffer.appendSlice(name); } } @@ -220,7 +238,7 @@ test "setup features" { defer env.deinit(); try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false, .@"ssh-env" = true, .@"ssh-terminfo" = false }); - try testing.expectEqualStrings("sudo,ssh-env", env.get("GHOSTTY_SHELL_FEATURES").?); + try testing.expectEqualStrings("ssh-env,sudo", env.get("GHOSTTY_SHELL_FEATURES").?); } }